Automating VoodooPad with scripts

Published
2016-01-17
Tagged

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.

VoodooPad has pretty good base plugin support: plugins can be Javascript or Python, although I’ve also seen Lua and Objective-C (for the really hardcore) scripts floating around. Additionally, plugins basically use a bridge to VoodooPad’s Cocoa framework, so if you’re halfway decent at navigating around Cocoa, you should be able to get VoodooPad to do what you want. Of course, this also means that editing text requires a knowledge of how 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
# -*- coding: utf-8 -*-
2
VPScriptMenuTitle = "List Page Names"
3
import AppKit
4
5
def main(windowController, *args, **kwargs):
6
    document = windowController.document()
7
    textView = windowController.textView()
8
    for key in document.keys():
9
        textView.insertText_(key)
10
        textView.insertText_("\n")

We immediately see some interesting bits and bobs:

  • We can set the global variable VPScriptMenuTitle to 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.
  • The main method takes a windowController (although goodness knows what’s in those *args and **kwargs, if anything).
  • We can access the textView via the windowController: this is where the text of a given page goes.
  • We can add text to the textView using 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 document:

1
# -*- coding: utf-8 -*-
2
VPScriptMenuTitle = "Output methods"
3
import AppKit
4
5
def main(windowController, *args, **kwargs):
6
    textView = windowController.textView()
7
    methods = dir(windowController.document())
8
9
    textView.insertText_("\nMethods on document():\n")
10
    for m in methods:
11
        textView.insertText_(m)
12
        textView.insertText_("\n")

If you save this in the approproate directory (the quickest way to get there in VoodooPad is to select HelpOpen 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
# -*- coding: utf-8 -*-
2
VPScriptMenuTitle = "Heading 1"
3
import AppKit
4
5
def main(windowController, *args, **kwargs):
6
    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:

1
textView.textStorage().addAttribute_value_range_()

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:

1
NSFont f = [NSFont fontWithName: @"Helvetica-Bold" size: 24]
2
[string addAttribute: NSFontAttributeName value: f range: textView.selectedRange];

The 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 NSFont object “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:

1
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.

1
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 NSFont through AppKit, and we also (handily) learn that Objective-C selectors need to take positional arguments. Thus, the following runs without any errors:

1
font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24)

Armed with this knowledge, we can add a little more to the “Heading” plugin:

1
# -*- coding: utf-8 -*-
2
VPScriptMenuTitle = "Heading 1"
3
import AppKit
4
5
def main(windowController, *args, **kwargs):
6
    textView = windowController.textView()
7
8
    range = textView.selectedRange()
9
    font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24)
10
11
    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 NSFont:

1
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
# -*- coding: utf-8 -*-
2
VPScriptMenuTitle = "Heading 1"
3
VPShortcutKey = "1"
4
VPShortcutMask = "command"
5
6
import AppKit
7
8
def main(windowController, *args, **kwargs):
9
    textView = windowController.textView()
10
11
    range = textView.selectedRange()
12
    font = AppKit.NSFont.fontWithName_size_("Helvetica-Bold", 24)
13
14
    textView.textStorage().addAttribute_value_range_(AppKit.NSFontAttributeName, font, range)

Further work

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…


  1. Although if someone knows of a great new app in this space that does something like Circus Ponies Notebook did - thanks, Google - I’d love to know about it. 

  2. So far - there may be one hiding out there that I haven’t stumbled across yet… 

  3. This program prints out the name of every page in the document, apparently.