GraphicsBindings

A PyObjC Example without documentation

Sources

Circle.py

#
#  Circle.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html

from math import cos, sin

import objc
from Cocoa import (
    NSArchiver,
    NSUnarchiver,
    NSBezierPath,
    NSColor,
    NSMakeRect,
    NSMakeSize,
    NSObject,
    NSShadow,
    NSUnionRect,
)
from objc import super  # noqa: A004


class Circle(NSObject):
    """
    Graphic protocol to define methods all graphics objects must implement

    Circle class, adopts Graphic protocol
    Adds radius and color, and support for drawing a shadow
    """

    xLoc = objc.ivar("xLoc", objc._C_DBL)
    yLoc = objc.ivar("yLoc", objc._C_DBL)

    radius = objc.ivar("radius", objc._C_DBL)
    color = objc.ivar("color")
    shadowOffset = objc.ivar("shadowOffset", objc._C_DBL)
    shadowAngle = objc.ivar("shadowAngle", objc._C_DBL)  # in radians

    @classmethod
    def keysForNonBoundsProperties(cls):
        return ["xLoc", "yLoc", "shadowOffset", "shadowAngle", "color", "radius"]

    def init(self):
        self = super().init()
        if self is None:
            return None

        self.color = NSColor.redColor()
        self.xLoc = 15.0
        self.yLoc = 15.0
        self.radius = 15.0
        return self

    def description(self):
        return "circle"

    def drawingBounds(self):
        drawingBounds = NSMakeRect(
            self.xLoc - self.radius - 1,
            self.yLoc - self.radius - 1,
            self.radius * 2 + 2,
            self.radius * 2 + 2,
        )
        if self.shadowOffset > 0.0:
            shadowXOffset = sin(self.shadowAngle) * self.shadowOffset
            shadowYOffset = cos(self.shadowAngle) * self.shadowOffset
            # allow for blur
            shadowBounds = NSMakeRect(
                self.xLoc - self.radius + shadowXOffset - (self.shadowOffset / 2),
                self.yLoc - self.radius + shadowYOffset - (self.shadowOffset / 2),
                (self.radius * 2) + self.shadowOffset,
                (self.radius * 2) + self.shadowOffset,
            )
            drawingBounds = NSUnionRect(shadowBounds, drawingBounds)
        return drawingBounds

    def drawInView_(self, aView):
        # ignore aView here for simplicity...
        (xLoc, yLoc, radius, shadowOffset, shadowAngle) = (
            self.xLoc,
            self.yLoc,
            self.radius,
            self.shadowOffset,
            self.shadowAngle,
        )

        circleBounds = NSMakeRect(xLoc - radius, yLoc - radius, radius * 2, radius * 2)

        # draw shadow if we'll see it
        shadow = NSShadow.alloc().init()
        if shadowOffset > 0.00001:
            shadowXOffset = sin(shadowAngle) * shadowOffset
            shadowYOffset = cos(shadowAngle) * shadowOffset
            shadow.setShadowOffset_(NSMakeSize(shadowXOffset, shadowYOffset))
            shadow.setShadowBlurRadius_(shadowOffset)
            shadow.set()

        # draw circle
        circle = NSBezierPath.bezierPathWithOvalInRect_(circleBounds)
        myColor = self.color
        if myColor is None:
            myColor = NSColor.redColor()
        myColor.set()
        circle.fill()

        shadow.setShadowColor_(None)
        shadow.set()

    def hitTest_isSelected_(self, point, isSelected):
        # ignore isSelected here for simplicity...
        # don't count shadow for selection
        hypotenuse2 = pow((self.xLoc - point.x), 2.0) + pow((self.yLoc - point.y), 2.0)
        return hypotenuse2 < (self.radius * self.radius)

    def initWithCoder_(self, coder):
        if not coder.allowsKeyedCoding():
            print("Circle only works with NSKeyedArchiver")
        self.xLoc = coder.decodeFloatForKey_("xLoc")
        self.yLoc = coder.decodeFloatForKey_("yLoc")
        self.radius = coder.decodeFloatForKey_("radius")
        self.shadowOffset = coder.decodeFloatForKey_("shadowOffset")
        self.shadowAngle = coder.decodeFloatForKey_("shadowAngle")

        colorData = coder.decodeObjectForKey_("color")
        self.color = NSUnarchiver.unarchiveObjectWithData_(colorData)
        return self

    def encodeWithCoder_(self, coder):
        if not coder.allowsKeyedCoding():
            print("Circle only works with NSKeyedArchiver")
        coder.encodeFloat_forKey_(self.xLoc, "xLoc")
        coder.encodeFloat_forKey_(self.yLoc, "yLoc")
        coder.encodeFloat_forKey_(self.radius, "radius")
        coder.encodeFloat_forKey_(self.shadowOffset, "shadowOffset")
        coder.encodeFloat_forKey_(self.shadowAngle, "shadowAngle")

        colorData = NSArchiver.archivedDataWithRootObject_(self.color)
        coder.encodeObject_forKey_(colorData, "color")


