Subscriber is an event manager for scripters, making the bridge of subscribing and unsubsribing all sort of events super easy.

Written and contributed by Tal Leming.

Overview

When scripting in RoboFont, it is helpful to get notified about events. A user clicks on the mouse, a font opens, the sidebearings in a glyph change value, and so on. If the tool you are working on requires reacting to the user’s actions, you need to tie some code to user events.

That’s a different approach compared to a procedural script, where a set of actions is lined up sequentially. For example:

  • open a font
  • remove outlines
  • generate a binary font
  • close the font

In this case, there is not need to tie any code to user’s events.

Think of RoboFont as a mailman, handling events and keeping updated whoever requested notifications about these events.

Dear Sir/Madam,

I just opened “someFont.ufo”. This is the path, here you can access the content. Please let me know if I need to do something with it. I’ll keep you posted with updates.

All the best,
RoboFont Notification Manager

Notifications are great for two reasons:

  • they allow a tool to listen to specific events, not every event
  • they allow a tool to trigger custom code when a notification is sent, through a callback function

Wait, what? A callback function? Is that a new type of function? No, just a regular function. Usually a method part of a class. If you have ever used vanilla to build some user interface elements, you have already used callbacks. Look at this snippet from the vanilla Button object reference page

from vanilla import Window, Button

class ButtonDemo:

    def __init__(self):
        self.w = Window((100, 40))
        self.w.button = Button((10, 10, -10, 20), "A Button",
                               callback=self.buttonCallback)
        self.w.open()

    def buttonCallback(self, sender):
        print("button hit!")


ButtonDemo()

You can run this code in the RoboFont scripting window. Check the print statements in the output window. Take yourself a moment to edit the buttonCallback function and check the results.

Let’s look at another example. Each time we change the spacing of the letter ‘a’, all accented letters using ‘a’ as a base glyph could/should be updated automatically. In the old way of subscribing to notifications (before RoboFont 4.0) this could be obtained using defcon. The new subscriber modules makes it so easy:

from mojo.subscriber import Subscriber, WindowController
from vanilla import FloatingWindow, TextBox

BASE_2_ACCENTED = {
    'a': ('agrave', 'aacute', 'acircumflex', 'atilde'),
}
class MarginsListener(Subscriber, WindowController):
    debug = True

    def build(self):
        self.w = FloatingWindow((160, 37), "")
        self.w.txt = TextBox((10, 10, -10, 17), "I am listening", alignment='center')

    def started(self):
        self.w.open()

    def currentGlyphMetricsDidChange(self, info):
        if CurrentGlyph().name in BASE_2_ACCENTED:
            for eachGlyphName in BASE_2_ACCENTED[CurrentGlyph().name]:
                CurrentFont()[eachGlyphName].leftMargin = CurrentGlyph().leftMargin
                CurrentFont()[eachGlyphName].rightMargin = CurrentGlyph().rightMargin


if __name__ == '__main__':
    MarginsListener(currentGlyph=True)

Over the years RoboFont made it easy to subscribe to lots and lots of events, but users were not incentivised to unsubscribe once they stopped listening. For example, if you don’t need to listen anymore to user’s clicks in the glyph view, you are supposed to inform RoboFont. You should explicitly unsubscribe from the notification. For different reasons, the unsubscription often does not happen and RoboFont keeps dispatching useless notifications around slowing down the application.

So, it was time to reconsider the way notifications are handled. For this reason, Tal and Frederik worked hard to ship Subscriber with RoboFont 4.0, a layer on top of the general notification system, to make life easier for all the scripters out there!

Three different approaches

