PythonBrowser
=============

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

A module and/or demo program implementing a Python object browser

It can be used in two ways:
1) as a standalone demo app that shows how to use the NSOutlineView class
2) as a module to add an object browser to your app.

For the latter usage, include PythonBrowser.nib in your app bundle,
make sure that PythonBrowser.py and PythonBrowserModel.py can be found
on sys.path, and call

.. ::

    PythonBrowser.PythonBrowserWindowController(aBrowsableObject)

from your app. The object to be browsed can't be a number, a string or
None, any other kind of object is fine.

To build the demo program, run this line in Terminal.app::

   $ python setup.py py2app -A

This creates a directory "dist" containing PythonBrowser.app. (The
-A option causes the files to be symlinked to the .app bundle instead
of copied. This means you don't have to rebuild the app if you edit the
sources or nibs.)


.. rst-class:: tabber

Sources
-------

.. rst-class:: tabbertab

PythonBrowser.py
................

.. sourcecode:: python

    """PythonBrowser.py -- a module and/or demo program implementing a Python
    object browser.
    
    It can be used in two ways:
    1) as a standalone demo app that shows how to use the NSOutlineView class
    2) as a module to add an object browser to your app.
    
    For the latter usage, include PythonBrowser.nib in your app bundle,
    make sure that PythonBrowser.py and PythonBrowserModel.py can be found
    on sys.path, and call
    
        PythonBrowser.PythonBrowserWindowController(aBrowsableObject)
    
    from your app. The object to be browsed can't be a number, a string or
    None, any other kind of object is fine.
    
    To build the demo program, run this line in Terminal.app:
    
        $ python setup.py py2app -A
    
    This creates a directory "dist" containing PythonBrowser.app. (The
    -A option causes the files to be symlinked to the .app bundle instead
    of copied. This means you don't have to rebuild the app if you edit the
    sources or nibs.)
    """
    
    import sys
    
    import Cocoa
    import objc
    from PythonBrowserModel import PythonBrowserModel
    
    
    # class defined in PythonBrowser.nib
    class PythonBrowserWindowController(Cocoa.NSWindowController):
        outlineView = objc.IBOutlet()
    
        def __new__(cls, obj):
            # "Pythonic" constructor
            return cls.alloc().initWithObject_(obj)
    
        def initWithObject_(self, obj):
            self = self.initWithWindowNibName_("PythonBrowser")
            self.setWindowTitleForObject_(obj)
            self.model = PythonBrowserModel.alloc().initWithObject_(obj)
            self.outlineView.setDataSource_(self.model)
            self.outlineView.setDelegate_(self.model)
            self.outlineView.setTarget_(self)
            self.outlineView.setDoubleAction_(b"doubleClick:")
            self.window().makeFirstResponder_(self.outlineView)
            self.showWindow_(self)
            # The window controller doesn't need to be retained (referenced)
            # anywhere, so we pretend to have a reference to ourselves to avoid
            # being garbage collected before the window is closed. The extra
            # reference will be released in self.windowWillClose_()
            self.retain()
            return self
    
        def windowWillClose_(self, notification):
            # see comment in self.initWithObject_()
            self.autorelease()
    
        def setWindowTitleForObject_(self, obj):
            if hasattr(obj, "__name__"):
                title = f"PythonBrowser -- {type(obj).__name__}: {obj.__name__}"
            else:
                title = f"PythonBrowser -- {type(obj).__name__}"
            self.window().setTitle_(title)
    
        def setObject_(self, obj):
            self.setWindowTitleForObject_(obj)
            self.model.setObject_(obj)
            self.outlineView.reloadData()
    
        @objc.IBAction
        def doubleClick_(self, sender):
            # Open a new browser window for each selected expandable item
            for row in self.outlineView.selectedRowEnumerator():
                item = self.outlineView.itemAtRow_(row)
                if item.isExpandable():
                    PythonBrowserWindowController(item.object)
    
        @objc.IBAction
        def pickRandomModule_(self, sender):
            """Test method, hooked up from the "Pick Random Module" menu in
            MainMenu.nib, to test changing the browsed object after the window
            has been created."""
            from random import choice
    
            mod = None
            while mod is None:
                mod = sys.modules[choice(sys.modules.keys())]
            self.setObject_(mod)
    
    
    class PythonBrowserAppDelegate(Cocoa.NSObject):
        def applicationDidFinishLaunching_(self, notification):
            self.newBrowser_(self)
    
        @objc.IBAction
        def newBrowser_(self, sender):
            # The PythonBrowserWindowController instance will retain itself,
            # so we don't (have to) keep track of all instances here.
            PythonBrowserWindowController(sys)
    
    
    if __name__ == "__main__":
        from PyObjCTools import AppHelper
    
        AppHelper.runEventLoop()

.. rst-class:: tabbertab

PythonBrowserModel.py
.....................