# if any of these properties changes, the bounds have changed
boundsChangingKeys = ["xLoc", "yLoc", "shadowOffset", "shadowAngle", "radius"]
Circle.setKeys_triggerChangeNotificationsForDependentKey_(
    boundsChangingKeys, "drawingBounds"
)

GraphicsArrayController.py

#
#  GraphicsArrayController.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html

from math import fabs
from random import random

import objc
from Cocoa import NSArrayController, NSCalibratedRGBColorSpace, NSColor
from objc import super  # noqa: A004


class GraphicsArrayController(NSArrayController):
    """Allow filtering by color, just for the fun of it"""

    filterColor = objc.IBOutlet()
    newCircle = objc.IBOutlet()
    shouldFilter = objc.ivar.BOOL()
    graphicsView = objc.IBOutlet()

    def arrangeObjects_(self, objects):
        """Filtering is not yet connected in IB!"""
        if self.shouldFilter:
            self.shouldFilter = False

        if not self.shouldFilter:
            return super().arrangeObjects_(objects)

        if self.filterColor is None:
            self.filterColor = NSColor.blackColor().colorUsingColorSpaceName_(
                NSCalibratedRGBColorSpace
            )

        filterHue = self.filterColor.hueComponent()
        filteredObjects = []
        for item in objects:
            hue = item.color.hueComponent()
            if (
                (fabs(hue - filterHue) < 0.05)
                or (fabs(hue - filterHue) > 0.95)
                or (item is self.newCircle)
            ):
                filteredObjects.append(item)
                self.newCircle = None
        return super().arrangeObjects_(filteredObjects)

    def newObject(self):
        """Randomize attributes of new circles so we get a pretty display"""
        self.newCircle = super().newObject()
        radius = 5.0 + 15.0 * random()
        self.newCircle.radius = radius

        height = self.graphicsView.bounds().size.height
        width = self.graphicsView.bounds().size.width

        xOffset = 10.0 + (height - 20.0) * random()
        yOffset = 10.0 + (width - 20.0) * random()

        self.newCircle.xLoc = xOffset
        self.newCircle.yLoc = height - yOffset

        color = NSColor.colorWithCalibratedHue_saturation_brightness_alpha_(
            random(), (0.5 + random() / 2.0), (0.333 + random() / 3.0), 1.0
        )

        self.newCircle.color = color
        return self.newCircle

GraphicsBindings.py

#
#  __main__.py
#  GraphicsBindings
#
#  Created by Fred Flintstone on 11.02.05.
#  Copyright (c) 2005 __MyCompanyName__. All rights reserved.
#

import Circle  # noqa: F401
import GraphicsArrayController  # noqa: F401
import GraphicsBindingsDocument  # noqa: F401
import GraphicsView  # noqa: F401
import JoystickView  # noqa: F401

# start the event loop
import objc
from PyObjCTools import AppHelper

objc.setVerbose(1)
AppHelper.runEventLoop(argv=[])

GraphicsBindingsDocument.py

#
#  GraphicsBindingsDocument.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html

import objc
from Cocoa import NSDocument, NSKeyedArchiver, NSKeyedUnarchiver, NSValueTransformer
from objc import super  # noqa: A004
from RadiansToDegreesTransformer import RadiansToDegreesTransformer