Subscriber considers three general approaches for subscribing to events. More setups are available to advanced developer, but the following ones cover all the use cases in the RoboFont extension ecosystems. There a couple of aspects to consider in order to choose the right approach:

  • you should aim to trigger as few events as possible to solve the task at hand. Some callbacks react very frequently to user events and they could significantly slower down the application. The Subscriber reference has a very detailed list of events you could subscribe to. If you need to watch the current glyph metrics, you could use the currentGlyphDidChange callback but that would be a waste. The callback will react also when other glyph attributes change. The currentGlyphMetricsDidChange is more specific and less wasteful. Consider that the tool you are working on will probably run alongside many others. Almost every extension listens to events, so you should try to achieve your task with just the right amount of notifications. It will make the user interaction more responsive.
  • you should think about the geometry of the notification subscriptions. Does the tool look to several glyphs across several fonts? Does it listen to multiple glyph editors at once? Or, does it just look at the current glyph? These considerations are very important when choosing how to register your subscriber object.

Let’s look at the possible geometries with some examples.

One object to one subscriber

You want to listen to changes to the current glyph and draw directly in the glyph editor. Tools with functionalities similar to SpeedPunk or WurstSchreiber. The following example draws two colored neighbours near the current glyph. Note that the tool is registered using the registerGlyphEditorSubscriber() function, and not regularly initiated with the default Neighbours() constructor.

from mojo.subscriber import Subscriber, registerGlyphEditorSubscriber

YELLOW = (1, 1, 0, 0.4)
RED = (1, 0, 0, 0.4)

class Neighbours(Subscriber):

    debug = True

    def build(self):
        glyphEditor = self.getGlyphEditor()
        self.container = glyphEditor.extensionContainer(
            identifier="com.roboFont.NeighboursDemo.foreground",
            location="foreground",
            clear=True)
        self.leftPathLayer = self.container.appendPathSublayer(
            fillColor=YELLOW,
            name="leftNeighbour")
        self.rightPathLayer = self.container.appendPathSublayer(
            fillColor=RED,
            name="rightNeighbour")

    def destroy(self):
        self.container.clearSublayers()

    def glyphEditorDidSetGlyph(self, info):
        glyph = info["glyph"]
        if glyph is None:
            return
        self._updateNeighbours(glyph)

    def glyphEditorGlyphDidChange(self, info):
        glyph = info["glyph"]
        if glyph is None:
            return
        self._updateNeighbours(glyph)

    def _updateNeighbours(self, glyph):
        glyphPath = glyph.getRepresentation("merz.CGPath")
        self.leftPathLayer.setPath(glyphPath)
        self.leftPathLayer.setPosition((glyph.width, 0))
        self.rightPathLayer.setPath(glyphPath)
        self.rightPathLayer.setPosition((-glyph.width, 0))


if __name__ == '__main__':
    registerGlyphEditorSubscriber(Neighbours)

Multiple objects to one subscriber

This approach is useful when a single subscriber object receives notifications from multiple objects. Mostly used when a script has a single window listening to events. For example, let’s say that you want to keep a constant reference of the current glyph in a separate vanilla window. The window does not belong to any open font, so the source glyph could come from any opened ufos. Tools with functionalities similar to Outliner would fit into this geometry.

import vanilla
import merz
from mojo.subscriber import Subscriber

class GlyphVisualizer(Subscriber):

    debug = True

    def build(self):
        self.glyph = CurrentGlyph()

        self.w = vanilla.FloatingWindow((400, 800), "Glyph Visualizer", minSize=(200, 300))
        self.merzView = merz.MerzView("auto")

        self.w.stack = vanilla.VerticalStackView(
            (0, 0, 0, 0),
            views=[dict(view=self.merzView)],
            edgeInsets=(15, 15, 15, 15)
        )
        container = self.merzView.getMerzContainer()

        # a layer for the glyph and the baseline
        self.backgroundLayer = container.appendBaseSublayer(
            size=(self.glyph.width, self.glyph.font.info.unitsPerEm),
            backgroundColor=(1, 1, 1, 1)
        )

        self.glyphLayer = self.backgroundLayer.appendPathSublayer(
            position=(0, -self.glyph.font.info.descender)
        )
        glyphPath = self.glyph.getRepresentation("merz.CGPath")
        self.glyphLayer.setPath(glyphPath)

        self.lineLayer = self.backgroundLayer.appendLineSublayer(
            startPoint=(0, -self.glyph.font.info.descender),
            endPoint=(self.glyph.width, -self.glyph.font.info.descender),
            strokeWidth=1,
            strokeColor=(1, 0, 0, 1)
        )

    def started(self):
        self.w.open()

    def glyphEditorDidSetGlyph(self, info):
        self.glyph = info['glyph']
        glyphPath = self.glyph.getRepresentation("merz.CGPath")
        self.glyphLayer.setPath(glyphPath)
        self.backgroundLayer.setSize((self.glyph.width, self.glyph.font.info.unitsPerEm))
        self.lineLayer.setEndPoint((self.glyph.width, -self.glyph.font.info.descender))


