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 or ezui 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:

import ezui
from mojo.subscriber import Subscriber, WindowController

BASE_2_ACCENTED = {
    'a': ('agrave', 'aacute', 'acircumflex', 'atilde'),
}

class MarginsListener(Subscriber, WindowController):
    debug = True

    def build(self):
        content = """
        I am listening!
        """
        self.w = ezui.EZWindow(
            title="Margins Listener",
            content=content,
            controller=self
        )

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

    def currentGlyphMetricsDidChange(self, info):
        glyph = info['glyph']
        if glyph.name in BASE_2_ACCENTED:
            for eachGlyphName in BASE_2_ACCENTED[glyph.name]:
                glyph.font[eachGlyphName].leftMargin = glyph.leftMargin
                glyph.font[eachGlyphName].rightMargin = glyph.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? What’s the relation of the subscriber instance with the user interface? 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. For example a tool like ShowDist. 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 subscribers to one window controller

You have a single WindowController receiving notifications from multiple subscribers. Each subscriber can have a different scope: glyph editor, space center, current font, the entire application. Tools with functionalities similar to Outliner or WurstSchreiber would fit into this pattern.

You can register custom subscriber events to provide a communication bridge between subscribers and the window controller. A reference to the controller in the subscriber class definition is also required before registering the subscriber, the .started() and .destroy() methods of the window controller are the perfect place for such operations. The following example draws a red border around outlines in the glyph editor. A subscriber instance is initiated every time a glyph editor is opened, the window controller is able to handle multiple glyph editors at once thanks to the custom subscriber event.

As this is possibly the most flexible and useful pattern, we provide an extension template as a starting point for your next tool!

#!/usr/bin/env python3

# -------------------- #
# Palette + Subscriber #
# -------------------- #

# -- Modules -- #
import ezui
from mojo.subscriber import Subscriber, WindowController, getRegisteredSubscriberEvents
from mojo.subscriber import registerGlyphEditorSubscriber, unregisterGlyphEditorSubscriber
from mojo.roboFont import OpenWindow
from mojo.events import postEvent
from mojo.subscriber import registerSubscriberEvent


DEFAULT_KEY = 'com.developerName.SomeTool'


class ToolPalette(WindowController):

    debug = True
    thickness = 5

    def build(self):
        content = """
        ----X----  123    @slider
        """

        descriptionData = dict(
            slider=dict(
                minValue=0,
                value=self.thickness,
                maxValue=25,
                stopOnTickMarks=True,
                tickMarks=6,
                continuous=False
            )
        )

        self.w = ezui.EZWindow(
            title="Tool",
            size=(200, "auto"),
            content=content,
            descriptionData=descriptionData,
            controller=self
        )

    def started(self):
        self.w.open()
        Tool.controller = self
        registerGlyphEditorSubscriber(Tool)

    def destroy(self):
        unregisterGlyphEditorSubscriber(Tool)
        Tool.controller = None

    def sliderCallback(self, sender):
        self.thickness = sender.get()
        postEvent(f"{DEFAULT_KEY}.changed")


class Tool(Subscriber):
    """
    Tool can only ask for information to the palette

    """

    debug = True
    controller = None

    def build(self):
        glyphEditor = self.getGlyphEditor()
        container = glyphEditor.extensionContainer(identifier=DEFAULT_KEY,
                                                   location='background',
                                                   clear=True)
        self.path = container.appendPathSublayer(
            fillColor=(0, 0, 0, 0),
            strokeColor=(1, 0, 0, 1),
            strokeWidth=self.controller.thickness if self.controller else 0,
        )
        glyph = self.getGlyphEditor().getGlyph()
        self.path.setPath(glyph.getRepresentation("merz.CGPath"))

    def destroy(self):
        glyphEditor = self.getGlyphEditor()
        container = glyphEditor.extensionContainer(DEFAULT_KEY, location='background')
        container.clearSublayers()

    def glyphEditorGlyphDidChangeOutline(self, info):
        self.path.setPath(info['glyph'].getRepresentation("merz.CGPath"))

    def paletteDidChange(self, info):
        self.path.setStrokeWidth(self.controller.thickness)


# -- Instructions -- #
if __name__ == '__main__':
    eventName = f"{DEFAULT_KEY}.changed"

    # we register the subscriber event only if necessary
    if eventName not in getRegisteredSubscriberEvents():
        registerSubscriberEvent(
            subscriberEventName=eventName,
            methodName="paletteDidChange",
            lowLevelEventNames=[eventName],
            dispatcher="roboFont",
            documentation="Send when the tool palette did change parameters.",
            delay=0,
            debug=True
        )
    OpenWindow(ToolPalette)

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 ezui
from mojo.subscriber import Subscriber, registerCurrentFontSubscriber


class BlurringLens(Subscriber, ezui.WindowController):

    debug = True

    def build(self):
        content = """
        * MerzView    @merzView
        """
        descriptionData = dict(
            merzView=dict(
                backgroundColor=(1, 1, 1, 1),
                delegate=self
            ),
        )

        self.w = ezui.EZWindow(
            title="Blurred Glass",
            content=content,
            size=(500, 200),
            minSize=(500, 200),
            descriptionData=descriptionData,
            controller=self
        )

    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 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
        container = self.w.getItem("merzView").getMerzContainer()
        container.clearSublayers()
        if font is None:
            print("NO FONT!")
            return

        pointSize = 150
        scale = pointSize / font.info.unitsPerEm
        offset = 25 * (1.0 / scale)
        container.addSublayerScaleTransformation(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.

import ezui
from mojo.subscriber import Subscriber
from datetime import datetime

class MouseFollower(Subscriber, ezui.WindowController):

    debug = True

    def build(self):
        content = """
        Check the output window!
        """
        self.w = ezui.EZWindow(
            title="Mouse Follower",
            content=content,
            controller=self
        )

    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.

import ezui
from mojo.subscriber import Subscriber
from datetime import datetime


class AutoSaver(Subscriber, ezui.WindowController):

    debug = True

    def build(self):
        content = """
        Autosaving... 💽
        """
        self.w = ezui.EZWindow(
            title="AutoSaver",
            content=content,
            controller=self
        )

    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

import ezui
from mojo.subscriber import Subscriber


class YourTool(Subscriber, ezui.WindowController):

    debug = True

    def build(self):
        content = """
        Observing stuff
        """
        self.w = ezui.EZWindow(
            title="AutoSaver",
            content=content,
            controller=self
        )

    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
Last edited on 01/09/2021