Chart

A PyObjC Example without documentation

Sources

AppController.py

import Cocoa
import objc
import Quartz  # noqa: F401
from objc import super  # noqa: A004

# APPLICATION DATA STORAGE NOTES:
# - This application uses a simple data storage as an array of entries,
#   each containing two attributes: a label and a value
# - The array is represented as a NSMutableArray, the entries as
#   NSMutableDictionaries, the label as a NSString with the "label" key and
#   the value as a NSNumber with the "value" key
#
#
# QUARTZ COMPOSER COMPOSITION NOTES:
# - The enclosed Quartz Composer composition renders a 3D bars chart and is
#   loaded on the QCView in the application's window
# - This composition has three input parameters:
#   * "Data": the data to display by the chart which must be formatted as a
#     NSArray of NSDictionaries, each NSDictionary containing "label" / NSString
#     and "value" / NSNumber value-key pairs
#   * "Scale": a NSNumber used to scale the chart bars
#   * "Spacing": a NSNumber indicating the extra spacing between the chart bars
# - The "Data" and "Scale" input parameters are set programmatically while the
#   "Spacing" is set directly from the UI through Cocoa bindings
# - Note that this composition is quite simple and has the following
#   limitations:
#   * it may have rendering artifacts when looking at the chart from some angles
#   * it does not support negative values
#   * labels are not truncated if too long
# - Basically, the composition performs the following:
#   * renders a background gradient
#   * draws three planes on the X, Y and Z axes
#   * uses an Iterator patch to loop on the chart data, which is available as
#     a Structure, and for each member, retrieves the label and value, then
#     draws them
#   * the chart rendering is enclosed into a Camera macro patch used to center
#     it in the view
#   * the Camera macro patch is itself enclosed into a TrackBall macro patch
#     so that the user can rotate the chart with the mouse
#   * the TrackBall macro patch is itself enclosed into a Lighting macro patch
#     so that the chart is lighted
# - This composition makes uses of transparency for a nicer effect, but
#   neither OpenGL nor Quartz Composer handle automatically proper rendering
#   of mixed opaque and transparent 3D objects
# - A simple, but not fail-proof, algorithm to render opaque and transparent
#   3D objects is to:
#   * render opaque objects first with depth testing set to "Read / Write"
#   * render transparent objects with depth testing set to "Read-Only"
#
#
#  NIB FILES NOTES:
# - The QCView is configured to start rendering automatically and forward user
#   events (mouse events are required to rotate the chart)
# - An AppController instance is connected as the data source for the
#   NSTableView
# - The NSTableView is set up so that the identifiers of table columns match
#   the keys used in the data storage
# - The "Value" column of the NSTableView has a NSNumberFormatter which
#   guarantees only positive or null numbers can be entered here
# - The "Label" column of the NSTableView simply contains text
#

# Keys for the entries in the data storage
kDataKey_Label = "label"  # NSString
kDataKey_Value = "value"  # NSNumber

# Keys for the composition input parameters
kParameterKey_Data = "Data"  # NSArray of NSDictionaries
kParameterKey_Scale = "Scale"  # NSNumber
kParameterKey_Spacing = "Spacing"  # NSNumber


class AppController(Cocoa.NSObject):
    tableView = objc.IBOutlet()
    view = objc.IBOutlet()

    _data = objc.ivar()

    def init(self):
        # Allocate our data storage
        self = super().init()
        if self is None:
            return None

        self._data = []

        return self

    def awakeFromNib(self):
        # Load the composition file into the QCView (because this
        # QCView is bound to a QCPatchController in the nib file, this
        # will actually update the QCPatchController along with all the
        # bindings)
        if not self.view.loadCompositionFromFile_(
            Cocoa.NSBundle.mainBundle().pathForResource_ofType_("Chart", "qtz")
        ):
            Cocoa.NSLog("Composition loading failed")
            Cocoa.NSApp.terminate_(None)

        # Populate data storage
        self._data.extend(
            [
                {kDataKey_Label: "Palo Alto", kDataKey_Value: 2},
                {kDataKey_Label: "Cupertino", kDataKey_Value: 1},
                {kDataKey_Label: "Menlo Park", kDataKey_Value: 4},
                {kDataKey_Label: "Mountain View", kDataKey_Value: 8},
                {kDataKey_Label: "San Francisco", kDataKey_Value: 7},
                {kDataKey_Label: "Los Altos", kDataKey_Value: 3},
            ]
        )

        # Initialize the views
        self.tableView.reloadData()
        self.updateChart()

    def updateChart(self):
        # Update the data displayed by the chart - it will be converted to a
        # Structure of Structures by Quartz Composer
        self.view.setValue_forInputKey_(self._data, kParameterKey_Data)

        # Compute the maximum value and set the chart scale accordingly
        max_value = 0.0
        for obj in self._data:
            value = obj[kDataKey_Value]
            if value > max_value:
                max_value = value

        if max_value == 0.0:
            scale = 1.0
        else:
            scale = 1 / max_value
        self.view.setValue_forInputKey_(scale, kParameterKey_Scale)

    @objc.IBAction
    def addEntry_(self, sender):
        # Add a new entry to the data storage
        self._data.append({kDataKey_Label: "Untitled", kDataKey_Value: 0})

        # Notify the NSTableView and update the chart
        self.tableView.reloadData()
        self.updateChart()

        # Automatically select and edit the new entry
        self.tableView.selectRow_byExtendingSelection_(len(self._data) - 1, False)
        self.tableView.editColumn_row_withEvent_select_(
            self.tableView.columnWithIdentifier_(kDataKey_Label),
            len(self._data) - 1,
            None,
            True,
        )

    @objc.IBAction
    def removeEntry_(self, sender):
        # Make sure we have a valid selected row
        selectedRow = self.tableView.selectedRow()
        if selectedRow < 0 or self.tableView.editedRow() == selectedRow:
            return

        # Remove the currently selected entry from the data storage
        del self._data[selectedRow]

        # Notify the NSTableView and update the chart
        self.tableView.reloadData()
        self.updateChart()

    def numberOfRowsInTableView_(self, aTableView):
        # Return the number of entries in the data storage
        return len(self._data)

    def tableView_objectValueForTableColumn_row_(
        self, aTableView, aTableColumn, rowIndex
    ):
        # Get the "label" or "value" attribute of the entry from the data
        # storage at index "rowIndex"
        return self._data[rowIndex][aTableColumn.identifier()]

    def tableView_setObjectValue_forTableColumn_row_(
        self, aTableView, anObject, aTableColumn, rowIndex
    ):
        # Set the "label" or "value" attribute of the entry from the data
        # storage at index "rowIndex"
        self._data[rowIndex][aTableColumn.identifier()] = anObject

        # Update the chart
        self.updateChart()

main.py

import AppController  # noqa: F401
from PyObjCTools import AppHelper

AppHelper.runEventLoop()

setup.py

"""
Script for building the example.

Usage:
    python3 setup.py py2app
"""

from setuptools import setup

setup(
    name="PyChart",
    app=["main.py"],
    data_files=["English.lproj", "Chart.qtz"],
    setup_requires=["py2app", "pyobjc-framework-Cocoa", "pyobjc-framework-Quartz"],
)