class GraphicsBindingsDocument(NSDocument):
    graphicsView = objc.IBOutlet()
    shadowInspector = objc.IBOutlet()
    graphicsController = objc.IBOutlet()
    graphics = objc.ivar()

    def init(self):
        self = super().init()
        if self is None:
            return None
        self.graphics = []  # NSMutableArray.array()
        self.bindings = []
        return self

    def windowNibName(self):
        return "GraphicsBindingsDocument"

    def makeBinding_fromObject_toObject_withKeyPath_options_(
        self, key, fromObject, toObject, withKeyPath, options
    ):
        self.bindings.append((fromObject, key))
        fromObject.bind_toObject_withKeyPath_options_(
            key, toObject, withKeyPath, options
        )

    def windowControllerDidLoadNib_(self, controller):
        super().windowControllerDidLoadNib_(controller)

        # we can't do these in IB at the moment, as
        # we don't have palette items for them

        # allow the shadow inspector (joystick) to handle multiple selections
        offsetOptions = {"NSAllowsEditingMultipleValuesSelection": True}
        angleOptions = {
            "NSValueTransformerName": "RadiansToDegreesTransformer",
            "NSAllowsEditingMultipleValuesSelection": True,
        }

        BINDINGS = [
            (
                "graphics",
                self.graphicsView,
                self.graphicsController,
                "arrangedObjects",
                None,
            ),
            (
                "selectionIndexes",
                self.graphicsView,
                self.graphicsController,
                "selectionIndexes",
                None,
            ),
            (
                "offset",
                self.shadowInspector,
                self.graphicsController,
                "selection.shadowOffset",
                offsetOptions,
            ),
            (
                "angle",
                self.shadowInspector,
                self.graphicsController,
                "selection.shadowAngle",
                angleOptions,
            ),
        ]
        for binding in BINDINGS:
            self.makeBinding_fromObject_toObject_withKeyPath_options_(*binding)

        # "fake" what should be set in IB if we had a palette...
        self.shadowInspector.maxOffset = 15

    def close(self):
        while self.bindings:
            obj, binding = self.bindings.pop()
            obj.unbind_(binding)
        super().close()

    def dataRepresentationOfType_(self, aType):
        return NSKeyedArchiver.archivedDataWithRootObject_(self.graphics)

    def loadDataRepresentation_ofType_(self, data, aType):
        self.graphics = NSKeyedUnarchiver.unarchiveObjectWithData_(data)
        return True


vt = RadiansToDegreesTransformer.alloc().init()
NSValueTransformer.setValueTransformer_forName_(vt, "RadiansToDegreesTransformer")

GraphicsView.py

#
#  GraphicsView.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html


import objc
from Circle import Circle
from Cocoa import (
    NSBezierPath,
    NSColor,
    NSDrawLightBezel,
    NSIndexSet,
    NSInsetRect,
    NSIntersectsRect,
    NSKeyValueChangeNewKey,
    NSKeyValueChangeOldKey,
    NSKeyValueObservingOptionNew,
    NSKeyValueObservingOptionOld,
    NSMakeRect,
    NSNotFound,
    NSShiftKeyMask,
    NSUnionRect,
    NSView,
)
from objc import super  # noqa: A004


PropertyObservationContext = 1091
GraphicsObservationContext = 1092
SelectionIndexesObservationContext = 1093


