Epic PyObjc, Part 4: Drag & Drop, Multiple Nibs
Welcome to the fourth segment of the Epic PyObjC tutorial series. In the third part of the tutorial we focused on three smaller tasks: handling double clicks, caching to disk, and adding a progress indicator.
This time we're going to focus on two slightly tricky, but extremely important patterns in Cocoa application development. We'll start out implementing some drag and drop functionality, and finish by looking at using a second nib file to create new windows while the application is running.
In the third article I said that I would also include a list of resources in this segment, but after it occured to me to cover using a second nib (understanding which opens up your Cocoa horizons), I felt like that was a topic that would be genuinely beneficial to cover, and that covering drag and drop, additional nibs and the references would be a bit much material in one article.
If you haven't been following along thus far, you can download the current zip of the project.
Since this is our last dance together (in this series, at least), lets have some fun.
Drag & Drop: A Philosophy, A Style
I'd like to start out the discussion of drag and drop in Cocoa by mentioning my long term dedication to the concept, but that would be a lie. When I first was getting started with PyObjC and Cocoa--about a year and a half ago (don't tell the recruiters)--I put together my first fairly complex application and I was pretty proud of it. The two critiques I remember most closely were: don't use brushed metal (I still haven't recovered from the anti-brushed metal conspiracy yet), and "Wouldn't drag and drop work better to transfer things between two lists than double clicking?"
I think I mumbled some lame excuse about not doing it, namely that it was more confusing than I could easily figure out, but damn it, who needs drag and drop anyway?
Well, previous antics to the contrary, I need drag and drop, and so do you. Drag and drop is one of the few places where you can experiment with your user interface and really do something interesting without breaking so many conventions that your users curse your birth. Drag and drop is the wild west of Cocoa programming.
So much of the potential of drag and drop isn't explored, simply because it doesn't need to be. Most applications can be implemented with no or minimal drag and drop, and thus they are implemented that way. But, I say, no longer!
There are an unending number of intuitive, fun and useful ways to use drag and drop more. This isn't a complete list in any sense, just a starting point:
Allow text and contents to be dropped on the dock icon. Its a great way of facilitating interapplication communication. In
MetaWindow
's case we might make it automatically search for strings dropped onto the dock icon. (I wrote a terse entry about implementation details here.)Make exporting data as simple as drag and drop. Lets say you wanted to email a report, wouldn't it be great if you could just drag the table into Mail.app, have it automatically convert into a PDF? But what if you dragged it into a text only email? Well, then it might export the data as text. Wouldn't it be great if exporting data was uniformly that simple and applications really cooperated?
Be thoughtful about contextually dragging and dropping data. If the user is trying to enter a phone address, it would be great if you could use a regex to parse the phone number out of a paragraph. Even better if you dropped a list of telephone numbers and it identified and added all of them. We're used to strict standards for the input we give applications, but there is the real potential for users to fall in love with applications that let them throw data in indiscriminately and handles the details for them. Of course, we have to let our users know when they can be indiscriminate, since they've been trained to serve as the computer's secretary, and won't expect otherwise unless your app liberates them.
To sum it up, drag and drop is the Unix pipes of Cocoa programming. It is the easiest and most seamless way for application to cooperate, and once we start getting creative, it'll open up a world of unexpected combination and utility.
Drag & Drop: Search by Dropping
In MetaWindow
we're going to implement two kinds of drag and drop, starting with making it possible to search by dragging text into the search window. Not just the search textfield, but the entire window containing it. This creates a bigger sweet spot, and makes it quicker to use.
The first thing we need to do is open up MWController.py
and create a helper function that will accept a search string as a parameter and then perform the search (as well as update the textfield with the search string for visual confirmation). We need to do this, because we'll be subclassing NSWindow
to listen for the drop operation, but if were handled searching in that subclass we'd have to re-implement the searching logic there. We avoid repeating ourselves by giving our NSWindow
subclass an IBOutlet and connecting it to our MWController
and its search_
method.
Thus we're adding the dragSearch
method to MWController
.
def dragSearch(self,searchString):
self.textField.setStringValue_(searchString)
self.search_(self)
dragSearch
really just loads the bullet in search_
's barrel and then fires it.
Now, we need to create a new file. Go to the File
menu, and then New File
(Apple-N). We really want to subclass NSWindow
, but there isn't a default template to do that, so we'll just create a Python NSObject subclass
file and tailor it to our uses. Name the file MWDragWindow.py
, and drag it into the Classes
group in the outline view in XCode (strictly for organizational purposes).
Then--the easy to forget but crucial step--open up main.py
and add an import for MWDragWindow
so that it looks like this:
# import modules containing classes required to start application and load MainMenu.nib
import metaweb
import MetaWindowAppDelegate
import MWController
import MWDragWindow
Now open up the MWDragWindow.py
file and we can get to work.
At the top you'll need to import *
from AppKit
and import objc
, so that the imports are now:
import objc
from Foundation import *
from AppKit import *
Next we want to change MWDragWindow
from subclassing NSObject
to subclassing NSWindow
. We also want to give it an IBOutlet named controller
.
class MWDragWindow(NSWindow):
controller = objc.IBOutlet()
Continuing, we begin to set it up to support drop operations. The first step is to register a class for the types of pasteboards it will respond to. In this case we just want to listen for the NSStringPboardType
, which is the pasteboard type for text.
To register for dragged types you call an object's registerForDraggedTypes_
method with a list of types to listen for. Its usually easiest to do this in the awakeFromNib
method, which gets called as the object is initialized.
For MWDragWindow
the code is as simple as this:
def awakeFromNib(self):
self.registerForDraggedTypes_([NSStringPboardType])
Be careful to pass registerForDraggedTypes_
a list of types, not just pass it NSStringPboardType
directly.
A brief sidenote on user created pasteboard types.
There are a number of different types of pasteboards already supported by Cocoa. We're using the NSStringPboardType
, but there are many other existing pasteboards to choose from: NSUrlPboardType
, NSColorPboardType
, NSFilenamesPboardType
, NSFileContentsPboardType
and so on.
Sometimes, however, you're trying to drag and drop something that falls outside of the purview of existing pasteboard types. In those cases, you can create your own pasteboard types. A pasteboard type is actually only a string, so creating a pasteboard type is as simple as creating a module level variable containing the string and importing it into any other modules that use the same pasteboard.
## Some Module A
MWRowPboardType = u"MWRowPboardType"
class MyWindow(NSWindow):
def awakeFromNib(self):
self.registerForDraggedTypes_([MWRowPboardType])
And using it in another module would be like this:
from module_a import MWRowPboardType
class AnotherWindow(NSWindow):
def awakeFromNib(self):
self.registerForDraggedTypes_([MWRowPboardType])
Typically you can only use user created pasteboard types within the application that declares it, simply because other applications won't know about it. However, if developers actively publicized the strings for their pasteboards, that wouldn't necessarily be the case.
Now that MWDragWindow
is registered for NSStringPboardType
, we need to implement two more methods to tell it how to handle incoming drags.
The first is draggingEntered_
, and it is used for determining the type of imagery to use to represent the incoming drag. For example, if you were dragging text into a textfield, you would probably want to use NSDragOperationAdd
, but if you were instead linking to objects together, you'd prefer NSDragOperationLink
. Perhaps the most important operation, however, is NSDragOperationNone
which you return when you refuse an incoming drag event.
When you return NSDragOperationNone
then there is no visual acknowledgement of the drag, and the object is refusing to handle the drag type. This provides a visual cue for users about which kinds of data an an application/window/field can accept.
In MWDragWindow
we'll implement draggingEntered_
as follows:
def draggingEntered_(self,sender):
pboard = sender.draggingPasteboard()
types = pboard.types()
opType = NSDragOperationNone
if NSStringPboardType in types:
opType = NSDragOperationCopy
return opType
If the pasteboard contains NSStringPboardType
we'll handle it, otherwise we won't. We're using the NSDragOperationCopy
imagery because that is the imagery the textfield uses to respond to the NSStringPboardType
, and changing between drag imagery can be a bit unsettling (unless you're trying to indicate that different behavior will occur if you drop in different locations, in which case it's entirely appropriate).
draggingEntered_
handles accepting or rejecting drags, and the visual imagery to display the drag, but so far we aren't handling the contents of an incoming drag. That is done by implementing performDragOperation_
.
def performDragOperation_(self,sender):
pboard = sender.draggingPasteboard()
successful = False
if NSStringPboardType in pboard.types():
txt = pboard.stringForType_(NSStringPboardType)
self.controller.dragSearch(txt)
successful = True
return successful
If we return False
from performDragOperation_
then the application will visually indicate that the drop was rejected--and if we return True
then it will visually indicate the drop has succeeded--but actually handling the incoming data is up to us.
Here we are simply calling the MWController
method dragSearch
with the string stored for type NSStringPboardType
in the pasteboard, and having MWController
handle things for us.
The last thing we need to do is open up InterfaceBuilder and modify things a bit, so go ahead and open up MainMenu.xib
.
Open the Inspector, and select Window (Window)
, and go to the sixth tab. The Inspector's title will change to Window Identity
when you are in the correct tab.
At the top of the inspector in the segment named Class Identity
, in the field named Class
replace NSWindow
with MWDragWindow
. Then go to the window containing all the objects, hold down control, and drag from Window (Window)
to Controller
and release. Select controller
from the popup menu.
Now drag some text into the window, release it, and it will begin searching as if we had typed the text into the textfield and hit search.
Drag & Drop: Export Data by Dragging
Now that we're implemented drag and drop in our window, we're going to take a look at another common drag and drop scenario: drag and drop in an NSTableView
.
Traditionally drag and drop was handled in the NSTableView
's datasource, but if remember back to segment two, we don't have a datasource specified yet, so we know things will work out a bit differently. In fact, we'll implement drag and drop by subclassing the NSArrayController
class and reclassing the controller
object in MainMenu.xib
with our custom class.
Let's start out by creating a new file in our project, subclassing from NSObject
, named MWDragArrayController.py
, and add the import to main.py
so that the imports in main.py
look like this:
# import modules containing classes required to start application and load MainMenu.nib
import metaweb
import MetaWindowAppDelegate
import MWController
import MWDragWindow
import MWDragArrayController
Now, back in MWDragArrayController.py
, lets change MWDragArrayController
from subclassing NSObject
to subclassing from NSArrayController
, and also import NSStringPboardType
from AppKit
.
from AppKit import NSStringPboardType
from Foundation import *
class MWDragArrayController(NSArrayController):
pass
Lets take a quick detour and open up MainMenu.xib
in InterfaceBuilder. Select the Array Controller
instance, open up the Inspector, select the Identity tab (the sixth one) and change the class from NSArrayController
to MWDragArrayController
.
Then, and this step is easy to forget but very important, you need to select the tableview (not the scroll view that contains it, so click on it twice) and connect it to Drag Array Controller
for both the delegate and datasource outlets.
Save, close Interface Builder, and return to editingMWDropArrayController.py
.
Surprisingly enough we're almost finished! We just need to add this method to MWDropArrayController
:
def tableView_writeRows_toPasteboard_(self,tv,rows,pb):
arranged = self.arrangedObjects()
data = ",".join([ arranged[x]['name'] for x in rows ])
pb.declareTypes_owner_([NSStringPboardType],self)
pb.setString_forType_(data,NSStringPboardType)
return True
Save and build the app, search for something and drag one of the rows out of the table. It's pretty neat, yeah, but somethings not quite right: you can't drag into other applications. Thats pretty inconvenient, and really cuts back on the utility of dragging.
To fix that open up MWController
, go to the awakeFromNib
method, and add this line:
self.tableView.setDraggingSourceOperationMask_forLocal_(NSDragOperationCopy, False)
All together that means that `MWController
's awakeFromNib
method looks like this:
def awakeFromNib(self):
if self.tableView:
self.tableView.setTarget_(self)
self.tableView.setDoubleAction_("open:")
self.tableView.setDraggingSourceOperationMask_forLocal_(NSDragOperationCopy, False)
Now save and trying building MetaWindow
again. You should be able to drag content from rows into other applications as well.
That sums up our look at drag and drop in MetaWindow
. We're not going to look at dropping data into our tableview, mostly because I couldn't imagine a single way to even contrive a use for that given that we're displaying the results from Metaweb as if they were immutable. Perhaps if MetaWindow
was extended to modify data, then such an operation might have some value (a way to import data, for example).
Using a Second Nib
Time to begin looking at our final topic in the Epic PyObjC tutorial series, and an important topic it is: using multiple nibs in one project. For many applications you can avoid using multiple nibs. For example, if you look at iTunes approach to playlists, by default when you click on a playlist it just displays in the list in the main window.
You can do that with one nib. No problem. But if you double click on a playlist, it will open a new window. That, on the other hand, you can't do with one nib (unless you create and populate the window programatically, which is possible but will typically be far more work).
Once you graduate from trivial applications, you will inevitable need to learn about using multiple nibs in one project, and--like everything we've looked at in this tutorial--it really isn't that hard once you decide to sit down and do it.
Right now when you double click on a row it opens up the entry's page in a web browser, but it would be nice if instead it opened up a new window that showed us more details about that entry. So far we're just scratching the surface of the data stored returned by metaweb.py
, and we could display a lot more of it with an entire window to dedicate to each result.
There are three steps that we will take to integrate the additional nib into our project:
Create a custom Python class that subclasses
NSWindowController
, and which is given the specific row's dictionary when initialized.Create a new nib, which we'll call
RowWindow.xib
, where we set theFile's Owner
class to be our custom subclass ofNSWindowController
.Update the
open_
method inMWController
to create a new window using theRowWindow.xib
nib instead of opening the row's entry in a web browser.
Subclassing NSWindowController
First, lets create a new file named MWRowWindowController.py
. Initially it will be subclassed from NSObject
, but lets change its parent class to NSWindowController
, and also give it a field named rowDict
.
class MWRowWindowController(NSWindowController):
rowDict = None
Then import it in main.py
like usual:
# import modules containing classes required to start application and load MainMenu.nib
import metaweb
import MetaWindowAppDelegate
import MWController
import MWDragWindow
import MWDragArrayController
import MWRowWindowController
And... thats it.
Creating RowWindow.xib
.
Create a new file, this time choosing the Window XIB
template, and naming it RowWindow.xib
. Then open it in InterfaceBuilder.
First, open the Inspector and select File's Owner
, then go to the Identity tab and change its class from NSObject
to RowWindowController
. Next create an NSTextField
in the nib's window, select it, and go to the Bindings tab.
For the Value
binding, bind it to File's Owner
, and for Model Key Path
type in rowDict.name
.
This is another example of using Cocoa bindings to avoid repetitive code. They are your friends.
Finally, we need to click on File's Owner
and connect its window
outlet to Window (Window)
.
Updating the open_
method
The last step for us is to update the open_
method in MWController
. To accomplish that we'll start by adding an import for the MWRowWindowController
class to MWController.py
.
import objc, metaweb, webbrowser, pickle, datetime, md5, threading
from MWRowWindowController import MWRowWindowController
from AppKit import *
from Foundation import *
And then we turn to open_
. Right now it looks like this:
def open_(self,sender):
selectedObjs = self.arrayController.selectedObjects()
if len(selectedObjs) == 0:
NSLog(u"No selected row!")
return
<span class="n">row</span> <span class="o">=</span> <span class="n">selectedObjs</span><span class="p">[</span><span class="mf">0</span><span class="p">]</span>
<span class="n">NSLog</span><span class="p">(</span><span class="s">u"Row: </span><span class="si">%s</span><span class="s">"</span> <span class="o">%</span> <span class="n">row</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">row</span><span class="o">.</span><span class="n">has_key</span><span class="p">(</span><span class="s">'id'</span><span class="p">)</span> <span class="ow">or</span> <span class="n">row</span><span class="p">[</span><span class="s">'id'</span><span class="p">]</span> <span class="o">==</span> <span class="bp">None</span><span class="p">:</span>
<span class="n">NSLog</span><span class="p">(</span><span class="s">u"Row has no id!"</span><span class="p">)</span>
<span class="k">return</span>
<span class="n">url</span> <span class="o">=</span> <span class="s">u"http://www.freebase.com/view</span><span class="si">%s</span><span class="s">"</span> <span class="o">%</span> <span class="n">row</span><span class="p">[</span><span class="s">'id'</span><span class="p">]</span>
<span class="n">webbrowser</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
Everything except for the last two lines is already perfect. Go ahead and delete the last two and replace them with this:
rwc = MWRowWindowController.alloc().initWithWindowNibName_(u"RowWindow")
rwc.rowDict = row
rwc.showWindow_(self)
rwc.retain()
Now go ahead and Build and Go
the application. Search for something then double click on the row and you'll see it opens up a new window with its name. Certainly we'd want to fill out the contents displayed in that window, but now you know enough to take care of that on your own.
So we're done.
Except For Two Big Problems
Well... done might have been a strong word. There are two glaring problems with our current implementation. The first is that we're calling retain
on rwc
, but we're never calling release
, so we're not allowing the garbage collector to release that window.
The second problem is that if you click on the same row twice, then you'll open up two windows for that one row. It would be much slicker if it just brought the existing window for a row to the front instead of creating a second one.
The good news is that the solutions to both these questions overlap, and won't take much time to deal with.
First add a field to MWController
named rowCache
that initializes with an empty dict.
class MWController(NSObject):
tableView = objc.IBOutlet()
textField = objc.IBOutlet()
arrayController = objc.IBOutlet()
indicator = objc.IBOutlet()
results = []
rowCache = {}
_cache = None
We're going to use rowCache
to keep track of the window controllers to allow us to reuse them. Now we'll redo open_
a final time:
def open_(self,sender):
selectedObjs = self.arrayController.selectedObjects()
if len(selectedObjs) == 0:
NSLog(u"No selected row!")
return
<span class="n">row</span> <span class="o">=</span> <span class="n">selectedObjs</span><span class="p">[</span><span class="mf">0</span><span class="p">]</span>
<span class="n">NSLog</span><span class="p">(</span><span class="s">u"Row: </span><span class="si">%s</span><span class="s">"</span> <span class="o">%</span> <span class="n">row</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">row</span><span class="o">.</span><span class="n">has_key</span><span class="p">(</span><span class="s">'id'</span><span class="p">)</span> <span class="ow">or</span> <span class="n">row</span><span class="p">[</span><span class="s">'id'</span><span class="p">]</span> <span class="o">==</span> <span class="bp">None</span><span class="p">:</span>
<span class="n">NSLog</span><span class="p">(</span><span class="s">u"Row has no id!"</span><span class="p">)</span>
<span class="k">return</span>
<span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">rowCache</span><span class="o">.</span><span class="n">has_key</span><span class="p">(</span><span class="n">row</span><span class="p">):</span>
<span class="n">rwc</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">rowCache</span><span class="p">[</span><span class="n">row</span><span class="p">]</span>
<span class="n">rwc</span><span class="o">.</span><span class="n">showWindow_</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
<span class="n">rwc</span> <span class="o">=</span> <span class="n">MWRowWindowController</span><span class="o">.</span><span class="n">alloc</span><span class="p">()</span><span class="o">.</span><span class="n">initWithWindowNibName_</span><span class="p">(</span><span class="s">u"RowWindow"</span><span class="p">)</span>
<span class="n">rwc</span><span class="o">.</span><span class="n">rowDict</span> <span class="o">=</span> <span class="n">row</span>
<span class="n">rwc</span><span class="o">.</span><span class="n">showWindow_</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span>
<span class="n">rwc</span><span class="o">.</span><span class="n">retain</span><span class="p">()</span>
<span class="bp">self</span><span class="o">.</span><span class="n">rowCache</span><span class="p">[</span><span class="n">row</span><span class="p">]</span> <span class="o">=</span> <span class="n">rwc</span>
The change is fairly simple: before we create a new MWRowWindowController
we are check to see if we already have one stored in rowCache
.
You can Build and Go
the application and gander at it properly reusing the windows. Hallelujah.
Now to solve the 'not calling release
' problem.
Meeting dealloc
So far in our journey into PyObjC and Cocoa we've touched on a lot of Cocoa topics and concepts: release
, retain
, Cocoa Bindings, NSArrayController
, and drag & drop. The last tool I'll add to your armament is dealloc
. dealloc
is the method that is called when an object subclassing NSObject
's memory is about to be released by garbage collection. It is your last chance to deattach any retained references before they turn into a memory leak.
There are two steps (and the order is significant) in every dealloc
method:
- Release any retained objects.
- Call the super class'
dealloc
method.
Its easy to forget the second step, but it's another common route to memory leakage.
The dealloc
method for MWController
is going to look like this:
def dealloc(self):
for key in self.rowCache:
value = self.rowCache[key]
value.release()
super(MWController,self).dealloc()
At some point you'll want to read the Cocoa Guide to Memory Management, which is a thorough introduction to the sundry nuances that come up as you dig deeper into memory management.
Ending Part Four
You can download the current zip of the project, or retrieve the code from the repository on GitHub.
We're finally done with the programming aspects of this tutorial. Its been a long, perhaps hellish for some, trip. The fifth and final entry will contain a variety of resources that can help you continue towards PyObjC and Cocoa mastery.
The last topic I want to discuss is the future of MetaWindow
, the application we built together in this tutorial. It is certainly a bit of a weird and not-fully-considered project, and shows that particularly when we consider just how damnably useless the currently iteration is.
Still, I do think there is a useful application somewhere down there. The GitHub repository is in laughably bad repair, for which I humbly apologize. Insert some vague excuse about the inherent awkwardness of writing an application while you simultaniously try to tutorialize. I intend to clean up the repository, and would love to see others take a stab at turning it into something useful and interesting. I'd be glad to field questions or help with problem spots that arise.
I'd love to devote more of my time to MetaWindow
, but have been somewhat neglegent while writing this series, and won't have much time for developing MW in the near future.