PredicateEditorSample
=====================

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

PredicateEditorSample
=====================

"PredicateEditorSample" is a Cocoa application that demonstrates how to use
the ``NSPredicateEditor`` class.  The ``NSPredicateEditor`` class is a
subclass of ``NSRuleEditor`` that is specialized for editing ``NSPredicate``
objects.  This sample is intended to show how to use the many different
features and aspects of this control and leverages Spotlight to search your
Address Book.

It shows how to manage this control inside your window along with an
``NSTableView``, build Spotlight friendly queries based on ``NSPredicate`` and
``NSCompountPredicate``, build search results based on ``NSMetadataQuery`` object.

Using the Sample
----------------

Simply build the example using the supplied ``setup.py`` file.  Enter
query information pertaining to your Address Book.  The application will
display matches in its table view.

Note that this sample uses Interface Builder 3.0 to build the
``NSPredicateEditorRowTemplates`` that make up the control's interface.

AddressBook searches are achieved by specifically requesting the "kind" of data to search via the ``kMDItemKind`` key constant.  This is the metadata attribute key that tells Spotlight to search for address book data only.  Together along with the other predicates from the ``NSPredicateEditor`` class we form a "compound predicate" and start the query.  The code snippet below found in this sample shows how this is done::

   # always search for items in the Address Book
   addrBookPredicate = NSPredicate.predicateWithFormat_("(kMDItemKind = 'Address Book Person Data')")
   predicate = NSCompoundPredicate.andPredicateWithSubpredicates_([addrBookPredicate, predicate])

   query.setPredicate_(predicate)
   query.startQuery()


.. rst-class:: tabber

Sources
-------

.. rst-class:: tabbertab

CaseInsensitivePredicateTemplate.py
...................................

.. sourcecode:: python

    import Cocoa
    from objc import super  # noqa: A004
    
    
    class CaseInsensitivePredicateTemplate(Cocoa.NSPredicateEditorRowTemplate):
        def predicateWithSubpredicates_(self, subpredicates):
            # we only make NSComparisonPredicates
            predicate = super().predicateWithSubpredicates_(subpredicates)
    
            # construct an identical predicate, but add the
            # NSCaseInsensitivePredicateOption flag
            return Cocoa.NSComparisonPredicate.predicateWithLeftExpression_rightExpression_modifier_type_options_(  # noqa: B950
                predicate.leftExpression(),
                predicate.rightExpression(),
                predicate.comparisonPredicateModifier(),
                predicate.predicateOperatorType(),
                predicate.options() | Cocoa.NSCaseInsensitivePredicateOption,
            )

.. rst-class:: tabbertab

MyWindowController.py
.....................