if __name__ == '__main__':
    GlyphVisualizer()

Observe many glyphs (or font attributes)

This use case might be the most complex one. Extension like MetricsMachine, Prepolator or GroundControl would definitely use this kind of scheme. When a tool needs to listen to a set of glyphs (never listen to all glyphs in a font otherwise you’ll wreck RoboFont performance) coming from different sources plus a bunch of font attributes as font.kerning, you should override the setAdjunctObjectsToObserve method and register the tool using registerCurrentFontSubscriber(). The following example collects the first three non-empty glyph in the current font and display them with a merz gaussian blur filter.

import vanilla
import merz
from mojo.subscriber import Subscriber, WindowController, registerCurrentFontSubscriber


class BlurringLens(Subscriber, WindowController):

    debug = True

    def build(self):
        self.w = vanilla.FloatingWindow((500, 200), "Blurred Glass", minSize=(500, 200))
        self.glyphsView = merz.MerzView("auto", backgroundColor=(1, 1, 1, 1))

        self.w.stack = vanilla.VerticalStackView(
            (0, 0, 0, 0),
            views=[dict(view=self.glyphsView)],
            edgeInsets=(10, 10, 10, 10)
        )

    def started(self):
        self.w.open()

    def currentFontDidSetFont(self, info):
        font = info['font']

        # chooses the first three non-empty glyphs
        # according to glyph order
        glyphs = []
        for name in sorted(font.glyphOrder):
            glyph = font[name]
            if not len(glyph):
                continue
            glyphs.append(glyph)
            if len(glyphs) == 3:
                break

        self.font = font
        self.glyphs = glyphs
        self.setAdjunctObjectsToObserve(glyphs)
        self.adjunctGlyphDidChangeContours(None)

    def adjunctGlyphDidChangeContours(self, info):
        print("adjunct!")
        font = self.font
        glyphs = self.glyphs
        view = self.glyphsView
        container = view.getMerzContainer()
        container.clearSublayers()
        if font is None:
            print("NO FONT!")
            return

        pointSize = 150
        scale = pointSize / font.info.unitsPerEm
        offset = 25 * (1.0 / scale)
        container.setSublayerScale(scale)

        contents = [
            (glyphs[0], "left", offset),
            (glyphs[1], "center", 0),
            (glyphs[2], "right", -offset)
        ]

        for glyph, xPosition, offset in contents:
            xMin, yMin, xMax, yMax = glyph.bounds
            width = xMax - xMin
            height = yMax - yMin
            glyphContainer = container.appendBaseSublayer(
                position=(
                    dict(
                        point=xPosition,
                        offset=offset
                    ),
                    "center"
                ),
                size=(width, height)
            )
            glyphLayer = glyphContainer.appendPathSublayer()
            glyphLayer.appendFilter(
                dict(
                    name="glyphLayerFilter",
                    filterType="gaussianBlur",
                    radius=5)
            )
            glyphPath = glyph.getRepresentation("merz.CGPath")
            glyphLayer.setPath(glyphPath)


if __name__ == '__main__':
    registerCurrentFontSubscriber(BlurringLens)

