diff --git a/Frameworks/AppKit/Browser.environment/AppKit.sj b/Frameworks/AppKit/Browser.environment/AppKit.sj index bdc20c747..3a9254b2a 100644 --- a/Frameworks/AppKit/Browser.environment/AppKit.sj +++ b/Frameworks/AppKit/Browser.environment/AppKit.sj @@ -30945,7 +30945,7 @@ var meta_class = the_class.isa;class_addMethods(the_class, [new objj_method(sel_ ,["void","BOOL"])]); } -p;13;CPSplitView.jt;96518;@STATIC;1.0;i;13;CPButtonBar.ji;10;CPCursor.ji;9;CPImage.ji;16;CPTrackingArea.ji;8;CPView.jt;96419;objj_executeFile("CPButtonBar.j", YES);objj_executeFile("CPCursor.j", YES);objj_executeFile("CPImage.j", YES);objj_executeFile("CPTrackingArea.j", YES);objj_executeFile("CPView.j", YES);{var the_protocol = objj_allocateProtocol("CPSplitViewDelegate"); +p;13;CPSplitView.jt;96732;@STATIC;1.0;i;13;CPButtonBar.ji;10;CPCursor.ji;9;CPImage.ji;16;CPTrackingArea.ji;8;CPView.jt;96633;objj_executeFile("CPButtonBar.j", YES);objj_executeFile("CPCursor.j", YES);objj_executeFile("CPImage.j", YES);objj_executeFile("CPTrackingArea.j", YES);objj_executeFile("CPView.j", YES);{var the_protocol = objj_allocateProtocol("CPSplitViewDelegate"); var aProtocol = objj_getProtocol("CPObject"); if (!aProtocol) throw new SyntaxError("*** Could not find definition for protocol \"CPSplitViewDelegate\""); protocol_addProtocol(the_protocol, aProtocol); @@ -31761,8 +31761,11 @@ class_addMethods(the_class, [new objj_method(sel_getUid("initWithFrame:"), funct ,["void","float","int"]), new objj_method(sel_getUid("setButtonBar:forDividerAtIndex:"), function $CPSplitView__setButtonBar_forDividerAtIndex_(self, _cmd, aButtonBar, dividerIndex) { - (aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["setAutomaticResizeControl:"] || _objj_forward)(aButtonBar, "setAutomaticResizeControl:", YES)); - (self.isa.method_msgSend["attachButtonBar:"] || _objj_forward)(self, "attachButtonBar:", aButtonBar); + if ((aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["respondsToSelector:"] || _objj_forward)(aButtonBar, "respondsToSelector:", sel_getUid("setAutomaticResizeControl:")))) + { + (aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["setAutomaticResizeControl:"] || _objj_forward)(aButtonBar, "setAutomaticResizeControl:", YES)); + (self.isa.method_msgSend["attachButtonBar:"] || _objj_forward)(self, "attachButtonBar:", aButtonBar); + } } ,["void","CPButtonBar","CPUInteger"]), new objj_method(sel_getUid("attachButtonBar:"), function $CPSplitView__attachButtonBar_(self, _cmd, aButtonBar) diff --git a/Frameworks/AppKit/CommonJS.environment/AppKit.sj b/Frameworks/AppKit/CommonJS.environment/AppKit.sj index 486d218b3..e599e364d 100644 --- a/Frameworks/AppKit/CommonJS.environment/AppKit.sj +++ b/Frameworks/AppKit/CommonJS.environment/AppKit.sj @@ -30104,7 +30104,7 @@ var meta_class = the_class.isa;class_addMethods(the_class, [new objj_method(sel_ ,["void","BOOL"])]); } -p;13;CPSplitView.jt;96518;@STATIC;1.0;i;13;CPButtonBar.ji;10;CPCursor.ji;9;CPImage.ji;16;CPTrackingArea.ji;8;CPView.jt;96419;objj_executeFile("CPButtonBar.j", YES);objj_executeFile("CPCursor.j", YES);objj_executeFile("CPImage.j", YES);objj_executeFile("CPTrackingArea.j", YES);objj_executeFile("CPView.j", YES);{var the_protocol = objj_allocateProtocol("CPSplitViewDelegate"); +p;13;CPSplitView.jt;96732;@STATIC;1.0;i;13;CPButtonBar.ji;10;CPCursor.ji;9;CPImage.ji;16;CPTrackingArea.ji;8;CPView.jt;96633;objj_executeFile("CPButtonBar.j", YES);objj_executeFile("CPCursor.j", YES);objj_executeFile("CPImage.j", YES);objj_executeFile("CPTrackingArea.j", YES);objj_executeFile("CPView.j", YES);{var the_protocol = objj_allocateProtocol("CPSplitViewDelegate"); var aProtocol = objj_getProtocol("CPObject"); if (!aProtocol) throw new SyntaxError("*** Could not find definition for protocol \"CPSplitViewDelegate\""); protocol_addProtocol(the_protocol, aProtocol); @@ -30920,8 +30920,11 @@ class_addMethods(the_class, [new objj_method(sel_getUid("initWithFrame:"), funct ,["void","float","int"]), new objj_method(sel_getUid("setButtonBar:forDividerAtIndex:"), function $CPSplitView__setButtonBar_forDividerAtIndex_(self, _cmd, aButtonBar, dividerIndex) { - (aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["setAutomaticResizeControl:"] || _objj_forward)(aButtonBar, "setAutomaticResizeControl:", YES)); - (self.isa.method_msgSend["attachButtonBar:"] || _objj_forward)(self, "attachButtonBar:", aButtonBar); + if ((aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["respondsToSelector:"] || _objj_forward)(aButtonBar, "respondsToSelector:", sel_getUid("setAutomaticResizeControl:")))) + { + (aButtonBar == null ? aButtonBar : (aButtonBar.isa.method_msgSend["setAutomaticResizeControl:"] || _objj_forward)(aButtonBar, "setAutomaticResizeControl:", YES)); + (self.isa.method_msgSend["attachButtonBar:"] || _objj_forward)(self, "attachButtonBar:", aButtonBar); + } } ,["void","CPButtonBar","CPUInteger"]), new objj_method(sel_getUid("attachButtonBar:"), function $CPSplitView__attachButtonBar_(self, _cmd, aButtonBar) diff --git a/Resources/SmartFoldersDemo/AppController.j b/Resources/SmartFoldersDemo/AppController.j new file mode 100644 index 000000000..c1d8f9209 --- /dev/null +++ b/Resources/SmartFoldersDemo/AppController.j @@ -0,0 +1,109 @@ +/* + * AppController.j + * SmartFoldersDemo + * + * Non-document-based Cappuccino integration test / demo, built entirely in + * code (no cib/xib). The application delegate only creates the model, the + * main menu and the main window controller; everything else lives in the + * view controllers. + * + * AppController + * └─ MainWindowController (CPWindowController, bridge window, toolbar) + * ├─ SourceListViewController (view-based CPOutlineView, badges, DnD target) + * ├─ PeopleViewController (bound CPTableView, CPArrayController, DnD source) + * │ └─ PersonDetailViewController (selection bindings) + * ├─ SmartFolderEditorController (sheet, CPPredicateEditor) + * └─ NameSheetController (sheet) + * + * Data (people, groups, smart folders) is persisted in CPUserDefaults. + * Use File ▸ Reset Library… to return to the deterministic sample data. + */ + +@import +@import + +@import "Library.j" +@import "MainWindowController.j" + +@implementation AppController : CPObject +{ + Library _library; + MainWindowController _mainWindowController; +} + +- (void)applicationDidFinishLaunching:(CPNotification)aNotification +{ + _library = [[Library alloc] init]; + + [self _buildMainMenu]; + [CPMenu setMenuBarVisible:YES]; + + _mainWindowController = [[MainWindowController alloc] initWithLibrary:_library]; + [_mainWindowController showWindow:self]; +} + +// ---------------------------------------------------------------- Menu + +- (CPMenuItem)_addItem:(CPString)aTitle action:(SEL)anAction key:(CPString)aKey modifiers:(unsigned)aMask to:(CPMenu)aMenu +{ + var item = [aMenu addItemWithTitle:aTitle action:anAction keyEquivalent:aKey || @""]; + + if (aMask) + [item setKeyEquivalentModifierMask:aMask]; + + return item; +} + +- (CPMenu)_addSubmenu:(CPString)aTitle to:(CPMenu)aMainMenu +{ + var item = [aMainMenu addItemWithTitle:aTitle action:nil keyEquivalent:@""], + menu = [[CPMenu alloc] initWithTitle:aTitle]; + + [aMainMenu setSubmenu:menu forItem:item]; + + return menu; +} + +- (void)_buildMainMenu +{ + var mainMenu = [CPApp mainMenu]; + + while ([mainMenu numberOfItems] > 0) + [mainMenu removeItemAtIndex:0]; + + var commandOption = CPCommandKeyMask | CPAlternateKeyMask; + + // File + var fileMenu = [self _addSubmenu:@"File" to:mainMenu]; + [self _addItem:@"New Smart Folder…" action:@selector(newSmartFolder:) key:@"n" modifiers:commandOption to:fileMenu]; + [self _addItem:@"New Group…" action:@selector(newGroup:) key:@"g" modifiers:commandOption to:fileMenu]; + [self _addItem:@"New Person" action:@selector(newPerson:) key:@"p" modifiers:commandOption to:fileMenu]; + [fileMenu addItem:[CPMenuItem separatorItem]]; + [self _addItem:@"Edit Smart Folder…" action:@selector(editSelectedSmartFolder:) key:@"e" modifiers:commandOption to:fileMenu]; + [self _addItem:@"Rename Group…" action:@selector(renameSelectedGroup:) key:nil modifiers:0 to:fileMenu]; + [self _addItem:@"Delete Group or Smart Folder" action:@selector(deleteSelectedSourceItem:) key:nil modifiers:0 to:fileMenu]; + [fileMenu addItem:[CPMenuItem separatorItem]]; + [self _addItem:@"Reset Library…" action:@selector(resetLibrary:) key:nil modifiers:0 to:fileMenu]; + + // Edit + var editMenu = [self _addSubmenu:@"Edit" to:mainMenu]; + [self _addItem:@"Undo" action:@selector(undo:) key:@"z" modifiers:0 to:editMenu]; + [self _addItem:@"Redo" action:@selector(redo:) key:@"Z" modifiers:0 to:editMenu]; + [editMenu addItem:[CPMenuItem separatorItem]]; + [self _addItem:@"Cut" action:@selector(cut:) key:@"x" modifiers:0 to:editMenu]; + [self _addItem:@"Copy" action:@selector(copy:) key:@"c" modifiers:0 to:editMenu]; + [self _addItem:@"Paste" action:@selector(paste:) key:@"v" modifiers:0 to:editMenu]; + [self _addItem:@"Delete" action:@selector(delete:) key:nil modifiers:0 to:editMenu]; + [self _addItem:@"Select All" action:@selector(selectAll:) key:@"a" modifiers:0 to:editMenu]; + [editMenu addItem:[CPMenuItem separatorItem]]; + [self _addItem:@"Find" action:@selector(focusSearchField:) key:@"f" modifiers:0 to:editMenu]; + + // View + var viewMenu = [self _addSubmenu:@"View" to:mainMenu]; + [self _addItem:@"Hide Sidebar" action:@selector(toggleSidebar:) key:@"s" modifiers:CPCommandKeyMask | CPControlKeyMask to:viewMenu]; + [self _addItem:@"Hide Details" action:@selector(toggleDetail:) key:@"d" modifiers:CPCommandKeyMask | CPControlKeyMask to:viewMenu]; + [viewMenu addItem:[CPMenuItem separatorItem]]; + [self _addItem:@"Show All People" action:@selector(showAllPeople:) key:@"1" modifiers:0 to:viewMenu]; +} + +@end diff --git a/Resources/SmartFoldersDemo/Info.plist b/Resources/SmartFoldersDemo/Info.plist new file mode 100644 index 000000000..22941d016 --- /dev/null +++ b/Resources/SmartFoldersDemo/Info.plist @@ -0,0 +1,14 @@ + + + + + CPDefaultTheme + Aristo2 + CPApplicationDelegateClass + AppController + CPBundleName + SmartFolderDemo + CPPrincipalClass + CPApplication + + diff --git a/Resources/SmartFoldersDemo/Jakefile b/Resources/SmartFoldersDemo/Jakefile new file mode 100644 index 000000000..80b5b8cf0 --- /dev/null +++ b/Resources/SmartFoldersDemo/Jakefile @@ -0,0 +1,94 @@ +/* + * Jakefile + * SmartFoldersTest + * + * Created by You on November 25, 2010. + * Copyright 2010, Your Company All rights reserved. + */ + +var ENV = process.env, + cp = require("child_process"), + fs = require("fs"), + path = require("path"), + task = JAKE.task, + FileList = JAKE.FileList, + app = CAPPUCCINO.Jake.applicationtask.app, + configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug"; + +app ("SmartFoldersTest", function(task) +{ + task.setBuildIntermediatesPath(path.join("Build", "SmartFoldersTest.build", configuration)); + task.setBuildPath(path.join("Build", configuration)); + + task.setProductName("SmartFoldersTest"); + task.setIdentifier("com.yourcompany."); + task.setVersion("1.0"); + task.setAuthor("Your Company"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("SmartFoldersTest"); + task.setSources((new FileList("**/*.j")).exclude(path.join("Build", "**"))); + task.setResources(new FileList("Resources/**")); + task.setIndexFilePath("index.html"); + task.setInfoPlistPath("Info.plist"); + task.setNib2CibFlags("-R Resources/"); + + if (configuration === "Debug") + task.setCompilerFlags("-DDEBUG -g"); + else + task.setCompilerFlags("-O"); +}); + +task ("default", ["SmartFoldersTest"], function() +{ + printResults(configuration); +}); + +task ("build", ["default"]); + +task ("debug", function() +{ + ENV["CONFIGURATION"] = "Debug"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("release", function() +{ + ENV["CONFIGURATION"] = "Release"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("run", ["debug"], function() +{ + cp.execSync(["open", path.join("Build", "Debug", "SmartFoldersTest", "index.html")].join(" "), { stdio: "inherit" }); +}); + +task ("run-release", ["release"], function() +{ + cp.execSync(["open", path.join("Build", "Release", "SmartFoldersTest", "index.html")].join(" "), { stdio: "inherit" }); +}); + +task ("deploy", ["release"], function() +{ + fs.mkdirSync(path.join("Build", "Deployment", "SmartFoldersTest"), { recursive: true }); + cp.execSync(["press", "-f", path.join("Build", "Release", "SmartFoldersTest"), path.join("Build", "Deployment", "SmartFoldersTest")].join(" "), { stdio: "inherit" }); + printResults("Deployment") +}); + +task ("desktop", ["release"], function() +{ + fs.mkdirSync(path.join("Build", "Desktop", "SmartFoldersTest"), { recursive: true }); + require("@objj/nativehost").buildNativeHost(path.join("Build", "Release", "SmartFoldersTest"), path.join("Build", "Desktop", "SmartFoldersTest", "SmartFoldersTest.app")); + printResults("Desktop") +}); + +task ("run-desktop", ["desktop"], function() +{ + cp.execSync([path.join("Build", "Desktop", "SmartFoldersTest", "SmartFoldersTest.app", "Contents", "MacOS", "NativeHost"), "-i"].join(" "), { stdio: "inherit" }); +}); + +function printResults(configuration) +{ + console.log("----------------------------"); + console.log(configuration+" app built at path: "+path.join("Build", configuration, "SmartFoldersTest")); + console.log("----------------------------"); +} diff --git a/Resources/SmartFoldersDemo/Library.j b/Resources/SmartFoldersDemo/Library.j new file mode 100644 index 000000000..b7ec8c682 --- /dev/null +++ b/Resources/SmartFoldersDemo/Library.j @@ -0,0 +1,787 @@ +/* + * Library.j + * SmartFoldersDemo + * + * The application model. Holds all people plus the three source list + * sections (Library, Groups, Smart Folders), persists itself to + * CPUserDefaults (local storage) and observes every Person via KVO so that + * edits made anywhere in the UI are noticed, saved and reflected in the + * smart folder badges. + * + * Change notifications: + * - structural changes (people added/removed, group membership, folders + * added/removed/renamed/reordered/edited) are posted synchronously; + * - property edits on a Person are coalesced and posted on the next run + * loop turn. + */ + +@import +@import "Person.j" + +LibraryDidChangeNotification = @"LibraryDidChangeNotification"; +LibraryChangeKindKey = @"kind"; +LibraryChangeStructural = @"structural"; +LibraryChangePersonEdited = @"personEdited"; + +PeopleDragType = @"SmartFoldersDemoPeopleDragType"; +SourceItemDragType = @"SmartFoldersDemoSourceItemDragType"; + +LibraryDepartments = [@"Engineering", @"Design", @"Marketing", @"Sales", @"Support", @"Finance", @"Legal"]; +LibraryCities = [@"Berlin", @"Hamburg", @"Munich", @"Zürich", @"Vienna", @"Paris", @"London", @"Lisbon", @"New York", @"San Francisco", @"Tokyo"]; + +var LibraryDefaultsKey = @"SmartFoldersDemo.Library.v1", + ObservedPersonKeys = [@"firstName", @"lastName", @"email", @"department", @"city", @"birthDate", @"salary", @"isManager"], + SourceItemUIDCounter = 0; + + +// -------------------------------------------------------------------------------- +// Source list items +// -------------------------------------------------------------------------------- + +@implementation SourceItem : CPObject +{ + CPString uid @accessors; + CPString name @accessors; +} + +- (id)initWithName:(CPString)aName +{ + if (self = [super init]) + { + uid = [self className] + "-" + (++SourceItemUIDCounter); + name = aName; + } + + return self; +} + +- (BOOL)isSection +{ + return NO; +} + +- (BOOL)isSelectable +{ + return YES; +} + +// Can be renamed, deleted and reordered by the user. +- (BOOL)isUserItem +{ + return NO; +} + +@end + + +@implementation SourceSection : SourceItem +{ + CPMutableArray children @accessors; +} + +- (id)initWithName:(CPString)aName +{ + if (self = [super initWithName:aName]) + children = []; + + return self; +} + +- (BOOL)isSection +{ + return YES; +} + +- (BOOL)isSelectable +{ + return NO; +} + +@end + + +@implementation AllPeopleItem : SourceItem +@end + + +@implementation LibraryGroup : SourceItem +{ + CPMutableArray memberIDs @accessors; +} + +- (id)initWithName:(CPString)aName memberIDs:(CPArray)theIDs +{ + if (self = [super initWithName:aName]) + memberIDs = [CPMutableArray arrayWithArray:theIDs || []]; + + return self; +} + +- (id)initWithDictionary:(CPDictionary)aDictionary +{ + return [self initWithName:[aDictionary objectForKey:@"name"] || @"Group" + memberIDs:[aDictionary objectForKey:@"members"] || []]; +} + +- (CPDictionary)dictionaryRepresentation +{ + return [CPDictionary dictionaryWithObjectsAndKeys:name, @"name", [memberIDs copy], @"members"]; +} + +- (BOOL)isUserItem +{ + return YES; +} + +@end + + +@implementation SmartFolder : SourceItem +{ + CPPredicate predicate @accessors; +} + +- (id)initWithName:(CPString)aName predicate:(CPPredicate)aPredicate +{ + if (self = [super initWithName:aName]) + predicate = aPredicate; + + return self; +} + +- (id)initWithDictionary:(CPDictionary)aDictionary +{ + var format = [aDictionary objectForKey:@"predicate"], + aPredicate = nil; + + try + { + if (format) + aPredicate = [CPPredicate predicateWithFormat:format]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: could not parse stored predicate '" + format + "': " + e); + } + + return [self initWithName:[aDictionary objectForKey:@"name"] || @"Smart Folder" + predicate:aPredicate || [CPPredicate predicateWithValue:YES]]; +} + +- (CPDictionary)dictionaryRepresentation +{ + return [CPDictionary dictionaryWithObjectsAndKeys:name, @"name", [predicate predicateFormat], @"predicate"]; +} + +- (BOOL)isUserItem +{ + return YES; +} + +@end + + +// -------------------------------------------------------------------------------- +// Library +// -------------------------------------------------------------------------------- + +@implementation Library : CPObject +{ + CPMutableArray _people; + Object _peopleByUID; + + SourceSection _librarySection; + SourceSection _groupsSection; + SourceSection _smartFoldersSection; + AllPeopleItem _allPeopleItem; + CPArray _sections; + + BOOL _editNotificationScheduled; + BOOL _saveScheduled; +} + +- (id)init +{ + if (self = [super init]) + { + _people = []; + _peopleByUID = {}; + + _allPeopleItem = [[AllPeopleItem alloc] initWithName:@"All People"]; + + _librarySection = [[SourceSection alloc] initWithName:@"LIBRARY"]; + [[_librarySection children] addObject:_allPeopleItem]; + + _groupsSection = [[SourceSection alloc] initWithName:@"GROUPS"]; + _smartFoldersSection = [[SourceSection alloc] initWithName:@"SMART FOLDERS"]; + + _sections = [_librarySection, _groupsSection, _smartFoldersSection]; + + if (![self _loadFromDefaults]) + [self _populateWithSampleData]; + } + + return self; +} + +// ---------------------------------------------------------------- Accessors + +- (CPArray)people +{ + return _people; +} + +- (CPArray)sections +{ + return _sections; +} + +- (AllPeopleItem)allPeopleItem +{ + return _allPeopleItem; +} + +- (SourceSection)groupsSection +{ + return _groupsSection; +} + +- (SourceSection)smartFoldersSection +{ + return _smartFoldersSection; +} + +- (CPArray)groups +{ + return [_groupsSection children]; +} + +- (CPArray)smartFolders +{ + return [_smartFoldersSection children]; +} + +- (CPArray)childrenOfItem:(id)anItem +{ + if (anItem == nil) + return _sections; + + if ([anItem isSection]) + return [anItem children]; + + return []; +} + +- (SourceSection)sectionForItem:(id)anItem +{ + if ([anItem isKindOfClass:[AllPeopleItem class]]) + return _librarySection; + + if ([anItem isKindOfClass:[LibraryGroup class]]) + return _groupsSection; + + if ([anItem isKindOfClass:[SmartFolder class]]) + return _smartFoldersSection; + + return nil; +} + +- (BOOL)containsItem:(id)anItem +{ + if (!anItem) + return NO; + + if ([_sections indexOfObjectIdenticalTo:anItem] !== CPNotFound) + return YES; + + var section = [self sectionForItem:anItem]; + return section && [[section children] indexOfObjectIdenticalTo:anItem] !== CPNotFound; +} + +- (id)itemWithUID:(CPString)aUID +{ + for (var i = 0; i < _sections.length; i++) + { + var section = _sections[i], + children = [section children]; + + if ([section uid] === aUID) + return section; + + for (var j = 0; j < children.length; j++) + if ([children[j] uid] === aUID) + return children[j]; + } + + return nil; +} + +- (Person)personWithUID:(CPString)aUID +{ + return _peopleByUID[aUID] || nil; +} + +- (CPArray)peopleWithUIDs:(CPArray)theUIDs +{ + var result = []; + + for (var i = 0; i < theUIDs.length; i++) + { + var person = _peopleByUID[theUIDs[i]]; + + if (person) + result.push(person); + } + + return result; +} + +// The unfiltered content for a source item (what goes into the array controller). +- (CPArray)baseContentForItem:(id)anItem +{ + if ([anItem isKindOfClass:[LibraryGroup class]]) + return [self peopleWithUIDs:[anItem memberIDs]]; + + if ([anItem isKindOfClass:[AllPeopleItem class]] || [anItem isKindOfClass:[SmartFolder class]]) + return _people; + + return []; +} + +- (CPPredicate)predicateForItem:(id)anItem +{ + if ([anItem isKindOfClass:[SmartFolder class]]) + return [anItem predicate]; + + return nil; +} + +- (CPArray)peopleMatchingPredicate:(CPPredicate)aPredicate +{ + if (!aPredicate) + return _people; + + try + { + return [_people filteredArrayUsingPredicate:aPredicate]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: predicate evaluation failed: " + e); + return nil; + } +} + +- (int)countForItem:(id)anItem +{ + if ([anItem isSection]) + return CPNotFound; + + var base = [self baseContentForItem:anItem], + predicate = [self predicateForItem:anItem]; + + if (!predicate) + return [base count]; + + var matches = [self peopleMatchingPredicate:predicate]; + return matches ? [matches count] : CPNotFound; +} + +// ---------------------------------------------------------------- People mutation + +- (Person)createPerson +{ + var person = [[Person alloc] init]; + + [person setFirstName:@"New"]; + [person setLastName:@"Person"]; + [person setEmail:@"new.person@example.com"]; + [person setCity:LibraryCities[0]]; + + [self addPeople:[person]]; + + return person; +} + +- (void)addPeople:(CPArray)thePeople +{ + if (![thePeople count]) + return; + + for (var i = 0; i < thePeople.length; i++) + [self _insertPerson:thePeople[i]]; + + [self _noteStructuralChange]; +} + +- (void)removePeople:(CPArray)thePeople +{ + if (![thePeople count]) + return; + + var uids = []; + + for (var i = 0; i < thePeople.length; i++) + { + var person = thePeople[i]; + + if (!_peopleByUID[[person uid]]) + continue; + + [self _stopObservingPerson:person]; + [_people removeObjectIdenticalTo:person]; + delete _peopleByUID[[person uid]]; + uids.push([person uid]); + } + + var groups = [self groups]; + + for (var i = 0; i < groups.length; i++) + [[groups[i] memberIDs] removeObjectsInArray:uids]; + + [self _noteStructuralChange]; +} + +- (void)addPeople:(CPArray)thePeople toGroup:(LibraryGroup)aGroup +{ + var members = [aGroup memberIDs], + changed = NO; + + for (var i = 0; i < thePeople.length; i++) + { + var uid = [thePeople[i] uid]; + + if (![members containsObject:uid]) + { + [members addObject:uid]; + changed = YES; + } + } + + if (changed) + [self _noteStructuralChange]; +} + +- (void)removePeople:(CPArray)thePeople fromGroup:(LibraryGroup)aGroup +{ + [[aGroup memberIDs] removeObjectsInArray:[thePeople valueForKey:@"uid"]]; + [self _noteStructuralChange]; +} + +// ---------------------------------------------------------------- Source item mutation + +- (LibraryGroup)addGroupWithName:(CPString)aName people:(CPArray)thePeople +{ + var group = [[LibraryGroup alloc] initWithName:aName memberIDs:[thePeople valueForKey:@"uid"] || []]; + + [[_groupsSection children] addObject:group]; + [self _noteStructuralChange]; + + return group; +} + +- (SmartFolder)addSmartFolderWithName:(CPString)aName predicate:(CPPredicate)aPredicate +{ + var folder = [[SmartFolder alloc] initWithName:aName predicate:aPredicate]; + + [[_smartFoldersSection children] addObject:folder]; + [self _noteStructuralChange]; + + return folder; +} + +- (void)updateSmartFolder:(SmartFolder)aFolder name:(CPString)aName predicate:(CPPredicate)aPredicate +{ + [aFolder setName:aName]; + [aFolder setPredicate:aPredicate]; + [self _noteStructuralChange]; +} + +- (void)renameItem:(SourceItem)anItem to:(CPString)aName +{ + if (![anItem isUserItem] || ![aName length]) + return; + + [anItem setName:aName]; + [self _noteStructuralChange]; +} + +- (void)removeItem:(SourceItem)anItem +{ + var section = [self sectionForItem:anItem]; + + if (![anItem isUserItem] || !section) + return; + + [[section children] removeObjectIdenticalTo:anItem]; + [self _noteStructuralChange]; +} + +// Moves a user item to a new index inside its own section. +// anIndex is the proposed insertion index *before* removal (outline view semantics). +- (void)moveItem:(SourceItem)anItem toIndex:(int)anIndex +{ + var section = [self sectionForItem:anItem], + children = [section children], + oldIndex = [children indexOfObjectIdenticalTo:anItem]; + + if (oldIndex === CPNotFound) + return; + + if (anIndex > oldIndex) + anIndex--; + + anIndex = MAX(0, MIN(anIndex, [children count] - 1)); + + if (anIndex === oldIndex) + return; + + [children removeObjectAtIndex:oldIndex]; + [children insertObject:anItem atIndex:anIndex]; + [self _noteStructuralChange]; +} + +- (void)resetToSampleData +{ + var people = [_people copy]; + + for (var i = 0; i < people.length; i++) + [self _stopObservingPerson:people[i]]; + + _people = []; + _peopleByUID = {}; + [[_groupsSection children] removeAllObjects]; + [[_smartFoldersSection children] removeAllObjects]; + + [[CPUserDefaults standardUserDefaults] removeObjectForKey:LibraryDefaultsKey]; + + [self _populateWithSampleData]; + [self _noteStructuralChange]; +} + +// ---------------------------------------------------------------- KVO + +- (void)_insertPerson:(Person)aPerson +{ + if (_peopleByUID[[aPerson uid]]) + return; + + _people.push(aPerson); + _peopleByUID[[aPerson uid]] = aPerson; + + for (var i = 0; i < ObservedPersonKeys.length; i++) + [aPerson addObserver:self forKeyPath:ObservedPersonKeys[i] options:CPKeyValueObservingOptionNew context:nil]; +} + +- (void)_stopObservingPerson:(Person)aPerson +{ + for (var i = 0; i < ObservedPersonKeys.length; i++) + [aPerson removeObserver:self forKeyPath:ObservedPersonKeys[i]]; +} + +- (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext +{ + if (_editNotificationScheduled) + return; + + _editNotificationScheduled = YES; + [CPTimer scheduledTimerWithTimeInterval:0 target:self selector:@selector(_postEditNotification:) userInfo:nil repeats:NO]; +} + +- (void)_postEditNotification:(CPTimer)aTimer +{ + _editNotificationScheduled = NO; + [self _scheduleSave]; + + [[CPNotificationCenter defaultCenter] postNotificationName:LibraryDidChangeNotification + object:self + userInfo:[CPDictionary dictionaryWithObject:LibraryChangePersonEdited forKey:LibraryChangeKindKey]]; +} + +- (void)_noteStructuralChange +{ + [self _scheduleSave]; + + [[CPNotificationCenter defaultCenter] postNotificationName:LibraryDidChangeNotification + object:self + userInfo:[CPDictionary dictionaryWithObject:LibraryChangeStructural forKey:LibraryChangeKindKey]]; +} + +// ---------------------------------------------------------------- Persistence + +- (void)_scheduleSave +{ + if (_saveScheduled) + return; + + _saveScheduled = YES; + [CPTimer scheduledTimerWithTimeInterval:0.25 target:self selector:@selector(_saveTimerFired:) userInfo:nil repeats:NO]; +} + +- (void)_saveTimerFired:(CPTimer)aTimer +{ + _saveScheduled = NO; + [self save]; +} + +- (void)save +{ + var people = [], + groups = [], + folders = [], + groupItems = [self groups], + folderItems = [self smartFolders]; + + for (var i = 0; i < _people.length; i++) + people.push([_people[i] dictionaryRepresentation]); + + for (var i = 0; i < groupItems.length; i++) + groups.push([groupItems[i] dictionaryRepresentation]); + + for (var i = 0; i < folderItems.length; i++) + folders.push([folderItems[i] dictionaryRepresentation]); + + var defaults = [CPUserDefaults standardUserDefaults], + dictionary = [CPDictionary dictionaryWithObjectsAndKeys: + 1, @"version", + people, @"people", + groups, @"groups", + folders, @"smartFolders"]; + + [defaults setObject:dictionary forKey:LibraryDefaultsKey]; + [defaults synchronize]; +} + +- (BOOL)_loadFromDefaults +{ + var dictionary = [[CPUserDefaults standardUserDefaults] objectForKey:LibraryDefaultsKey]; + + if (!dictionary || ![dictionary isKindOfClass:[CPDictionary class]]) + return NO; + + try + { + var people = [dictionary objectForKey:@"people"] || [], + groups = [dictionary objectForKey:@"groups"] || [], + folders = [dictionary objectForKey:@"smartFolders"] || []; + + for (var i = 0; i < people.length; i++) + [self _insertPerson:[[Person alloc] initWithDictionary:people[i]]]; + + for (var i = 0; i < groups.length; i++) + [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithDictionary:groups[i]]]; + + for (var i = 0; i < folders.length; i++) + [[_smartFoldersSection children] addObject:[[SmartFolder alloc] initWithDictionary:folders[i]]]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: stored library is unreadable, using sample data. " + e); + + _people = []; + _peopleByUID = {}; + [[_groupsSection children] removeAllObjects]; + [[_smartFoldersSection children] removeAllObjects]; + + return NO; + } + + return [_people count] > 0; +} + +// ---------------------------------------------------------------- Sample data + +- (void)_populateWithSampleData +{ + // Deterministic Park-Miller generator so every fresh launch shows identical data. + var seed = 20101125; + + function random() + { + seed = (seed * 16807) % 2147483647; + return (seed - 1) / 2147483646; + } + + function pick(anArray) + { + return anArray[Math.floor(random() * anArray.length)]; + } + + // Intentionally includes diacritics to exercise [cd] predicate options. + var firstNames = [@"Anna", @"Ben", @"Chloé", @"David", @"Elif", @"François", @"Greta", @"Hiro", @"Ingrid", @"José", + @"Karl", @"Lena", @"Mateo", @"Nina", @"Oskar", @"Paula", @"Quentin", @"Renée", @"Søren", @"Tomás", + @"Ute", @"Viktor", @"Wiebke", @"Xavier", @"Yuki", @"Zoë", @"Amélie", @"Björn", @"Clara", @"Dario"], + lastNames = [@"Müller", @"Schmidt", @"Bauer", @"García", @"Dubois", @"Rossi", @"Nowak", @"Jensen", @"Tanaka", @"Smith", + @"Johnson", @"Öztürk", @"Novák", @"Silva", @"Fischer", @"Weber", @"Lefèvre", @"Kowalski", @"Andersson", @"Brown", + @"Costa", @"Hoffmann", @"Ivanova", @"Kim", @"Lambert", @"Martín", @"Nilsson", @"O'Brien", @"Peña", @"Wagner"]; + + function emailFor(aFirst, aLast) + { + var raw = (aFirst + "." + aLast).toLowerCase(); + + if (raw.normalize) + raw = raw.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + + return raw.replace(/ø/g, "o").replace(/[^a-z.]/g, "") + "@example.com"; + } + + var count = 240; + + for (var i = 0; i < count; i++) + { + var uid = "p" + ("000" + i).slice(-4), + person = [[Person alloc] initWithUID:uid], + first = pick(firstNames), + last = pick(lastNames); + + [person setFirstName:first]; + [person setLastName:last]; + [person setEmail:emailFor(first, last)]; + [person setDepartment:pick(LibraryDepartments)]; + [person setCity:pick(LibraryCities)]; + [person setBirthDate:new Date(1955 + Math.floor(random() * 48), Math.floor(random() * 12), 1 + Math.floor(random() * 28))]; + [person setSalary:38000 + Math.round(random() * 240) * 500]; + [person setIsManager:random() < 0.12]; + + [self _insertPerson:person]; + } + + // Groups (static membership) + var someIDs = function(n) + { + var ids = []; + + while (ids.length < n) + { + var candidate = _people[Math.floor(random() * _people.length)]; + + if (ids.indexOf([candidate uid]) === -1) + ids.push([candidate uid]); + } + + return ids; + }; + + [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Favorites" memberIDs:someIDs(6)]]; + [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Hiking Club" memberIDs:someIDs(14)]]; + [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Book Club" memberIDs:someIDs(9)]]; + [[_groupsSection children] addObject:[[LibraryGroup alloc] initWithName:@"Drop People Here" memberIDs:[]]]; + + // Smart folders. Every predicate below is representable by the row templates + // in SmartFolderEditorController, so CPPredicateEditor -setObjectValue: is + // exercised on all of them. + var folders = [ + [@"Engineering over 40", [CPPredicate predicateWithFormat:@"department == %@ AND age > %@", @"Engineering", 40]], + [@"Names starting with A", [CPPredicate predicateWithFormat:@"firstName BEGINSWITH[cd] %@", @"a"]], + [@"Müller (diacritics test)",[CPPredicate predicateWithFormat:@"lastName CONTAINS[cd] %@", @"muller"]], + [@"High earners in Berlin", [CPPredicate predicateWithFormat:@"salary >= %@ AND city ==[cd] %@", 120000, @"berlin"]], + [@"Sales or Marketing", [CPPredicate predicateWithFormat:@"department == %@ OR department == %@", @"Sales", @"Marketing"]], + [@"Not in Support", [CPPredicate predicateWithFormat:@"NOT (department == %@)", @"Support"]], + [@"Nested compound", [CPPredicate predicateWithFormat:@"age < %@ AND (city ==[cd] %@ OR city ==[cd] %@)", 35, @"tokyo", @"lisbon"]] + ]; + + for (var i = 0; i < folders.length; i++) + [[_smartFoldersSection children] addObject:[[SmartFolder alloc] initWithName:folders[i][0] predicate:folders[i][1]]]; +} + +@end diff --git a/Resources/SmartFoldersDemo/MainWindowController.j b/Resources/SmartFoldersDemo/MainWindowController.j new file mode 100644 index 000000000..575784a74 --- /dev/null +++ b/Resources/SmartFoldersDemo/MainWindowController.j @@ -0,0 +1,585 @@ +/* + * MainWindowController.j + * SmartFoldersDemo + * + * Owns the full-platform (bridge) main window. Instantiates the sidebar and + * people view controllers and loads their views into a vertical CPSplitView. + * Exercises: + * - CPWindowController created in code, window delegate + responder chain + * target for menu / toolbar / button bar actions (sent to nil) + * - CPToolbar with view items (buttons, CPSearchField) + * - CPSplitView delegate API: min/max constraints, collapsing, custom + * resizeSubviewsWithOldSize:, autosave, button bar resize control + * - menu validation (-validateMenuItem:) + * - CPAlert as a sheet + * - CPPasteboard copy / paste + */ + +@import +@import "Library.j" +@import "SourceListViewController.j" +@import "PeopleViewController.j" +@import "SmartFolderEditorController.j" +@import "NameSheetController.j" + +var ToolbarSidebarIdentifier = @"SidebarItem", + ToolbarNewFolderIdentifier = @"NewSmartFolderItem", + ToolbarNewGroupIdentifier = @"NewGroupItem", + ToolbarEditIdentifier = @"EditFolderItem", + ToolbarDetailIdentifier = @"DetailItem", + ToolbarSearchIdentifier = @"SearchItem", + SidebarDefaultWidth = 230.0, + SidebarMinWidth = 160.0, + SidebarMaxWidth = 420.0, + ResetAlertResetTag = 1001, + ResetAlertCancelTag = 1002; + +@implementation MainWindowController : CPWindowController +{ + Library _library; + + CPSplitView _splitView; + SourceListViewController _sourceListController; + PeopleViewController _peopleController; + SmartFolderEditorController _folderEditor; + NameSheetController _nameSheet; + + CPSearchField _searchField; + CPPredicate _searchPredicate; + id _selectedItem; + float _lastSidebarWidth; +} + +- (id)initWithLibrary:(Library)aLibrary +{ + var window = [[CPWindow alloc] initWithContentRect:CGRectMakeZero() styleMask:CPBorderlessBridgeWindowMask]; + + if (self = [super initWithWindow:window]) + { + _library = aLibrary; + _lastSidebarWidth = SidebarDefaultWidth; + + [window setDelegate:self]; + [window setTitle:@"Smart Folders Demo"]; + + _sourceListController = [[SourceListViewController alloc] initWithLibrary:_library]; + [_sourceListController setDelegate:self]; + + _peopleController = [[PeopleViewController alloc] initWithLibrary:_library]; + + [self _buildToolbar]; + [self _buildSplitView]; + + [[CPNotificationCenter defaultCenter] addObserver:self + selector:@selector(libraryDidChange:) + name:LibraryDidChangeNotification + object:_library]; + + [self _selectSourceItem:[_library allPeopleItem]]; + } + + return self; +} + +// ---------------------------------------------------------------- Interface + +- (void)_buildSplitView +{ + var contentView = [[self window] contentView], + bounds = [contentView bounds]; + + _splitView = [[CPSplitView alloc] initWithFrame:bounds]; + [_splitView setVertical:YES]; + [_splitView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_splitView setDelegate:self]; + + var sidebar = [_sourceListController view], + people = [_peopleController view]; + + [sidebar setFrame:CGRectMake(0, 0, SidebarDefaultWidth, CGRectGetHeight(bounds))]; + [people setFrame:CGRectMake(SidebarDefaultWidth + 1, 0, MAX(0, CGRectGetWidth(bounds) - SidebarDefaultWidth - 1), CGRectGetHeight(bounds))]; + + [_splitView addSubview:sidebar]; + [_splitView addSubview:people]; + [contentView addSubview:_splitView]; + + if ([_splitView respondsToSelector:@selector(setButtonBar:forDividerAtIndex:)]) + [_splitView setButtonBar:[_sourceListController buttonBar] forDividerAtIndex:0]; + + if ([_splitView respondsToSelector:@selector(setAutosaveName:)]) + [_splitView setAutosaveName:@"SmartFoldersDemo.MainSplit"]; +} + +- (void)_buildToolbar +{ + _searchField = [[CPSearchField alloc] initWithFrame:CGRectMake(0, 0, 220, 30)]; + [_searchField setPlaceholderString:@"Search people"]; + [_searchField setTarget:self]; + [_searchField setAction:@selector(searchFieldDidChange:)]; + [_searchField setSendsSearchStringImmediately:YES]; + [_searchField setRecentsAutosaveName:@"SmartFoldersDemo.RecentSearches"]; + [_searchField setSearchMenuTemplate:[_searchField defaultSearchMenuTemplate]]; + + var toolbar = [[CPToolbar alloc] initWithIdentifier:@"SmartFoldersDemoToolbar"]; + [toolbar setDelegate:self]; + + // Buttons carry their own titles, so labels below them would be redundant. + [toolbar setDisplayMode:CPToolbarDisplayModeIconOnly]; + + [toolbar setVisible:YES]; + [[self window] setToolbar:toolbar]; +} + +- (CPArray)toolbarAllowedItemIdentifiers:(CPToolbar)aToolbar +{ + return [ToolbarSidebarIdentifier, ToolbarNewFolderIdentifier, ToolbarNewGroupIdentifier, ToolbarEditIdentifier, + ToolbarDetailIdentifier, ToolbarSearchIdentifier, + CPToolbarFlexibleSpaceItemIdentifier, CPToolbarSpaceItemIdentifier, CPToolbarSeparatorItemIdentifier]; +} + +- (CPArray)toolbarDefaultItemIdentifiers:(CPToolbar)aToolbar +{ + return [ToolbarSidebarIdentifier, CPToolbarSpaceItemIdentifier, + ToolbarNewFolderIdentifier, ToolbarNewGroupIdentifier, ToolbarEditIdentifier, + CPToolbarFlexibleSpaceItemIdentifier, + ToolbarDetailIdentifier, ToolbarSearchIdentifier]; +} + +- (CPToolbarItem)toolbar:(CPToolbar)aToolbar itemForItemIdentifier:(CPString)anIdentifier willBeInsertedIntoToolbar:(BOOL)aFlag +{ + var item = [[CPToolbarItem alloc] initWithItemIdentifier:anIdentifier]; + + switch (anIdentifier) + { + case ToolbarSidebarIdentifier: + [self _configureItem:item label:@"Sidebar" buttonTitle:@"Sidebar" action:@selector(toggleSidebar:) toolTip:@"Show or hide the sidebar"]; + break; + + case ToolbarNewFolderIdentifier: + [self _configureItem:item label:@"New Smart Folder" buttonTitle:@"New Smart Folder" action:@selector(newSmartFolder:) toolTip:@"Create a smart folder from a predicate"]; + break; + + case ToolbarNewGroupIdentifier: + [self _configureItem:item label:@"New Group" buttonTitle:@"New Group" action:@selector(newGroup:) toolTip:@"Create an empty group (or drop people on GROUPS)"]; + break; + + case ToolbarEditIdentifier: + [self _configureItem:item label:@"Edit" buttonTitle:@"Edit…" action:@selector(editSelectedSmartFolder:) toolTip:@"Edit the selected smart folder"]; + break; + + case ToolbarDetailIdentifier: + [self _configureItem:item label:@"Details" buttonTitle:@"Details" action:@selector(toggleDetail:) toolTip:@"Show or hide the detail pane"]; + break; + + case ToolbarSearchIdentifier: + [item setLabel:@"Search"]; + [item setPaletteLabel:@"Search"]; + [item setView:_searchField]; + [item setMinSize:CGSizeMake(140, 30)]; + [item setMaxSize:CGSizeMake(260, 30)]; + break; + } + + return item; +} + +- (void)_configureItem:(CPToolbarItem)anItem label:(CPString)aLabel buttonTitle:(CPString)aTitle action:(SEL)anAction toolTip:(CPString)aToolTip +{ + var button = [CPButton buttonWithTitle:aTitle]; + + [button setBezelStyle:CPRoundedBezelStyle]; + [button setTarget:self]; + [button setAction:anAction]; + [button sizeToFit]; + + var size = CGSizeMake(MAX(CGRectGetWidth([button frame]), 40), 25); + + [anItem setLabel:aLabel]; + [anItem setPaletteLabel:aLabel]; + [anItem setToolTip:aToolTip]; + [anItem setTarget:self]; + [anItem setAction:anAction]; + [anItem setView:button]; + [anItem setMinSize:size]; + [anItem setMaxSize:size]; +} + +// ---------------------------------------------------------------- Split view delegate (sidebar) + +- (BOOL)splitView:(CPSplitView)aSplitView canCollapseSubview:(CPView)aSubview +{ + return aSubview === [_sourceListController view]; +} + +- (float)splitView:(CPSplitView)aSplitView constrainMinCoordinate:(float)proposedMin ofSubviewAt:(int)anIndex +{ + return SidebarMinWidth; +} + +- (float)splitView:(CPSplitView)aSplitView constrainMaxCoordinate:(float)proposedMax ofSubviewAt:(int)anIndex +{ + return MIN(SidebarMaxWidth, CGRectGetWidth([aSplitView bounds]) - 300.0); +} + +- (void)splitView:(CPSplitView)aSplitView resizeSubviewsWithOldSize:(CGSize)oldSize +{ + // Sidebar keeps its width, the people pane takes up the rest. + var subviews = [aSplitView subviews], + sidebar = subviews[0], + main = subviews[1], + size = [aSplitView bounds].size, + divider = [aSplitView dividerThickness], + sidebarWidth = [aSplitView isSubviewCollapsed:sidebar] ? 0 : CGRectGetWidth([sidebar frame]); + + [sidebar setFrame:CGRectMake(0, 0, sidebarWidth, size.height)]; + [main setFrame:CGRectMake(sidebarWidth + divider, 0, MAX(0, size.width - sidebarWidth - divider), size.height)]; + + // Custom layout bypasses CPSplitView's own pass: redraw the divider. + [aSplitView setNeedsDisplay:YES]; +} + +- (BOOL)isSidebarVisible +{ + var sidebar = [_sourceListController view]; + return ![_splitView isSubviewCollapsed:sidebar] && CGRectGetWidth([sidebar frame]) > 0; +} + +// ---------------------------------------------------------------- Source list delegate + +- (void)sourceListViewController:(SourceListViewController)aController didSelectItem:(id)anItem +{ + _selectedItem = anItem; + [self _refreshPeople]; +} + +- (void)_selectSourceItem:(id)anItem +{ + _selectedItem = anItem; + [_sourceListController reloadAndSelectItem:anItem]; + [self _refreshPeople]; +} + +// ---------------------------------------------------------------- Model -> view + +- (void)libraryDidChange:(CPNotification)aNotification +{ + var kind = [[aNotification userInfo] objectForKey:LibraryChangeKindKey]; + + if (![_library containsItem:_selectedItem]) + _selectedItem = [_library allPeopleItem]; + + // Names, order and badges are all rendered by the outline. + [_sourceListController reloadAndSelectItem:_selectedItem]; + + if (kind === LibraryChangeStructural) + [self _refreshPeople]; + else + [_peopleController rearrange]; +} + +- (CPPredicate)_combinedPredicate +{ + var subpredicates = [], + folderPredicate = [_library predicateForItem:_selectedItem]; + + if (folderPredicate) + subpredicates.push(folderPredicate); + + if (_searchPredicate) + subpredicates.push(_searchPredicate); + + if (subpredicates.length === 0) + return nil; + + if (subpredicates.length === 1) + return subpredicates[0]; + + return [CPCompoundPredicate andPredicateWithSubpredicates:subpredicates]; +} + +- (void)_refreshPeople +{ + var item = _selectedItem || [_library allPeopleItem]; + + [_peopleController setPeople:[_library baseContentForItem:item] + filterPredicate:[self _combinedPredicate] + title:[item name]]; +} + +// ---------------------------------------------------------------- Search + +- (void)searchFieldDidChange:(id)sender +{ + var searchString = [[_searchField stringValue] stringByTrimmingWhitespace]; + + if (![searchString length]) + _searchPredicate = nil; + else + _searchPredicate = [CPPredicate predicateWithFormat:@"(firstName CONTAINS[cd] %@) OR (lastName CONTAINS[cd] %@) OR (email CONTAINS[cd] %@) OR (city CONTAINS[cd] %@) OR (department CONTAINS[cd] %@)", + searchString, searchString, searchString, searchString, searchString]; + + [self _refreshPeople]; +} + +- (void)focusSearchField:(id)sender +{ + [[self window] makeFirstResponder:_searchField]; +} + +// ---------------------------------------------------------------- Source item actions + +- (SmartFolderEditorController)_folderEditor +{ + if (!_folderEditor) + _folderEditor = [[SmartFolderEditorController alloc] initWithLibrary:_library]; + + return _folderEditor; +} + +- (NameSheetController)_nameSheet +{ + if (!_nameSheet) + _nameSheet = [[NameSheetController alloc] init]; + + return _nameSheet; +} + +- (BOOL)_isSheetVisible +{ + return !![[self window] attachedSheet]; +} + +- (void)newSmartFolder:(id)sender +{ + if ([self _isSheetVisible]) + return; + + [[self _folderEditor] beginEditingFolder:nil modalForWindow:[self window] completion:function(folder) + { + [self _selectSourceItem:folder]; + }]; +} + +- (void)editSelectedSmartFolder:(id)sender +{ + if (![_selectedItem isKindOfClass:[SmartFolder class]] || [self _isSheetVisible]) + return; + + var folder = _selectedItem; + + [[self _folderEditor] beginEditingFolder:folder modalForWindow:[self window] completion:function(aFolder) + { + [self _selectSourceItem:aFolder]; + }]; +} + +- (void)newGroup:(id)sender +{ + if ([self _isSheetVisible]) + return; + + [[self _nameSheet] beginWithTitle:@"New Group" name:@"Untitled Group" okTitle:@"Create" modalForWindow:[self window] completion:function(name) + { + var group = [_library addGroupWithName:name people:[]]; + [self _selectSourceItem:group]; + }]; +} + +- (void)renameSelectedGroup:(id)sender +{ + if (![_selectedItem isKindOfClass:[LibraryGroup class]] || [self _isSheetVisible]) + return; + + var group = _selectedItem; + + [[self _nameSheet] beginWithTitle:@"Rename Group" name:[group name] okTitle:@"Rename" modalForWindow:[self window] completion:function(name) + { + [_library renameItem:group to:name]; + }]; +} + +- (void)deleteSelectedSourceItem:(id)sender +{ + if (![_selectedItem isUserItem]) + return; + + [_library removeItem:_selectedItem]; +} + +// ---------------------------------------------------------------- People actions + +- (void)newPerson:(id)sender +{ + // A new person would be invisible in a smart folder or a filtered list. + if ([_selectedItem isKindOfClass:[SmartFolder class]]) + _selectedItem = [_library allPeopleItem]; + + if (_searchPredicate) + { + [_searchField setStringValue:@""]; + _searchPredicate = nil; + } + + var group = [_selectedItem isKindOfClass:[LibraryGroup class]] ? _selectedItem : nil, + person = [_library createPerson]; + + if (group) + [_library addPeople:[person] toGroup:group]; + + [_peopleController selectPerson:person edit:YES]; +} + +- (void)removeSelectedPeople:(id)sender +{ + var people = [_peopleController selectedPeople]; + + if (![people count]) + return; + + if ([_selectedItem isKindOfClass:[LibraryGroup class]]) + [_library removePeople:people fromGroup:_selectedItem]; + else + [_library removePeople:people]; +} + +- (void)delete:(id)sender +{ + [self removeSelectedPeople:sender]; +} + +- (void)copy:(id)sender +{ + [_peopleController copySelectionToPasteboard:[CPPasteboard generalPasteboard]]; +} + +- (void)cut:(id)sender +{ + [self copy:sender]; + [self removeSelectedPeople:sender]; +} + +- (void)paste:(id)sender +{ + var pasteboard = [CPPasteboard generalPasteboard], + people = [_peopleController peopleFromTabSeparatedString:[pasteboard stringForType:CPStringPboardType]]; + + if (![people count]) + return; + + if ([_selectedItem isKindOfClass:[SmartFolder class]]) + _selectedItem = [_library allPeopleItem]; + + [_library addPeople:people]; + + if ([_selectedItem isKindOfClass:[LibraryGroup class]]) + [_library addPeople:people toGroup:_selectedItem]; + + [[_peopleController arrayController] setSelectedObjects:people]; +} + +// ---------------------------------------------------------------- View actions + +- (void)toggleSidebar:(id)sender +{ + if ([self isSidebarVisible]) + { + _lastSidebarWidth = CGRectGetWidth([[_sourceListController view] frame]); + [_splitView setPosition:0 ofDividerAtIndex:0]; + } + else + [_splitView setPosition:MAX(_lastSidebarWidth, SidebarMinWidth) ofDividerAtIndex:0]; +} + +- (void)toggleDetail:(id)sender +{ + [_peopleController toggleDetail]; +} + +- (void)showAllPeople:(id)sender +{ + [self _selectSourceItem:[_library allPeopleItem]]; +} + +- (void)resetLibrary:(id)sender +{ + if ([self _isSheetVisible]) + return; + + var alert = [[CPAlert alloc] init]; + + [alert setAlertStyle:CPWarningAlertStyle]; + [alert setMessageText:@"Reset the library?"]; + [alert setInformativeText:@"All people, groups and smart folders will be replaced by the sample data. This cannot be undone."]; + [alert addButtonWithTitle:@"Reset"]; + [alert addButtonWithTitle:@"Cancel"]; + + // CPAlert reports the clicked button's tag as the return code. Assign our own + // tags rather than relying on a framework-specific numbering scheme. + var buttons = [alert buttons]; + [buttons[0] setTag:ResetAlertResetTag]; + [buttons[1] setTag:ResetAlertCancelTag]; + + [alert beginSheetModalForWindow:[self window] + modalDelegate:self + didEndSelector:@selector(resetAlertDidEnd:returnCode:contextInfo:) + contextInfo:nil]; +} + +- (void)resetAlertDidEnd:(CPAlert)anAlert returnCode:(int)aReturnCode contextInfo:(id)aContext +{ + if (aReturnCode !== ResetAlertResetTag) + return; + + [_searchField setStringValue:@""]; + _searchPredicate = nil; + _selectedItem = [_library allPeopleItem]; + + [_library resetToSampleData]; +} + +// ---------------------------------------------------------------- Validation + +- (BOOL)validateMenuItem:(CPMenuItem)anItem +{ + var action = [anItem action], + sheet = [self _isSheetVisible], + hasPeopleSelection = [[_peopleController selectedPeople] count] > 0; + + switch (action) + { + case @selector(newSmartFolder:): + case @selector(newGroup:): + case @selector(resetLibrary:): + return !sheet; + + case @selector(editSelectedSmartFolder:): + return !sheet && [_selectedItem isKindOfClass:[SmartFolder class]]; + + case @selector(renameSelectedGroup:): + return !sheet && [_selectedItem isKindOfClass:[LibraryGroup class]]; + + case @selector(deleteSelectedSourceItem:): + return !sheet && [_selectedItem isUserItem]; + + case @selector(copy:): + case @selector(cut:): + case @selector(delete:): + case @selector(removeSelectedPeople:): + return !sheet && hasPeopleSelection; + + case @selector(paste:): + return !sheet && [[[CPPasteboard generalPasteboard] types] containsObject:CPStringPboardType]; + + case @selector(toggleSidebar:): + [anItem setTitle:[self isSidebarVisible] ? @"Hide Sidebar" : @"Show Sidebar"]; + return YES; + + case @selector(toggleDetail:): + [anItem setTitle:[_peopleController isDetailVisible] ? @"Hide Details" : @"Show Details"]; + return YES; + } + + return YES; +} + +@end diff --git a/Resources/SmartFoldersDemo/NameSheetController.j b/Resources/SmartFoldersDemo/NameSheetController.j new file mode 100644 index 000000000..fafc614d7 --- /dev/null +++ b/Resources/SmartFoldersDemo/NameSheetController.j @@ -0,0 +1,102 @@ +/* + * NameSheetController.j + * SmartFoldersDemo + * + * Minimal reusable sheet asking for a name (new group, rename group). + * Exercises a second, independent CPWindowController-owned sheet, first + * responder handling and default/escape key equivalents. + */ + +@import + +@implementation NameSheetController : CPWindowController +{ + CPTextField _titleField; + CPTextField _nameField; + CPButton _okButton; + Function _completion; +} + +- (id)init +{ + var window = [[CPWindow alloc] initWithContentRect:CGRectMake(0, 0, 380, 140) styleMask:CPDocModalWindowMask]; + + if (self = [super initWithWindow:window]) + [self _buildInterface]; + + return self; +} + +- (void)_buildInterface +{ + var content = [[self window] contentView], + width = CGRectGetWidth([content bounds]), + height = CGRectGetHeight([content bounds]); + + _titleField = [CPTextField labelWithTitle:@""]; + [_titleField setFont:[CPFont boldSystemFontOfSize:13.0]]; + [_titleField setFrame:CGRectMake(20, 16, width - 40, 20)]; + [content addSubview:_titleField]; + + _nameField = [[CPTextField alloc] initWithFrame:CGRectMake(20, 46, width - 40, 29)]; + [_nameField setEditable:YES]; + [_nameField setBezeled:YES]; + [content addSubview:_nameField]; + + var cancelButton = [CPButton buttonWithTitle:@"Cancel"]; + [cancelButton setFrame:CGRectMake(width - 200, height - 44, 85, 25)]; + [cancelButton setTag:CPCancelButton]; + [cancelButton setTarget:self]; + [cancelButton setAction:@selector(closeSheet:)]; + [cancelButton setKeyEquivalent:CPEscapeFunctionKey]; + [content addSubview:cancelButton]; + + _okButton = [CPButton buttonWithTitle:@"OK"]; + [_okButton setFrame:CGRectMake(width - 105, height - 44, 85, 25)]; + [_okButton setTag:CPOKButton]; + [_okButton setTarget:self]; + [_okButton setAction:@selector(closeSheet:)]; + [content addSubview:_okButton]; + + [[self window] setDefaultButton:_okButton]; +} + +// aCallback(name) is invoked only when the user confirms with a non-empty name. +- (void)beginWithTitle:(CPString)aTitle name:(CPString)aName okTitle:(CPString)anOKTitle modalForWindow:(CPWindow)aWindow completion:(Function)aCallback +{ + _completion = aCallback; + + [_titleField setStringValue:aTitle]; + [_nameField setStringValue:aName || @""]; + [_okButton setTitle:anOKTitle || @"OK"]; + + [CPApp beginSheet:[self window] + modalForWindow:aWindow + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:contextInfo:) + contextInfo:nil]; + + [[self window] makeFirstResponder:_nameField]; + [_nameField selectText:self]; +} + +- (void)closeSheet:(id)sender +{ + if ([sender tag] === CPOKButton && ![[[_nameField stringValue] stringByTrimmingWhitespace] length]) + return; + + [CPApp endSheet:[self window] returnCode:[sender tag]]; +} + +- (void)sheetDidEnd:(CPWindow)aSheet returnCode:(int)aReturnCode contextInfo:(id)aContext +{ + [aSheet orderOut:self]; + + var callback = _completion; + _completion = nil; + + if (aReturnCode === CPOKButton && callback) + callback([[_nameField stringValue] stringByTrimmingWhitespace]); +} + +@end diff --git a/Resources/SmartFoldersDemo/PeopleViewController.j b/Resources/SmartFoldersDemo/PeopleViewController.j new file mode 100644 index 000000000..efd3794cd --- /dev/null +++ b/Resources/SmartFoldersDemo/PeopleViewController.j @@ -0,0 +1,461 @@ +/* + * PeopleViewController.j + * SmartFoldersDemo + * + * Right hand side of the main split view. Exercises: + * - CPArrayController with content, filterPredicate, sortDescriptors and + * selection (incl. setSelectedObjects: across content changes) + * - cell-based CPTableView fully driven by bindings (value, selection, + * sort descriptors), editable columns, formatters (CPDateFormatter, + * CPNumberFormatter), checkbox data views, column reordering/resizing, + * autosaved column state, alternating rows, grid lines + * - the table as drag source (rows -> source list groups) + * - CPPasteboard (copy / paste as tab separated text) + * - nested CPSplitView hosting a child view controller (detail pane) + */ + +@import +@import "Library.j" +@import "PersonDetailViewController.j" + +@implementation PeopleViewController : CPViewController +{ + Library _library; + CPArrayController _arrayController; + CPTableView _tableView; + CPSplitView _splitView; + CPView _tablePane; + CPTextField _statusField; + PersonDetailViewController _detailController; + CPString _contentTitle; + float _lastDetailHeight; +} + +- (id)initWithLibrary:(Library)aLibrary +{ + if (self = [super initWithCibName:nil bundle:nil]) + { + _library = aLibrary; + _contentTitle = @""; + _lastDetailHeight = PersonDetailPreferredHeight; + + _arrayController = [[CPArrayController alloc] init]; + [_arrayController setEditable:YES]; + [_arrayController setAvoidsEmptySelection:NO]; + [_arrayController setPreservesSelection:YES]; + [_arrayController setSelectsInsertedObjects:YES]; + [_arrayController setSortDescriptors:[ + [CPSortDescriptor sortDescriptorWithKey:@"lastName" ascending:YES selector:@selector(caseInsensitiveCompare:)], + [CPSortDescriptor sortDescriptorWithKey:@"firstName" ascending:YES selector:@selector(caseInsensitiveCompare:)] + ]]; + + [_arrayController addObserver:self forKeyPath:@"arrangedObjects" options:0 context:nil]; + [_arrayController addObserver:self forKeyPath:@"selectionIndexes" options:0 context:nil]; + } + + return self; +} + +- (CPArrayController)arrayController +{ + return _arrayController; +} + +- (CPTableView)tableView +{ + [self view]; + return _tableView; +} + +- (void)loadView +{ + var frame = CGRectMake(0, 0, 800, 600), + view = [[CPView alloc] initWithFrame:frame]; + + [view setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + + _splitView = [[CPSplitView alloc] initWithFrame:frame]; + [_splitView setVertical:NO]; + [_splitView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_splitView setDelegate:self]; + + _tablePane = [self _buildTablePane:CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame) - _lastDetailHeight)]; + [_splitView addSubview:_tablePane]; + + _detailController = [[PersonDetailViewController alloc] initWithArrayController:_arrayController]; + var detailView = [_detailController view]; + [detailView setFrame:CGRectMake(0, 0, CGRectGetWidth(frame), _lastDetailHeight)]; + [_splitView addSubview:detailView]; + + [view addSubview:_splitView]; + [self setView:view]; + + if ([_splitView respondsToSelector:@selector(setAutosaveName:)]) + [_splitView setAutosaveName:@"SmartFoldersDemo.PeopleSplit"]; + + [self _updateStatus]; +} + +- (CPView)_buildTablePane:(CGRect)aFrame +{ + var pane = [[CPView alloc] initWithFrame:aFrame], + barHeight = 26.0; + + [pane setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + + var scrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth(aFrame), CGRectGetHeight(aFrame) - barHeight)]; + [scrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [scrollView setAutohidesScrollers:YES]; + [scrollView setBorderType:CPNoBorder]; + + _tableView = [[CPTableView alloc] initWithFrame:[[scrollView contentView] bounds]]; + [_tableView setUsesAlternatingRowBackgroundColors:YES]; + [_tableView setAllowsMultipleSelection:YES]; + [_tableView setAllowsEmptySelection:YES]; + [_tableView setAllowsColumnReordering:YES]; + [_tableView setAllowsColumnResizing:YES]; + [_tableView setAllowsColumnSelection:NO]; + [_tableView setGridStyleMask:CPTableViewSolidVerticalGridLineMask]; + [_tableView setColumnAutoresizingStyle:CPTableViewLastColumnOnlyAutoresizingStyle]; + [_tableView setRowHeight:24.0]; + [_tableView setIntercellSpacing:CGSizeMake(6.0, 0.0)]; + + var dateFormatter = [[CPDateFormatter alloc] init]; + [dateFormatter setDateStyle:CPDateFormatterMediumStyle]; + [dateFormatter setTimeStyle:CPDateFormatterNoStyle]; + + var numberFormatter = [[CPNumberFormatter alloc] init]; + [numberFormatter setNumberStyle:CPNumberFormatterDecimalStyle]; + + [self _addColumn:@"lastName" title:@"Last Name" width:120 editable:YES dataView:nil]; + [self _addColumn:@"firstName" title:@"First Name" width:110 editable:YES dataView:nil]; + [self _addColumn:@"department" title:@"Department" width:110 editable:YES dataView:nil]; + [self _addColumn:@"city" title:@"City" width:110 editable:YES dataView:nil]; + [self _addColumn:@"age" title:@"Age" width:46 editable:NO dataView:[self _textDataViewWithFormatter:nil alignment:CPRightTextAlignment]]; + [self _addColumn:@"birthDate" title:@"Born" width:110 editable:NO dataView:[self _textDataViewWithFormatter:dateFormatter alignment:CPRightTextAlignment]]; + [self _addColumn:@"salary" title:@"Salary" width:90 editable:YES dataView:[self _textDataViewWithFormatter:numberFormatter alignment:CPRightTextAlignment]]; + + // Title-less checkbox, image centred in the cell. + var checkBox = [CPCheckBox checkBoxWithTitle:@""]; + [checkBox setImagePosition:CPImageOnly]; + [checkBox setAlignment:CPCenterTextAlignment]; + + var managerColumn = [self _addColumn:@"isManager" title:@"Manager" width:64 editable:YES dataView:checkBox]; + [[managerColumn headerView] setAlignment:CPCenterTextAlignment]; + [managerColumn setResizingMask:CPTableColumnNoResizing]; + + [self _addColumn:@"email" title:@"Email" width:220 editable:YES dataView:nil]; + + // Selection and sorting through bindings. + [_tableView bind:CPSelectionIndexesBinding toObject:_arrayController withKeyPath:@"selectionIndexes" options:nil]; + [_tableView bind:@"sortDescriptors" toObject:_arrayController withKeyPath:@"sortDescriptors" options:nil]; + + // The data source only provides drag & drop; content comes from the bindings + // (the column value bindings above establish the table's content binding). + [_tableView setDataSource:self]; + [_tableView setDelegate:self]; + [_tableView setDraggingSourceOperationMask:CPDragOperationCopy | CPDragOperationMove forLocal:YES]; + [_tableView setDraggingSourceOperationMask:CPDragOperationCopy forLocal:NO]; + + if ([_tableView respondsToSelector:@selector(setAutosaveName:)]) + { + [_tableView setAutosaveName:@"SmartFoldersDemo.PeopleTable"]; + + if ([_tableView respondsToSelector:@selector(setAutosaveTableColumns:)]) + [_tableView setAutosaveTableColumns:YES]; + } + + [scrollView setDocumentView:_tableView]; + [pane addSubview:scrollView]; + + // --- Button bar with status text + var buttonBar = [[CPButtonBar alloc] initWithFrame:CGRectMake(0, CGRectGetHeight(aFrame) - barHeight, CGRectGetWidth(aFrame), barHeight)]; + [buttonBar setAutoresizingMask:CPViewWidthSizable | CPViewMinYMargin]; + + if ([buttonBar respondsToSelector:@selector(setHasResizeControl:)]) + [buttonBar setHasResizeControl:NO]; + + var plusButton = [CPButtonBar plusButton]; + [plusButton setTarget:nil]; + [plusButton setAction:@selector(newPerson:)]; + [plusButton setToolTip:@"Add a person"]; + + var minusButton = [CPButtonBar minusButton]; + [minusButton setTarget:nil]; + [minusButton setAction:@selector(removeSelectedPeople:)]; + [minusButton setToolTip:@"Remove the selected people (from the group, if a group is selected)"]; + [minusButton bind:CPEnabledBinding toObject:_arrayController withKeyPath:@"canRemove" options:nil]; + + [buttonBar setButtons:[plusButton, minusButton]]; + + _statusField = [CPTextField labelWithTitle:@""]; + [_statusField setFont:[CPFont systemFontOfSize:11.0]]; + [_statusField setTextColor:[CPColor colorWithWhite:0.35 alpha:1.0]]; + [_statusField setAlignment:CPRightTextAlignment]; + [_statusField setVerticalAlignment:CPCenterVerticalTextAlignment]; + [_statusField setFrame:CGRectMake(80, 0, CGRectGetWidth(aFrame) - 92, barHeight)]; + [_statusField setLineBreakMode:CPLineBreakByTruncatingHead]; + [_statusField setAutoresizingMask:CPViewWidthSizable]; + [buttonBar addSubview:_statusField]; + + [pane addSubview:buttonBar]; + + return pane; +} + +- (CPTextField)_textDataViewWithFormatter:(CPFormatter)aFormatter alignment:(CPTextAlignment)anAlignment +{ + var field = [[CPTextField alloc] initWithFrame:CGRectMakeZero()]; + + [field setAlignment:anAlignment]; + [field setVerticalAlignment:CPCenterVerticalTextAlignment]; + [field setLineBreakMode:CPLineBreakByTruncatingTail]; + + if (aFormatter) + [field setFormatter:aFormatter]; + + return field; +} + +- (CPTableColumn)_addColumn:(CPString)aKey title:(CPString)aTitle width:(float)aWidth editable:(BOOL)isEditable dataView:(CPView)aDataView +{ + var column = [[CPTableColumn alloc] initWithIdentifier:aKey], + selector = [@"firstName", @"lastName", @"department", @"city", @"email"].indexOf(aKey) !== -1 ? @selector(caseInsensitiveCompare:) : @selector(compare:); + + [[column headerView] setStringValue:aTitle]; + [column setWidth:aWidth]; + [column setMinWidth:36]; + [column setEditable:isEditable]; + [column setSortDescriptorPrototype:[CPSortDescriptor sortDescriptorWithKey:aKey ascending:YES selector:selector]]; + + if (!aDataView) + { + aDataView = [self _textDataViewWithFormatter:nil alignment:CPLeftTextAlignment]; + [aDataView setEditable:isEditable]; + } + + [column setDataView:aDataView]; + [_tableView addTableColumn:column]; + [column bind:CPValueBinding toObject:_arrayController withKeyPath:@"arrangedObjects." + aKey options:nil]; + + return column; +} + +// ---------------------------------------------------------------- Content + +- (void)setPeople:(CPArray)thePeople filterPredicate:(CPPredicate)aPredicate title:(CPString)aTitle +{ + var selected = [_arrayController selectedObjects] || []; + + _contentTitle = aTitle || @""; + + try + { + [_arrayController setFilterPredicate:aPredicate]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: filter predicate failed: " + e); + [_arrayController setFilterPredicate:nil]; + } + + [_arrayController setContent:[thePeople copy]]; + [_arrayController setSelectedObjects:selected]; + + [self _updateStatus]; +} + +- (void)rearrange +{ + [_arrayController rearrangeObjects]; + [self _updateStatus]; +} + +- (CPArray)selectedPeople +{ + return [_arrayController selectedObjects] || []; +} + +- (void)selectPerson:(Person)aPerson edit:(BOOL)shouldEdit +{ + [_arrayController setSelectedObjects:[aPerson]]; + + var row = [[_arrayController arrangedObjects] indexOfObjectIdenticalTo:aPerson]; + + if (row === CPNotFound) + return; + + [_tableView scrollRowToVisible:row]; + + if (shouldEdit) + { + [[_tableView window] makeFirstResponder:_tableView]; + [_tableView editColumn:[_tableView columnWithIdentifier:@"firstName"] row:row withEvent:nil select:YES]; + } +} + +- (void)focus +{ + [[_tableView window] makeFirstResponder:_tableView]; +} + +// ---------------------------------------------------------------- Detail pane + +- (BOOL)isDetailVisible +{ + var detailView = [[_splitView subviews] objectAtIndex:1]; + return ![_splitView isSubviewCollapsed:detailView] && CGRectGetHeight([detailView frame]) > 0; +} + +- (void)toggleDetail +{ + var total = CGRectGetHeight([_splitView bounds]), + divider = [_splitView dividerThickness]; + + if ([self isDetailVisible]) + { + _lastDetailHeight = CGRectGetHeight([[[_splitView subviews] objectAtIndex:1] frame]); + [_splitView setPosition:total ofDividerAtIndex:0]; + } + else + [_splitView setPosition:total - divider - MAX(_lastDetailHeight, 150) ofDividerAtIndex:0]; +} + +// Split view delegate (detail pane) +- (BOOL)splitView:(CPSplitView)aSplitView canCollapseSubview:(CPView)aSubview +{ + return aSubview === [[aSplitView subviews] objectAtIndex:1]; +} + +- (float)splitView:(CPSplitView)aSplitView constrainMinCoordinate:(float)proposedMin ofSubviewAt:(int)anIndex +{ + return 120.0; +} + +- (float)splitView:(CPSplitView)aSplitView constrainMaxCoordinate:(float)proposedMax ofSubviewAt:(int)anIndex +{ + return CGRectGetHeight([aSplitView bounds]) - 150.0; +} + +- (void)splitView:(CPSplitView)aSplitView resizeSubviewsWithOldSize:(CGSize)oldSize +{ + // Keep the detail pane at a fixed height while the window resizes. + var subviews = [aSplitView subviews], + top = subviews[0], + bottom = subviews[1], + size = [aSplitView bounds].size, + divider = [aSplitView dividerThickness], + bottomHeight = [aSplitView isSubviewCollapsed:bottom] ? 0 : CGRectGetHeight([bottom frame]); + + bottomHeight = MIN(bottomHeight, MAX(0, size.height - divider - 120)); + + [top setFrame:CGRectMake(0, 0, size.width, MAX(0, size.height - bottomHeight - divider))]; + [bottom setFrame:CGRectMake(0, size.height - bottomHeight, size.width, bottomHeight)]; + + // A custom resizeSubviewsWithOldSize: bypasses CPSplitView's own layout pass, + // so the divider must be told to redraw at the new width. + [aSplitView setNeedsDisplay:YES]; +} + +// ---------------------------------------------------------------- Pasteboard helpers + +- (CPString)tabSeparatedStringForPeople:(CPArray)thePeople +{ + var lines = [[Person tabSeparatedHeader]]; + + for (var i = 0; i < thePeople.length; i++) + lines.push([thePeople[i] tabSeparatedRepresentation]); + + return lines.join("\n"); +} + +- (CPArray)peopleFromTabSeparatedString:(CPString)aString +{ + var lines = (aString || "").split(/\r?\n/), + result = []; + + for (var i = 0; i < lines.length; i++) + { + var line = lines[i]; + + if (!line || line === [Person tabSeparatedHeader]) + continue; + + var parts = line.split("\t"); + + if (parts.length < 2) + continue; + + var person = [[Person alloc] init]; + [person setFirstName:parts[0] || @""]; + [person setLastName:parts[1] || @""]; + [person setEmail:parts[2] || @""]; + [person setDepartment:parts[3] || LibraryDepartments[0]]; + [person setCity:parts[4] || @""]; + [person setSalary:parseInt(parts[5], 10) || 0]; + + result.push(person); + } + + return result; +} + +- (void)copySelectionToPasteboard:(CPPasteboard)aPasteboard +{ + var people = [self selectedPeople]; + + if (![people count]) + return; + + [aPasteboard declareTypes:[CPStringPboardType, PeopleDragType] owner:nil]; + [aPasteboard setString:[self tabSeparatedStringForPeople:people] forType:CPStringPboardType]; + [aPasteboard setData:[CPKeyedArchiver archivedDataWithRootObject:[people valueForKey:@"uid"]] forType:PeopleDragType]; +} + +// ---------------------------------------------------------------- Table data source: drag source + +- (BOOL)tableView:(CPTableView)aTableView writeRowsWithIndexes:(CPIndexSet)theRows toPasteboard:(CPPasteboard)aPasteboard +{ + var people = [[_arrayController arrangedObjects] objectsAtIndexes:theRows]; + + if (![people count]) + return NO; + + [aPasteboard declareTypes:[PeopleDragType, CPStringPboardType] owner:self]; + [aPasteboard setData:[CPKeyedArchiver archivedDataWithRootObject:[people valueForKey:@"uid"]] forType:PeopleDragType]; + [aPasteboard setString:[self tabSeparatedStringForPeople:people] forType:CPStringPboardType]; + + return YES; +} + +// ---------------------------------------------------------------- Table delegate + +- (void)tableViewDeleteKeyPressed:(CPTableView)aTableView +{ + [CPApp sendAction:@selector(removeSelectedPeople:) to:nil from:aTableView]; +} + +// ---------------------------------------------------------------- KVO / status + +- (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext +{ + [self _updateStatus]; +} + +- (void)_updateStatus +{ + if (!_statusField) + return; + + var shown = [[_arrayController arrangedObjects] count], + total = [[_arrayController content] count], + selected = [[_arrayController selectionIndexes] count], + text = [CPString stringWithFormat:@"%@ — %d of %d people", _contentTitle, shown, total]; + + if (selected) + text += [CPString stringWithFormat:@", %d selected", selected]; + + [_statusField setStringValue:text]; +} + +@end diff --git a/Resources/SmartFoldersDemo/Person.j b/Resources/SmartFoldersDemo/Person.j new file mode 100644 index 000000000..4f15f25cc --- /dev/null +++ b/Resources/SmartFoldersDemo/Person.j @@ -0,0 +1,141 @@ +/* + * Person.j + * SmartFoldersDemo + * + * The record type shown in the people table. Plain KVC/KVO compliant model + * object: every property is exercised by bindings (table columns, detail + * pane), by CPPredicate evaluation (smart folders, search) and by + * CPSortDescriptor (column sorting). + * + * "age" and "fullName" are derived properties registered with + * +keyPathsForValuesAffecting so that KVO dependent-key support is + * exercised as well. + */ + +@import + +var PersonUIDCounter = 0; + +@implementation Person : CPObject +{ + CPString uid @accessors; + CPString firstName @accessors; + CPString lastName @accessors; + CPString email @accessors; + CPString department @accessors; + CPString city @accessors; + CPDate birthDate @accessors; + CPNumber salary @accessors; + BOOL isManager @accessors; +} + ++ (CPString)nextUID +{ + return @"p" + (new Date()).getTime().toString(36) + "-" + (++PersonUIDCounter); +} + ++ (CPSet)keyPathsForValuesAffectingAge +{ + return [CPSet setWithObject:@"birthDate"]; +} + ++ (CPSet)keyPathsForValuesAffectingFullName +{ + return [CPSet setWithObjects:@"firstName", @"lastName"]; +} + +- (id)init +{ + return [self initWithUID:[Person nextUID]]; +} + +- (id)initWithUID:(CPString)aUID +{ + if (self = [super init]) + { + uid = aUID; + firstName = @""; + lastName = @""; + email = @""; + department = @"Engineering"; + city = @""; + birthDate = new Date(1990, 0, 1); + salary = 50000; + isManager = NO; + } + + return self; +} + +- (id)initWithDictionary:(CPDictionary)aDictionary +{ + if (self = [self initWithUID:[aDictionary objectForKey:@"uid"] || [Person nextUID]]) + { + firstName = [aDictionary objectForKey:@"firstName"] || @""; + lastName = [aDictionary objectForKey:@"lastName"] || @""; + email = [aDictionary objectForKey:@"email"] || @""; + department = [aDictionary objectForKey:@"department"] || @""; + city = [aDictionary objectForKey:@"city"] || @""; + + var interval = [aDictionary objectForKey:@"birthDate"]; + birthDate = (interval !== nil && interval !== undefined) ? [CPDate dateWithTimeIntervalSince1970:interval] : nil; + + salary = Number([aDictionary objectForKey:@"salary"] || 0); + isManager = !![aDictionary objectForKey:@"isManager"]; + } + + return self; +} + +// Property-list safe representation used for CPUserDefaults persistence. +- (CPDictionary)dictionaryRepresentation +{ + return [CPDictionary dictionaryWithObjectsAndKeys: + uid, @"uid", + firstName || @"", @"firstName", + lastName || @"", @"lastName", + email || @"", @"email", + department || @"", @"department", + city || @"", @"city", + birthDate ? [birthDate timeIntervalSince1970] : 0, @"birthDate", + Number(salary || 0), @"salary", + isManager ? 1 : 0, @"isManager"]; +} + +- (int)age +{ + if (!birthDate) + return 0; + + var now = new Date(), + years = now.getFullYear() - birthDate.getFullYear(), + months = now.getMonth() - birthDate.getMonth(); + + if (months < 0 || (months === 0 && now.getDate() < birthDate.getDate())) + years--; + + return years; +} + +- (CPString)fullName +{ + return [[CPString stringWithFormat:@"%@ %@", firstName || @"", lastName || @""] stringByTrimmingWhitespace]; +} + +// One line of tab separated text; used for copy / paste and drag & drop. +- (CPString)tabSeparatedRepresentation +{ + return [firstName || @"", lastName || @"", email || @"", department || @"", city || @"", String(salary || 0)].join("\t"); +} + ++ (CPString)tabSeparatedHeader +{ + return ["First Name", "Last Name", "Email", "Department", "City", "Salary"].join("\t"); +} + +- (CPString)description +{ + return [CPString stringWithFormat:@"", uid, [self fullName]]; +} + +@end diff --git a/Resources/SmartFoldersDemo/PersonDetailViewController.j b/Resources/SmartFoldersDemo/PersonDetailViewController.j new file mode 100644 index 000000000..28de9481e --- /dev/null +++ b/Resources/SmartFoldersDemo/PersonDetailViewController.j @@ -0,0 +1,180 @@ +/* + * PersonDetailViewController.j + * SmartFoldersDemo + * + * Detail pane below the people table. Every control is bound to + * arrayController.selection., which exercises CPControllerSelectionProxy, + * placeholder binding options (no selection / multiple values / null) and a + * read-only binding to a KVO-dependent derived key ("age"). + * + * Controls covered: CPTextField (+formatter), CPComboBox, CPPopUpButton + * (selectedValue binding), CPDatePicker, CPCheckBox, CPBox. + */ + +@import +@import "Library.j" + +// Layout grid. Every row is RowHeight tall; labels and controls are centred on +// the row's midline regardless of each control's intrinsic height. +var RowHeight = 30.0, + RowPitch = 36.0, + LabelWidth = 90.0, + LabelGap = 8.0, + FieldWidth = 230.0, + ColumnGap = 40.0, + TextFieldHeight = 29.0, + PopUpHeight = 25.0, + DatePickerHeight= 25.0; + +PersonDetailPreferredHeight = 5 * RowPitch + 52.0; + +@implementation PersonDetailViewController : CPViewController +{ + CPArrayController _arrayController; +} + +- (id)initWithArrayController:(CPArrayController)anArrayController +{ + if (self = [super initWithCibName:nil bundle:nil]) + _arrayController = anArrayController; + + return self; +} + +- (CPDictionary)_placeholderOptions +{ + return [CPDictionary dictionaryWithObjectsAndKeys: + @"No Selection", CPNoSelectionPlaceholderBindingOption, + @"Multiple Values", CPMultipleValuesPlaceholderBindingOption, + @"", CPNullPlaceholderBindingOption]; +} + +- (void)loadView +{ + var frame = CGRectMake(0, 0, 800, PersonDetailPreferredHeight), + view = [[CPView alloc] initWithFrame:frame]; + + [view setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [view setBackgroundColor:[CPColor colorWithWhite:0.96 alpha:1.0]]; + + var box = [[CPBox alloc] initWithFrame:CGRectInset(frame, 12, 8)]; + [box setTitle:@"Selected Person"]; + [box setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [view addSubview:box]; + + var content = [box contentView], + options = [self _placeholderOptions], + left = 12.0, + right = left + LabelWidth + LabelGap + FieldWidth + ColumnGap; + + // ---- Left column + [[self _textFieldInRow:0 column:left label:@"First Name:" in:content] + bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.firstName" options:options]; + + [[self _textFieldInRow:1 column:left label:@"Last Name:" in:content] + bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.lastName" options:options]; + + [[self _textFieldInRow:2 column:left label:@"Email:" in:content] + bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.email" options:options]; + + [self _labelInRow:3 column:left title:@"Department:" in:content]; + var department = [[CPPopUpButton alloc] initWithFrame:CGRectMakeZero() pullsDown:NO]; + [department addItemsWithTitles:LibraryDepartments]; + [self _place:department row:3 x:left + LabelWidth + LabelGap width:FieldWidth height:PopUpHeight in:content]; + [department bind:CPSelectedValueBinding toObject:_arrayController withKeyPath:@"selection.department" options:nil]; + + var manager = [CPCheckBox checkBoxWithTitle:@"Manager"]; + [manager setAllowsMixedState:YES]; + [manager sizeToFit]; + [self _place:manager row:4 x:left + LabelWidth + LabelGap width:CGRectGetWidth([manager frame]) height:CGRectGetHeight([manager frame]) in:content]; + [manager bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.isManager" options:nil]; + + // ---- Right column + [self _labelInRow:0 column:right title:@"City:" in:content]; + var city = [[CPComboBox alloc] initWithFrame:CGRectMakeZero()]; + [city addItemsWithObjectValues:LibraryCities]; + [city setEditable:YES]; + [self _place:city row:0 x:right + LabelWidth + LabelGap width:FieldWidth height:TextFieldHeight in:content]; + [city bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.city" options:options]; + + [self _labelInRow:1 column:right title:@"Birth Date:" in:content]; + var birthDate = [[CPDatePicker alloc] initWithFrame:CGRectMake(0, 0, 140, DatePickerHeight)]; + [birthDate setDatePickerStyle:CPTextFieldAndStepperDatePickerStyle]; + [birthDate setDatePickerElements:CPYearMonthDayDatePickerElementFlag]; + [self _place:birthDate row:1 x:right + LabelWidth + LabelGap width:140 height:CGRectGetHeight([birthDate frame]) in:content]; + [birthDate bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.birthDate" options:nil]; + + [self _labelInRow:2 column:right title:@"Age:" in:content]; + var age = [self _valueLabelInRow:2 x:right + LabelWidth + LabelGap in:content]; + [age bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.age" options:options]; + + var salary = [self _textFieldInRow:3 column:right label:@"Salary:" in:content], + numberFormatter = [[CPNumberFormatter alloc] init]; + [numberFormatter setNumberStyle:CPNumberFormatterDecimalStyle]; + [salary setFormatter:numberFormatter]; + [salary setAlignment:CPRightTextAlignment]; + [salary setFrameSize:CGSizeMake(140, CGRectGetHeight([salary frame]))]; + [salary bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.salary" options:options]; + + [self _labelInRow:4 column:right title:@"Record ID:" in:content]; + var uid = [self _valueLabelInRow:4 x:right + LabelWidth + LabelGap in:content]; + [uid setSelectable:YES]; + [uid setTextColor:[CPColor grayColor]]; + [uid bind:CPValueBinding toObject:_arrayController withKeyPath:@"selection.uid" options:options]; + + [self setView:view]; +} + +// ---------------------------------------------------------------- Layout helpers + +- (float)_rowTop:(int)aRow +{ + return 10.0 + aRow * RowPitch; +} + +// Centres aView vertically on the row's midline. +- (void)_place:(CPView)aView row:(int)aRow x:(float)anX width:(float)aWidth height:(float)aHeight in:(CPView)aSuperview +{ + var y = [self _rowTop:aRow] + Math.round((RowHeight - aHeight) / 2); + + [aView setFrame:CGRectMake(anX, y, aWidth, aHeight)]; + [aSuperview addSubview:aView]; +} + +- (CPTextField)_labelInRow:(int)aRow column:(float)anX title:(CPString)aTitle in:(CPView)aSuperview +{ + var label = [CPTextField labelWithTitle:aTitle]; + + [label setAlignment:CPRightTextAlignment]; + [label setVerticalAlignment:CPCenterVerticalTextAlignment]; + [label setTextColor:[CPColor colorWithWhite:0.25 alpha:1.0]]; + [self _place:label row:aRow x:anX width:LabelWidth height:RowHeight in:aSuperview]; + + return label; +} + +- (CPTextField)_valueLabelInRow:(int)aRow x:(float)anX in:(CPView)aSuperview +{ + var label = [CPTextField labelWithTitle:@""]; + + [label setVerticalAlignment:CPCenterVerticalTextAlignment]; + [self _place:label row:aRow x:anX + 4 width:FieldWidth - 4 height:RowHeight in:aSuperview]; + + return label; +} + +- (CPTextField)_textFieldInRow:(int)aRow column:(float)anX label:(CPString)aTitle in:(CPView)aSuperview +{ + [self _labelInRow:aRow column:anX title:aTitle in:aSuperview]; + + var field = [[CPTextField alloc] initWithFrame:CGRectMakeZero()]; + + [field setEditable:YES]; + [field setBezeled:YES]; + [field setVerticalAlignment:CPCenterVerticalTextAlignment]; + [self _place:field row:aRow x:anX + LabelWidth + LabelGap width:FieldWidth height:TextFieldHeight in:aSuperview]; + + return field; +} + +@end diff --git a/Resources/SmartFoldersDemo/README.md b/Resources/SmartFoldersDemo/README.md new file mode 100644 index 000000000..101cca2de --- /dev/null +++ b/Resources/SmartFoldersDemo/README.md @@ -0,0 +1,40 @@ +# SmartFoldersDemo + +Non-document-based Cappuccino integration test, built entirely in code (no cib). + +Copy `index.html`, `index-debug.html` and `Jakefile` from `Tests/Manual/SmartFoldersTest` +(they are toolchain specific and need no changes apart from the title). No `Resources/` +are required: all icons and badges are drawn with `CPBezierPath`. + +## Structure + +| File | Role | +|---|---| +| `main.j`, `Info.plist` | entry point, `CPApplicationDelegateClass` = `AppController` | +| `AppController.j` | creates model, main menu, main window controller | +| `MainWindowController.j` | bridge window, toolbar, sidebar split view, routed actions, menu validation | +| `SourceListViewController.j` | view-based `CPOutlineView` source list, badges, drop target, button bar | +| `PeopleViewController.j` | `CPArrayController` + bound `CPTableView`, drag source, pasteboard, detail split | +| `PersonDetailViewController.j` | controls bound to `selection.*` | +| `SmartFolderEditorController.j` | sheet with `CPPredicateEditor` built from row templates | +| `NameSheetController.j` | small reusable name sheet | +| `Library.j`, `Person.j` | model, KVO observation, `CPUserDefaults` persistence, sample data | + +## Manual test checklist + +1. Launch: sidebar shows LIBRARY / GROUPS / SMART FOLDERS with count badges; table shows 240 people. +2. Click each smart folder: table filters, status line shows "n of 240", badge equals n. +3. Double-click a smart folder: sheet opens with the stored predicate reconstructed in the editor + (incl. nested compound and NOT). Add/remove rows: sheet resizes, match count and format update live. +4. OK: folder renamed/updated, badge updated. Cancel: nothing changes. +5. "Müller (diacritics test)" must match Müller with predicate `muller` (`[cd]`). +6. Type in the search field: filter combines with the selected folder (AND). +7. Sort by every column header; reorder and resize columns; reload: column layout restored. +8. Select people, drag onto a group: badge increases. Drag onto the GROUPS header: new group created and selected. +9. Drag groups / smart folders to reorder them within their section; reload: order persisted. +10. Edit a cell (name, salary, Mgr checkbox) and the detail pane fields: other views and badges follow; + multi-select shows "Multiple Values"; changing Birth Date updates Age (dependent key). +11. Copy (⌘C) then Paste (⌘V): duplicated people appear selected. Delete key removes people + (from the group only, when a group is selected). +12. View menu / toolbar: hide and show sidebar and detail pane; drag the sidebar resize control in the button bar. +13. File ▸ Reset Library…: alert sheet, then sample data restored. diff --git a/Resources/SmartFoldersDemo/SmartFolderEditorController.j b/Resources/SmartFoldersDemo/SmartFolderEditorController.j new file mode 100644 index 000000000..cd69152f9 --- /dev/null +++ b/Resources/SmartFoldersDemo/SmartFolderEditorController.j @@ -0,0 +1,390 @@ +/* + * SmartFolderEditorController.j + * SmartFoldersDemo + * + * Document-modal sheet for creating / editing a smart folder. Exercises: + * - CPWindowController owning a CPDocModalWindowMask window, run as a sheet + * with -beginSheet:modalForWindow:modalDelegate:didEndSelector:contextInfo: + * - CPPredicateEditor (CPRuleEditor subclass) built entirely from + * CPPredicateEditorRowTemplate instances: compound (AND/OR/NOT), + * key path + string attribute type with [cd] options, key path + integer + * attribute type, key path + constant right expressions (pop-up) + * - round trip predicate -> editor (setObjectValue:) -> predicate + * - CPRuleEditorRowsDidChangeNotification + target/action for live preview + * - resizing a sheet while it is attached + * - default button / escape key equivalent + */ + +@import +@import "Library.j" + +var EditorWidth = 680.0, + EditorChromeHeight = 200.0, + EditorMinHeight = 300.0, + EditorMaxHeight = 600.0, + EditorRowHeight = 28.0, + EditorTitles = { + "firstName" : "First Name", + "lastName" : "Last Name", + "email" : "Email", + "city" : "City", + "department" : "Department", + "age" : "Age", + "salary" : "Salary" + }; + + +// -------------------------------------------------------------------------------- +// LabeledRowTemplate – shows human readable titles for key path expressions +// -------------------------------------------------------------------------------- + +@implementation LabeledRowTemplate : CPPredicateEditorRowTemplate +{ +} + +- (CPArray)templateViews +{ + var views = [super templateViews], + first = [views firstObject]; + + if ([first isKindOfClass:[CPPopUpButton class]]) + { + var items = [first itemArray]; + + for (var i = 0; i < items.length; i++) + { + var expression = [items[i] representedObject]; + + if ([expression respondsToSelector:@selector(expressionType)] && [expression expressionType] === CPKeyPathExpressionType) + { + var title = EditorTitles[[expression keyPath]]; + + if (title && [items[i] title] !== title) + [items[i] setTitle:title]; + } + } + } + + return views; +} + +@end + + +// -------------------------------------------------------------------------------- +// SmartFolderEditorController +// -------------------------------------------------------------------------------- + +@implementation SmartFolderEditorController : CPWindowController +{ + Library _library; + CPTextField _nameField; + CPScrollView _editorScrollView; + CPPredicateEditor _predicateEditor; + CPTextField _formatField; + CPTextField _matchField; + CPButton _okButton; + + SmartFolder _folder; + Function _completion; +} + +- (id)initWithLibrary:(Library)aLibrary +{ + var window = [[CPWindow alloc] initWithContentRect:CGRectMake(0, 0, EditorWidth, EditorMinHeight) styleMask:CPDocModalWindowMask]; + + if (self = [super initWithWindow:window]) + { + _library = aLibrary; + [self _buildInterface]; + + [[CPNotificationCenter defaultCenter] addObserver:self + selector:@selector(ruleEditorRowsDidChange:) + name:CPRuleEditorRowsDidChangeNotification + object:_predicateEditor]; + } + + return self; +} + +- (void)_buildInterface +{ + var window = [self window], + content = [window contentView], + bounds = [content bounds], + width = CGRectGetWidth(bounds), + height = CGRectGetHeight(bounds); + + var title = [CPTextField labelWithTitle:@"Smart Folder"]; + [title setFont:[CPFont boldSystemFontOfSize:13.0]]; + [title setFrameOrigin:CGPointMake(20, 14)]; + [content addSubview:title]; + + var nameLabel = [CPTextField labelWithTitle:@"Name:"]; + [nameLabel setAlignment:CPRightTextAlignment]; + [nameLabel setFrame:CGRectMake(20, 46, 60, 20)]; + [content addSubview:nameLabel]; + + _nameField = [[CPTextField alloc] initWithFrame:CGRectMake(85, 40, width - 105, 29)]; + [_nameField setEditable:YES]; + [_nameField setBezeled:YES]; + [_nameField setAutoresizingMask:CPViewWidthSizable]; + [_nameField setPlaceholderString:@"Smart folder name"]; + [content addSubview:_nameField]; + + var matchLabel = [CPTextField labelWithTitle:@"Match people where:"]; + [matchLabel setFrameOrigin:CGPointMake(20, 80)]; + [content addSubview:matchLabel]; + + // --- Predicate editor inside a scroll view + var top = 102.0, + bottomChrome = EditorChromeHeight - top; + + _editorScrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(20, top, width - 40, height - top - bottomChrome)]; + [_editorScrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_editorScrollView setAutohidesScrollers:YES]; + [_editorScrollView setHasHorizontalScroller:NO]; + [_editorScrollView setBorderType:CPBezelBorder]; + + _predicateEditor = [[CPPredicateEditor alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth([[_editorScrollView contentView] bounds]), EditorRowHeight)]; + [_predicateEditor setAutoresizingMask:CPViewWidthSizable]; + [_predicateEditor setRowHeight:EditorRowHeight]; + [_predicateEditor setNestingMode:CPRuleEditorNestingModeCompound]; + [_predicateEditor setCanRemoveAllRows:NO]; + [_predicateEditor setRowTemplates:[self _rowTemplates]]; + [_predicateEditor setTarget:self]; + [_predicateEditor setAction:@selector(predicateEditorChanged:)]; + + [_editorScrollView setDocumentView:_predicateEditor]; + [content addSubview:_editorScrollView]; + + // --- Footer + _formatField = [CPTextField labelWithTitle:@""]; + [_formatField setFrame:CGRectMake(20, height - 88, width - 40, 18)]; + [_formatField setAutoresizingMask:CPViewWidthSizable | CPViewMinYMargin]; + [_formatField setFont:[CPFont fontWithName:@"Menlo, Courier" size:11.0]]; + [_formatField setTextColor:[CPColor grayColor]]; + [_formatField setLineBreakMode:CPLineBreakByTruncatingTail]; + [_formatField setSelectable:YES]; + [content addSubview:_formatField]; + + _matchField = [CPTextField labelWithTitle:@""]; + [_matchField setFrame:CGRectMake(20, height - 40, 300, 20)]; + [_matchField setAutoresizingMask:CPViewMaxXMargin | CPViewMinYMargin]; + [content addSubview:_matchField]; + + var cancelButton = [CPButton buttonWithTitle:@"Cancel"]; + [cancelButton setFrame:CGRectMake(width - 200, height - 44, 85, 25)]; + [cancelButton setAutoresizingMask:CPViewMinXMargin | CPViewMinYMargin]; + [cancelButton setTag:CPCancelButton]; + [cancelButton setTarget:self]; + [cancelButton setAction:@selector(closeSheet:)]; + [cancelButton setKeyEquivalent:CPEscapeFunctionKey]; + [content addSubview:cancelButton]; + + _okButton = [CPButton buttonWithTitle:@"OK"]; + [_okButton setFrame:CGRectMake(width - 105, height - 44, 85, 25)]; + [_okButton setAutoresizingMask:CPViewMinXMargin | CPViewMinYMargin]; + [_okButton setTag:CPOKButton]; + [_okButton setTarget:self]; + [_okButton setAction:@selector(closeSheet:)]; + [content addSubview:_okButton]; + + [window setDefaultButton:_okButton]; +} + +- (CPArray)_rowTemplates +{ + var keyPaths = function(keys) + { + var result = []; + + for (var i = 0; i < keys.length; i++) + result.push([CPExpression expressionForKeyPath:keys[i]]); + + return result; + }; + + var compound = [[CPPredicateEditorRowTemplate alloc] initWithCompoundTypes:[CPAndPredicateType, CPOrPredicateType, CPNotPredicateType]]; + + var strings = [[LabeledRowTemplate alloc] initWithLeftExpressions:keyPaths([@"lastName", @"firstName", @"email", @"city"]) + rightExpressionAttributeType:CPStringAttributeType + modifier:CPDirectPredicateModifier + operators:[CPContainsPredicateOperatorType, + CPBeginsWithPredicateOperatorType, + CPEndsWithPredicateOperatorType, + CPEqualToPredicateOperatorType, + CPNotEqualToPredicateOperatorType, + CPMatchesPredicateOperatorType] + options:CPCaseInsensitivePredicateOption | CPDiacriticInsensitivePredicateOption]; + + var numbers = [[LabeledRowTemplate alloc] initWithLeftExpressions:keyPaths([@"age", @"salary"]) + rightExpressionAttributeType:CPInteger32AttributeType + modifier:CPDirectPredicateModifier + operators:[CPEqualToPredicateOperatorType, + CPNotEqualToPredicateOperatorType, + CPLessThanPredicateOperatorType, + CPLessThanOrEqualToPredicateOperatorType, + CPGreaterThanPredicateOperatorType, + CPGreaterThanOrEqualToPredicateOperatorType] + options:0]; + + var departmentValues = []; + + for (var i = 0; i < LibraryDepartments.length; i++) + departmentValues.push([CPExpression expressionForConstantValue:LibraryDepartments[i]]); + + var departments = [[LabeledRowTemplate alloc] initWithLeftExpressions:keyPaths([@"department"]) + rightExpressions:departmentValues + modifier:CPDirectPredicateModifier + operators:[CPEqualToPredicateOperatorType, CPNotEqualToPredicateOperatorType] + options:0]; + + return [compound, strings, numbers, departments]; +} + +// ---------------------------------------------------------------- Running the sheet + +// aFolder == nil creates a new folder. aCallback(folderOrNil) is invoked after OK. +- (void)beginEditingFolder:(SmartFolder)aFolder modalForWindow:(CPWindow)aWindow completion:(Function)aCallback +{ + _folder = aFolder; + _completion = aCallback; + + [_nameField setStringValue:aFolder ? [aFolder name] : @"Untitled Smart Folder"]; + + var predicate = aFolder ? [aFolder predicate] : [CPPredicate predicateWithFormat:@"lastName CONTAINS[cd] %@", @""]; + [self _setEditorPredicate:predicate]; + + [CPApp beginSheet:[self window] + modalForWindow:aWindow + modalDelegate:self + didEndSelector:@selector(sheetDidEnd:returnCode:contextInfo:) + contextInfo:nil]; + + [[self window] makeFirstResponder:_nameField]; + [_nameField selectText:self]; + + [self _updatePreview]; + [self _fitWindowToRows]; +} + +- (void)_setEditorPredicate:(CPPredicate)aPredicate +{ + // In compound nesting mode the root row must be a compound predicate. + if (![aPredicate isKindOfClass:[CPCompoundPredicate class]]) + aPredicate = [CPCompoundPredicate andPredicateWithSubpredicates:[aPredicate]]; + + try + { + [_predicateEditor setObjectValue:aPredicate]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: CPPredicateEditor could not represent " + [aPredicate predicateFormat] + ": " + e); + [_predicateEditor setObjectValue:[CPCompoundPredicate andPredicateWithSubpredicates:[[CPPredicate predicateWithFormat:@"lastName CONTAINS[cd] %@", @""]]]]; + } +} + +- (CPPredicate)_currentPredicate +{ + try + { + [_predicateEditor reloadPredicate]; + return [_predicateEditor predicate]; + } + catch (e) + { + CPLog.warn("SmartFoldersDemo: could not build predicate: " + e); + return nil; + } +} + +- (void)closeSheet:(id)sender +{ + if ([sender tag] === CPOKButton) + { + if (![[[_nameField stringValue] stringByTrimmingWhitespace] length]) + { + [[self window] makeFirstResponder:_nameField]; + return; + } + + if (![self _currentPredicate]) + return; + } + + [CPApp endSheet:[self window] returnCode:[sender tag]]; +} + +- (void)sheetDidEnd:(CPWindow)aSheet returnCode:(int)aReturnCode contextInfo:(id)aContext +{ + [aSheet orderOut:self]; + + var callback = _completion, + folder = _folder; + + _completion = nil; + _folder = nil; + + if (aReturnCode !== CPOKButton) + return; + + var name = [[_nameField stringValue] stringByTrimmingWhitespace], + predicate = [self _currentPredicate]; + + if (folder) + [_library updateSmartFolder:folder name:name predicate:predicate]; + else + folder = [_library addSmartFolderWithName:name predicate:predicate]; + + if (callback) + callback(folder); +} + +// ---------------------------------------------------------------- Live preview + +- (void)predicateEditorChanged:(id)sender +{ + [self _updatePreview]; +} + +- (void)ruleEditorRowsDidChange:(CPNotification)aNotification +{ + [self _updatePreview]; + [self _fitWindowToRows]; +} + +- (void)_updatePreview +{ + var predicate = [self _currentPredicate], + matches = predicate ? [_library peopleMatchingPredicate:predicate] : nil; + + [_formatField setStringValue:predicate ? [predicate predicateFormat] : @"(incomplete predicate)"]; + + if (matches) + [_matchField setStringValue:[CPString stringWithFormat:@"%d of %d people match", [matches count], [[_library people] count]]]; + else + [_matchField setStringValue:@"Predicate cannot be evaluated"]; + + [_okButton setEnabled:!!matches]; +} + +- (void)_fitWindowToRows +{ + var window = [self window], + rows = MAX([_predicateEditor numberOfRows], 1), + desired = EditorChromeHeight + rows * EditorRowHeight + 6, + height = MIN(MAX(desired, EditorMinHeight), EditorMaxHeight), + frame = [window frame]; + + // Make sure the editor's own frame tracks its rows so the scroll view can scroll. + [_predicateEditor setFrameSize:CGSizeMake(CGRectGetWidth([[_editorScrollView contentView] bounds]), rows * EditorRowHeight)]; + + if (ABS(CGRectGetHeight(frame) - height) < 1) + return; + + [window setFrame:CGRectMake(CGRectGetMinX(frame), CGRectGetMinY(frame), CGRectGetWidth(frame), height) display:YES animate:NO]; +} + +@end diff --git a/Resources/SmartFoldersDemo/SourceListViewController.j b/Resources/SmartFoldersDemo/SourceListViewController.j new file mode 100644 index 000000000..49f2b0353 --- /dev/null +++ b/Resources/SmartFoldersDemo/SourceListViewController.j @@ -0,0 +1,721 @@ +/* + * SourceListViewController.j + * SmartFoldersDemo + * + * Sidebar. Exercises: + * - CPViewController with a programmatic -loadView + * - view-based CPOutlineView (outlineView:viewForTableColumn:item:, + * makeViewWithIdentifier:owner:, CPTableCellView subclass) + * - source list highlight style, expand/collapse, non-selectable headers + * - custom drawing (CPBezierPath) for icons and count badges + * - drag & drop: reordering groups / smart folders inside their section, + * dropping people (from the table) onto a group, or onto the GROUPS + * header to create a new group + * - CPButtonBar with plus / minus / action pop-up and a split view + * resize control + */ + +@import +@import "Library.j" + +var SourceCellIdentifier = @"SourceCell", + SourceRowHeight = 26.0; + +// Tags of the action pop-up items. +SourceActionNewGroup = 1; +SourceActionNewSmartFolder = 2; +SourceActionEdit = 3; +SourceActionRename = 4; +SourceActionDelete = 5; + + +// -------------------------------------------------------------------------------- +// SourceIconView – small vector icon drawn with CPBezierPath +// -------------------------------------------------------------------------------- + +@implementation SourceIconView : CPView +{ + CPString _iconKind; + BOOL _isHighlighted; +} + +- (void)setKind:(CPString)aKind +{ + if (_iconKind === aKind) + return; + + _iconKind = aKind; + [self setNeedsDisplay:YES]; +} + +- (void)setSelected:(BOOL)aFlag +{ + if (_isHighlighted === aFlag) + return; + + _isHighlighted = aFlag; + [self setNeedsDisplay:YES]; +} + +- (void)drawRect:(CGRect)aRect +{ + var bounds = [self bounds], + w = CGRectGetWidth(bounds), + h = CGRectGetHeight(bounds); + + if (_iconKind === @"all") + { + var color = _isHighlighted ? [CPColor whiteColor] : [CPColor colorWithRed:0.33 green:0.45 blue:0.62 alpha:1.0], + head = [CPBezierPath bezierPath], + body = [CPBezierPath bezierPath]; + + [color set]; + [head appendBezierPathWithOvalInRect:CGRectMake(w / 2 - 3.5, 1.5, 7, 7)]; + [head fill]; + [body appendBezierPathWithRoundedRect:CGRectMake(w / 2 - 6.5, 9.5, 13, 6) xRadius:3 yRadius:3]; + [body fill]; + return; + } + + // Folder shape for groups and smart folders. + var fill = _isHighlighted ? [CPColor whiteColor] : + (_iconKind === @"smart" ? [CPColor colorWithRed:0.55 green:0.42 blue:0.78 alpha:1.0] + : [CPColor colorWithRed:0.38 green:0.62 blue:0.88 alpha:1.0]), + folder = [CPBezierPath bezierPath]; + + [folder moveToPoint:CGPointMake(1, 3)]; + [folder lineToPoint:CGPointMake(6, 3)]; + [folder lineToPoint:CGPointMake(8, 5)]; + [folder lineToPoint:CGPointMake(w - 1, 5)]; + [folder lineToPoint:CGPointMake(w - 1, h - 2)]; + [folder lineToPoint:CGPointMake(1, h - 2)]; + [folder closePath]; + + [fill set]; + [folder fill]; + + if (_iconKind === @"smart") + { + // Gear-ish dot to distinguish smart folders. + var mark = [CPBezierPath bezierPath], + markColor = _isHighlighted ? [CPColor colorWithRed:0.55 green:0.42 blue:0.78 alpha:1.0] : [CPColor whiteColor]; + + [markColor set]; + [mark appendBezierPathWithOvalInRect:CGRectMake(w / 2 - 2.5, 7.5, 5, 5)]; + [mark fill]; + } +} + +@end + + +// -------------------------------------------------------------------------------- +// SourceBadgeView – rounded count badge +// -------------------------------------------------------------------------------- + +var BadgeFont = nil, + BadgeHeight = 16.0, + BadgeMinWidth = 24.0, + BadgeHPadding = 7.0; + +@implementation SourceBadgeView : CPView +{ + CPTextField _badgeLabel; + int _badgeCount; + BOOL _isHighlighted; +} + ++ (void)initialize +{ + if (self !== [SourceBadgeView class]) + return; + + BadgeFont = [CPFont boldSystemFontOfSize:11.0]; +} + +- (id)initWithFrame:(CGRect)aFrame +{ + if (self = [super initWithFrame:aFrame]) + { + // A plain, inset-free label that is positioned by hand: the text field's + // own vertical centering includes theme insets and ends up a pixel or two low. + _badgeLabel = [[CPTextField alloc] initWithFrame:CGRectMakeZero()]; + [_badgeLabel setFont:BadgeFont]; + [_badgeLabel setAlignment:CPCenterTextAlignment]; + [_badgeLabel setEditable:NO]; + [_badgeLabel setSelectable:NO]; + [_badgeLabel setBezeled:NO]; + [_badgeLabel setBordered:NO]; + [_badgeLabel setDrawsBackground:NO]; + [_badgeLabel setValue:CGInsetMakeZero() forThemeAttribute:@"content-inset"]; + [self addSubview:_badgeLabel]; + + [self _updateColors]; + } + + return self; +} + ++ (CPString)stringForCount:(int)aCount +{ + // Keep badges compact: 1234 -> "1.2k", 12345 -> "12k" + if (aCount >= 10000) + return Math.round(aCount / 1000) + "k"; + + if (aCount >= 1000) + return (Math.round(aCount / 100) / 10) + "k"; + + return String(aCount); +} + +- (void)setCount:(int)aCount +{ + _badgeCount = aCount; + + var hidden = aCount === CPNotFound || aCount === nil || aCount === undefined; + + [self setHidden:hidden]; + + if (!hidden) + { + [_badgeLabel setStringValue:[SourceBadgeView stringForCount:aCount]]; + [self _layoutLabel]; + } + + [self setNeedsDisplay:YES]; +} + +- (void)setSelected:(BOOL)aFlag +{ + if (_isHighlighted === aFlag) + return; + + _isHighlighted = aFlag; + [self _updateColors]; + [self setNeedsDisplay:YES]; +} + +- (float)preferredWidth +{ + var size = [[_badgeLabel stringValue] sizeWithFont:BadgeFont]; + return MAX(BadgeMinWidth, Math.ceil(size.width) + 2 * BadgeHPadding); +} + +- (void)setFrameSize:(CGSize)aSize +{ + [super setFrameSize:aSize]; + [self _layoutLabel]; +} + +- (void)_layoutLabel +{ + var bounds = [self bounds], + textHeight = Math.ceil([[_badgeLabel stringValue] sizeWithFont:BadgeFont].height); + + [_badgeLabel setFrame:CGRectMake(0, Math.round((CGRectGetHeight(bounds) - textHeight) / 2), CGRectGetWidth(bounds), textHeight)]; +} + +- (void)_updateColors +{ + [_badgeLabel setTextColor:_isHighlighted ? [CPColor colorWithRed:0.29 green:0.54 blue:0.82 alpha:1.0] : [CPColor whiteColor]]; +} + +- (void)drawRect:(CGRect)aRect +{ + var bounds = [self bounds], + radius = CGRectGetHeight(bounds) / 2, + path = [CPBezierPath bezierPath]; + + [path appendBezierPathWithRoundedRect:bounds xRadius:radius yRadius:radius]; + + [(_isHighlighted ? [CPColor whiteColor] : [CPColor colorWithRed:0.56 green:0.63 blue:0.76 alpha:1.0]) set]; + [path fill]; +} + +@end + + +// -------------------------------------------------------------------------------- +// SourceListCellView – view-based outline cell +// -------------------------------------------------------------------------------- + +@implementation SourceListCellView : CPTableCellView +{ + SourceIconView _iconView; + CPTextField _titleField; + SourceBadgeView _badgeView; + BOOL _isHeader; +} + +- (id)initWithFrame:(CGRect)aFrame +{ + if (self = [super initWithFrame:aFrame]) + { + var height = CGRectGetHeight(aFrame); + + _iconView = [[SourceIconView alloc] initWithFrame:CGRectMake(2, (height - 16) / 2, 18, 16)]; + [_iconView setAutoresizingMask:CPViewMinYMargin | CPViewMaxYMargin]; + [self addSubview:_iconView]; + + _titleField = [[CPTextField alloc] initWithFrame:CGRectMake(24, 0, CGRectGetWidth(aFrame) - 24, height)]; + [_titleField setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_titleField setVerticalAlignment:CPCenterVerticalTextAlignment]; + [_titleField setLineBreakMode:CPLineBreakByTruncatingTail]; + [_titleField setEditable:NO]; + [_titleField setBezeled:NO]; + [_titleField setDrawsBackground:NO]; + [self addSubview:_titleField]; + + _badgeView = [[SourceBadgeView alloc] initWithFrame:CGRectMake(CGRectGetWidth(aFrame) - 30, (height - BadgeHeight) / 2, BadgeMinWidth, BadgeHeight)]; + [_badgeView setAutoresizingMask:CPViewMinXMargin | CPViewMinYMargin | CPViewMaxYMargin]; + [self addSubview:_badgeView]; + + [self setIdentifier:SourceCellIdentifier]; + } + + return self; +} + +- (void)configureWithItem:(SourceItem)anItem count:(int)aCount +{ + _isHeader = [anItem isSection]; + + var bounds = [self bounds], + width = CGRectGetWidth(bounds), + height = CGRectGetHeight(bounds); + + [_titleField setStringValue:[anItem name]]; + + if (_isHeader) + { + [_iconView setHidden:YES]; + [_badgeView setHidden:YES]; + [_titleField setFont:[CPFont boldSystemFontOfSize:11.0]]; + [_titleField setFrame:CGRectMake(0, 0, width, height)]; + } + else + { + var kind = [anItem isKindOfClass:[SmartFolder class]] ? @"smart" : + [anItem isKindOfClass:[LibraryGroup class]] ? @"group" : @"all"; + + [_iconView setHidden:NO]; + [_iconView setKind:kind]; + [_titleField setFont:[CPFont systemFontOfSize:12.0]]; + + [_badgeView setCount:aCount]; + + var badgeWidth = [_badgeView isHidden] ? 0 : [_badgeView preferredWidth]; + + [_badgeView setFrame:CGRectMake(width - badgeWidth - 8, Math.round((height - BadgeHeight) / 2), badgeWidth, BadgeHeight)]; + [_titleField setFrame:CGRectMake(24, 0, MAX(0, width - 24 - badgeWidth - 14), height)]; + } + + [self _updateSelectionAppearance]; +} + +- (void)setThemeState:(ThemeState)aState +{ + [super setThemeState:aState]; + [self _updateSelectionAppearance]; +} + +- (void)unsetThemeState:(ThemeState)aState +{ + [super unsetThemeState:aState]; + [self _updateSelectionAppearance]; +} + +- (void)_updateSelectionAppearance +{ + // CPTableView marks data views of selected rows with CPThemeStateSelectedDataView + // and, only while the table is focused (first responder in the key window), + // additionally with CPThemeStateFirstResponder. The unfocused selection colour + // is a light gray, so white text is only legible in the focused case. + var selected = [self hasThemeState:CPThemeStateSelectedDataView], + emphasized = selected && [self hasThemeState:CPThemeStateFirstResponder]; + + [_iconView setSelected:emphasized]; + [_badgeView setSelected:emphasized]; + + if (_isHeader) + [_titleField setTextColor:[CPColor colorWithWhite:0.45 alpha:1.0]]; + else + [_titleField setTextColor:emphasized ? [CPColor whiteColor] : [CPColor colorWithWhite:0.15 alpha:1.0]]; +} + +@end + + +// -------------------------------------------------------------------------------- +// SourceListViewController +// -------------------------------------------------------------------------------- + +@implementation SourceListViewController : CPViewController +{ + Library _library; + CPOutlineView _outlineView; + CPButtonBar _buttonBar; + BOOL _suppressSelectionCallbacks; + + id _sourceListDelegate @accessors(property=delegate); +} + +- (id)initWithLibrary:(Library)aLibrary +{ + if (self = [super initWithCibName:nil bundle:nil]) + _library = aLibrary; + + return self; +} + +- (CPButtonBar)buttonBar +{ + [self view]; + return _buttonBar; +} + +- (void)loadView +{ + var frame = CGRectMake(0, 0, 230, 500), + view = [[CPView alloc] initWithFrame:frame], + barHeight = 26.0; + + [view setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + + // --- Outline in scroll view + var scrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame) - barHeight)]; + [scrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [scrollView setAutohidesScrollers:YES]; + [scrollView setHasHorizontalScroller:NO]; + [scrollView setBorderType:CPNoBorder]; + + _outlineView = [[CPOutlineView alloc] initWithFrame:[[scrollView contentView] bounds]]; + + var column = [[CPTableColumn alloc] initWithIdentifier:@"source"]; + [column setWidth:CGRectGetWidth(frame) - 20]; + [column setResizingMask:CPTableColumnAutoresizingMask]; + [column setEditable:NO]; + [_outlineView addTableColumn:column]; + [_outlineView setOutlineTableColumn:column]; + + [_outlineView setHeaderView:nil]; + [_outlineView setCornerView:nil]; + [_outlineView setColumnAutoresizingStyle:CPTableViewLastColumnOnlyAutoresizingStyle]; + [_outlineView setSelectionHighlightStyle:CPTableViewSelectionHighlightStyleSourceList]; + [_outlineView setBackgroundColor:[CPColor colorWithRed:212.0 / 255.0 green:221.0 / 255.0 blue:230.0 / 255.0 alpha:1.0]]; + [_outlineView setRowHeight:SourceRowHeight]; + [_outlineView setAllowsMultipleSelection:NO]; + [_outlineView setAllowsEmptySelection:YES]; + + [_outlineView setTarget:self]; + [_outlineView setDoubleAction:@selector(outlineViewDoubleClicked:)]; + + [_outlineView registerForDraggedTypes:[PeopleDragType, SourceItemDragType]]; + [_outlineView setDraggingSourceOperationMask:CPDragOperationMove forLocal:YES]; + + [_outlineView setDataSource:self]; + [_outlineView setDelegate:self]; + + [scrollView setDocumentView:_outlineView]; + [view addSubview:scrollView]; + + // --- Button bar + _buttonBar = [[CPButtonBar alloc] initWithFrame:CGRectMake(0, CGRectGetHeight(frame) - barHeight, CGRectGetWidth(frame), barHeight)]; + [_buttonBar setAutoresizingMask:CPViewWidthSizable | CPViewMinYMargin]; + + if ([_buttonBar respondsToSelector:@selector(setHasResizeControl:)]) + [_buttonBar setHasResizeControl:YES]; + + if ([_buttonBar respondsToSelector:@selector(setResizeControlIsLeftAligned:)]) + [_buttonBar setResizeControlIsLeftAligned:NO]; + + var plusButton = [CPButtonBar plusButton]; + [plusButton setTarget:self]; + [plusButton setAction:@selector(_plusClicked:)]; + [plusButton setToolTip:@"New smart folder"]; + + var minusButton = [CPButtonBar minusButton]; + [minusButton setTarget:self]; + [minusButton setAction:@selector(_minusClicked:)]; + [minusButton setToolTip:@"Delete the selected group or smart folder"]; + + var actionButton = [CPButtonBar actionPopupButton]; + + if (![[actionButton itemArray] count]) + [actionButton addItemWithTitle:@""]; + + [self _addActionItem:@"New Group…" tag:SourceActionNewGroup to:actionButton]; + [self _addActionItem:@"New Smart Folder…" tag:SourceActionNewSmartFolder to:actionButton]; + [[actionButton menu] addItem:[CPMenuItem separatorItem]]; + [self _addActionItem:@"Edit Smart Folder…" tag:SourceActionEdit to:actionButton]; + [self _addActionItem:@"Rename Group…" tag:SourceActionRename to:actionButton]; + [self _addActionItem:@"Delete" tag:SourceActionDelete to:actionButton]; + [actionButton setTarget:self]; + [actionButton setAction:@selector(_actionPopupChosen:)]; + + [_buttonBar setButtons:[plusButton, minusButton, actionButton]]; + [view addSubview:_buttonBar]; + + [self setView:view]; + + [_outlineView reloadData]; + [self _expandSections]; +} + +- (void)_addActionItem:(CPString)aTitle tag:(int)aTag to:(CPPopUpButton)aPopUp +{ + [aPopUp addItemWithTitle:aTitle]; + [[aPopUp lastItem] setTag:aTag]; +} + +- (void)_expandSections +{ + var sections = [_library sections]; + + for (var i = 0; i < sections.length; i++) + [_outlineView expandItem:sections[i]]; +} + +// ---------------------------------------------------------------- Public + +- (id)selectedItem +{ + var row = [_outlineView selectedRow]; + return row >= 0 ? [_outlineView itemAtRow:row] : nil; +} + +- (void)selectItem:(id)anItem +{ + var row = [_outlineView rowForItem:anItem]; + + if (row === CPNotFound || row < 0) + return; + + [_outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [_outlineView scrollRowToVisible:row]; +} + +// Reloads the outline (names, order, badges) without emitting selection callbacks. +- (void)reloadAndSelectItem:(id)anItem +{ + _suppressSelectionCallbacks = YES; + + [_outlineView reloadData]; + [self _expandSections]; + + var row = anItem ? [_outlineView rowForItem:anItem] : CPNotFound; + + if (row !== CPNotFound && row >= 0) + [_outlineView selectRowIndexes:[CPIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + else + [_outlineView deselectAll:nil]; + + _suppressSelectionCallbacks = NO; +} + +- (void)focus +{ + [[_outlineView window] makeFirstResponder:_outlineView]; +} + +// ---------------------------------------------------------------- Actions + +- (void)_plusClicked:(id)sender +{ + [CPApp sendAction:@selector(newSmartFolder:) to:nil from:sender]; +} + +- (void)_minusClicked:(id)sender +{ + [CPApp sendAction:@selector(deleteSelectedSourceItem:) to:nil from:sender]; +} + +- (void)_actionPopupChosen:(id)sender +{ + var selector = nil; + + switch ([[sender selectedItem] tag]) + { + case SourceActionNewGroup: selector = @selector(newGroup:); break; + case SourceActionNewSmartFolder: selector = @selector(newSmartFolder:); break; + case SourceActionEdit: selector = @selector(editSelectedSmartFolder:); break; + case SourceActionRename: selector = @selector(renameSelectedGroup:); break; + case SourceActionDelete: selector = @selector(deleteSelectedSourceItem:); break; + } + + if (selector) + [CPApp sendAction:selector to:nil from:sender]; +} + +- (void)outlineViewDoubleClicked:(id)sender +{ + var row = [_outlineView clickedRow]; + + if (row < 0) + return; + + var item = [_outlineView itemAtRow:row]; + + if ([item isKindOfClass:[SmartFolder class]]) + [CPApp sendAction:@selector(editSelectedSmartFolder:) to:nil from:self]; + else if ([item isKindOfClass:[LibraryGroup class]]) + [CPApp sendAction:@selector(renameSelectedGroup:) to:nil from:self]; +} + +// ---------------------------------------------------------------- Data source + +- (int)outlineView:(CPOutlineView)anOutlineView numberOfChildrenOfItem:(id)anItem +{ + return [[_library childrenOfItem:anItem] count]; +} + +- (id)outlineView:(CPOutlineView)anOutlineView child:(int)anIndex ofItem:(id)anItem +{ + return [[_library childrenOfItem:anItem] objectAtIndex:anIndex]; +} + +- (BOOL)outlineView:(CPOutlineView)anOutlineView isItemExpandable:(id)anItem +{ + return [anItem isSection]; +} + +- (id)outlineView:(CPOutlineView)anOutlineView objectValueForTableColumn:(CPTableColumn)aColumn byItem:(id)anItem +{ + return anItem; +} + +// ---------------------------------------------------------------- Delegate (view based) + +- (CPView)outlineView:(CPOutlineView)anOutlineView viewForTableColumn:(CPTableColumn)aColumn item:(id)anItem +{ + var cell = [anOutlineView makeViewWithIdentifier:SourceCellIdentifier owner:self]; + + if (!cell) + cell = [[SourceListCellView alloc] initWithFrame:CGRectMake(0, 0, [aColumn width], SourceRowHeight)]; + + [cell configureWithItem:anItem count:[_library countForItem:anItem]]; + + return cell; +} + +- (BOOL)outlineView:(CPOutlineView)anOutlineView shouldSelectItem:(id)anItem +{ + return [anItem isSelectable]; +} + +- (void)outlineViewSelectionDidChange:(CPNotification)aNotification +{ + if (_suppressSelectionCallbacks) + return; + + var item = [self selectedItem]; + + if (item && [_sourceListDelegate respondsToSelector:@selector(sourceListViewController:didSelectItem:)]) + [_sourceListDelegate sourceListViewController:self didSelectItem:item]; +} + +// ---------------------------------------------------------------- Drag & drop + +- (BOOL)outlineView:(CPOutlineView)anOutlineView writeItems:(CPArray)theItems toPasteboard:(CPPasteboard)aPasteboard +{ + var item = [theItems firstObject]; + + if ([theItems count] !== 1 || ![item isUserItem]) + return NO; + + [aPasteboard declareTypes:[SourceItemDragType] owner:self]; + [aPasteboard setString:[item uid] forType:SourceItemDragType]; + + return YES; +} + +- (CPDragOperation)outlineView:(CPOutlineView)anOutlineView validateDrop:(id)theInfo proposedItem:(id)anItem proposedChildIndex:(int)anIndex +{ + var pasteboard = [theInfo draggingPasteboard], + types = [pasteboard types]; + + // People from the table + if ([types containsObject:PeopleDragType]) + { + if ([anItem isKindOfClass:[LibraryGroup class]] && anIndex === CPOutlineViewDropOnItemIndex) + return CPDragOperationCopy; + + // Anywhere on/inside the GROUPS section: create a new group. + if (anItem === [_library groupsSection]) + { + [anOutlineView setDropItem:anItem dropChildIndex:CPOutlineViewDropOnItemIndex]; + return CPDragOperationCopy; + } + + return CPDragOperationNone; + } + + // Reordering of groups / smart folders + if ([types containsObject:SourceItemDragType]) + { + var dragged = [_library itemWithUID:[pasteboard stringForType:SourceItemDragType]], + section = [_library sectionForItem:dragged]; + + if (!dragged || !section) + return CPDragOperationNone; + + if (anItem === section && anIndex !== CPOutlineViewDropOnItemIndex) + return CPDragOperationMove; + + // Dropped onto a sibling: retarget to "insert above sibling". + if (anIndex === CPOutlineViewDropOnItemIndex && [_library sectionForItem:anItem] === section) + { + [anOutlineView setDropItem:section dropChildIndex:[[section children] indexOfObjectIdenticalTo:anItem]]; + return CPDragOperationMove; + } + } + + return CPDragOperationNone; +} + +- (BOOL)outlineView:(CPOutlineView)anOutlineView acceptDrop:(id)theInfo item:(id)anItem childIndex:(int)anIndex +{ + var pasteboard = [theInfo draggingPasteboard], + types = [pasteboard types]; + + if ([types containsObject:PeopleDragType]) + { + var uids = [CPKeyedUnarchiver unarchiveObjectWithData:[pasteboard dataForType:PeopleDragType]], + people = [_library peopleWithUIDs:uids || []]; + + if (![people count]) + return NO; + + if ([anItem isKindOfClass:[LibraryGroup class]]) + { + [_library addPeople:people toGroup:anItem]; + return YES; + } + + if (anItem === [_library groupsSection]) + { + var name = [people count] === 1 ? [people[0] fullName] : [CPString stringWithFormat:@"%d People", [people count]], + group = [_library addGroupWithName:name people:people]; + + [self selectItem:group]; + return YES; + } + + return NO; + } + + if ([types containsObject:SourceItemDragType]) + { + var dragged = [_library itemWithUID:[pasteboard stringForType:SourceItemDragType]]; + + if (!dragged) + return NO; + + [_library moveItem:dragged toIndex:anIndex]; + return YES; + } + + return NO; +} + +@end diff --git a/Resources/SmartFoldersDemo/index.html b/Resources/SmartFoldersDemo/index.html new file mode 100644 index 000000000..ea103c811 --- /dev/null +++ b/Resources/SmartFoldersDemo/index.html @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Resources/SmartFoldersDemo/main.j b/Resources/SmartFoldersDemo/main.j new file mode 100644 index 000000000..c446f82f7 --- /dev/null +++ b/Resources/SmartFoldersDemo/main.j @@ -0,0 +1,18 @@ +/* + * AppController.j + * null + * + * Created by You on November 25, 2010. + * Copyright 2010, Your Company All rights reserved. + */ + +@import +@import + +@import "AppController.j" + + +function main(args, namedArgs) +{ + CPApplicationMain(args, namedArgs); +} diff --git a/Resources/recipes.txt b/Resources/recipes.txt index 77e908a4e..ce0134633 100644 --- a/Resources/recipes.txt +++ b/Resources/recipes.txt @@ -1,148 +1,149 @@ -1791907547966|ArrayControllerInitialSelectionTest| -1791907547979|ArrayControllerRemovingFirstTest| -1791907547991|ArrayControllerTest| -1791907548004|AttachedSheet2Test| -1791907548016|AttachedSheetTest| -1791907548029|BindingContentArrayTest| -1791907548041|CGCanvasContextTest| -1791907548054|CGPathTest| -1791907548067|CPAlertTest| -1791907548079|CPAnimatablePropertyContainerTest| -1791907548091|CPAnimationContextTest| -1791907548104|CPAnimationTest| -1791907548117|CPBinderEchoBug| -1791907548129|CPBoxCibTest| -1791907548141|CPBoxTest| -1791907548154|CPBrowserTest| -1791907548167|CPButtonBarTest| -1791907548179|CPButtonKeyEquivalentDefaultTest| -1791907548192|CPButtonTest| -1791907548204|CPByteCountFormatterTest| -1791907548217|CPCollectionViewNibTest| -1791907548229|CPColorCPImageDescriptionTest| -1791907548242|CPColorWellBindingsTest| -1791907548254|CPComboBoxTest| -1791907548267|CPControlPerformClickExceptionTest| -1791907548280|CPControlSizeTest| -1791907548292|CPCursorTest| -1791907548305|CPDateFormatterTest| -1791907548318|CPDatePickerTest| -1791907548331|CPDictionaryControllerTest| -1791907548343|CPDocumentControllerTest| -1791907548356|CPFormatterTest| -1791907548368|CPGraphicsTest| -1791907548381|CPImageAndTextViewImageScalingTest| -1791907548393|CPImageViewAlignmentScaling| -1791907548406|CPImageViewTest| -1791907548419|CPImageViewbindingsTest| -1791907548431|CPLanguageModelChatbotTest| -1791907548444|CPLevelIndicator| -1791907548456|CPLevelIndicatorTest| -1791907548469|CPLocalizationTest| -1791907548481|CPLogTest| -1791907548494|CPMarkdownParserTest| -1791907548506|CPMenuCALayerTest| -1791907548519|CPMenuEnabledBindingsTest| -1791907548531|CPMenuKeyEquivalentsTest| -1791907548543|CPMenuRemoveAllTest| -1791907548556|CPMenuSubmenuPlacementConstraints| -1791907548568|CPMenuTest| -1791907548581|CPOutlineViewCibTest| -1791907548593|CPOutlineViewTest| -1791907548606|CPOutlineViewViewBasedCibTest| -1791907548619|CPPanelTest| -1791907548631|CPPlatformWindow| -1791907548644|CPPlattformWindowMinSize| -1791907548656|CPPopUpButtonBindingsTest| -1791907548668|CPPopUpButtonTest| -1791907548681|CPPopoverTest| -1791907548693|CPPredicateEditorCibTest| -1791907548706|CPProgressIndicatorTest| -1791907548718|CPPropertyListSerializationTest| -1791907548731|CPRadioBindingsTest| -1791907548743|CPRuleEditorCibTest| -1791907548755|CPRuleEditorTest| -1791907548768|CPScrollView2Test| -1791907548780|CPScrollViewTest| -1791907548793|CPSearchFieldPredicateBinding| -1791907548805|CPSearchFieldTest| -1791907548818|CPSegmentedControlBindingsTest| -1791907548830|CPSegmentedControlTest| -1791907548843|CPSoundTest| -1791907548855|CPSplitViewDividerTest| -1791907548868|CPSplitViewTest| -1791907548880|CPStackViewTest| -1791907548893|CPStepperBindingsTest| -1791907548905|CPStepperNibTest| -1791907548918|CPTabView2Test| -1791907548930|CPTabViewBindingsTest| -1791907548943|CPTabViewNibTest| -1791907548955|CPTableViewGroupRowsTest| -1791907548967|CPTextFieldEditingStyleTest| -1791907548980|CPTextFieldHeightTest| -1791907548992|CPTextFieldMovementsTest| -1791907549005|CPTextFieldMultiLineTest| -1791907549017|CPTextFieldTest| -1791907549029|CPTextViewCibTest| -1791907549042|CPTextViewDelegateAndNotificationsTest| -1791907549054|CPTimeZoneTest| -1791907549067|CPTokenFieldTest| -1791907549079|CPToolTipTest| -1791907549092|CPToolbarTest| -1791907549104|CPTreeControllerTest| -1791907549117|CPURLConnectionAsyncTest| -1791907549129|CPURLConnectionSynchronousTest| -1791907549142|CPUserDefaultsControllerTest| -1791907549154|CPUserDefaultsTest| -1791907549166|CPUserNotificationTest| -1791907549179|CPViewControllerTest| -1791907549191|CPVisualEffectViewTest| -1791907549204|CPWebViewTest| -1791907549216|CPWebViewTest2| -1791907549229|CPWindowLiveResizeTest| -1791907549241|CPWindowMovableTest| -1791907549254|CPWindowResizeStyleTest| -1791907549266|ChildWindowsTest| -1791907549279|CompilationTests| -1791907549291|ContinuouslyUpdatesValueBinding| -1791907549304|CopyAndPasteTest| -1791907549316|CrossOriginTest| -1791907549329|FF4096Test| -1791907549341|FontEnhancementTest| -1791907549354|FontMetricsExplorerTest| -1791907549366|KeyEquivalentsTest| -1791907549379|KeyViewLoopTest| -1791907549391|KeyWindowInTheBackgroundActivation| -1791907549404|LayoutTest| -1791907549417|LoadDynamicFrameworksTest| -1791907549429|LoadTimeTest| -1791907549442|LongBindingsTest| -1791907549454|MobileTest| -1791907549467|MultipleValueBindingsTest| -1791907549479|NSBoxTest| -1791907549492|NSBrowserTest| -1791907549504|NewTextFieldBezelTest| -1791907549517|Nib2CibAlignmentTest| -1791907549529|Nib2CibBaselineAlignementTest| -1791907549542|Nib2CibSubviewsOrderTest| -1791907549555|NibAppRemixedTest| -1791907549567|PatternFillTest| -1791907549580|PlatformWindowsTest| -1791907549592|PopUpButtonEscTest| -1791907549604|PredicateWithFormatTest| -1791907549617|ResignFirstResponderWithCPControlTextDidEndEditingTest| -1791907549630|Scaling2Test| -1791907549642|ScalingTest| -1791907549654|ScrollviewThemingTest| -1791907549667|SimpleBindings2Test| -1791907549679|SizeToFitTest| -1791907549692|SmartFoldersTest| -1791907549704|TableTest| -1791907549716|ThemeBrowserTest| -1791907549729|ThemeKitchenSinkTest| -1791907549742|TrackingAreaTest| -1791907549754|UndoRedoWithMenuUpdateTest| -1791907549766|UserlandNSTest| -1791907549779|WindowSheetOrderTest| -1791907549791|layoutEphemeralSubviewTest| -1791907549804|performKeyEquivalentTest| +1792528807445|ArrayControllerInitialSelectionTest| +1792528807460|ArrayControllerRemovingFirstTest| +1792528807474|ArrayControllerTest| +1792528807488|AttachedSheet2Test| +1792528807501|AttachedSheetTest| +1792528807515|BindingContentArrayTest| +1792528807528|CGCanvasContextTest| +1792528807542|CGPathTest| +1792528807556|CPAlertTest| +1792528807569|CPAnimatablePropertyContainerTest| +1792528807583|CPAnimationContextTest| +1792528807596|CPAnimationTest| +1792528807610|CPBinderEchoBug| +1792528807624|CPBoxCibTest| +1792528807637|CPBoxTest| +1792528807651|CPBrowserTest| +1792528807664|CPButtonBarTest| +1792528807678|CPButtonKeyEquivalentDefaultTest| +1792528807691|CPButtonTest| +1792528807705|CPByteCountFormatterTest| +1792528807718|CPCollectionViewNibTest| +1792528807732|CPColorCPImageDescriptionTest| +1792528807745|CPColorWellBindingsTest| +1792528807759|CPComboBoxTest| +1792528807772|CPControlPerformClickExceptionTest| +1792528807786|CPControlSizeTest| +1792528807800|CPCursorTest| +1792528807813|CPDateFormatterTest| +1792528807827|CPDatePickerTest| +1792528807841|CPDictionaryControllerTest| +1792528807854|CPDocumentControllerTest| +1792528807868|CPFormatterTest| +1792528807882|CPGraphicsTest| +1792528807895|CPImageAndTextViewImageScalingTest| +1792528807909|CPImageViewAlignmentScaling| +1792528807922|CPImageViewTest| +1792528807936|CPImageViewbindingsTest| +1792528807949|CPLanguageModelChatbotTest| +1792528807963|CPLevelIndicator| +1792528807976|CPLevelIndicatorTest| +1792528807990|CPLocalizationTest| +1792528808004|CPLogTest| +1792528808017|CPMarkdownParserTest| +1792528808031|CPMenuCALayerTest| +1792528808044|CPMenuEnabledBindingsTest| +1792528808058|CPMenuKeyEquivalentsTest| +1792528808071|CPMenuRemoveAllTest| +1792528808085|CPMenuSubmenuPlacementConstraints| +1792528808099|CPMenuTest| +1792528808112|CPOutlineViewCibTest| +1792528808126|CPOutlineViewTest| +1792528808140|CPOutlineViewViewBasedCibTest| +1792528808153|CPPanelTest| +1792528808167|CPPlatformWindow| +1792528808180|CPPlattformWindowMinSize| +1792528808194|CPPopUpButtonBindingsTest| +1792528808207|CPPopUpButtonTest| +1792528808221|CPPopoverTest| +1792528808234|CPPredicateEditorCibTest| +1792528808248|CPProgressIndicatorTest| +1792528808261|CPPropertyListSerializationTest| +1792528808275|CPRadioBindingsTest| +1792528808288|CPRuleEditorCibTest| +1792528808302|CPRuleEditorTest| +1792528808316|CPScrollView2Test| +1792528808329|CPScrollViewTest| +1792528808343|CPSearchFieldPredicateBinding| +1792528808357|CPSearchFieldTest| +1792528808370|CPSegmentedControlBindingsTest| +1792528808384|CPSegmentedControlTest| +1792528808397|CPSoundTest| +1792528808411|CPSplitViewDividerTest| +1792528808425|CPSplitViewTest| +1792528808438|CPStackViewTest| +1792528808452|CPStepperBindingsTest| +1792528808465|CPStepperNibTest| +1792528808479|CPTabView2Test| +1792528808493|CPTabViewBindingsTest| +1792528808506|CPTabViewNibTest| +1792528808520|CPTableViewGroupRowsTest| +1792528808533|CPTextFieldEditingStyleTest| +1792528808547|CPTextFieldHeightTest| +1792528808561|CPTextFieldMovementsTest| +1792528808574|CPTextFieldMultiLineTest| +1792528808588|CPTextFieldTest| +1792528808601|CPTextViewCibTest| +1792528808615|CPTextViewDelegateAndNotificationsTest| +1792528808629|CPTimeZoneTest| +1792528808642|CPTokenFieldTest| +1792528808656|CPToolTipTest| +1792528808670|CPToolbarTest| +1792528808683|CPTreeControllerTest| +1792528808697|CPURLConnectionAsyncTest| +1792528808714|CPURLConnectionSynchronousTest| +1792528808727|CPUserDefaultsControllerTest| +1792528808741|CPUserDefaultsTest| +1792528808755|CPUserNotificationTest| +1792528808768|CPViewControllerTest| +1792528808782|CPVisualEffectViewTest| +1792528808795|CPWebViewTest| +1792528808809|CPWebViewTest2| +1792528808823|CPWindowLiveResizeTest| +1792528808836|CPWindowMovableTest| +1792528808850|CPWindowResizeStyleTest| +1792528808863|ChildWindowsTest| +1792528808877|CompilationTests| +1792528808890|ContinuouslyUpdatesValueBinding| +1792528808904|CopyAndPasteTest| +1792528808917|CrossOriginTest| +1792528808931|FF4096Test| +1792528808945|FontEnhancementTest| +1792528808958|FontMetricsExplorerTest| +1792528808972|KeyEquivalentsTest| +1792528808985|KeyViewLoopTest| +1792528808999|KeyWindowInTheBackgroundActivation| +1792528809013|LayoutTest| +1792528809026|LoadDynamicFrameworksTest| +1792528809040|LoadTimeTest| +1792528809054|LongBindingsTest| +1792528809067|MobileTest| +1792528809081|MultipleValueBindingsTest| +1792528809095|NSBoxTest| +1792528809108|NSBrowserTest| +1792528809122|NewTextFieldBezelTest| +1792528809135|Nib2CibAlignmentTest| +1792528809149|Nib2CibBaselineAlignementTest| +1792528809162|Nib2CibSubviewsOrderTest| +1792528809176|NibAppRemixedTest| +1792528809190|PatternFillTest| +1792528809203|PlatformWindowsTest| +1792528809217|PopUpButtonEscTest| +1792528809231|PredicateWithFormatTest| +1792528809244|ResignFirstResponderWithCPControlTextDidEndEditingTest| +1792528809258|Scaling2Test| +1792528809271|ScalingTest| +1792528809285|ScrollviewThemingTest| +1792528809299|SimpleBindings2Test| +1792528809312|SizeToFitTest| +1792528809326|SmartFoldersDemo| +1792528809339|SmartFoldersTest| +1792528809353|TableTest| +1792528809366|ThemeBrowserTest| +1792528809380|ThemeKitchenSinkTest| +1792528809394|TrackingAreaTest| +1792528809407|UndoRedoWithMenuUpdateTest| +1792528809421|UserlandNSTest| +1792528809434|WindowSheetOrderTest| +1792528809448|layoutEphemeralSubviewTest| +1792528809461|performKeyEquivalentTest|