GraphicsBindings
================

* :download:`Download example <PyObjCExample-GraphicsBindings.zip>`

Shows the use of a custom controller, a value transformer, and two custom
bindings-enabled views. One view is a control that allows you to set the angle
and offset of a shadow; the other view observes and displays a collection of
graphic objects.

Originally from "Cocoa Bindings Examples and Hints", converted to PyObjC
by u.fiedler.

.. http://homepage.mac.com/mmalc/CocoaExamples/controllers.html
   (dead link)


.. rst-class:: tabber

Sources
-------

.. rst-class:: tabbertab

Circle.py
.........

.. sourcecode:: python

    #
    #  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
    
    
    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(Circle, self).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"
    )

.. rst-class:: tabbertab

GraphicsArrayController.py
..........................

.. sourcecode:: python

    #
    #  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
    
    
    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(GraphicsArrayController, self).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(GraphicsArrayController, self).arrangeObjects_(filteredObjects)
    
        def newObject(self):
            """ Randomize attributes of new circles so we get a pretty display """
            self.newCircle = super(GraphicsArrayController, self).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

.. rst-class:: tabbertab

GraphicsBindings.py
...................

.. sourcecode:: python

    #
    #  __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=[])

.. rst-class:: tabbertab

GraphicsBindingsDocument.py
...........................

.. sourcecode:: python

    #
    #  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
    from RadiansToDegreesTransformer import RadiansToDegreesTransformer
    
    
    class GraphicsBindingsDocument(NSDocument):
        graphicsView = objc.IBOutlet()
        shadowInspector = objc.IBOutlet()
        graphicsController = objc.IBOutlet()
        graphics = objc.ivar()
    
        def init(self):
            self = super(GraphicsBindingsDocument, self).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(GraphicsBindingsDocument, self).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(GraphicsBindingsDocument, self).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")

.. rst-class:: tabbertab

GraphicsView.py
...............

.. sourcecode:: python

    #
    #  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
    
    
    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(GraphicsView, self).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()
            )  # ist das wir ein Array im Indezes?
            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
                    # aGraphic soll spaeter einen Wert haben, falls es getroffene gibt!
            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")

.. rst-class:: tabbertab

JoystickView.py
...............

.. sourcecode:: python

    #
    #  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
    
    
    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(JoystickView, self).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
                    # dont' 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")

.. rst-class:: tabbertab

RadiansToDegreesTransformer.py
..............................

.. sourcecode:: python

    #
    #  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)

.. rst-class:: tabbertab

setup.py
........

.. sourcecode:: python

    """
    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"],
    )