.. sourcecode:: python

    import Cocoa
    import objc
    
    searchIndex = 0
    
    
    class MyWindowController(Cocoa.NSWindowController):
        query = objc.ivar()
        previousRowCount = objc.ivar(type=objc._C_INT)
    
        myTableView = objc.IBOutlet()
        mySearchResults = objc.IBOutlet()
        predicateEditor = objc.IBOutlet()
        progressView = objc.IBOutlet()  # the progress search view
        progressSearch = objc.IBOutlet()  # spinning gear
        progressSearchLabel = objc.IBOutlet()  # search result #
    
        def dealloc(self):
            Cocoa.NSNotificationCenter.defaultCenter().removeObserver_(self)
    
        def awakeFromNib(self):
            # no vertical scrolling, we always want to show all rows
            self.predicateEditor.enclosingScrollView().setHasVerticalScroller_(False)
    
            self.previousRowCount = 3
            self.predicateEditor.addRow_(self)
    
            # put the focus in the first text field
            displayValue = self.predicateEditor.displayValuesForRow_(1).lastObject()
            if isinstance(displayValue, Cocoa.NSControl):
                self.window().makeFirstResponder_(displayValue)
    
            # create and initialize our query
            self.query = Cocoa.NSMetadataQuery.alloc().init()
    
            # setup our Spotlight notifications
            nf = Cocoa.NSNotificationCenter.defaultCenter()
            nf.addObserver_selector_name_object_(
                self, "queryNotification:", None, self.query
            )
    
            # initialize our Spotlight query, sort by contact name
    
            self.query.setSortDescriptors_(
                [
                    Cocoa.NSSortDescriptor.alloc().initWithKey_ascending_(
                        "kMDItemContactKeywords", True
                    )
                ]
            )
            self.query.setDelegate_(self)
    
            # start with our progress search label empty
            self.progressSearchLabel.setStringValue_("")
    
            return
    
        def applicationShouldTerminateAfterLastWindowClosed_(self, sender):
            return True
    
        def loadResultsFromQuery_(self, notif):
            results = notif.object().results()
    
            Cocoa.NSLog("search count = %d", len(results))
            foundResultsStr = "Results found: %d" % (len(results),)
            self.progressSearchLabel.setStringValue_(foundResultsStr)
    
            if len(results) == 0:
                return
    
            # iterate through the array of results, and match to the existing stores
            for item in results:
                cityStr = item.valueForAttribute_("kMDItemCity")
                nameStr = item.valueForAttribute_("kMDItemDisplayName")
                stateStr = item.valueForAttribute_("kMDItemStateOrProvince")
                phoneNumbers = item.valueForAttribute_("kMDItemPhoneNumbers")
                phoneStr = None
                if phoneNumbers:
                    phoneStr = phoneNumbers[0]
    
                storePath = item.valueForAttribute_(
                    "kMDItemPath"
                ).stringByResolvingSymlinksInPath()
    
                # create a dictionary entry to be added to our search results array
                aDict = {
                    "name": nameStr or "",
                    "phone": phoneStr or "",
                    "city": cityStr or "",
                    "state": stateStr or "",
                    "url": Cocoa.NSURL.fileURLWithPath_(storePath),
                }
                self.mySearchResults.append(aDict)
    
        def queryNotification_(self, note):
            # the NSMetadataQuery will send back a note when updates are happening.
            # By looking at the [note name], we can tell what is happening
            if note.name() == Cocoa.NSMetadataQueryDidStartGatheringNotification:
                # the query has just started
                Cocoa.NSLog("search: started gathering")
    
                self.progressSearch.setHidden_(False)
                self.progressSearch.startAnimation_(self)
                self.progressSearch.animate_(self)
                self.progressSearchLabel.setStringValue_("Searching...")
    
            elif note.name() == Cocoa.NSMetadataQueryDidFinishGatheringNotification:
                # at this point, the query will be done. You may receive an update
                # later on.
                Cocoa.NSLog("search: finished gathering")
    
                self.progressSearch.setHidden_(True)
                self.progressSearch.stopAnimation_(self)
    
                self.loadResultsFromQuery_(note)
    
            elif note.name() == Cocoa.NSMetadataQueryGatheringProgressNotification:
                # the query is still gathering results...
                Cocoa.NSLog("search: progressing...")
    
                self.progressSearch.animate_(self)
    
            elif note.name() == Cocoa.NSMetadataQueryDidUpdateNotification:
                # an update will happen when Spotlight notices that a file as
                # added, removed, or modified that affected the search results.
                Cocoa.NSLog("search: an update happened.")
    
        # -------------------------------------------------------------------------
        #   inspect:selectedObjects
        #
        #   This method obtains the selected object (in our case for single selection,
        #   it's the first item), and opens its URL.
        # -------------------------------------------------------------------------
        def inspect_(self, selectedObjects):
            objectDict = selectedObjects[0]
            if objectDict is not None:
                url = objectDict["url"]
                Cocoa.NSWorkspace.sharedWorkspace().openURL_(url)
    
        # ------------------------------------------------------------------------
        #   spotlightFriendlyPredicate:predicate
        #
        #   This method will "clean up" an NSPredicate to make it ready for Spotlight, or return nil
        #   if the predicate can't be cleaned.
        #
        #   Foundation's Spotlight support in NSMetdataQuery places the following requirements on
        #   an NSPredicate:
        #           - Value-type (always YES or NO) predicates are not allowed
        #           - Any compound predicate (other than NOT) must have at least two subpredicates
        # -------------------------------------------------------------------------
        def spotlightFriendlyPredicate_(self, predicate):
            if predicate == Cocoa.NSPredicate.predicateWithValue_(
                True
            ) or predicate == Cocoa.NSPredicate.predicateWithValue_(False):
                return False
    
            elif isinstance(predicate, Cocoa.NSCompoundPredicate):
                predicate_type = predicate.compoundPredicateType()
                cleanSubpredicates = []
                for dirtySubpredicate in predicate.subpredicates():
                    cleanSubpredicate = self.spotlightFriendlyPredicate_(dirtySubpredicate)
                    if cleanSubpredicate:
                        cleanSubpredicates.append(cleanSubpredicate)
    
                if len(cleanSubpredicates) == 0:
                    return None
    
                else:
                    if (
                        len(cleanSubpredicates) == 1
                        and predicate_type != Cocoa.NSNotPredicateType
                    ):
                        return cleanSubpredicates[0]
    
                    else:
                        return (
                            Cocoa.NSCompoundPredicate.alloc().initWithType_subpredicates_(
                                predicate_type, cleanSubpredicates
                            )
                        )
    
            else:
                return predicate
    
        # -------------------------------------------------------------------------
        #   createNewSearchForPredicate:predicate:withTitle
        #
        # -------------------------------------------------------------------------
        def createNewSearchForPredicate_withTitle_(self, predicate, title):
            if predicate is not None:
                self.mySearchResults.removeObjects_(self.mySearchResults.arrangedObjects())
                # remove the old search results
    
                # always search for items in the Address Book
                addrBookPredicate = Cocoa.NSPredicate.predicateWithFormat_(
                    "(kMDItemKind = 'Address Book Person Data')"
                )
                predicate = Cocoa.NSCompoundPredicate.andPredicateWithSubpredicates_(
                    [addrBookPredicate, predicate]
                )
    
                self.query.setPredicate_(predicate)
                self.query.startQuery()
    
        # --------------------------------------------------------------------------
        #   predicateEditorChanged:sender
        #
        #  This method gets called whenever the predicate editor changes.
        #   It is the action of our predicate editor and the single plate for all our updates.
        #
        #   We need to do potentially three things:
        #           1) Fire off a search if the user hits enter.
        #           2) Add some rows if the user deleted all of them, so the user isn't left
        #              without any rows.
        #           3) Resize the window if the number of rows changed (the user hit + or -).
        # --------------------------------------------------------------------------
        @objc.IBAction
        def predicateEditorChanged_(self, sender):
            # check NSApp currentEvent for the return key
            event = Cocoa.NSApp.currentEvent()
            if event is None:
                return
    
            if event.type() == Cocoa.NSKeyDown:
                characters = event.characters()
                if len(characters) > 0 and characters[0] == "\r":
                    # get the predicate, which is the object value of our view
                    predicate = self.predicateEditor.objectValue()
    
                    # make it Spotlight friendly
                    predicate = self.spotlightFriendlyPredicate_(predicate)
                    if predicate is not None:
                        global searchIndex
                        title = Cocoa.NSLocalizedString("Search #%ld", "Search title")
                        self.createNewSearchForPredicate_withTitle_(
                            predicate, title % searchIndex
                        )
                        searchIndex += 1
    
            # if the user deleted the first row, then add it again - no sense
            # leaving the user with no rows
            if self.predicateEditor.numberOfRows() == 0:
                self.predicateEditor.addRow_(self)
    
            # resize the window vertically to accommodate our views:
    
            # get the new number of rows, which tells us the needed change in height,
            # note that we can't just get the view frame, because it's currently
            # animating - this method is called before the animation is finished.
            newRowCount = self.predicateEditor.numberOfRows()
    
            # if there's no change in row count, there's no need to resize anything
            if newRowCount == self.previousRowCount:
                return
    
            # The autoresizing masks, by default, allows the NSTableView to grow
            # and keeps the predicate editor fixed. We need to temporarily grow the
            # predicate editor, and keep the NSTableView fixed, so we have to change
            # the autoresizing masks.
            # Save off the old ones; we'll restore them after changing the window frame.
            tableScrollView = self.myTableView.enclosingScrollView()
            oldOutlineViewMask = tableScrollView.autoresizingMask()
    
            predicateEditorScrollView = self.predicateEditor.enclosingScrollView()
            oldPredicateEditorViewMask = predicateEditorScrollView.autoresizingMask()
    
            tableScrollView.setAutoresizingMask_(
                Cocoa.NSViewWidthSizable | Cocoa.NSViewMaxYMargin
            )
            predicateEditorScrollView.setAutoresizingMask_(
                Cocoa.NSViewWidthSizable | Cocoa.NSViewHeightSizable
            )
    
            # determine if we need to grow or shrink the window
            growing = newRowCount > self.previousRowCount
    
            # if growing, figure out by how much.  Sizes must contain nonnegative
            # values, which is why we avoid negative floats here.
            heightDifference = abs(
                self.predicateEditor.rowHeight() * (newRowCount - self.previousRowCount)
            )
    
            # convert the size to window coordinates -
            # if we didn't do this, we would break under scale factors other than 1.
            # We don't care about the horizontal dimension, so leave that as 0.
            #
            sizeChange = self.predicateEditor.convertSize_toView_(
                Cocoa.NSMakeSize(0, heightDifference), None
            )
    
            # offset our status view
            frame = self.progressView.frame()
            self.progressView.setFrameOrigin_(
                Cocoa.NSMakePoint(
                    frame.origin.x,
                    frame.origin.y
                    - self.predicateEditor.rowHeight()
                    * (newRowCount - self.previousRowCount),
                )
            )
    
            # change the window frame size:
            # - if we're growing, the height goes up and the origin goes down
            #    (corresponding to growing down).
            # - if we're shrinking, the height goes down and the origin goes up.
            windowFrame = self.window().frame()
            if growing:
                windowFrame.size.height += sizeChange.height
                windowFrame.origin.y -= sizeChange.height
            else:
                windowFrame.size.height -= sizeChange.height
                windowFrame.origin.y += sizeChange.height
    
            self.window().setFrame_display_animate_(windowFrame, True, True)
    
            # restore the autoresizing mask
            tableScrollView.setAutoresizingMask_(oldOutlineViewMask)
            predicateEditorScrollView.setAutoresizingMask_(oldPredicateEditorViewMask)
    
            self.previousRowCount = newRowCount  # save our new row count

.. rst-class:: tabbertab

main.py
.......

.. sourcecode:: python

    import CaseInsensitivePredicateTemplate  # noqa: F401
    import MyWindowController  # noqa: F401
    from PyObjCTools import AppHelper
    
    AppHelper.runEventLoop()

.. rst-class:: tabbertab

setup.py
........

.. sourcecode:: python

    """
    Script for building the example.
    
    Usage:
        python3 setup.py py2app
    """
    from setuptools import setup
    
    setup(
        name="PredicateEditorSample",
        app=["main.py"],
        data_files=["English.lproj"],
        setup_requires=["py2app", "pyobjc-framework-Cocoa"],
    )