Event coalescing

Subscriber uses a technique called coalescing events to avoid posting events redundantly. For example, a callback like currentGlyphDidChange posts several events during a single mouse drag while editing a contour. Try to edit a glyph while running this script. You can check the log in the output window.

from mojo.subscriber import Subscriber, WindowController
from datetime import datetime
from vanilla import FloatingWindow, TextBox

class MouseFollower(Subscriber, WindowController):

    debug = True

    def build(self):
        self.w = FloatingWindow((220, 37), "")
        self.w.txt = TextBox((10, 10, -10, 17), "Check the output window!", alignment='center')

    def started(self):
        self.w.open()

    def currentGlyphDidChange(self, info):
        glyph = info['glyph']
        now = datetime.now()
        print(f'{now:%Y-%m-%d %H:%M:%S %f} – {glyph} did change!')


if __name__ == '__main__':
    MouseFollower(currentGlyph=True)

That’s a lot of events, right? That’s great if you need to update a real-time visualization using glyph data, as in the “One to one” approach example. It could be an issue if you instead tie a heavier operation to the callback, like autosaving the font you are working on.

from mojo.subscriber import Subscriber, WindowController
from datetime import datetime
from vanilla import FloatingWindow, TextBox


class AutoSaver(Subscriber, WindowController):

    debug = True

    def build(self):
        self.w = FloatingWindow((160, 37), "")
        self.w.txt = TextBox((10, 10, -10, 17),
                             "Autosaving... 💽",
                             alignment='center')

    def started(self):
        self.w.open()

    currentGlyphDidChangeDelay = 30

    def currentGlyphDidChange(self, info):
        glyph = info['glyph']
        glyph.font.save()
        now = datetime.now()
        print(f'{now:%Y-%m-%d %H:%M:%S} – AUTOSAVED!')


if __name__ == '__main__':
    AutoSaver(currentGlyph=True)

For this reason, Subscriber provides the opportunity to set a coalescing event delay for each available callback. You just need to create an attribute in your Subscriber subclass with the same method name plus Delay and set a Float value representing seconds. If you want an event to be posted as soon as possible, set the value to 0. A few examples

from mojo.subscriber import Subscriber, WindowController
from vanilla import FloatingWindow, TextBox


class YourTool(Subscriber, WindowController):

    debug = True

    def build(self):
        self.w = FloatingWindow((160, 37), "")
        self.w.txt = TextBox((10, 10, -10, 17), "Observing stuff", alignment='center')

    def started(self):
        self.w.open()

    ## -- CALLBACK DELAYS EXAMPLES -- ##

    currentGlyphDidChangeDelay = 2   # seconds
    def currentGlyphDidChange(self, info):
        print('currentGlyphDidChange', info)

    fontLayersDidRemoveLayerDelay = 2   # seconds
    def fontLayersDidRemoveLayer(self, info):
        print('fontLayersDidRemoveLayer', info)

    spaceCenterDidChangeSelectionDelay = 2   # seconds
    def spaceCenterDidChangeSelection(self, info):
        print('spaceCenterDidChangeSelection', info)


if __name__ == '__main__':
    YourTool(currentGlyph=True)

Any event with Will or Wants in its name should always have a delay of 0 to prevent unexpected asynchronous behavior.

Robofont sets two default coalescing events delay values:

  • 1/30s for defcon objects callback, like currentGlyphDidChange
    • defcon.Font
    • defcon.Info
    • defcon.Kerning
    • defcon.Groups
    • defcon.Features
    • defcon.LayerSet
    • defcon.Layer
    • defcon.Glyph
  • No delay for Robofont components callbacks, like glyphEditorDidSetGlyph
    • RoboFont
    • Font Document
    • Font Overview
    • Glyph Editor
    • Space Center

Check the subscriber reference for a detailed list of callbacks

Check these extensive examples of combined usage of Subscriber and Merz

Last edited on 15/07/2021