Automating VoodooPad with scripts
Say what you want about Microsoft, OneNote is a pretty good notebook tool. Well, let’s clarify: OneNote for Windows is pretty good. I tried OneNote for OS X recently, and it leaves a lot to be desired. My go-to equivalent on OS X for the moment is Flying Meat/Plausible Labs’ VoodooPad1: it’s not quite as nice (in my opinion) as OneNote is on Windows, and some of the UI decisions could benefit from an update, but it does the job OK. I used to use it for a bunch of my literature notes during my Ph.D., and now I’m making a return to it for some of my other projects around home.
Every program has its benefits and its drawbacks, but some things just aren’t worth not having. When you’re writing notes, being able to quickly add headings or format lines is super-handy: something OneNote does really well, but something that’s still missing in VoodooPad. After about five minutes of playing around with VoodooPad I decided I needed to fix this problem, somehow. The obvious answer would be to take advantage of VoodooPad’s ability to run plugins, as long as I could work out how to actually do this.
NSAttributedString works, which is a bit more complex than a little AppleScript or what-have-you. There’s one big drawback, however: documentation. I haven’t2 found a good guide to actually writing plugins for VoodooPad, in any language.
Nonetheless: armed with the knowledge that this is (at least in theory) possible, and some sample scripts, I went to work.
Heading 1: a proof of concept
In OneNote, you can turn a line into a heading by highlighting it and hitting
Ctrl+1. It seems like a good proof of concept would be to try replicating this in VoodooPad.
Python VDP plugins are located in
~/Application Support/VoodooPad/Script Plugins/, and a sample plugin (according to the manual) looks something like this3:
1 2 3 4 5 6 7 8 9 10
# -*- coding: utf-8 -*- VPScriptMenuTitle = "List Page Names" import AppKit def main(windowController, *args, **kwargs): document = windowController.document() textView = windowController.textView() for key in document.keys(): textView.insertText_(key) textView.insertText_("\n")
We immediately see some interesting bits and bobs:
- We can set the global variable
VPScriptMenuTitleto give this script (presumably) a title in the menu bar.
- We need to import
AppKit, which presumably allows us access to various Cocoa/AppKit methods.
mainmethod takes a
windowController(although goodness knows what’s in those
**kwargs, if anything).
- We can access the
windowController: this is where the text of a given page goes.
- We can add text to the
insertText_()method, which ends with an underscore for some reason.
This answers some questions, but leaves a lot more unanswered - including “which methods can we play with?”, which is always a nice thing to know. Let’s divert from our main goal quickly to see what we can find out about this.
You can fetch the methods of an object in python using the
dir() command, so let’s make a quick plugin to splurge out all the methods of the
1 2 3 4 5 6 7 8 9 10 11 12
# -*- coding: utf-8 -*- VPScriptMenuTitle = "Output methods" import AppKit def main(windowController, *args, **kwargs): textView = windowController.textView() methods = dir(windowController.document()) textView.insertText_("\nMethods on document():\n") for m in methods: textView.insertText_(m) textView.insertText_("\n")
If you save this in the approproate directory (the quickest way to get there in VoodooPad is to select Help→Open VoodooPad’s App Support Folder) and restart VDP, you’ll see a new entry in the Plugin menu, and clicking it should give you a long list of methods on the document. Most of these aren’t that interesting, but if you scroll down past all the underscore-prepended methods you’ll find a bunch of methods that mirror Objective-C method calls. One thing work noticing about these is that the colons in Objective-C method calls are replaced with underscores in python. This explains why
insertText_() above ended with an underscore.
While this is all fun, it isn’t getting us that much closer to making a “heading” plugin. However, we’ve seen that we can retrieve the window controller’s text view, and a quick look through Apple’s documentation suggests this will be an instance of
NSTextView. Given we should presumably be able to edit the appearance of the text in Cocoa, we might be able to mirror it in Python in this plugin.
Let’s look at what we have so far:
1 2 3 4 5 6
# -*- coding: utf-8 -*- VPScriptMenuTitle = "Heading 1" import AppKit def main(windowController, *args, **kwargs): textView = windowController.textView()
A quick look through the documentation for
NSTextView shows us that we should be able to use its
textStorage property to modify the text.
textStorage returns an
NSTextStorage object, which is a subclass of an
NSMutableAttributedString. If you’ve done Cocoa programming before, this might mean something. If you haven’t, however, there’s a few good things about this class:
- It’s mutable, which means that we can edit it (for example, change bits of it into other bits).
- It’s attributed, which means that it’s not just a bunch of letters, it’s a bunch of letters with associated styles such as font size and type, bold or underline, text spacing, etc. etc. Basically: we can modify the style on this.
In order to turn text into a heading, we want to change the font size and probably make it bold. Looking through the docs, we find that
NSMutableAttributedStrings respond to
-addAttribute:value:range:. This sounds like exactly the method we need. Remembering the rules about colons turning into underscores, that means our method call looks something like this:
Now all we need to do is work out what sort of arguments we should pass.
Mucking about with attributes
In Objective-C, if I wanted to make the selected text Helvetica Bold and 24 pt, I’d do the following:
NSFont f = [NSFont fontWithName: @"Helvetica-Bold" size: 24] [string addAttribute: NSFontAttributeName value: f range: textView.selectedRange];
addAttribute can add all sorts of attributes, with the first argument being a constant telling which attribute it’s editing. We don’t care what the actual value of
NSFontAttributeName is: all that matters is that it tells the program we want to edit the font.
We have two problems:
- How do we create the font in python? That is, how do we make the
NSFontobject “f” above?
- How do we pass all of these values into
addAttribute_value_range_()? Do we use positional arguments (i.e.
arg1, arg2, arg3) or keyword arguments (i.e.
val1=arg1, val2=arg2, val3=arg3)?
First, the font. The following is a good attempt:
font = NSFont.fontWithName_size_("Helvetica-Bold", size=24)
But if you put this into a plugin and run it, you get an error.
So VoodooPad doesn’t recognise
NSFont. However, we did import
AppKit: maybe that’s storing all our classes.
font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", size=24)
This gives us a new error:
Importantly, it’s now complaining about the arguments, not about
NSFont. So it looks like we can get to
AppKit, and we also (handily) learn that Objective-C selectors need to take positional arguments. Thus, the following runs without any errors:
font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24)
Armed with this knowledge, we can add a little more to the “Heading” plugin:
1 2 3 4 5 6 7 8 9 10 11
# -*- coding: utf-8 -*- VPScriptMenuTitle = "Heading 1" import AppKit def main(windowController, *args, **kwargs): textView = windowController.textView() range = textView.selectedRange() font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24) textView.textStorage().addAttribute_value_range_(NSFontAttributeName, font, range)
This is almost correct: however, if we run it we find that again VoodooPad doesn’t recognise the
NSFontAttributeName constant. This is a simple fix, similar to what we did for
textView.textStorage().addAttribute_value_range_(AppKit.NSFontAttributeName, font, range)
If you restart VoodooPad, select some text and run this from the “Plugins” menu, you should find that you can turn anything you like into Helvetica-Bold and 24 point.
Where’s my keyboard shortcuts?
There’s one problem remaining: as it stands, to make text nice and big I still have to select this from the menu. In an ideal world, I could just hit ⌘+1 and have it do everything for me. It seems like it might be possible, as there are already entries in the Plugins menu with their own keyboard shortcuts.
At first I was at a loss. How could you register this sort of thing with VoodooPad? I was wondering about
VPScriptMenuTitle, however: were there other “VP”-prefixed constants that exist? No official listing, but having a search around the internet, I found several scripts with the following constants:
- VPScriptSuperMenuTitle: Presumably so you can put a bunch of scripts into a folder?
- VPShortcutKey: This is what we want to see! Especially when coupled with…
- VPShortcutMask: A space-separated string containing “control”, “command”, etc. Which modifiers need to be held down?
I don’t know if other constants are in use, but these will do for us. Here’s our final script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
# -*- coding: utf-8 -*- VPScriptMenuTitle = "Heading 1" VPShortcutKey = "1" VPShortcutMask = "command" import AppKit def main(windowController, *args, **kwargs): textView = windowController.textView() range = textView.selectedRange() font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24) textView.textStorage().addAttribute_value_range_(AppKit.NSFontAttributeName, font, range)
And of course, now we know what we’re doing, there’s plenty more. Just piggybacking on top of Cocoa means that the learning curve for writing plugins is pretty steep, but once you get the hang of it, there’s a lot of stuff you can do. Of course, whether or not you’re actually saving time with these plugins is another thing entirely…
So far - there may be one hiding out there that I haven’t stumbled across yet… ↩
This program prints out the name of every page in the document, apparently. ↩