.. sourcecode:: python

    """PythonBrowserModel.py -- module implementing the data model for PythonBrowser."""
    
    import sys
    from operator import getitem, setitem
    
    from AppKit import NSBeep
    from Foundation import NSObject
    
    
    class PythonBrowserModel(NSObject):
        """This is a delegate as well as a data source for NSOutlineViews."""
    
        def initWithObject_(self, obj):
            self = self.init()
            self.setObject_(obj)
            return self
    
        def setObject_(self, obj):
            self.root = PythonItem("<root>", obj, None, None)
    
        # NSOutlineViewDataSource  methods
    
        def outlineView_numberOfChildrenOfItem_(self, view, item):
            if item is None:
                item = self.root
            return len(item)
    
        def outlineView_child_ofItem_(self, view, child, item):
            if item is None:
                item = self.root
            return item.getChild_(child)
    
        def outlineView_isItemExpandable_(self, view, item):
            if item is None:
                item = self.root
            return item.isExpandable()
    
        def outlineView_objectValueForTableColumn_byItem_(self, view, col, item):
            if item is None:
                item = self.root
            return getattr(item, col.identifier())
    
        def outlineView_setObjectValue_forTableColumn_byItem_(self, view, value, col, item):
            assert col.identifier() == "value"
            if item.value == value:
                return
            try:
                obj = eval(value, {})
            except:  # noqa: E722, B001
                NSBeep()
                print("Error:", sys.exc_info())
                print("     :", repr(value))
            else:
                item.setValue_(obj)
    
        # delegate method
        def outlineView_shouldEditTableColumn_item_(self, view, col, item):
            return item.isEditable()
    
    
    # objects of these types are not eligible for expansion in the outline view
    try:
        SIMPLE_TYPES = (str, unicode, int, long, float, complex)
    except NameError:
        SIMPLE_TYPES = (str, int, float, complex)
    
    
    def getInstanceVarNames(obj):
        """Return a list the names of all (potential) instance variables."""
        # Recipe from Guido
        slots = {}
        if hasattr(obj, "__dict__"):
            slots.update(obj.__dict__)
        if hasattr(obj, "__class__"):
            slots["__class__"] = 1
        cls = getattr(obj, "__class__", type(obj))
        if hasattr(cls, "__mro__"):
            for base in cls.__mro__:
                for name, value in base.__dict__.items():
                    if (
                        hasattr(value, "__get__")
                        and not callable(value)
                        and hasattr(obj, name)
                    ):
                        slots[name] = 1
        if "__dict__" in slots:
            del slots["__dict__"]
        slots = sorted(slots.keys())
        return slots
    
    
    class NiceError:
        """Wrapper for an exception so we can display it nicely in the browser."""
    
        def __init__(self, exc_info):
            self.exc_info = exc_info
    
        def __repr__(self):
            from traceback import format_exception_only
    
            lines = format_exception_only(*self.exc_info[:2])
            assert len(lines) == 1
            error = lines[0].strip()
            return "*** error *** %s" % error
    
    
    class PythonItem(NSObject):
        """Wrapper class for items to be displayed in the outline view."""
    
        # We keep references to all child items (once created). This is
        # necessary because NSOutlineView holds on to PythonItem instances
        # without retaining them. If we don't make sure they don't get
        # garbage collected, the app will crash. For the same reason this
        # class _must_ derive from NSObject, since otherwise autoreleased
        # proxies will be fed to NSOutlineView, which will go away too soon.
    
        def __new__(cls, *args, **kwargs):
            # "Pythonic" constructor
            return cls.alloc().init()
    
        def __init__(self, name, obj, parent, setvalue):
            self.realName = name
            self.name = str(name)
            self.parent = parent
            self._setValue = setvalue
            self.type = type(obj).__name__
            try:
                self.value = repr(obj)[:256]
                assert isinstance(self.value, str)
            except:  # noqa: E722, B001
                self.value = repr(NiceError(sys.exc_info()))
            self.object = obj
            self.childrenEditable = 0
            if isinstance(obj, dict):
                self.children = list(obj.keys())
                self.children.sort()
                self._getChild = getitem
                self._setChild = setitem
                self.childrenEditable = 1
            elif obj is None or isinstance(obj, SIMPLE_TYPES):
                self._getChild = None
                self._setChild = None
            elif isinstance(obj, (list, tuple)):
                self.children = range(len(obj))
                self._getChild = getitem
                self._setChild = setitem
                if isinstance(obj, list):
                    self.childrenEditable = 1
            else:
                self.children = getInstanceVarNames(obj)
                self._getChild = getattr
                self._setChild = setattr
                self.childrenEditable = 1
            self._childRefs = {}
    
        def setValue_(self, value):
            self._setValue(self.parent, self.realName, value)
            self.__init__(self.realName, value, self.parent, self._setValue)
    
        def isEditable(self):
            return self._setValue is not None
    
        def isExpandable(self):
            return self._getChild is not None
    
        def getChild_(self, child):
            if child in self._childRefs:
                return self._childRefs[child]
    
            name = self.children[child]
            try:
                obj = self._getChild(self.object, name)
            except:  # noqa: E722, B001
                obj = NiceError(sys.exc_info())
    
            if self.childrenEditable:
                childObj = PythonItem(name, obj, self.object, self._setChild)
            else:
                childObj = PythonItem(name, obj, None, None)
            self._childRefs[child] = childObj
            return childObj
    
        def __len__(self):
            return len(self.children)

.. rst-class:: tabbertab

setup.py
........

.. sourcecode:: python

    """
    Script for building the example.
    
    Usage:
        python3 setup.py py2app
    """
    
    from setuptools import setup
    
    setup(
        app=["PythonBrowser.py"],
        data_files=["MainMenu.nib", "PythonBrowser.nib"],
        setup_requires=["py2app", "pyobjc-framework-Cocoa"],
    )