class GraphicsView(NSView):
    graphicsContainer = objc.ivar("graphicsContainer")
    graphicsKeyPath = objc.ivar("graphicsKeyPath")

    selectionIndexesContainer = objc.ivar(
        "selectionIndexesContainer"
    )  # GraphicsArrayController
    selectionIndexesKeyPath = objc.ivar("selectionIndexesKeyPath")

    oldGraphics = objc.ivar("oldGraphics")

    def exposedBindings(self):
        return ["graphics", "selectedObjects"]

    def initWithFrame_(self, frameRect):
        return super().initWithFrame_(frameRect)

    def graphics(self):
        if not self.graphicsContainer:
            return None
        return self.graphicsContainer.valueForKeyPath_(self.graphicsKeyPath)

    def selectionIndexes(self):
        if not self.selectionIndexesContainer:
            return None
        return self.selectionIndexesContainer.valueForKeyPath_(
            self.selectionIndexesKeyPath
        )

    def startObservingGraphics_(self, graphics):
        if not graphics:
            return
        # Register to observe each of the new graphics, and
        # each of their observable properties -- we need old and new
        # values for drawingBounds to figure out what our dirty rect
        for newGraphic in graphics:
            # Register as observer for all the drawing-related properties
            newGraphic.addObserver_forKeyPath_options_context_(
                self,
                "drawingBounds",
                (NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld),
                PropertyObservationContext,
            )
            keys = Circle.keysForNonBoundsProperties()
            for key in keys:
                newGraphic.addObserver_forKeyPath_options_context_(
                    self, key, 0, PropertyObservationContext
                )

    def stopObservingGraphics_(self, graphics):
        if graphics is None:
            return
        for graphic in graphics:
            for key in graphic.class__().keysForNonBoundsProperties():
                graphic.removeObserver_forKeyPath_(self, key)
            graphic.removeObserver_forKeyPath_(self, "drawingBounds")

    def bind_toObject_withKeyPath_options_(
        self, bindingName, observableObject, observableKeyPath, options
    ):
        if bindingName == "graphics":
            self.graphicsContainer = observableObject
            self.graphicsKeyPath = observableKeyPath
            self.graphicsContainer.addObserver_forKeyPath_options_context_(
                self,
                self.graphicsKeyPath,
                (NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld),
                GraphicsObservationContext,
            )
            self.startObservingGraphics_(self.graphics())

        elif bindingName == "selectionIndexes":
            self.selectionIndexesContainer = observableObject
            self.selectionIndexesKeyPath = observableKeyPath
            self.selectionIndexesContainer.addObserver_forKeyPath_options_context_(
                self,
                self.selectionIndexesKeyPath,
                0,
                SelectionIndexesObservationContext,
            )
        self.setNeedsDisplay_(True)

    def unbind_(self, bindingName):
        if bindingName == "graphics":
            self.graphicsContainer.removeObserver_forKeyPath_(
                self, self.graphicsKeyPath
            )
            self.graphicsContainer = None
            self.graphicsKeyPath = None
        if bindingName == "selectionIndexes":
            self.selectionIndexesContainer.removeObserver_forKeyPath_(
                self, self.selectionIndexesKeyPath
            )
            self.seletionIndexesContainer = None
            self.selectionIndexesKeyPath = None
        self.setNeedsDisplay_(True)

    def observeValueForKeyPath_ofObject_change_context_(
        self, keyPath, an_object, change, context
    ):
        if context == GraphicsObservationContext:
            # Should be able to use
            # NSArray *oldGraphics = [change objectForKey:NSKeyValueChangeOldKey];
            # etc. but the dictionary doesn't contain old and new arrays...??
            newGraphics = set(an_object.valueForKeyPath_(self.graphicsKeyPath))
            onlyNew = newGraphics - set(self.oldGraphics or [])
            self.startObservingGraphics_(onlyNew)

            if self.oldGraphics:
                removed = set(self.oldGraphics) - newGraphics
                self.stopObservingGraphics_(removed)

            self.oldGraphics = newGraphics

            # could check drawingBounds of old and new, but...
            self.setNeedsDisplay_(True)
            return

        if context == PropertyObservationContext:
            updateRect = (0,)
            # Note: for Circle, drawingBounds is a dependent key of all the other
            # property keys except color, so we'll get this anyway...
            if keyPath == "drawingBounds":
                newBounds = change.objectForKey_(NSKeyValueChangeNewKey)
                oldBounds = change.objectForKey_(NSKeyValueChangeOldKey)
                updateRect = NSUnionRect(newBounds, oldBounds)
            else:
                updateRect = an_object.drawingBounds()
            updateRect = NSMakeRect(
                updateRect.origin.x - 1.0,
                updateRect.origin.y - 1.0,
                updateRect.size.width + 2.0,
                updateRect.size.height + 2.0,
            )
            self.setNeedsDisplay_(True)
            return

        if context == SelectionIndexesObservationContext:
            self.setNeedsDisplay_(True)
            return

    def drawRect_(self, rect):
        myBounds = self.bounds()
        NSDrawLightBezel(myBounds, myBounds)  # AppKit Function
        clipRect = NSBezierPath.bezierPathWithRect_(NSInsetRect(myBounds, 2.0, 2.0))
        clipRect.addClip()

        # Draw graphics
        graphicsArray = self.graphics()
        if graphicsArray:
            for graphic in graphicsArray:
                graphicDrawingBounds = graphic.drawingBounds()
                if NSIntersectsRect(rect, graphicDrawingBounds):
                    graphic.drawInView_(self)

        # Draw a red box around items in the current selection.
        # Selection should be handled by the graphic, but this is a
        # shortcut simply for display.

        currentSelectionIndexes = self.selectionIndexes()
        if currentSelectionIndexes is not None:
            path = NSBezierPath.bezierPath()
            index = currentSelectionIndexes.firstIndex()
            while index != NSNotFound:
                graphicDrawingBounds = graphicsArray[index].drawingBounds()
                if NSIntersectsRect(rect, graphicDrawingBounds):
                    path.appendBezierPathWithRect_(graphicDrawingBounds)
                index = currentSelectionIndexes.indexGreaterThanIndex_(index)

            NSColor.redColor().set()
            path.setLineWidth_(1.5)
            path.stroke()

        # Fairly simple just to illustrate the point

    def mouseDown_(self, event):
        # find out if we hit anything
        p = self.convertPoint_fromView_(event.locationInWindow(), None)
        for aGraphic in self.graphics():
            if aGraphic.hitTest_isSelected_(p, False):
                break

        else:
            aGraphic = None

        # if no graphic hit, then if extending selection do nothing
        # else set selection to nil
        if aGraphic is None:
            if not event.modifierFlags() & NSShiftKeyMask:
                self.selectionIndexesContainer.setValue_forKeyPath_(
                    None, self.selectionIndexesKeyPath
                )
            return

        # graphic hit
        # if not extending selection (Shift key down) then set
        # selection to this graphic
        # if extending selection, then:
        # - if graphic in selection remove it
        # - if not in selection add it
        graphicIndex = self.graphics().index(aGraphic)
        if not event.modifierFlags() & NSShiftKeyMask:
            selection = NSIndexSet.indexSetWithIndex_(graphicIndex)
        else:
            if self.selectionIndexes().containsIndex_(graphicIndex):
                selection = self.selectionIndexes().mutableCopy()
                selection.removeIndex_(graphicIndex)
            else:
                selection = self.selectionIndexes().mutableCopy()
                selection.addIndex_(graphicIndex)

        self.selectionIndexesContainer.setValue_forKeyPath_(
            selection, self.selectionIndexesKeyPath
        )


