PredicateEditorSample

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

Sources

CaseInsensitivePredicateTemplate.py

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

MyWindowController.py

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

main.py

import CaseInsensitivePredicateTemplate  # noqa: F401
import MyWindowController  # 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="PredicateEditorSample",
    app=["main.py"],
    data_files=["English.lproj"],
    setup_requires=["py2app", "pyobjc-framework-Cocoa"],
)