GraphicsView.exposeBinding_("graphics")
GraphicsView.exposeBinding_("selectionIndexes")

JoystickView.py

#
#  JoystickView.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html

from math import atan2, cos, pi, sin, sqrt

import objc
from Cocoa import (
    NSError,
    NSLocalizedDescriptionKey,
    NSAffineTransform,
    NSBezierPath,
    NSColor,
    NSDrawDarkBezel,
    NSDrawLightBezel,
    NSInsetRect,
    NSLocalizedStringFromTable,
    NSMakePoint,
    NSMakeRect,
    NSMultipleValuesMarker,
    NSNoSelectionMarker,
    NSNotApplicableMarker,
    NSNumber,
    NSShiftKeyMask,
    NSValueTransformer,
    NSView,
)
from objc import super  # noqa: A004


class JoystickView(NSView):
    AngleObservationContext = 2091
    OffsetObservationContext = 2092

    maxOffset = objc.ivar("maxOffset", objc._C_DBL)
    angle = objc.ivar("angle")  # , 'd') # expect angle in degrees
    offset = objc.ivar("offset")  # , 'd')

    observedObjectForAngle = objc.ivar("observedObjectForAngle")
    observedKeyPathForAngle = objc.ivar("observedKeyPathForAngle")
    angleValueTransformerName = objc.ivar("angleValueTransformerName")
    badSelectionForAngle = objc.ivar("badSelectionForAngle")
    multipleSelectionForAngle = objc.ivar("multipleSelectionForAngle")
    allowsMultipleSelectionForAngle = objc.ivar("allowsMultipleSelectionForAngle")

    observedObjectForOffset = objc.ivar("observedObjectForOffset")
    observedKeyPathForOffset = objc.ivar("observedKeyPathForOffset")
    offsetValueTransformerName = objc.ivar("offsetValueTransformerName")
    badSelectionForOffset = objc.ivar("badSelectionForOffset")
    multipleSelectionForOffset = objc.ivar("multipleSelectionForOffset")
    allowsMultipleSelectionForOffset = objc.ivar("allowsMultipleSelectionForOffset")

    @classmethod
    def valueClassForBinding_(cls, binding):
        # both require numbers
        return NSNumber

    def initWithFrame_(self, frameRect):
        self = super().initWithFrame_(frameRect)
        if self is None:
            return None
        self.maxOffset = 15.0
        self.offset = 0.0
        self.angle = 28.0
        self.multipleSelectionForAngle = False
        self.multipleSelectionForOffset = False
        return self

    def bind_toObject_withKeyPath_options_(
        self, bindingName, observableController, keyPath, options
    ):
        if bindingName == "angle":
            # observe the controller for changes -- note, pass binding identifier
            # as the context, so we get that back in observeValueForKeyPath:...
            # that way we can determine what needs to be updated.
            observableController.addObserver_forKeyPath_options_context_(
                self, keyPath, 0, self.AngleObservationContext
            )
            # register what controller and what keypath are
            # associated with this binding
            self.observedObjectForAngle = observableController
            self.observedKeyPathForAngle = keyPath
            # options
            self.angleValueTransformerName = options["NSValueTransformerName"]
            self.allowsMultipleSelectionForAngle = False
            if options["NSAllowsEditingMultipleValuesSelection"]:
                self.allowsMultipleSelectionForAngle = True

        if bindingName == "offset":
            observableController.addObserver_forKeyPath_options_context_(
                self, keyPath, 0, self.OffsetObservationContext
            )
            self.observedObjectForOffset = observableController
            self.observedKeyPathForOffset = keyPath
            self.allowsMultipleSelectionForOffset = False
            if options["NSAllowsEditingMultipleValuesSelection"]:
                self.allowsMultipleSelectionForOffset = True

    def unbind_(self, bindingName):
        if bindingName == "angle":
            if self.observedObjectForAngle is None:
                return
            self.observedObjectForAngle.removeObserver_forKeyPath_(
                self, self.observedKeyPathForAngle
            )
            self.observedObjectForAngle = None
            self.observedKeyPathForAngle = None
            self.angleValueTransformerName = None
        elif bindingName == "offset":
            if self.observedObjectForOffset is None:
                return None
            self.observedObjectForOffset.removeObserver_forKeyPath_(
                self, self.observedKeyPathForOffset
            )
            self.observedObjectForOffset = None
            self.observedKeyPathForOffset = None

    def observeValueForKeyPath_ofObject_change_context_(
        self, keyPath, an_object, change, context
    ):
        # we passed the binding as the context when we added ourselves
        # as an observer -- use that to decide what to update...
        # should ask the dictionary for the value...
        if context == self.AngleObservationContext:
            # angle changed
            # if we got a NSNoSelectionMarker or NSNotApplicableMarker, or
            # if we got a NSMultipleValuesMarker and we don't allow multiple selections
            # then note we have a bad angle
            newAngle = self.observedObjectForAngle.valueForKeyPath_(
                self.observedKeyPathForAngle
            )
            if (
                newAngle == NSNoSelectionMarker
                or newAngle == NSNotApplicableMarker
                or (
                    newAngle == NSMultipleValuesMarker
                    and not self.allowsMultipleSelectionForAngle
                )
            ):
                self.badSelectionForAngle = True

            else:
                # note we have a good selection
                # if we got a NSMultipleValuesMarker, note it but don't update value
                self.badSelectionForAngle = False
                if newAngle == NSMultipleValuesMarker:
                    self.multipleSelectionForAngle = True
                else:
                    self.multipleSelectionForAngle = False
                    if self.angleValueTransformerName is not None:
                        vt = NSValueTransformer.valueTransformerForName_(
                            self.angleValueTransformerName
                        )
                        newAngle = vt.transformedValue_(newAngle)
                    self.setValue_forKey_(newAngle, "angle")

        if context == self.OffsetObservationContext:
            # offset changed
            # if we got a NSNoSelectionMarker or NSNotApplicableMarker, or
            # if we got a NSMultipleValuesMarker and we don't allow multiple selections
            # then note we have a bad selection
            newOffset = self.observedObjectForOffset.valueForKeyPath_(
                self.observedKeyPathForOffset
            )
            if (
                newOffset == NSNoSelectionMarker
                or newOffset == NSNotApplicableMarker
                or (
                    newOffset == NSMultipleValuesMarker
                    and not self.allowsMultipleSelectionForOffset
                )
            ):
                self.badSelectionForOffset = True
            else:
                # note we have a good selection
                # if we got a NSMultipleValuesMarker, note it but don't update value
                self.badSelectionForOffset = False
                if newOffset == NSMultipleValuesMarker:
                    self.multipleSelectionForOffset = True
                else:
                    self.setValue_forKey_(newOffset, "offset")
                    self.multipleSelectionForOffset = False
        self.setNeedsDisplay_(True)

    def updateForMouseEvent_(self, event):
        """
        update based on event location and selection state
        behavior based on modifier key
        """
        if self.badSelectionForAngle or self.badSelectionForOffset:
            return  # don't do anything

        # find out where the event is, offset from the view center
        p = self.convertPoint_fromView_(event.locationInWindow(), None)
        myBounds = self.bounds()
        xOffset = p.x - (myBounds.size.width / 2)
        yOffset = p.y - (myBounds.size.height / 2)

        newOffset = sqrt(xOffset * xOffset + yOffset * yOffset)
        if newOffset > self.maxOffset:
            newOffset = self.maxOffset
        elif newOffset < -self.maxOffset:
            newOffset = -self.maxOffset

        # if we have a multiple selection for offset and Shift key is pressed
        # then don't update the offset
        # this allows offsets to remain constant, but change angle
        if not (
            self.multipleSelectionForOffset and (event.modifierFlags() & NSShiftKeyMask)
        ):
            self.offset = newOffset
            # update observed controller if set
            if self.observedObjectForOffset is not None:
                self.observedObjectForOffset.setValue_forKeyPath_(
                    newOffset, self.observedKeyPathForOffset
                )

        # if we have a multiple selection for angle and Shift key is pressed
        # then don't update the angle
        # this allows angles to remain constant, but change offset
        if not (
            self.multipleSelectionForAngle and (event.modifierFlags() & NSShiftKeyMask)
        ):
            newAngle = atan2(xOffset, yOffset)
            newAngleDegrees = newAngle / (pi / 180.0)
            if newAngleDegrees < 0:
                newAngleDegrees += 360
            self.angle = newAngleDegrees
            # update observed controller if set
            if self.observedObjectForAngle is not None:
                if self.observedObjectForAngle is not None:
                    vt = NSValueTransformer.valueTransformerForName_(
                        self.angleValueTransformerName
                    )
                    newControllerAngle = vt.reverseTransformedValue_(newAngleDegrees)
                else:
                    newControllerAngle = newAngle
            self.observedObjectForAngle.setValue_forKeyPath_(
                newControllerAngle, self.observedKeyPathForAngle
            )
        self.setNeedsDisplay_(True)

    def mouseDown_(self, event):
        self.mouseDown = True
        self.updateForMouseEvent_(event)

    def mouseDragged_(self, event):
        self.updateForMouseEvent_(event)

    def mouseUp_(self, event):
        self.mouseDown = False
        self.updateForMouseEvent_(event)

    def acceptsFirstMouse_(self, event):
        return True

    def acceptsFirstResponder(self):
        return True

    def drawRect_(self, rect):
        """
        Basic goals here:
        If either the angle or the offset has a "bad selection":
        then draw a gray rectangle, and that's it.
        Note: bad selection is set if there's a multiple selection
        but the "allows multiple selection" binding is NO.

        If there's a multiple selection for either angle or offset:
        then what you draw depends on what's multiple.

        - First, draw a white background to show all's OK.

        - If both are multiple, then draw a special symbol.

        - If offset is multiple, draw a line from the center of the view
        - to the edge at the shared angle.

        - If angle is multiple, draw a circle of radius the shared offset
        - centered in the view.

        If neither is multiple, draw a cross at the center of the view
        and a cross at distance 'offset' from the center at angle 'angle'
        """
        myBounds = self.bounds()
        if self.badSelectionForAngle or self.badSelectionForOffset:
            # "disable" and exit
            NSDrawDarkBezel(myBounds, myBounds)
            return
        # user can do something, so draw white background and
        # clip in anticipation of future drawing
        NSDrawLightBezel(myBounds, myBounds)
        clipRect = NSBezierPath.bezierPathWithRect_(NSInsetRect(myBounds, 2.0, 2.0))
        clipRect.addClip()

        if self.multipleSelectionForAngle or self.multipleSelectionForOffset:
            originOffsetX = myBounds.size.width / 2 + 0.5
            originOffsetY = myBounds.size.height / 2 + 0.5
            if self.multipleSelectionForAngle and self.multipleSelectionForOffset:
                # draw a diagonal line and circle to denote
                # multiple selections for angle and offset
                NSBezierPath.strokeLineFromPoint_toPoint_(
                    NSMakePoint(0, 0),
                    NSMakePoint(myBounds.size.width, myBounds.size.height),
                )
                circleBounds = NSMakeRect(originOffsetX - 5, originOffsetY - 5, 10, 10)
                path = NSBezierPath.bezierPathWithOvalInRect_(circleBounds)
                path.stroke()
                return
            if self.multipleSelectionForOffset:
                # draw a line from center to a point outside
                # bounds in the direction specified by angle
                angleRadians = self.angle * (pi / 180.0)
                x = sin(angleRadians) * myBounds.size.width + originOffsetX
                y = cos(angleRadians) * myBounds.size.height + originOffsetX
                NSBezierPath.strokeLineFromPoint_toPoint_(
                    NSMakePoint(originOffsetX, originOffsetY), NSMakePoint(x, y)
                )
                return
            if self.multipleSelectionForAngle:
                # draw a circle with radius the shared offset
                # don't draw radius < 1.0, else invisible
                drawRadius = self.offset
                if drawRadius < 1.0:
                    drawRadius = 1.0
                offsetBounds = NSMakeRect(
                    originOffsetX - drawRadius,
                    originOffsetY - drawRadius,
                    drawRadius * 2,
                    drawRadius * 2,
                )
                path = NSBezierPath.bezierPathWithOvalInRect_(offsetBounds)
                path.stroke()
                return
            # shouldn't get here
            return
        trans = NSAffineTransform.transform()
        trans.translateXBy_yBy_(
            myBounds.size.width / 2 + 0.5, myBounds.size.height / 2 + 0.5
        )
        trans.concat()
        path = NSBezierPath.bezierPath()

        # draw + where shadow extends
        angleRadians = self.angle * (pi / 180.0)
        xOffset = sin(angleRadians) * self.offset
        yOffset = cos(angleRadians) * self.offset

        path.moveToPoint_(NSMakePoint(xOffset, yOffset - 5))
        path.lineToPoint_(NSMakePoint(xOffset, yOffset + 5))
        path.moveToPoint_(NSMakePoint(xOffset - 5, yOffset))
        path.lineToPoint_(NSMakePoint(xOffset + 5, yOffset))

        NSColor.lightGrayColor().set()
        path.setLineWidth_(1.5)
        path.stroke()

        # draw + in center of view
        path = NSBezierPath.bezierPath()

        path.moveToPoint_(NSMakePoint(0, -5))
        path.lineToPoint_(NSMakePoint(0, +5))
        path.moveToPoint_(NSMakePoint(-5, 0))
        path.lineToPoint_(NSMakePoint(+5, 0))

        NSColor.blackColor().set()
        path.setLineWidth_(1.0)
        path.stroke()

    def setNilValueForKey_(self, key):
        """We may get passed nil for angle or offset. Just use 0"""
        self.setValue_forKey_(0, key)

    def validateMaxOffset_error_(self, ioValue, error):
        if ioValue is None:
            # trap this in setNilValueForKey
            # alternative might be to create new NSNumber with value 0 here
            return True
        if ioValue <= 0.0:
            errorString = NSLocalizedStringFromTable(
                "Maximum Offset must be greater than zero",
                "Joystick",
                "validation: zero maxOffset error",
            )
            userInfoDict = {NSLocalizedDescriptionKey: errorString}
            error = NSError.alloc().initWithDomain_code_userInfo_(
                "JoystickView", 1, userInfoDict
            )
            return False, error
        return True, None


JoystickView.exposeBinding_("offset")
JoystickView.exposeBinding_("angle")

RadiansToDegreesTransformer.py

#
#  RadiansToDegreesTransformer.py
#  GraphicsBindings
#
#  Converted by u.fiedler on feb 2005
#  with great help from Bob Ippolito - Thank you Bob!
#
#  The original version was written in Objective-C by Malcolm Crawford
#  http://homepage.mac.com/mmalc/CocoaExamples/controllers.html

from Foundation import NSNumber, NSValueTransformer


class RadiansToDegreesTransformer(NSValueTransformer):
    @classmethod
    def transformedValueClass(cls):
        return NSNumber

    @classmethod
    def allowsReverseTransformation(cls):
        return True

    def transformedValue_(self, radians):
        return radians / (3.141_592_7 / 180.0)

    def reverseTransformedValue_(self, degrees):
        if isinstance(degrees, float):
            # when using jostickview we get a value of type float()
            return degrees * (3.141_592_7 / 180.0)
        else:
            # we get a decimalNumber when entering a value in the textfield
            return degrees.doubleValue() * (3.141_592_7 / 180.0)

setup.py

"""
Script for building the example:

Usage:
    python3 setup.py py2app
"""
from setuptools import setup

plist = {
    "CFBundleDocumentTypes": [
        {
            "CFBundleTypeExtensions": ["GraphicsBindings", "*"],
            "CFBundleTypeName": "GraphicsBindings File",
            "CFBundleTypeRole": "Editor",
            "NSDocumentClass": "GraphicsBindingsDocument",
        }
    ]
}

setup(
    name="GraphicsBinding",
    app=["GraphicsBindings.py"],
    data_files=["English.lproj"],
    options={"py2app": {"plist": plist}},
    setup_requires=["py2app", "pyobjc-framework-Cocoa"],
)