Add a whole ton more code.
This commit is contained in:
parent
4969c44c40
commit
19ce82329b
@ -17,6 +17,42 @@
|
||||
8471A2C51ED4CEBF008F099E /* DataModel.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2B71ED4CEAD008F099E /* DataModel.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
8471A2F51ED4D062008F099E /* LocalAccount.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2F21ED4D04D008F099E /* LocalAccount.framework */; };
|
||||
8471A2F61ED4D062008F099E /* LocalAccount.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8471A2F21ED4D04D008F099E /* LocalAccount.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
849A97431ED9EAA9007D329B /* AddFolderWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97421ED9EAA9007D329B /* AddFolderWindowController.swift */; };
|
||||
849A97531ED9EAC0007D329B /* AddFeedController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97511ED9EAC0007D329B /* AddFeedController.swift */; };
|
||||
849A97541ED9EAC0007D329B /* AddFeedWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97521ED9EAC0007D329B /* AddFeedWindowController.swift */; };
|
||||
849A975A1ED9EB0D007D329B /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97571ED9EB0D007D329B /* AccountManager.swift */; };
|
||||
849A975B1ED9EB0D007D329B /* ArticleUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97581ED9EB0D007D329B /* ArticleUtilities.swift */; };
|
||||
849A975C1ED9EB0D007D329B /* DefaultFeedsImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97591ED9EB0D007D329B /* DefaultFeedsImporter.swift */; };
|
||||
849A975E1ED9EB72007D329B /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A975D1ED9EB72007D329B /* MainWindowController.swift */; };
|
||||
849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97601ED9EB96007D329B /* SidebarOutlineView.swift */; };
|
||||
849A97651ED9EB96007D329B /* SidebarTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* SidebarTreeControllerDelegate.swift */; };
|
||||
849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97621ED9EB96007D329B /* SidebarViewController.swift */; };
|
||||
849A97671ED9EB96007D329B /* UnreadCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97631ED9EB96007D329B /* UnreadCountView.swift */; };
|
||||
849A976C1ED9EBC8007D329B /* TimelineTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97691ED9EBC8007D329B /* TimelineTableRowView.swift */; };
|
||||
849A976D1ED9EBC8007D329B /* TimelineTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A976A1ED9EBC8007D329B /* TimelineTableView.swift */; };
|
||||
849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A976B1ED9EBC8007D329B /* TimelineViewController.swift */; };
|
||||
849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97701ED9EC04007D329B /* TimelineCellAppearance.swift */; };
|
||||
849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97711ED9EC04007D329B /* TimelineCellData.swift */; };
|
||||
849A97781ED9EC04007D329B /* TimelineCellLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97721ED9EC04007D329B /* TimelineCellLayout.swift */; };
|
||||
849A97791ED9EC04007D329B /* TimelineStringUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97731ED9EC04007D329B /* TimelineStringUtilities.swift */; };
|
||||
849A977A1ED9EC04007D329B /* TimelineTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97741ED9EC04007D329B /* TimelineTableCellView.swift */; };
|
||||
849A977B1ED9EC04007D329B /* UnreadIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97751ED9EC04007D329B /* UnreadIndicatorView.swift */; };
|
||||
849A977F1ED9EC42007D329B /* ArticleRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A977D1ED9EC42007D329B /* ArticleRenderer.swift */; };
|
||||
849A97801ED9EC42007D329B /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A977E1ED9EC42007D329B /* DetailViewController.swift */; };
|
||||
849A97831ED9EC63007D329B /* StatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97821ED9EC63007D329B /* StatusBarView.swift */; };
|
||||
849A97851ED9ECCD007D329B /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97841ED9ECCD007D329B /* PreferencesWindowController.swift */; };
|
||||
849A97891ED9ECEF007D329B /* ArticleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97871ED9ECEF007D329B /* ArticleStyle.swift */; };
|
||||
849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97881ED9ECEF007D329B /* ArticleStylesManager.swift */; };
|
||||
849A978D1ED9EE4D007D329B /* FeedListWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A978C1ED9EE4D007D329B /* FeedListWindowController.swift */; };
|
||||
849A978F1ED9EE72007D329B /* DefaultFeeds.plist in Resources */ = {isa = PBXBuildFile; fileRef = 849A978E1ED9EE72007D329B /* DefaultFeeds.plist */; };
|
||||
849A97921ED9EF65007D329B /* IndeterminateProgressWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97911ED9EF65007D329B /* IndeterminateProgressWindowController.swift */; };
|
||||
849A97951ED9EF7A007D329B /* IndeterminateProgressWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 849A97931ED9EF7A007D329B /* IndeterminateProgressWindow.xib */; };
|
||||
849A97981ED9EFAA007D329B /* Node-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97971ED9EFAA007D329B /* Node-Extensions.swift */; };
|
||||
849A979C1ED9EFEB007D329B /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 849A979A1ED9EFEB007D329B /* styleSheet.css */; };
|
||||
849A979D1ED9EFEB007D329B /* template.html in Resources */ = {isa = PBXBuildFile; fileRef = 849A979B1ED9EFEB007D329B /* template.html */; };
|
||||
849A979F1ED9F130007D329B /* SidebarCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A979E1ED9F130007D329B /* SidebarCell.swift */; };
|
||||
849A97A21ED9F180007D329B /* FeedTitleDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A01ED9F180007D329B /* FeedTitleDownloader.swift */; };
|
||||
849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; };
|
||||
849C64641ED37A5D003D8FC0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849C64631ED37A5D003D8FC0 /* AppDelegate.swift */; };
|
||||
849C64661ED37A5D003D8FC0 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849C64651ED37A5D003D8FC0 /* ViewController.swift */; };
|
||||
849C64681ED37A5D003D8FC0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
|
||||
@ -313,12 +349,48 @@
|
||||
842E45E61ED8C747000A8B52 /* DB5.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = DB5.plist; path = Evergreen/Resources/DB5.plist; sourceTree = "<group>"; };
|
||||
8471A2B21ED4CEAD008F099E /* DataModel.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = DataModel.xcodeproj; path = Frameworks/DataModel/DataModel.xcodeproj; sourceTree = "<group>"; };
|
||||
8471A2EC1ED4D04D008F099E /* LocalAccount.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = LocalAccount.xcodeproj; path = Frameworks/LocalAccount/LocalAccount.xcodeproj; sourceTree = "<group>"; };
|
||||
849A97421ED9EAA9007D329B /* AddFolderWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFolderWindowController.swift; sourceTree = "<group>"; };
|
||||
849A97511ED9EAC0007D329B /* AddFeedController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AddFeedController.swift; path = AddFeed/AddFeedController.swift; sourceTree = "<group>"; };
|
||||
849A97521ED9EAC0007D329B /* AddFeedWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AddFeedWindowController.swift; path = AddFeed/AddFeedWindowController.swift; sourceTree = "<group>"; };
|
||||
849A97571ED9EB0D007D329B /* AccountManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = "<group>"; };
|
||||
849A97581ED9EB0D007D329B /* ArticleUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleUtilities.swift; sourceTree = "<group>"; };
|
||||
849A97591ED9EB0D007D329B /* DefaultFeedsImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultFeedsImporter.swift; sourceTree = "<group>"; };
|
||||
849A975D1ED9EB72007D329B /* MainWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
|
||||
849A97601ED9EB96007D329B /* SidebarOutlineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarOutlineView.swift; sourceTree = "<group>"; };
|
||||
849A97611ED9EB96007D329B /* SidebarTreeControllerDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarTreeControllerDelegate.swift; sourceTree = "<group>"; };
|
||||
849A97621ED9EB96007D329B /* SidebarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarViewController.swift; sourceTree = "<group>"; };
|
||||
849A97631ED9EB96007D329B /* UnreadCountView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnreadCountView.swift; sourceTree = "<group>"; };
|
||||
849A97691ED9EBC8007D329B /* TimelineTableRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTableRowView.swift; sourceTree = "<group>"; };
|
||||
849A976A1ED9EBC8007D329B /* TimelineTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTableView.swift; sourceTree = "<group>"; };
|
||||
849A976B1ED9EBC8007D329B /* TimelineViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||
849A97701ED9EC04007D329B /* TimelineCellAppearance.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineCellAppearance.swift; sourceTree = "<group>"; };
|
||||
849A97711ED9EC04007D329B /* TimelineCellData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineCellData.swift; sourceTree = "<group>"; };
|
||||
849A97721ED9EC04007D329B /* TimelineCellLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineCellLayout.swift; sourceTree = "<group>"; };
|
||||
849A97731ED9EC04007D329B /* TimelineStringUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineStringUtilities.swift; sourceTree = "<group>"; };
|
||||
849A97741ED9EC04007D329B /* TimelineTableCellView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTableCellView.swift; sourceTree = "<group>"; };
|
||||
849A97751ED9EC04007D329B /* UnreadIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnreadIndicatorView.swift; sourceTree = "<group>"; };
|
||||
849A977D1ED9EC42007D329B /* ArticleRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleRenderer.swift; sourceTree = "<group>"; };
|
||||
849A977E1ED9EC42007D329B /* DetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailViewController.swift; sourceTree = "<group>"; };
|
||||
849A97821ED9EC63007D329B /* StatusBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarView.swift; sourceTree = "<group>"; };
|
||||
849A97841ED9ECCD007D329B /* PreferencesWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PreferencesWindowController.swift; path = Evergreen/Preferences/PreferencesWindowController.swift; sourceTree = "<group>"; };
|
||||
849A97871ED9ECEF007D329B /* ArticleStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleStyle.swift; sourceTree = "<group>"; };
|
||||
849A97881ED9ECEF007D329B /* ArticleStylesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleStylesManager.swift; sourceTree = "<group>"; };
|
||||
849A978C1ED9EE4D007D329B /* FeedListWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedListWindowController.swift; sourceTree = "<group>"; };
|
||||
849A978E1ED9EE72007D329B /* DefaultFeeds.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = DefaultFeeds.plist; path = Evergreen/Resources/DefaultFeeds.plist; sourceTree = SOURCE_ROOT; };
|
||||
849A97911ED9EF65007D329B /* IndeterminateProgressWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressWindowController.swift; sourceTree = "<group>"; };
|
||||
849A97941ED9EF7A007D329B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Evergreen/Base.lproj/IndeterminateProgressWindow.xib; sourceTree = SOURCE_ROOT; };
|
||||
849A97971ED9EFAA007D329B /* Node-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Node-Extensions.swift"; sourceTree = "<group>"; };
|
||||
849A979A1ED9EFEB007D329B /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; name = styleSheet.css; path = Evergreen/Resources/styleSheet.css; sourceTree = SOURCE_ROOT; };
|
||||
849A979B1ED9EFEB007D329B /* template.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = template.html; path = Evergreen/Resources/template.html; sourceTree = SOURCE_ROOT; };
|
||||
849A979E1ED9F130007D329B /* SidebarCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarCell.swift; sourceTree = "<group>"; };
|
||||
849A97A01ED9F180007D329B /* FeedTitleDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FeedTitleDownloader.swift; path = AddFeed/FeedTitleDownloader.swift; sourceTree = "<group>"; };
|
||||
849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FolderTreeControllerDelegate.swift; path = AddFeed/FolderTreeControllerDelegate.swift; sourceTree = "<group>"; };
|
||||
849C64601ED37A5D003D8FC0 /* Evergreen.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Evergreen.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
849C64631ED37A5D003D8FC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Evergreen/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
849C64651ED37A5D003D8FC0 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewController.swift; path = Evergreen/ViewController.swift; sourceTree = "<group>"; };
|
||||
849C64671ED37A5D003D8FC0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Evergreen/Assets.xcassets; sourceTree = "<group>"; };
|
||||
849C646A1ED37A5D003D8FC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
849C646C1ED37A5D003D8FC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Evergreen/Info.plist; sourceTree = "<group>"; };
|
||||
849C646C1ED37A5D003D8FC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Info.plist; sourceTree = "<group>"; };
|
||||
849C64711ED37A5D003D8FC0 /* EvergreenTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EvergreenTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
849C64751ED37A5D003D8FC0 /* EvergreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvergreenTests.swift; sourceTree = "<group>"; };
|
||||
849C64771ED37A5D003D8FC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@ -364,6 +436,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
842E45DE1ED8C582000A8B52 /* Defaults.swift */,
|
||||
849A97841ED9ECCD007D329B /* PreferencesWindowController.swift */,
|
||||
);
|
||||
name = Preferences;
|
||||
sourceTree = "<group>";
|
||||
@ -372,7 +445,14 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
842E45E21ED8C681000A8B52 /* KeyboardDelegateProtocol.swift */,
|
||||
849A975D1ED9EB72007D329B /* MainWindowController.swift */,
|
||||
842E45E41ED8C6B7000A8B52 /* MainWindowSplitView.swift */,
|
||||
849A975F1ED9EB95007D329B /* Sidebar */,
|
||||
849A97681ED9EBC8007D329B /* Timeline */,
|
||||
849A977C1ED9EC42007D329B /* Detail */,
|
||||
849A97811ED9EC63007D329B /* Status Bar */,
|
||||
849A97551ED9EAC3007D329B /* Add Feed */,
|
||||
849A97411ED9EAA9007D329B /* Add Folder */,
|
||||
);
|
||||
name = MainWindow;
|
||||
path = Evergreen/MainWindow;
|
||||
@ -395,6 +475,141 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97411ED9EAA9007D329B /* Add Folder */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97421ED9EAA9007D329B /* AddFolderWindowController.swift */,
|
||||
);
|
||||
name = "Add Folder";
|
||||
path = AddFolder;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97551ED9EAC3007D329B /* Add Feed */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97511ED9EAC0007D329B /* AddFeedController.swift */,
|
||||
849A97521ED9EAC0007D329B /* AddFeedWindowController.swift */,
|
||||
849A97A01ED9F180007D329B /* FeedTitleDownloader.swift */,
|
||||
849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */,
|
||||
);
|
||||
name = "Add Feed";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97561ED9EB0D007D329B /* Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97571ED9EB0D007D329B /* AccountManager.swift */,
|
||||
849A97581ED9EB0D007D329B /* ArticleUtilities.swift */,
|
||||
849A97591ED9EB0D007D329B /* DefaultFeedsImporter.swift */,
|
||||
849A978E1ED9EE72007D329B /* DefaultFeeds.plist */,
|
||||
);
|
||||
name = Data;
|
||||
path = Evergreen/Data;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A975F1ED9EB95007D329B /* Sidebar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97621ED9EB96007D329B /* SidebarViewController.swift */,
|
||||
849A97601ED9EB96007D329B /* SidebarOutlineView.swift */,
|
||||
849A979E1ED9F130007D329B /* SidebarCell.swift */,
|
||||
849A97611ED9EB96007D329B /* SidebarTreeControllerDelegate.swift */,
|
||||
849A97631ED9EB96007D329B /* UnreadCountView.swift */,
|
||||
);
|
||||
path = Sidebar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97681ED9EBC8007D329B /* Timeline */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97691ED9EBC8007D329B /* TimelineTableRowView.swift */,
|
||||
849A976A1ED9EBC8007D329B /* TimelineTableView.swift */,
|
||||
849A976B1ED9EBC8007D329B /* TimelineViewController.swift */,
|
||||
849A976F1ED9EC04007D329B /* Cell */,
|
||||
);
|
||||
path = Timeline;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A976F1ED9EC04007D329B /* Cell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97701ED9EC04007D329B /* TimelineCellAppearance.swift */,
|
||||
849A97711ED9EC04007D329B /* TimelineCellData.swift */,
|
||||
849A97721ED9EC04007D329B /* TimelineCellLayout.swift */,
|
||||
849A97731ED9EC04007D329B /* TimelineStringUtilities.swift */,
|
||||
849A97741ED9EC04007D329B /* TimelineTableCellView.swift */,
|
||||
849A97751ED9EC04007D329B /* UnreadIndicatorView.swift */,
|
||||
);
|
||||
path = Cell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A977C1ED9EC42007D329B /* Detail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A977D1ED9EC42007D329B /* ArticleRenderer.swift */,
|
||||
849A977E1ED9EC42007D329B /* DetailViewController.swift */,
|
||||
);
|
||||
path = Detail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97811ED9EC63007D329B /* Status Bar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97821ED9EC63007D329B /* StatusBarView.swift */,
|
||||
);
|
||||
name = "Status Bar";
|
||||
path = StatusBar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97861ED9ECEF007D329B /* Article Styles */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97871ED9ECEF007D329B /* ArticleStyle.swift */,
|
||||
849A97881ED9ECEF007D329B /* ArticleStylesManager.swift */,
|
||||
);
|
||||
name = "Article Styles";
|
||||
path = Evergreen/ArticleStyles;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
849A978B1ED9EE4D007D329B /* Feed List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A978C1ED9EE4D007D329B /* FeedListWindowController.swift */,
|
||||
);
|
||||
name = "Feed List";
|
||||
path = Evergreen/FeedList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97901ED9EF65007D329B /* Progress Window */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97931ED9EF7A007D329B /* IndeterminateProgressWindow.xib */,
|
||||
849A97911ED9EF65007D329B /* IndeterminateProgressWindowController.swift */,
|
||||
);
|
||||
name = "Progress Window";
|
||||
path = Evergreen/ProgressWindow;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97961ED9EFAA007D329B /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A97971ED9EFAA007D329B /* Node-Extensions.swift */,
|
||||
);
|
||||
name = Extensions;
|
||||
path = Evergreen/Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849A97991ED9EFB6007D329B /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
849A979A1ED9EFEB007D329B /* styleSheet.css */,
|
||||
849A979B1ED9EFEB007D329B /* template.html */,
|
||||
849C646C1ED37A5D003D8FC0 /* Info.plist */,
|
||||
);
|
||||
name = Resources;
|
||||
path = Evergreen/Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849C64571ED37A5D003D8FC0 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -407,7 +622,12 @@
|
||||
842E45DC1ED8C54B000A8B52 /* Browser.swift */,
|
||||
842E45E11ED8C681000A8B52 /* MainWindow */,
|
||||
842E45E01ED8C587000A8B52 /* Preferences */,
|
||||
849C646C1ED37A5D003D8FC0 /* Info.plist */,
|
||||
849A97861ED9ECEF007D329B /* Article Styles */,
|
||||
849A978B1ED9EE4D007D329B /* Feed List */,
|
||||
849A97901ED9EF65007D329B /* Progress Window */,
|
||||
849A97561ED9EB0D007D329B /* Data */,
|
||||
849A97961ED9EFAA007D329B /* Extensions */,
|
||||
849A97991ED9EFB6007D329B /* Resources */,
|
||||
849C64741ED37A5D003D8FC0 /* EvergreenTests */,
|
||||
849C64611ED37A5D003D8FC0 /* Products */,
|
||||
84B06FC61ED37F7200F0B54B /* DB5.xcodeproj */,
|
||||
@ -819,9 +1039,13 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
849A97951ED9EF7A007D329B /* IndeterminateProgressWindow.xib in Resources */,
|
||||
849A978F1ED9EE72007D329B /* DefaultFeeds.plist in Resources */,
|
||||
849A979D1ED9EFEB007D329B /* template.html in Resources */,
|
||||
842E45E71ED8C747000A8B52 /* DB5.plist in Resources */,
|
||||
849C64681ED37A5D003D8FC0 /* Assets.xcassets in Resources */,
|
||||
849C646B1ED37A5D003D8FC0 /* Main.storyboard in Resources */,
|
||||
849A979C1ED9EFEB007D329B /* styleSheet.css in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -840,12 +1064,44 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
842E45DD1ED8C54B000A8B52 /* Browser.swift in Sources */,
|
||||
849A975B1ED9EB0D007D329B /* ArticleUtilities.swift in Sources */,
|
||||
849A97891ED9ECEF007D329B /* ArticleStyle.swift in Sources */,
|
||||
849A975A1ED9EB0D007D329B /* AccountManager.swift in Sources */,
|
||||
849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */,
|
||||
849A97541ED9EAC0007D329B /* AddFeedWindowController.swift in Sources */,
|
||||
849A97791ED9EC04007D329B /* TimelineStringUtilities.swift in Sources */,
|
||||
849A979F1ED9F130007D329B /* SidebarCell.swift in Sources */,
|
||||
849A97981ED9EFAA007D329B /* Node-Extensions.swift in Sources */,
|
||||
849A97531ED9EAC0007D329B /* AddFeedController.swift in Sources */,
|
||||
849A97831ED9EC63007D329B /* StatusBarView.swift in Sources */,
|
||||
849C64661ED37A5D003D8FC0 /* ViewController.swift in Sources */,
|
||||
842E45DF1ED8C582000A8B52 /* Defaults.swift in Sources */,
|
||||
849A97431ED9EAA9007D329B /* AddFolderWindowController.swift in Sources */,
|
||||
849A97921ED9EF65007D329B /* IndeterminateProgressWindowController.swift in Sources */,
|
||||
849A97801ED9EC42007D329B /* DetailViewController.swift in Sources */,
|
||||
849A975E1ED9EB72007D329B /* MainWindowController.swift in Sources */,
|
||||
849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */,
|
||||
842E45E31ED8C681000A8B52 /* KeyboardDelegateProtocol.swift in Sources */,
|
||||
849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */,
|
||||
849A978D1ED9EE4D007D329B /* FeedListWindowController.swift in Sources */,
|
||||
849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */,
|
||||
842E45CE1ED8C308000A8B52 /* AppConstants.swift in Sources */,
|
||||
849C64641ED37A5D003D8FC0 /* AppDelegate.swift in Sources */,
|
||||
849A975C1ED9EB0D007D329B /* DefaultFeedsImporter.swift in Sources */,
|
||||
849A97781ED9EC04007D329B /* TimelineCellLayout.swift in Sources */,
|
||||
849A976C1ED9EBC8007D329B /* TimelineTableRowView.swift in Sources */,
|
||||
849A977B1ED9EC04007D329B /* UnreadIndicatorView.swift in Sources */,
|
||||
842E45E51ED8C6B7000A8B52 /* MainWindowSplitView.swift in Sources */,
|
||||
849A976D1ED9EBC8007D329B /* TimelineTableView.swift in Sources */,
|
||||
849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */,
|
||||
849A97671ED9EB96007D329B /* UnreadCountView.swift in Sources */,
|
||||
849A97851ED9ECCD007D329B /* PreferencesWindowController.swift in Sources */,
|
||||
849A977A1ED9EC04007D329B /* TimelineTableCellView.swift in Sources */,
|
||||
849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */,
|
||||
849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */,
|
||||
849A97A21ED9F180007D329B /* FeedTitleDownloader.swift in Sources */,
|
||||
849A97651ED9EB96007D329B /* SidebarTreeControllerDelegate.swift in Sources */,
|
||||
849A977F1ED9EC42007D329B /* ArticleRenderer.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -918,6 +1174,14 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
849A97931ED9EF7A007D329B /* IndeterminateProgressWindow.xib */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
849A97941ED9EF7A007D329B /* Base */,
|
||||
);
|
||||
name = IndeterminateProgressWindow.xib;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
849C64691ED37A5D003D8FC0 /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
@ -8,6 +8,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
let appName = "Evergreen"
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
static let SidebarSelectionDidChange = Notification.Name("SidebarSelectionDidChangeNotification")
|
||||
|
@ -2,25 +2,323 @@
|
||||
// AppDelegate.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 5/22/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
// Created by Brent Simmons on 7/11/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DB5
|
||||
import DataModel
|
||||
import RSTextDrawing
|
||||
import RSTree
|
||||
import RSXML
|
||||
|
||||
var currentTheme: VSTheme!
|
||||
|
||||
@NSApplicationMain
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
let windowControllers = NSMutableArray()
|
||||
var preferencesWindowController: NSWindowController?
|
||||
var mainWindowController: NSWindowController?
|
||||
var feedListWindowController: NSWindowController?
|
||||
var addFeedController: AddFeedController?
|
||||
var addFolderWindowController: AddFolderWindowController?
|
||||
let themeLoader = VSThemeLoader()
|
||||
private let appNewsURLString = "https://ranchero.com/evergreen/json.feed"
|
||||
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
// Insert code here to initialize your application
|
||||
var unreadCount = 0 {
|
||||
didSet {
|
||||
updateBadgeCoalesced()
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ aNotification: Notification) {
|
||||
// Insert code here to tear down your application
|
||||
override init() {
|
||||
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: NSApplicationDelegate
|
||||
|
||||
func applicationDidFinishLaunching(_ note: Notification) {
|
||||
|
||||
registerDefaults()
|
||||
|
||||
currentTheme = themeLoader.defaultTheme
|
||||
|
||||
let _ = AccountManager.sharedInstance
|
||||
|
||||
let kFirstRunDateKey = "firstRun"
|
||||
var isFirstRun = false
|
||||
if UserDefaults.standard.object(forKey: kFirstRunDateKey) == nil {
|
||||
isFirstRun = true
|
||||
UserDefaults.standard.set(Date(), forKey: kFirstRunDateKey)
|
||||
}
|
||||
|
||||
importDefaultFeedsIfNeeded(isFirstRun, account: AccountManager.sharedInstance.localAccount)
|
||||
createAndShowMainWindow()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
AccountManager.sharedInstance.updateUnreadCount()
|
||||
|
||||
#if RELEASE
|
||||
DispatchQueue.main.async {
|
||||
self.refreshAll(self)
|
||||
}
|
||||
#endif
|
||||
|
||||
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(AppDelegate.getURL(_:_:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
|
||||
if (!flag) {
|
||||
createAndShowMainWindow()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationDidResignActive(_ notification: Notification) {
|
||||
|
||||
RSSingleLineRenderer.emptyCache()
|
||||
RSMultiLineRenderer.emptyCache()
|
||||
TimelineCellData.emptyCache()
|
||||
timelineEmptyCaches()
|
||||
}
|
||||
|
||||
// MARK: GetURL Apple Event
|
||||
|
||||
func getURL(_ event: NSAppleEventDescriptor, _ withReplyEvent: NSAppleEventDescriptor) {
|
||||
|
||||
guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedURLString = urlString.rs_normalizedURL()
|
||||
if !normalizedURLString.rs_stringMayBeURL() {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
self.addFeed(normalizedURLString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Badge
|
||||
|
||||
private func updateBadgeCoalesced() {
|
||||
|
||||
rs_performSelectorCoalesced(#selector(updateBadge), with: nil, afterDelay: 0.01)
|
||||
}
|
||||
|
||||
dynamic func updateBadge() {
|
||||
|
||||
let label = unreadCount > 0 ? "\(unreadCount)" : ""
|
||||
NSApplication.shared().dockTile.badgeLabel = label
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
func unreadCountDidChange(_ note: Notification) {
|
||||
|
||||
let updatedUnreadCount = AccountManager.sharedInstance.unreadCount
|
||||
if updatedUnreadCount != unreadCount {
|
||||
unreadCount = updatedUnreadCount
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Main Window
|
||||
|
||||
func windowControllerWithName(_ storyboardName: String) -> NSWindowController {
|
||||
|
||||
let storyboard = NSStoryboard(name: storyboardName, bundle: nil)
|
||||
return storyboard.instantiateInitialController()! as! NSWindowController
|
||||
}
|
||||
|
||||
func createAndShowMainWindow() {
|
||||
|
||||
if mainWindowController == nil {
|
||||
mainWindowController = windowControllerWithName("MainWindow")
|
||||
}
|
||||
|
||||
mainWindowController!.showWindow(self)
|
||||
}
|
||||
|
||||
// MARK: NSUserInterfaceValidations
|
||||
|
||||
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
||||
|
||||
if item.action == #selector(refreshAll(_:)) {
|
||||
return !AccountManager.sharedInstance.refreshInProgress
|
||||
}
|
||||
if item.action == #selector(addAppNews(_:)) {
|
||||
return !AccountManager.sharedInstance.anyAccountHasFeedWithURL(appNewsURLString)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Add Feed
|
||||
|
||||
func addFeed(_ urlString: String?, _ name: String? = nil) {
|
||||
|
||||
createAndShowMainWindow()
|
||||
|
||||
addFeedController = AddFeedController(hostWindow: mainWindowController!.window!)
|
||||
addFeedController?.showAddFeedSheet(urlString, name)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func showPreferences(_ sender: AnyObject) {
|
||||
|
||||
if preferencesWindowController == nil {
|
||||
preferencesWindowController = windowControllerWithName("Preferences")
|
||||
}
|
||||
|
||||
preferencesWindowController!.showWindow(self)
|
||||
}
|
||||
|
||||
@IBAction func showMainWindow(_ sender: AnyObject) {
|
||||
|
||||
createAndShowMainWindow()
|
||||
}
|
||||
|
||||
@IBAction func refreshAll(_ sender: AnyObject) {
|
||||
|
||||
AccountManager.sharedInstance.refreshAll()
|
||||
}
|
||||
|
||||
@IBAction func showAddFeedWindow(_ sender: AnyObject) {
|
||||
|
||||
addFeed(nil)
|
||||
}
|
||||
|
||||
@IBAction func showAddFolderWindow(_ sender: AnyObject) {
|
||||
|
||||
createAndShowMainWindow()
|
||||
|
||||
addFolderWindowController = AddFolderWindowController()
|
||||
addFolderWindowController!.runSheetOnWindow(mainWindowController!.window!)
|
||||
}
|
||||
|
||||
@IBAction func showFeedList(_ sender: AnyObject) {
|
||||
|
||||
if feedListWindowController == nil {
|
||||
feedListWindowController = windowControllerWithName("FeedList")
|
||||
}
|
||||
feedListWindowController!.showWindow(self)
|
||||
}
|
||||
|
||||
// @IBAction func exportOPML(_ sender: AnyObject) {
|
||||
//
|
||||
// }
|
||||
|
||||
@IBAction func importOPMLFromFile(_ sender: AnyObject) {
|
||||
|
||||
let panel = NSOpenPanel()
|
||||
panel.canDownloadUbiquitousContents = true
|
||||
panel.canResolveUbiquitousConflicts = true
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.resolvesAliases = true
|
||||
panel.allowedFileTypes = ["opml"]
|
||||
panel.allowsOtherFileTypes = false
|
||||
|
||||
let result = panel.runModal()
|
||||
if result == NSFileHandlingPanelOKButton {
|
||||
if let url = panel.url {
|
||||
DispatchQueue.main.async {
|
||||
self.parseAndImportOPML(url, AccountManager.sharedInstance.localAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func exportOPML(_ sender: AnyObject) {
|
||||
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedFileTypes = ["opml"]
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.prompt = NSLocalizedString("Export OPML", comment: "Export OPML")
|
||||
panel.title = NSLocalizedString("Export OPML", comment: "Export OPML")
|
||||
panel.nameFieldLabel = NSLocalizedString("Export to:", comment: "Export OPML")
|
||||
panel.message = NSLocalizedString("Choose a location for the exported OPML file.", comment: "Export OPML")
|
||||
panel.isExtensionHidden = false
|
||||
panel.nameFieldStringValue = "MySubscriptions.opml"
|
||||
|
||||
let result = panel.runModal()
|
||||
if result == NSFileHandlingPanelOKButton {
|
||||
if let url = panel.url {
|
||||
DispatchQueue.main.async {
|
||||
let opmlString = AccountManager.sharedInstance.localAccount.opmlString(indentLevel: 0)
|
||||
do {
|
||||
try opmlString.write(to: url, atomically: true, encoding: String.Encoding.utf8)
|
||||
}
|
||||
catch let error as NSError {
|
||||
NSApplication.shared().presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func emailSupport(_ sender: AnyObject) {
|
||||
|
||||
let escapedAppName = appName.replacingOccurrences(of: " ", with: "%20")
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")!
|
||||
let urlString = "mailto:support@ranchero.com?subject=I%20need%20help%20with%20\(escapedAppName)%20\(version)&body=I%20ran%20into%20a%20problem:%20"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared().open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func addAppNews(_ sender: AnyObject) {
|
||||
|
||||
if AccountManager.sharedInstance.anyAccountHasFeedWithURL(appNewsURLString) {
|
||||
return
|
||||
}
|
||||
addFeed(appNewsURLString, "Evergreen News")
|
||||
}
|
||||
|
||||
// @IBAction func importOPMLFromURL(_ sender: AnyObject) {
|
||||
//
|
||||
// }
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
|
||||
func parseAndImportOPML(_ url: URL, _ account: Account) {
|
||||
|
||||
var fileData: Data?
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: url)
|
||||
} catch {
|
||||
print("Error reading OPML file. \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let opmlData = fileData else {
|
||||
return
|
||||
}
|
||||
|
||||
let xmlData = RSXMLData(data: opmlData, urlString: url.absoluteString)
|
||||
RSParseOPML(xmlData) { (opmlDocument, error) in
|
||||
|
||||
if let error = error {
|
||||
NSApplication.shared().presentError(error)
|
||||
return
|
||||
}
|
||||
|
||||
if let opmlDocument = opmlDocument {
|
||||
account.importOPML(opmlDocument)
|
||||
// account.refreshAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
82
Evergreen/ArticleStyles/ArticleStyle.swift
Normal file
82
Evergreen/ArticleStyles/ArticleStyle.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// ArticleStyle.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 9/26/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ArticleStyle: Equatable {
|
||||
|
||||
static let defaultStyle = ArticleStyle()
|
||||
let path: String?
|
||||
let template: String?
|
||||
let css: String?
|
||||
let emptyCSS: String?
|
||||
let info: NSDictionary?
|
||||
|
||||
init() {
|
||||
|
||||
//Default style
|
||||
|
||||
self.path = nil;
|
||||
self.emptyCSS = nil
|
||||
|
||||
self.info = ["CreatorHomePage": "http://ranchero.com/", "CreatorName": "Ranchero Software, LLC", "Version": "1.0"]
|
||||
|
||||
let cssPath = Bundle.main.path(forResource: "styleSheet", ofType: "css")!
|
||||
css = stringAtPath(cssPath)
|
||||
|
||||
let templatePath = Bundle.main.path(forResource: "template", ofType: "html")!
|
||||
template = stringAtPath(templatePath)
|
||||
}
|
||||
|
||||
init(path: String) {
|
||||
|
||||
self.path = path
|
||||
|
||||
let isFolder = FileManager.default.rs_fileIsFolder(path)
|
||||
|
||||
if isFolder {
|
||||
|
||||
let infoPath = (path as NSString).appendingPathComponent("Info.plist")
|
||||
self.info = NSDictionary(contentsOfFile: infoPath)
|
||||
|
||||
let cssPath = (path as NSString).appendingPathComponent("stylesheet.css")
|
||||
self.css = stringAtPath(cssPath)
|
||||
|
||||
let emptyCSSPath = (path as NSString).appendingPathComponent("stylesheet_empty.css")
|
||||
self.emptyCSS = stringAtPath(emptyCSSPath)
|
||||
|
||||
let templatePath = (path as NSString).appendingPathComponent("template.html")
|
||||
self.template = stringAtPath(templatePath)
|
||||
}
|
||||
|
||||
else {
|
||||
|
||||
self.css = stringAtPath(path)
|
||||
self.template = nil
|
||||
self.emptyCSS = nil
|
||||
self.info = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stringAtPath(_ f: String) -> String? {
|
||||
|
||||
if !FileManager.default.fileExists(atPath: f) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let s = try? NSString(contentsOfFile: f, usedEncoding: nil) as String {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func ==(lhs: ArticleStyle, rhs: ArticleStyle) -> Bool {
|
||||
|
||||
return lhs.path == rhs.path && lhs.template == rhs.template && lhs.css == rhs.css && lhs.info == rhs.info
|
||||
}
|
181
Evergreen/ArticleStyles/ArticleStylesManager.swift
Normal file
181
Evergreen/ArticleStyles/ArticleStylesManager.swift
Normal file
@ -0,0 +1,181 @@
|
||||
//
|
||||
// ArticleStylesManager.sqift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 9/26/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
|
||||
let ArticleStyleNamesDidChangeNotification = "ArticleStyleNamesDidChangeNotification"
|
||||
let CurrentArticleStyleDidChangeNotification = "CurrentArticleStyleDidChangeNotification"
|
||||
|
||||
private let styleKey = "style"
|
||||
private let defaultStyleName = "Default"
|
||||
private let stylesFolderName = "Styles"
|
||||
private let stylesInResourcesFolderName = "Styles"
|
||||
private let styleSuffix = ".evergreenstyle"
|
||||
private let nnwStyleSuffix = ".nnwstyle"
|
||||
private let cssStyleSuffix = ".css"
|
||||
private let styleSuffixes = [styleSuffix, nnwStyleSuffix, cssStyleSuffix];
|
||||
|
||||
public final class ArticleStylesManager {
|
||||
|
||||
static let sharedInstance = ArticleStylesManager()
|
||||
private let folderPath = RSDataSubfolder(nil, stylesFolderName)!
|
||||
|
||||
var currentStyleName: String {
|
||||
get {
|
||||
return UserDefaults.standard.string(forKey: styleKey)!
|
||||
}
|
||||
set {
|
||||
if newValue != currentStyleName {
|
||||
UserDefaults.standard.set(newValue, forKey: styleKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentStyle: ArticleStyle {
|
||||
didSet {
|
||||
NotificationCenter.default.post(name: Notification.Name(rawValue: CurrentArticleStyleDidChangeNotification), object: self)
|
||||
}
|
||||
}
|
||||
|
||||
var styleNames = [defaultStyleName] {
|
||||
didSet {
|
||||
NotificationCenter.default.post(name: Notification.Name(rawValue: ArticleStyleNamesDidChangeNotification), object: self)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
UserDefaults.standard.register(defaults: [styleKey: defaultStyleName])
|
||||
|
||||
let defaultStylesFolder = (Bundle.main.resourcePath! as NSString).appendingPathComponent(stylesInResourcesFolderName)
|
||||
do {
|
||||
try FileManager.default.rs_copyFiles(inFolder: defaultStylesFolder, destination: folderPath)
|
||||
}
|
||||
catch {
|
||||
print(error)
|
||||
}
|
||||
|
||||
currentStyle = ArticleStyle.defaultStyle
|
||||
|
||||
updateStyleNames()
|
||||
updateCurrentStyle()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: NSNotification.Name.NSApplicationDidBecomeActive, object: nil)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
dynamic func applicationDidBecomeActive(_ note: Notification) {
|
||||
|
||||
updateStyleNames()
|
||||
updateCurrentStyle()
|
||||
}
|
||||
|
||||
// MARK : Internal
|
||||
|
||||
private func updateStyleNames() {
|
||||
|
||||
let updatedStyleNames = allStylePaths(folderPath).map { styleNameForPath($0) }
|
||||
|
||||
if updatedStyleNames != styleNames {
|
||||
styleNames = updatedStyleNames
|
||||
}
|
||||
}
|
||||
|
||||
private func articleStyleWithStyleName(_ styleName: String) -> ArticleStyle? {
|
||||
|
||||
if styleName == defaultStyleName {
|
||||
return ArticleStyle.defaultStyle
|
||||
}
|
||||
|
||||
guard let path = pathForStyleName(styleName, folder: folderPath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ArticleStyle(path: path)
|
||||
}
|
||||
|
||||
private func defaultArticleStyle() -> ArticleStyle {
|
||||
|
||||
return articleStyleWithStyleName(defaultStyleName)!
|
||||
}
|
||||
|
||||
private func updateCurrentStyle() {
|
||||
|
||||
var styleName = currentStyleName
|
||||
if !styleNames.contains(styleName) {
|
||||
styleName = defaultStyleName
|
||||
currentStyleName = defaultStyleName
|
||||
}
|
||||
|
||||
var articleStyle = articleStyleWithStyleName(styleName)
|
||||
if articleStyle == nil {
|
||||
articleStyle = defaultArticleStyle()
|
||||
currentStyleName = defaultStyleName
|
||||
}
|
||||
|
||||
if let articleStyle = articleStyle, articleStyle != currentStyle {
|
||||
currentStyle = articleStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func allStylePaths(_ folder: String) -> [String] {
|
||||
|
||||
let filepaths = FileManager.default.rs_filepaths(inFolder: folder)
|
||||
return filepaths.filter { fileAtPathIsStyle($0) }
|
||||
}
|
||||
|
||||
private func fileAtPathIsStyle(_ f: String) -> Bool {
|
||||
|
||||
if !f.hasSuffix(styleSuffix) && !f.hasSuffix(nnwStyleSuffix) && !f.hasSuffix(cssStyleSuffix) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (f as NSString).lastPathComponent.hasPrefix(".") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func filenameWithStyleSuffixRemoved(_ filename: String) -> String {
|
||||
|
||||
for oneSuffix in styleSuffixes {
|
||||
if filename.hasSuffix(oneSuffix) {
|
||||
return (filename as NSString).rs_string(byStrippingSuffix: oneSuffix, caseSensitive: false)
|
||||
}
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
private func styleNameForPath(_ f: String) -> String {
|
||||
|
||||
let filename = (f as NSString).lastPathComponent
|
||||
return filenameWithStyleSuffixRemoved(filename)
|
||||
}
|
||||
|
||||
private func pathIsPathForStyleName(_ styleName: String, path: String) -> Bool {
|
||||
|
||||
let filename = (path as NSString).lastPathComponent
|
||||
return filenameWithStyleSuffixRemoved(filename) == styleName
|
||||
}
|
||||
|
||||
private func pathForStyleName(_ styleName: String, folder: String) -> String? {
|
||||
|
||||
for onePath in allStylePaths(folder) {
|
||||
if pathIsPathForStyleName(styleName, path: onePath) {
|
||||
return onePath
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
59
Evergreen/Base.lproj/IndeterminateProgressWindow.xib
Normal file
59
Evergreen/Base.lproj/IndeterminateProgressWindow.xib
Normal file
@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="11198.2" systemVersion="15G1004" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="11198.2"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="IndeterminateProgressWindowController" customModule="Rainier" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="messageLabel" destination="qcz-WI-q10" id="IJ9-M8-kzt"/>
|
||||
<outlet property="progressIndicator" destination="MFX-Q2-XtZ" id="jpc-TD-TWd"/>
|
||||
<outlet property="window" destination="NbJ-SP-fgw" id="e4d-eP-QkY"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" hidesOnDeactivate="YES" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="NbJ-SP-fgw" customClass="NSPanel">
|
||||
<windowStyleMask key="styleMask" titled="YES" utility="YES" HUD="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="272" y="172" width="267" height="85"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
|
||||
<view key="contentView" id="gai-Qn-u7b">
|
||||
<rect key="frame" x="0.0" y="0.0" width="267" height="85"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="qcz-WI-q10">
|
||||
<rect key="frame" x="90" y="46" width="87" height="19"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Message…" id="84N-t0-TEo">
|
||||
<font key="font" metaFont="systemBold" size="15"/>
|
||||
<color key="textColor" white="0.96999999999999997" alpha="1" colorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
<binding destination="-2" name="value" keyPath="message" id="EL8-wI-e9T">
|
||||
<dictionary key="options">
|
||||
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
|
||||
<bool key="NSRaisesForNotApplicableKeys" value="NO"/>
|
||||
</dictionary>
|
||||
</binding>
|
||||
</connections>
|
||||
</textField>
|
||||
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" maxValue="100" bezeled="NO" indeterminate="YES" style="bar" translatesAutoresizingMaskIntoConstraints="NO" id="MFX-Q2-XtZ">
|
||||
<rect key="frame" x="20" y="19" width="227" height="20"/>
|
||||
</progressIndicator>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="qcz-WI-q10" firstAttribute="top" secondItem="gai-Qn-u7b" secondAttribute="top" constant="20" symbolic="YES" id="6hM-39-Lu6"/>
|
||||
<constraint firstItem="qcz-WI-q10" firstAttribute="centerX" secondItem="gai-Qn-u7b" secondAttribute="centerX" id="B2C-49-Glg"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="centerX" secondItem="gai-Qn-u7b" secondAttribute="centerX" id="Byx-UU-xWP"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="leading" secondItem="gai-Qn-u7b" secondAttribute="leading" constant="20" symbolic="YES" id="J05-i6-91Y"/>
|
||||
<constraint firstAttribute="bottom" secondItem="MFX-Q2-XtZ" secondAttribute="bottom" constant="20" symbolic="YES" id="ZRz-VV-P8J"/>
|
||||
<constraint firstAttribute="trailing" secondItem="MFX-Q2-XtZ" secondAttribute="trailing" constant="20" symbolic="YES" id="k2g-Jl-o6g"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="top" secondItem="qcz-WI-q10" secondAttribute="bottom" constant="8" symbolic="YES" id="xxr-Ok-3Bb"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<point key="canvasLocation" x="-71.5" y="-167.5"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="11134" systemVersion="15F34" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="12118" systemVersion="16F73" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="11134"/>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="12118"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Application-->
|
||||
@ -21,7 +22,11 @@
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
|
||||
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
|
||||
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW">
|
||||
<connections>
|
||||
<action selector="showPreferences:" target="Ady-hI-5gd" id="Syu-AN-6P4"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
|
||||
<menuItem title="Services" id="NMo-om-nkz">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
@ -653,41 +658,10 @@
|
||||
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
|
||||
</connections>
|
||||
</application>
|
||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModuleProvider="target"/>
|
||||
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="Evergreen" customModuleProvider="target"/>
|
||||
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="75" y="0.0"/>
|
||||
</scene>
|
||||
<!--Window Controller-->
|
||||
<scene sceneID="R2V-B0-nI4">
|
||||
<objects>
|
||||
<windowController id="B8D-0N-5wS" sceneMemberID="viewController">
|
||||
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" visibleAtLaunch="NO" animationBehavior="default" id="IQv-IB-iLA">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="196" y="240" width="480" height="270"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1680" height="1027"/>
|
||||
</window>
|
||||
<connections>
|
||||
<segue destination="XfG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
|
||||
</connections>
|
||||
</windowController>
|
||||
<customObject id="Oky-zY-oP4" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="75" y="250"/>
|
||||
</scene>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="hIz-AP-VOD">
|
||||
<objects>
|
||||
<viewController id="XfG-lQ-9wD" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" wantsLayer="YES" id="m2S-Jp-Qdl">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<customObject id="rPt-NT-nkU" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="75" y="655"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
222
Evergreen/Data/AccountManager.swift
Normal file
222
Evergreen/Data/AccountManager.swift
Normal file
@ -0,0 +1,222 @@
|
||||
//
|
||||
// AccountManager.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/18/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import DataModel
|
||||
import LocalAccount
|
||||
|
||||
let AccountsDidChangeNotification = "AccountsDidChangeNotification"
|
||||
|
||||
private let localAccountFolderName = "OnMyMac"
|
||||
private let localAccountIdentifier = "OnMyMac"
|
||||
|
||||
final class AccountManager: UnreadCountProvider {
|
||||
|
||||
static let sharedInstance = AccountManager()
|
||||
private let accountsFolder = RSDataSubfolder(nil, "Accounts")!
|
||||
private var accountsDictionary = [String: Account]()
|
||||
let localAccount: Account
|
||||
var unreadCount = 0 {
|
||||
didSet {
|
||||
postUnreadCountDidChangeNotification()
|
||||
}
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
get {
|
||||
return Array(accountsDictionary.values)
|
||||
}
|
||||
}
|
||||
var sortedAccounts: [Account] {
|
||||
get {
|
||||
return accountsSortedByName()
|
||||
}
|
||||
}
|
||||
|
||||
var refreshInProgress: Bool {
|
||||
get {
|
||||
for oneAccount in accountsDictionary.values {
|
||||
if oneAccount.refreshInProgress {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
// The local "On My Mac" account must always exist, even if it's empty.
|
||||
|
||||
let localAccountFolder = (accountsFolder as NSString).appendingPathComponent("OnMyMac")
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: localAccountFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
catch {
|
||||
assertionFailure("Could not create folder for OnMyMac account.")
|
||||
abort()
|
||||
}
|
||||
|
||||
let localAccountSettingsFile = accountFilePathWithFolder(localAccountFolder)
|
||||
localAccount = LocalAccount(settingsFile: localAccountSettingsFile, dataFolder: localAccountFolder, identifier: localAccountIdentifier)
|
||||
accountsDictionary[localAccount.identifier] = localAccount
|
||||
|
||||
readNonLocalAccountsFromDisk()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func existingAccountWithIdentifier(_ identifier: String) -> Account? {
|
||||
|
||||
return accountsDictionary[identifier]
|
||||
}
|
||||
|
||||
func refreshAll() {
|
||||
|
||||
accounts.forEach { (oneAccount) in
|
||||
oneAccount.refreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
func anyAccountHasAtLeastOneFeed() -> Bool {
|
||||
|
||||
for oneAccount in accounts {
|
||||
if oneAccount.hasAtLeastOneFeed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func anyAccountHasFeedWithURL(_ urlString: String) -> Bool {
|
||||
|
||||
for oneAccount in accounts {
|
||||
if let _ = oneAccount.existingFeedWithURL(urlString) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: UnreadCountProvider
|
||||
|
||||
func updateUnreadCount() {
|
||||
|
||||
let updatedUnreadCount = calculateUnreadCount(accounts)
|
||||
if updatedUnreadCount != unreadCount {
|
||||
unreadCount = updatedUnreadCount
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
dynamic func unreadCountDidChange(_ notification: Notification) {
|
||||
|
||||
guard let _ = notification.object as? Account else {
|
||||
return
|
||||
}
|
||||
updateUnreadCount()
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func createAccount(_ accountSpecifier: AccountSpecifier) -> Account? {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func createAccount(_ filename: String) -> Account? {
|
||||
|
||||
let folderPath = (accountsFolder as NSString).appendingPathComponent(filename)
|
||||
if let accountSpecifier = AccountSpecifier(folderPath: folderPath) {
|
||||
return createAccount(accountSpecifier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func readNonLocalAccountsFromDisk() {
|
||||
|
||||
var filenames: [String]?
|
||||
|
||||
do {
|
||||
filenames = try FileManager.default.contentsOfDirectory(atPath: accountsFolder)
|
||||
}
|
||||
catch {
|
||||
print("Error reading Accounts folder: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
filenames?.forEach { (oneFilename) in
|
||||
|
||||
guard oneFilename != localAccountFolderName else {
|
||||
return
|
||||
}
|
||||
if let oneAccount = createAccount(oneFilename) {
|
||||
accountsDictionary[oneAccount.identifier] = oneAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accountsSortedByName() -> [Account] {
|
||||
|
||||
// LocalAccount is first.
|
||||
|
||||
return accounts.sorted { (account1, account2) -> Bool in
|
||||
|
||||
if account1 === localAccount {
|
||||
return true
|
||||
}
|
||||
if account2 === localAccount {
|
||||
return false
|
||||
}
|
||||
|
||||
//TODO: Use localizedCaseInsensitiveCompare:
|
||||
return account1.nameForDisplay < account2.nameForDisplay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let accountDataFileName = "AccountData.plist"
|
||||
|
||||
private func accountFilePathWithFolder(_ folderPath: String) -> String {
|
||||
|
||||
return NSString(string: folderPath).appendingPathComponent(accountDataFileName)
|
||||
}
|
||||
|
||||
private struct AccountSpecifier {
|
||||
|
||||
let type: String
|
||||
let identifier: String
|
||||
let folderPath: String
|
||||
let folderName: String
|
||||
let dataFilePath: String
|
||||
|
||||
init?(folderPath: String) {
|
||||
|
||||
self.folderPath = folderPath
|
||||
self.folderName = NSString(string: folderPath).lastPathComponent
|
||||
|
||||
let nameComponents = self.folderName.components(separatedBy: "-")
|
||||
let satisfyCompilerFolderName = self.folderName
|
||||
assert(nameComponents.count == 2, "Can’t determine account info from \(satisfyCompilerFolderName)")
|
||||
if nameComponents.count != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.type = nameComponents[0]
|
||||
self.identifier = nameComponents[1]
|
||||
|
||||
self.dataFilePath = accountFilePathWithFolder(self.folderPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
61
Evergreen/Data/ArticleUtilities.swift
Normal file
61
Evergreen/Data/ArticleUtilities.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// ArticleUtilities.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/25/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DataModel
|
||||
|
||||
// These handle multiple accounts.
|
||||
|
||||
func markArticles(_ articles: NSSet, statusKey: ArticleStatusKey, flag: Bool) {
|
||||
|
||||
let d: [String: NSSet] = accountAndArticlesDictionary(articles)
|
||||
|
||||
d.keys.forEach { (oneAccountIdentifier) in
|
||||
|
||||
guard let oneAccountArticles = d[oneAccountIdentifier], let oneAccount = accountWithIdentifier(oneAccountIdentifier) else {
|
||||
return
|
||||
}
|
||||
|
||||
oneAccount.markArticles(oneAccountArticles, statusKey: statusKey, flag: flag)
|
||||
}
|
||||
}
|
||||
|
||||
private func accountAndArticlesDictionary(_ articles: NSSet) -> [String: NSSet] {
|
||||
|
||||
var d = [String: NSMutableSet]()
|
||||
|
||||
articles.forEach { (oneObject) in
|
||||
|
||||
guard let oneArticle = oneObject as? Article else {
|
||||
return
|
||||
}
|
||||
guard let oneAccountIdentifier = oneArticle.account?.identifier else {
|
||||
return
|
||||
}
|
||||
|
||||
let oneArticleSet: NSMutableSet = d[oneAccountIdentifier] ?? NSMutableSet()
|
||||
oneArticleSet.add(oneArticle)
|
||||
d[oneAccountIdentifier] = oneArticleSet
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
private func accountWithIdentifier(_ identifier: String) -> Account? {
|
||||
|
||||
return AccountManager.sharedInstance.existingAccountWithIdentifier(identifier)
|
||||
}
|
||||
|
||||
func preferredLink(for article: Article) -> String? {
|
||||
|
||||
if let s = article.permalink {
|
||||
return s
|
||||
}
|
||||
return article.link
|
||||
}
|
||||
|
53
Evergreen/Data/DefaultFeedsImporter.swift
Normal file
53
Evergreen/Data/DefaultFeedsImporter.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// DefaultFeedsImporter.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/13/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DataModel
|
||||
import LocalAccount
|
||||
|
||||
private func shouldImportDefaultFeeds(_ isFirstRun: Bool) -> Bool {
|
||||
|
||||
if !isFirstRun {
|
||||
return false
|
||||
}
|
||||
|
||||
for oneAccount in AccountManager.sharedInstance.accounts {
|
||||
if oneAccount.hasAtLeastOneFeed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func defaultFeedsArray() -> NSArray {
|
||||
|
||||
let f = Bundle.main.path(forResource: "DefaultFeeds", ofType: "plist")!
|
||||
return NSArray(contentsOfFile: f)!
|
||||
}
|
||||
|
||||
private func importFeedsWithArray(_ defaultFeeds: NSArray, _ account: Account) {
|
||||
|
||||
for d in defaultFeeds {
|
||||
|
||||
guard let oneFeedDictionary = d as? NSDictionary else {
|
||||
continue
|
||||
}
|
||||
|
||||
let oneFeed = LocalFeed(account: account, diskDictionary: oneFeedDictionary)!
|
||||
let _ = account.addItem(oneFeed)
|
||||
}
|
||||
}
|
||||
|
||||
func importDefaultFeedsIfNeeded(_ isFirstRun: Bool, account: Account) {
|
||||
|
||||
if !shouldImportDefaultFeeds(isFirstRun) {
|
||||
return
|
||||
}
|
||||
|
||||
importFeedsWithArray(defaultFeedsArray(), account)
|
||||
}
|
51
Evergreen/Extensions/Node-Extensions.swift
Normal file
51
Evergreen/Extensions/Node-Extensions.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// Node-Extensions.swift
|
||||
// Local
|
||||
//
|
||||
// Created by Brent Simmons on 8/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSTree
|
||||
import DataModel
|
||||
|
||||
extension Node {
|
||||
|
||||
class func nodesSortedAlphabetically(_ nodes: [Node]) -> [Node] {
|
||||
|
||||
return nodes.sorted { (node1, node2) -> Bool in
|
||||
|
||||
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
||||
return false
|
||||
}
|
||||
|
||||
let name1 = obj1.nameForDisplay
|
||||
let name2 = obj2.nameForDisplay
|
||||
|
||||
return name1.localizedStandardCompare(name2) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
class func nodesSortedAlphabeticallyWithFoldersAtEnd(_ nodes: [Node]) -> [Node] {
|
||||
|
||||
return nodes.sorted { (node1, node2) -> Bool in
|
||||
|
||||
if node1.canHaveChildNodes != node2.canHaveChildNodes {
|
||||
if node1.canHaveChildNodes {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
|
||||
return false
|
||||
}
|
||||
|
||||
let name1 = obj1.nameForDisplay
|
||||
let name2 = obj2.nameForDisplay
|
||||
|
||||
return name1.localizedStandardCompare(name2) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
15
Evergreen/FeedList/FeedListWindowController.swift
Normal file
15
Evergreen/FeedList/FeedListWindowController.swift
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// FeedListWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class FeedListWindowController : NSWindowController {
|
||||
|
||||
|
||||
}
|
||||
|
238
Evergreen/MainWindow/AddFeed/AddFeedController.swift
Normal file
238
Evergreen/MainWindow/AddFeed/AddFeedController.swift
Normal file
@ -0,0 +1,238 @@
|
||||
//
|
||||
// AddFeedController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/28/16.
|
||||
// Copyright © 2016 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import RSCore
|
||||
import RSTree
|
||||
import DataModel
|
||||
import RSFeedFinder
|
||||
|
||||
// Run add-feed sheet.
|
||||
// If it returns with URL and optional name,
|
||||
// run FeedFinder plus modal progress window.
|
||||
// If FeedFinder returns feed,
|
||||
// add feed.
|
||||
// Else,
|
||||
// display error sheet.
|
||||
|
||||
let UserDidAddFeedNotification = Notification.Name("UserDidAddFeedNotification")
|
||||
let UserDidAddFeedKey = "feed"
|
||||
|
||||
class AddFeedController: AddFeedWindowControllerDelegate, FeedFinderDelegate {
|
||||
|
||||
fileprivate let hostWindow: NSWindow
|
||||
fileprivate var addFeedWindowController: AddFeedWindowController?
|
||||
fileprivate var userEnteredURL: URL?
|
||||
fileprivate var userEnteredFolder: Folder?
|
||||
fileprivate var userEnteredTitle: String?
|
||||
fileprivate var foundFeedURLString: String?
|
||||
fileprivate var titleFromFeed: String?
|
||||
fileprivate var feedFinder: FeedFinder?
|
||||
fileprivate var isFindingFeed = false
|
||||
fileprivate var bestFeedSpecifier: FeedSpecifier?
|
||||
|
||||
init(hostWindow: NSWindow) {
|
||||
|
||||
self.hostWindow = hostWindow
|
||||
}
|
||||
|
||||
func showAddFeedSheet(_ urlString: String?, _ name: String?) {
|
||||
|
||||
let folderTreeControllerDelegate = FolderTreeControllerDelegate()
|
||||
|
||||
let rootNode = Node(representedObject: AccountManager.sharedInstance.localAccount, parent: nil)
|
||||
rootNode.canHaveChildNodes = true
|
||||
let folderTreeController = TreeController(delegate: folderTreeControllerDelegate, rootNode: rootNode)
|
||||
|
||||
addFeedWindowController = AddFeedWindowController(urlString: urlString ?? urlStringFromPasteboard, name: name, folderTreeController: folderTreeController, delegate: self)
|
||||
addFeedWindowController!.runSheetOnWindow(hostWindow)
|
||||
}
|
||||
|
||||
// MARK: AddFeedWindowControllerDelegate
|
||||
|
||||
func addFeedWindowController(_: AddFeedWindowController, userEnteredURL url: URL, userEnteredTitle title: String?, folder: Folder) {
|
||||
|
||||
closeAddFeedSheet(NSModalResponseOK)
|
||||
|
||||
assert(folder.account != nil, "Folder must have an account.")
|
||||
let account = folder.account ?? AccountManager.sharedInstance.localAccount
|
||||
|
||||
if account.hasFeedWithURLString(url.absoluteString) {
|
||||
showAlreadySubscribedError(url.absoluteString, folder)
|
||||
return
|
||||
}
|
||||
|
||||
userEnteredURL = url
|
||||
userEnteredFolder = folder
|
||||
userEnteredTitle = title
|
||||
|
||||
findFeed()
|
||||
}
|
||||
|
||||
func addFeedWindowControllerUserDidCancel(_: AddFeedWindowController) {
|
||||
|
||||
closeAddFeedSheet(NSModalResponseCancel)
|
||||
}
|
||||
|
||||
// MARK: FeedFinderDelegate
|
||||
|
||||
public func feedFinder(_ feedFinder: FeedFinder, didFindFeeds feedSpecifiers: Set<FeedSpecifier>) {
|
||||
|
||||
isFindingFeed = false
|
||||
endShowingProgress()
|
||||
|
||||
if let error = feedFinder.initialDownloadError {
|
||||
if feedFinder.initialDownloadStatusCode == 404 {
|
||||
showNoFeedsErrorMessage()
|
||||
}
|
||||
else {
|
||||
showInitialDownloadError(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers) else {
|
||||
showNoFeedsErrorMessage()
|
||||
return
|
||||
}
|
||||
|
||||
self.bestFeedSpecifier = bestFeedSpecifier
|
||||
self.foundFeedURLString = bestFeedSpecifier.urlString
|
||||
|
||||
if let _ = userEnteredTitle {
|
||||
addFeedIfPossible()
|
||||
}
|
||||
|
||||
if let url = URL(string: bestFeedSpecifier.urlString) {
|
||||
|
||||
downloadTitleForFeed(url, { (title) in
|
||||
self.titleFromFeed = title
|
||||
self.addFeedIfPossible()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Shouldn't happen.
|
||||
showNoFeedsErrorMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extension AddFeedController {
|
||||
|
||||
var urlStringFromPasteboard: String? {
|
||||
get {
|
||||
if let urlString = NSPasteboard.rs_urlString(from: NSPasteboard.general()) {
|
||||
return urlString.rs_normalizedURL()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func closeAddFeedSheet(_ returnCode: NSModalResponse) {
|
||||
|
||||
if let sheetWindow = addFeedWindowController?.window {
|
||||
hostWindow.endSheet(sheetWindow, returnCode: returnCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func addFeedIfPossible() {
|
||||
|
||||
// Add feed if not already subscribed-to.
|
||||
|
||||
guard let folder = userEnteredFolder else {
|
||||
assertionFailure("Folder must not be nil here.")
|
||||
return
|
||||
}
|
||||
guard let account = userEnteredFolder?.account else {
|
||||
assertionFailure("Folder must have an account.")
|
||||
return
|
||||
}
|
||||
guard let feedURLString = foundFeedURLString else {
|
||||
assertionFailure("urlString must not be nil here.")
|
||||
return
|
||||
}
|
||||
|
||||
if account.hasFeedWithURLString(feedURLString) {
|
||||
showAlreadySubscribedError(feedURLString, folder)
|
||||
return
|
||||
}
|
||||
|
||||
if let feed = folder.createFeedWithName(titleFromFeed, editedName: userEnteredTitle, urlString: feedURLString) {
|
||||
print(feedURLString)
|
||||
if folder.addItem(feed) {
|
||||
NotificationCenter.default.post(name: UserDidAddFeedNotification, object: self, userInfo: [UserDidAddFeedKey: feed])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Find Feeds
|
||||
|
||||
func findFeed() {
|
||||
|
||||
guard let url = userEnteredURL else {
|
||||
assertionFailure("userEnteredURL must not be nil.")
|
||||
return
|
||||
}
|
||||
|
||||
isFindingFeed = true
|
||||
feedFinder = FeedFinder(url: url, delegate: self)
|
||||
|
||||
beginShowingProgress()
|
||||
}
|
||||
|
||||
// MARK: Errors
|
||||
|
||||
func showAlreadySubscribedError(_ urlString: String, _ folder: Folder) {
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = NSLocalizedString("Already subscribed", comment: "Feed finder")
|
||||
alert.informativeText = NSLocalizedString("Can’t add this feed because you’ve already subscribed to it.", comment: "Feed finder")
|
||||
|
||||
alert.beginSheetModal(for: hostWindow)
|
||||
}
|
||||
|
||||
func showInitialDownloadError(_ error: Error) {
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = NSLocalizedString("Download Error", comment: "Feed finder")
|
||||
|
||||
let formatString = NSLocalizedString("Can’t add this feed because of a download error: “%@”", comment: "Feed finder")
|
||||
let errorText = NSString.localizedStringWithFormat(formatString as NSString, error.localizedDescription)
|
||||
alert.informativeText = errorText as String
|
||||
|
||||
alert.beginSheetModal(for: hostWindow)
|
||||
}
|
||||
|
||||
func showNoFeedsErrorMessage() {
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = NSLocalizedString("Feed not found", comment: "Feed finder")
|
||||
alert.informativeText = NSLocalizedString("Can’t add a feed because no feed was found.", comment: "Feed finder")
|
||||
|
||||
alert.beginSheetModal(for: hostWindow)
|
||||
}
|
||||
|
||||
// MARK: Progress
|
||||
|
||||
func beginShowingProgress() {
|
||||
|
||||
runIndeterminateProgressWithMessage(NSLocalizedString("Finding feed…", comment:"Feed finder"))
|
||||
}
|
||||
|
||||
func endShowingProgress() {
|
||||
|
||||
stopIndeterminateProgress()
|
||||
hostWindow.makeKeyAndOrderFront(self)
|
||||
}
|
||||
}
|
||||
|
175
Evergreen/MainWindow/AddFeed/AddFeedWindowController.swift
Normal file
175
Evergreen/MainWindow/AddFeed/AddFeedWindowController.swift
Normal file
@ -0,0 +1,175 @@
|
||||
//
|
||||
// AddFeedWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import RSCore
|
||||
import RSTree
|
||||
import DataModel
|
||||
|
||||
protocol AddFeedWindowControllerDelegate: class {
|
||||
|
||||
// userEnteredURL will have already been validated and normalized.
|
||||
func addFeedWindowController(_: AddFeedWindowController, userEnteredURL: URL, userEnteredTitle: String?, folder: Folder)
|
||||
|
||||
func addFeedWindowControllerUserDidCancel(_: AddFeedWindowController)
|
||||
}
|
||||
|
||||
class AddFeedWindowController : NSWindowController {
|
||||
|
||||
@IBOutlet var urlTextField: NSTextField!
|
||||
@IBOutlet var nameTextField: NSTextField!
|
||||
@IBOutlet var addButton: NSButton!
|
||||
@IBOutlet var folderPopupButton: NSPopUpButton!
|
||||
|
||||
private var urlString: String?
|
||||
private var initialName: String?
|
||||
fileprivate weak var delegate: AddFeedWindowControllerDelegate?
|
||||
fileprivate var folderTreeController: TreeController!
|
||||
|
||||
private var userEnteredTitle: String? {
|
||||
get {
|
||||
var s = nameTextField.stringValue
|
||||
s = s.rs_stringWithCollapsedWhitespace()
|
||||
if s.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
var hostWindow: NSWindow!
|
||||
|
||||
convenience init(urlString: String?, name: String?, folderTreeController: TreeController, delegate: AddFeedWindowControllerDelegate?) {
|
||||
|
||||
self.init(windowNibName: "AddFeedSheet")
|
||||
self.urlString = urlString
|
||||
self.initialName = name
|
||||
self.delegate = delegate
|
||||
self.folderTreeController = folderTreeController
|
||||
}
|
||||
|
||||
func runSheetOnWindow(_ w: NSWindow) {
|
||||
|
||||
hostWindow = w
|
||||
hostWindow.beginSheet(window!) { (returnCode: NSModalResponse) -> Void in
|
||||
}
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
if let urlString = urlString {
|
||||
urlTextField.stringValue = urlString
|
||||
}
|
||||
if let initialName = initialName, !initialName.isEmpty {
|
||||
nameTextField.stringValue = initialName
|
||||
}
|
||||
|
||||
folderPopupButton.menu = createFolderPopupMenu()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
|
||||
var addButtonEnabled = false
|
||||
let urlString = urlTextField.stringValue
|
||||
if urlString.rs_stringMayBeURL() {
|
||||
addButtonEnabled = true
|
||||
}
|
||||
|
||||
addButton.isEnabled = addButtonEnabled
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func cancel(_ sender: AnyObject) {
|
||||
|
||||
cancelSheet()
|
||||
}
|
||||
|
||||
@IBAction func addFeed(_ sender: AnyObject) {
|
||||
|
||||
let urlString = urlTextField.stringValue
|
||||
let normalizedURLString = (urlString as NSString).rs_normalizedURL()
|
||||
|
||||
if normalizedURLString.isEmpty {
|
||||
cancelSheet()
|
||||
return;
|
||||
}
|
||||
guard let url = URL(string: normalizedURLString) else {
|
||||
cancelSheet()
|
||||
return
|
||||
}
|
||||
|
||||
delegate?.addFeedWindowController(self, userEnteredURL: url, userEnteredTitle: userEnteredTitle, folder: selectedFolder()!)
|
||||
}
|
||||
|
||||
@IBAction func localShowFeedList(_ sender: AnyObject) {
|
||||
|
||||
NSApplication.shared().sendAction(NSSelectorFromString("showFeedList:"), to: nil, from: sender)
|
||||
hostWindow.endSheet(window!, returnCode: NSModalResponseContinue)
|
||||
}
|
||||
|
||||
// MARK: NSTextFieldDelegate
|
||||
|
||||
override func controlTextDidEndEditing(_ obj: Notification) {
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override func controlTextDidChange(_ obj: Notification) {
|
||||
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddFeedWindowController {
|
||||
|
||||
func cancelSheet() {
|
||||
|
||||
delegate?.addFeedWindowControllerUserDidCancel(self)
|
||||
}
|
||||
|
||||
|
||||
func selectedFolder() -> Folder? {
|
||||
|
||||
return folderPopupButton.selectedItem?.representedObject as? Folder
|
||||
}
|
||||
|
||||
func createFolderPopupMenu() -> NSMenu {
|
||||
|
||||
let menu = NSMenu(title: "Folders")
|
||||
|
||||
let menuItem = NSMenuItem(title: NSLocalizedString("Top Level", comment: "Add Feed Sheet"), action: nil, keyEquivalent: "")
|
||||
menuItem.representedObject = folderTreeController.rootNode.representedObject
|
||||
menu.addItem(menuItem)
|
||||
|
||||
if let childNodes = folderTreeController.rootNode.childNodes {
|
||||
addFolderItemsToMenuWithNodes(menu: menu, nodes: childNodes, indentationLevel: 1)
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func addFolderItemsToMenuWithNodes(menu: NSMenu, nodes: [Node], indentationLevel: Int) {
|
||||
|
||||
nodes.forEach { (oneNode) in
|
||||
|
||||
if let nameProvider = oneNode.representedObject as? DisplayNameProvider {
|
||||
|
||||
let menuItem = NSMenuItem(title: nameProvider.nameForDisplay, action: nil, keyEquivalent: "")
|
||||
menuItem.indentationLevel = indentationLevel
|
||||
menuItem.representedObject = oneNode.representedObject
|
||||
menu.addItem(menuItem)
|
||||
|
||||
if oneNode.numberOfChildNodes > 0 {
|
||||
addFolderItemsToMenuWithNodes(menu: menu, nodes: oneNode.childNodes!, indentationLevel: indentationLevel + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
Evergreen/MainWindow/AddFeed/FeedTitleDownloader.swift
Normal file
28
Evergreen/MainWindow/AddFeed/FeedTitleDownloader.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// FeedTitleDownloader.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 9/3/16.
|
||||
// Copyright © 2016 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSXML
|
||||
import RSWeb
|
||||
|
||||
func downloadTitleForFeed(_ url: URL, _ completionHandler: @escaping (_ title: String?) -> ()) {
|
||||
|
||||
download(url) { (data, response, error) in
|
||||
|
||||
guard let data = data else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let xmlData = RSXMLData(data: data, urlString: url.absoluteString)
|
||||
RSParseFeed(xmlData) { (parsedFeed : RSParsedFeed?, error: Error?) in
|
||||
|
||||
completionHandler(parsedFeed?.title)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
//
|
||||
// FolderTreeControllerDelegate.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSTree
|
||||
import DataModel
|
||||
|
||||
final class FolderTreeControllerDelegate: TreeControllerDelegate {
|
||||
|
||||
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
||||
|
||||
return node.isRoot ? childNodesForRootNode(node) : nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension FolderTreeControllerDelegate {
|
||||
|
||||
func childNodesForRootNode(_ node: Node) -> [Node]? {
|
||||
|
||||
// Root node is “Top Level” and children are folders. Folders can’t have subfolders.
|
||||
// This will have to be revised later.
|
||||
|
||||
var folderNodes = [Node]()
|
||||
|
||||
let _ = AccountManager.sharedInstance.localAccount.visitChildren { (oneRepresentedObject) in
|
||||
|
||||
if let folder = oneRepresentedObject as? Folder {
|
||||
folderNodes += [createNode(folder, parent: node)]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return Node.nodesSortedAlphabetically(folderNodes)
|
||||
}
|
||||
|
||||
func createNode(_ folder: Folder, parent: Node) -> Node {
|
||||
|
||||
let node = Node(representedObject: folder as AnyObject, parent: parent)
|
||||
node.canHaveChildNodes = false
|
||||
return node
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
//
|
||||
// AddFolderWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DataModel
|
||||
|
||||
//func addFolderWindowController() -> AddFolderWindowController {
|
||||
//
|
||||
// return AddFolderWindowController(windowNibName: "AddFolderSheet")
|
||||
//}
|
||||
|
||||
class AddFolderWindowController : NSWindowController {
|
||||
|
||||
@IBOutlet var folderNameTextField: NSTextField!
|
||||
@IBOutlet var accountPopupButton: NSPopUpButton!
|
||||
var hostWindow: NSWindow?
|
||||
|
||||
convenience init() {
|
||||
|
||||
self.init(windowNibName: "AddFolderSheet")
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func runSheetOnWindow(_ w: NSWindow) {
|
||||
|
||||
hostWindow = w
|
||||
hostWindow!.beginSheet(window!) { (returnCode: NSModalResponse) -> Void in
|
||||
|
||||
if returnCode == NSModalResponseOK {
|
||||
self.addFolderIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSViewController
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
accountPopupButton.removeAllItems()
|
||||
let menu = NSMenu()
|
||||
for oneAccount in AccountManager.sharedInstance.sortedAccounts {
|
||||
let oneMenuItem = NSMenuItem()
|
||||
oneMenuItem.title = oneAccount.nameForDisplay
|
||||
oneMenuItem.representedObject = oneAccount
|
||||
menu.addItem(oneMenuItem)
|
||||
}
|
||||
accountPopupButton.menu = menu
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func addFolderIfNeeded() {
|
||||
|
||||
guard let menuItem = accountPopupButton.selectedItem else {
|
||||
return
|
||||
}
|
||||
let account = menuItem.representedObject as! Account
|
||||
|
||||
let folderName = self.folderNameTextField.stringValue
|
||||
if folderName.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
let _ = account.ensureFolderWithName(folderName)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func cancel(_ sender: AnyObject) {
|
||||
|
||||
hostWindow!.endSheet(window!, returnCode: NSModalResponseCancel)
|
||||
}
|
||||
|
||||
@IBAction func addFolder(_ sender: AnyObject) {
|
||||
|
||||
hostWindow!.endSheet(window!, returnCode: NSModalResponseOK)
|
||||
}
|
||||
|
||||
}
|
191
Evergreen/MainWindow/Detail/ArticleRenderer.swift
Normal file
191
Evergreen/MainWindow/Detail/ArticleRenderer.swift
Normal file
@ -0,0 +1,191 @@
|
||||
//
|
||||
// ArticleRenderer.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 9/8/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import DataModel
|
||||
|
||||
var cachedStyleString = ""
|
||||
var cachedTemplate = ""
|
||||
|
||||
class ArticleRenderer {
|
||||
|
||||
let article: Article
|
||||
let articleStyle: ArticleStyle
|
||||
|
||||
lazy var longDateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .long
|
||||
dateFormatter.timeStyle = .medium
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
lazy var mediumDateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .short
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
lazy var shortDateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
lazy var title: String = {
|
||||
if let articleTitle = self.article.title {
|
||||
return articleTitle
|
||||
}
|
||||
|
||||
return ""
|
||||
}()
|
||||
|
||||
lazy var baseURL: URL? = {
|
||||
|
||||
var s = self.article.permalink
|
||||
if s == nil {
|
||||
s = self.article.feed?.homePageURL
|
||||
}
|
||||
if s == nil {
|
||||
s = self.article.feed?.url
|
||||
}
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let url = URL(string: s!) {
|
||||
if url.scheme == "http" || url.scheme == "https" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
var html: String {
|
||||
|
||||
return renderedHTML()
|
||||
}
|
||||
|
||||
init(article: Article, style: ArticleStyle) {
|
||||
|
||||
self.article = article
|
||||
self.articleStyle = style
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func textInsideTag(_ text: String, _ tag: String) -> String {
|
||||
|
||||
return "<\(tag)>\(text)</\(tag)>"
|
||||
}
|
||||
|
||||
private func styleString() -> String {
|
||||
|
||||
if let s = articleStyle.css {
|
||||
return s
|
||||
}
|
||||
|
||||
if cachedStyleString.isEmpty {
|
||||
|
||||
let path = Bundle.main.path(forResource: "styleSheet", ofType: "css")!
|
||||
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
|
||||
cachedStyleString = "\n\(s)\n"
|
||||
}
|
||||
|
||||
return cachedStyleString
|
||||
}
|
||||
|
||||
private func template() -> String {
|
||||
|
||||
if let s = articleStyle.template {
|
||||
return s
|
||||
}
|
||||
|
||||
if cachedTemplate.isEmpty {
|
||||
let path = Bundle.main.path(forResource: "template", ofType: "html")!
|
||||
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
|
||||
cachedTemplate = s as String
|
||||
}
|
||||
|
||||
return cachedTemplate
|
||||
}
|
||||
|
||||
private func linkWithTextAndClass(_ text: String, _ href: String, _ className: String) -> String {
|
||||
|
||||
return "<a class=\"\(className)\" href=\"\(href)\">\(text)</a>"
|
||||
}
|
||||
|
||||
private func linkWithText(_ text: String, _ href: String) -> String {
|
||||
|
||||
return "<a href=\"\(href)\">\(text)</a>"
|
||||
}
|
||||
|
||||
private func titleOrTitleLink() -> String {
|
||||
|
||||
let link = preferredLink(for: article)
|
||||
if let link = link {
|
||||
return linkWithText(title, link)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
private func substitutions() -> [String: String] {
|
||||
|
||||
var d = [String: String]()
|
||||
|
||||
let title = titleOrTitleLink()
|
||||
d["newsitem_title"] = title
|
||||
d["article_title"] = title
|
||||
|
||||
let body = article.body == nil ? "" : article.body
|
||||
d["article_description"] = body
|
||||
d["newsitem_description"] = body
|
||||
|
||||
var feedLink = ""
|
||||
if let feedTitle = article.feed?.nameForDisplay {
|
||||
feedLink = feedTitle
|
||||
if let feedURL = article.feed?.homePageURL {
|
||||
feedLink = linkWithTextAndClass(feedTitle, feedURL, "feedLink")
|
||||
}
|
||||
}
|
||||
d["feedlink"] = feedLink
|
||||
d["feedlink_withfavicon"] = feedLink
|
||||
|
||||
let longDate = longDateFormatter.string(from: article.logicalDatePublished)
|
||||
d["date_long"] = longDate
|
||||
|
||||
let mediumDate = mediumDateFormatter.string(from: article.logicalDatePublished)
|
||||
d["date_medium"] = mediumDate
|
||||
|
||||
let shortDate = shortDateFormatter.string(from: article.logicalDatePublished)
|
||||
d["date_short"] = shortDate
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
private func renderedHTML() -> String {
|
||||
|
||||
var s = "<!DOCTYPE html><html><head>"
|
||||
s += textInsideTag(title, "title")
|
||||
s += textInsideTag(styleString(), "style")
|
||||
s += "</head></body>"
|
||||
|
||||
s += RSMacroProcessor.renderedText(withTemplate: template(), substitutions: substitutions(), macroStart: "[[", macroEnd: "]]")
|
||||
|
||||
s += "</body></html>"
|
||||
|
||||
// print(s)
|
||||
|
||||
return s
|
||||
|
||||
}
|
||||
|
||||
}
|
114
Evergreen/MainWindow/Detail/DetailViewController.swift
Normal file
114
Evergreen/MainWindow/Detail/DetailViewController.swift
Normal file
@ -0,0 +1,114 @@
|
||||
//
|
||||
// DetailViewController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/26/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
import RSCore
|
||||
import DataModel
|
||||
|
||||
class DetailViewController: NSViewController, WKNavigationDelegate, WKUIDelegate {
|
||||
|
||||
var webview: WKWebView!
|
||||
|
||||
var article: Article? {
|
||||
didSet {
|
||||
reloadHTML()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(timelineSelectionDidChange(_:)), name: .TimelineSelectionDidChange, object: nil)
|
||||
|
||||
let preferences = WKPreferences()
|
||||
preferences.minimumFontSize = 12.0
|
||||
preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
preferences.javaEnabled = false
|
||||
preferences.javaScriptEnabled = true
|
||||
preferences.plugInsEnabled = false
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.preferences = preferences
|
||||
|
||||
webview = WKWebView(frame: self.view.bounds, configuration: configuration)
|
||||
webview.uiDelegate = self
|
||||
webview.navigationDelegate = self
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
let boxView = self.view as! DetailBox
|
||||
boxView.contentView = webview
|
||||
boxView.rs_addFullSizeConstraints(forSubview: webview)
|
||||
|
||||
boxView.viewController = self
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
func timelineSelectionDidChange(_ note: Notification) {
|
||||
|
||||
let timelineView = note.userInfo?[viewKey] as! NSView
|
||||
|
||||
if timelineView.window! === self.view.window {
|
||||
article = note.userInfo?[articleKey] as? Article
|
||||
}
|
||||
}
|
||||
|
||||
func viewWillStartLiveResize() {
|
||||
|
||||
webview.evaluateJavaScript("document.body.style.overflow = 'hidden';", completionHandler: nil)
|
||||
}
|
||||
|
||||
func viewDidEndLiveResize() {
|
||||
|
||||
webview.evaluateJavaScript("document.body.style.overflow = 'visible';", completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func reloadHTML() {
|
||||
|
||||
if let article = article {
|
||||
let articleRenderer = ArticleRenderer(article: article, style: ArticleStylesManager.sharedInstance.currentStyle)
|
||||
webview.loadHTMLString(articleRenderer.html, baseURL: articleRenderer.baseURL)
|
||||
}
|
||||
else {
|
||||
webview.loadHTMLString("", baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: WKNavigationDelegate
|
||||
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
|
||||
if let url = navigationAction.request.url {
|
||||
openInBrowser(url.absoluteString)
|
||||
}
|
||||
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
class DetailBox: NSBox {
|
||||
|
||||
weak var viewController: DetailViewController?
|
||||
|
||||
override func viewWillStartLiveResize() {
|
||||
|
||||
viewController?.viewWillStartLiveResize()
|
||||
}
|
||||
|
||||
override func viewDidEndLiveResize() {
|
||||
|
||||
viewController?.viewDidEndLiveResize()
|
||||
}
|
||||
}
|
197
Evergreen/MainWindow/MainWindowController.swift
Normal file
197
Evergreen/MainWindow/MainWindowController.swift
Normal file
@ -0,0 +1,197 @@
|
||||
//
|
||||
// MainWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DataModel
|
||||
|
||||
private let kWindowFrameKey = "MainWindow"
|
||||
|
||||
class MainWindowController : NSWindowController, NSUserInterfaceValidations {
|
||||
|
||||
// MARK: NSWindowController
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
super.windowDidLoad()
|
||||
|
||||
// window?.titleVisibility = .hidden
|
||||
window?.setFrameUsingName(kWindowFrameKey, force: true)
|
||||
|
||||
detailSplitViewItem?.minimumThickness = 384
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(applicationWillTerminate(_:)), name: .NSApplicationWillTerminate, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appNavigationKeyPressed(_:)), name: .AppNavigationKeyPressed, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(refreshProgressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
func applicationWillTerminate(_ note: Notification) {
|
||||
|
||||
window?.saveFrame(usingName: kWindowFrameKey)
|
||||
}
|
||||
|
||||
func appNavigationKeyPressed(_ note: Notification) {
|
||||
|
||||
guard let key = note.userInfo?[appNavigationKey] as? Int else {
|
||||
return
|
||||
}
|
||||
guard let contentView = window?.contentView, let view = note.object as? NSView, view.isDescendant(of: contentView) else {
|
||||
return
|
||||
}
|
||||
|
||||
print(key)
|
||||
}
|
||||
|
||||
func refreshProgressDidChange(_ note: Notification) {
|
||||
|
||||
rs_performSelectorCoalesced(#selector(MainWindowController.coalescedMakeToolbarValidate(_:)), with: nil, afterDelay: 0.1)
|
||||
}
|
||||
|
||||
// MARK: Toolbar
|
||||
|
||||
func coalescedMakeToolbarValidate(_ sender: Any) {
|
||||
|
||||
window?.toolbar?.validateVisibleItems()
|
||||
}
|
||||
|
||||
// MARK: NSUserInterfaceValidations
|
||||
|
||||
public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
||||
|
||||
if item.action == #selector(openArticleInBrowser(_:)) {
|
||||
return currentLink != nil
|
||||
}
|
||||
|
||||
if item.action == #selector(nextUnread(_:)) {
|
||||
return canGoToNextUnread()
|
||||
}
|
||||
|
||||
if item.action == #selector(markAllAsRead(_:)) {
|
||||
return canMarkAllAsRead()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func openArticleInBrowser(_ sender: AnyObject?) {
|
||||
|
||||
if let link = currentLink {
|
||||
openInBrowser(link)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func nextUnread(_ sender: AnyObject?) {
|
||||
|
||||
guard let timelineViewController = timelineViewController, let sidebarViewController = sidebarViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
func makeTimelineViewFirstResponder() {
|
||||
window!.makeFirstResponderUnlessDescendantIsFirstResponder(timelineViewController.tableView)
|
||||
}
|
||||
|
||||
if timelineViewController.canGoToNextUnread() {
|
||||
timelineViewController.goToNextUnread()
|
||||
makeTimelineViewFirstResponder()
|
||||
}
|
||||
else if sidebarViewController.canGoToNextUnread() {
|
||||
sidebarViewController.goToNextUnread()
|
||||
makeTimelineViewFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func markAllAsRead(_ sender: AnyObject?) {
|
||||
|
||||
timelineViewController?.markAllAsRead()
|
||||
}
|
||||
|
||||
@IBAction func toggleSidebar(_ sender: AnyObject?) {
|
||||
|
||||
splitViewController!.toggleSidebar(sender)
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainWindowController {
|
||||
|
||||
var splitViewController: NSSplitViewController? {
|
||||
get {
|
||||
guard let viewController = contentViewController else {
|
||||
return nil
|
||||
}
|
||||
return viewController.childViewControllers.first as? NSSplitViewController
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarViewController: SidebarViewController? {
|
||||
get {
|
||||
return splitViewController?.splitViewItems[0].viewController as? SidebarViewController
|
||||
}
|
||||
}
|
||||
|
||||
var timelineViewController: TimelineViewController? {
|
||||
get {
|
||||
return splitViewController?.splitViewItems[1].viewController as? TimelineViewController
|
||||
}
|
||||
}
|
||||
|
||||
var detailSplitViewItem: NSSplitViewItem? {
|
||||
|
||||
return splitViewController?.splitViewItems[2]
|
||||
}
|
||||
|
||||
var detailViewController: DetailViewController? {
|
||||
|
||||
get {
|
||||
return splitViewController?.splitViewItems[2].viewController as? DetailViewController
|
||||
}
|
||||
}
|
||||
|
||||
var selectedArticles: [Article]? {
|
||||
get {
|
||||
return timelineViewController?.selectedArticles
|
||||
}
|
||||
}
|
||||
|
||||
var oneSelectedArticle: Article? {
|
||||
get {
|
||||
if let articles = selectedArticles {
|
||||
return articles.count == 1 ? articles[0] : nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var currentLink: String? {
|
||||
get {
|
||||
if let article = oneSelectedArticle {
|
||||
return preferredLink(for: article)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func canGoToNextUnread() -> Bool {
|
||||
|
||||
guard let timelineViewController = timelineViewController, let sidebarViewController = sidebarViewController else {
|
||||
return false
|
||||
}
|
||||
|
||||
return timelineViewController.canGoToNextUnread() || sidebarViewController.canGoToNextUnread()
|
||||
}
|
||||
|
||||
func canMarkAllAsRead() -> Bool {
|
||||
|
||||
return timelineViewController?.canMarkAllAsRead() ?? false
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// MainWindowSplitView.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 2/5/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
125
Evergreen/MainWindow/Sidebar/SidebarCell.swift
Normal file
125
Evergreen/MainWindow/Sidebar/SidebarCell.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// SidebarCell.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DB5
|
||||
|
||||
private var textSizeCache = [String: NSSize]()
|
||||
|
||||
class SidebarCell : NSTableCellView {
|
||||
|
||||
var image: NSImage?
|
||||
private let unreadCountView = UnreadCountView(frame: NSZeroRect)
|
||||
|
||||
var unreadCount: Int {
|
||||
get {
|
||||
return unreadCountView.unreadCount
|
||||
}
|
||||
set {
|
||||
if unreadCountView.unreadCount != newValue {
|
||||
unreadCountView.unreadCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
get {
|
||||
if let s = textField?.stringValue {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
set {
|
||||
if textField?.stringValue != newValue {
|
||||
textField?.stringValue = newValue
|
||||
needsDisplay = true
|
||||
needsLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var isFlipped: Bool {
|
||||
get {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
|
||||
unreadCountView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(unreadCountView)
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
|
||||
super.init(frame: frameRect)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
|
||||
resizeSubviews(withOldSize: NSZeroSize)
|
||||
}
|
||||
|
||||
private let kTextFieldOriginX: CGFloat = 4.0
|
||||
private let kTextFieldMarginRight: CGFloat = 4.0
|
||||
private let kUnreadCountMarginLeft: CGFloat = 4.0
|
||||
private let kUnreadCountMarginRight: CGFloat = 4.0
|
||||
|
||||
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
||||
|
||||
var r = textField!.frame
|
||||
r.origin.x = kTextFieldOriginX
|
||||
r.size.width = NSWidth(bounds) - (kTextFieldOriginX + kTextFieldMarginRight);
|
||||
|
||||
let unreadCountSize = unreadCountView.intrinsicContentSize
|
||||
if unreadCountSize.width > 0.1 {
|
||||
r.size.width = NSWidth(bounds) - (kTextFieldOriginX + kUnreadCountMarginLeft + unreadCountSize.width + kUnreadCountMarginRight)
|
||||
}
|
||||
|
||||
let size = textField!.intrinsicContentSize
|
||||
r.size.height = size.height
|
||||
r = rs_rectCenteredVertically(r)
|
||||
r.origin.y -= 1.0
|
||||
|
||||
textField?.rs_setFrameIfNotEqual(r)
|
||||
|
||||
layoutUnreadCountView(unreadCountSize)
|
||||
}
|
||||
|
||||
private func layoutUnreadCountView(_ size: NSSize) {
|
||||
|
||||
if size == NSZeroSize {
|
||||
if !unreadCountView.isHidden {
|
||||
unreadCountView.isHidden = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if unreadCountView.isHidden {
|
||||
unreadCountView.isHidden = false
|
||||
}
|
||||
|
||||
var r = NSZeroRect
|
||||
r.size = size
|
||||
r.origin.x = NSMaxX(textField!.frame) + kUnreadCountMarginLeft
|
||||
r = rs_rectCenteredVertically(r)
|
||||
|
||||
unreadCountView.rs_setFrameIfNotEqual(r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
68
Evergreen/MainWindow/Sidebar/SidebarOutlineView.swift
Normal file
68
Evergreen/MainWindow/Sidebar/SidebarOutlineView.swift
Normal file
@ -0,0 +1,68 @@
|
||||
//
|
||||
// SidebarOutlineView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 11/17/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
class SidebarOutlineView : NSOutlineView {
|
||||
|
||||
//MARK: NSResponder
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
|
||||
guard !event.rs_keyIsModified() else {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
let ch = Int(event.rs_unmodifiedCharacter())
|
||||
if ch == NSNotFound {
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
var keyHandled = false
|
||||
|
||||
switch(ch) {
|
||||
|
||||
case NSRightArrowFunctionKey:
|
||||
keyHandled = true
|
||||
|
||||
case NSDeleteFunctionKey:
|
||||
keyHandled = true
|
||||
Swift.print("NSDeleteFunctionKey")
|
||||
|
||||
default:
|
||||
keyHandled = false
|
||||
|
||||
}
|
||||
|
||||
if keyHandled {
|
||||
NotificationCenter.default.post(name: .AppNavigationKeyPressed, object: self, userInfo: [appNavigationKey: ch])
|
||||
}
|
||||
|
||||
else {
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillStartLiveResize() {
|
||||
|
||||
if let scrollView = self.enclosingScrollView {
|
||||
scrollView.hasVerticalScroller = false
|
||||
}
|
||||
super.viewWillStartLiveResize()
|
||||
}
|
||||
|
||||
override func viewDidEndLiveResize() {
|
||||
|
||||
if let scrollView = self.enclosingScrollView {
|
||||
scrollView.hasVerticalScroller = true
|
||||
}
|
||||
super.viewDidEndLiveResize()
|
||||
}
|
||||
}
|
115
Evergreen/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift
Normal file
115
Evergreen/MainWindow/Sidebar/SidebarTreeControllerDelegate.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// SidebarTreeControllerDelegate.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/24/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSTree
|
||||
import DataModel
|
||||
|
||||
final class SidebarTreeControllerDelegate: TreeControllerDelegate {
|
||||
|
||||
func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? {
|
||||
|
||||
if node.isRoot {
|
||||
return childNodesForRootNode(node)
|
||||
}
|
||||
if node.representedObject is Folder {
|
||||
return childNodesForFolderNode(node)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension SidebarTreeControllerDelegate {
|
||||
|
||||
func childNodesForRootNode(_ node: Node) -> [Node]? {
|
||||
|
||||
// The child nodes are the top-level items of the local Account.
|
||||
// This will be expanded later to add synthetic feeds (All Unread, for instance).
|
||||
|
||||
var updatedChildNodes = [Node]()
|
||||
|
||||
let _ = AccountManager.sharedInstance.localAccount.visitChildren { (oneRepresentedObject) in
|
||||
|
||||
if let existingNode = node.childNodeRepresentingObject(oneRepresentedObject as AnyObject) {
|
||||
// Reuse nodes.
|
||||
if !updatedChildNodes.contains(existingNode) {
|
||||
updatedChildNodes += [existingNode]
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if let newNode = createNode(representedObject: oneRepresentedObject as AnyObject, parent: node) {
|
||||
updatedChildNodes += [newNode]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
updatedChildNodes = Node.nodesSortedAlphabeticallyWithFoldersAtEnd(updatedChildNodes)
|
||||
return updatedChildNodes
|
||||
}
|
||||
|
||||
func childNodesForFolderNode(_ node: Node) -> [Node]? {
|
||||
|
||||
var updatedChildNodes = [Node]()
|
||||
let folder = node.representedObject as! Folder
|
||||
|
||||
let _ = folder.visitChildren { (oneRepresentedObject) -> Bool in
|
||||
|
||||
if let existingNode = node.childNodeRepresentingObject(oneRepresentedObject) {
|
||||
if !updatedChildNodes.contains(existingNode) {
|
||||
updatedChildNodes += [existingNode]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if let newNode = self.createNode(representedObject: oneRepresentedObject, parent: node) {
|
||||
updatedChildNodes += [newNode]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
updatedChildNodes = Node.nodesSortedAlphabeticallyWithFoldersAtEnd(updatedChildNodes)
|
||||
return updatedChildNodes
|
||||
}
|
||||
|
||||
func createNode(representedObject: AnyObject, parent: Node) -> Node? {
|
||||
|
||||
if let feed = representedObject as? Feed {
|
||||
return createNode(feed: feed, parent: parent)
|
||||
}
|
||||
if let folder = representedObject as? Folder {
|
||||
return createNode(folder: folder, parent: parent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNode(feed: Feed, parent: Node) -> Node {
|
||||
|
||||
return Node(representedObject: feed, parent: parent)
|
||||
}
|
||||
|
||||
func createNode(folder: Folder, parent: Node) -> Node {
|
||||
|
||||
let node = Node(representedObject: folder as AnyObject, parent: parent)
|
||||
node.canHaveChildNodes = true
|
||||
return node
|
||||
}
|
||||
|
||||
func nodeInArrayRepresentingObject(_ nodes: [Node], _ representedObject: AnyObject) -> Node? {
|
||||
|
||||
for oneNode in nodes {
|
||||
if oneNode.representedObject === representedObject {
|
||||
return oneNode
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
353
Evergreen/MainWindow/Sidebar/SidebarViewController.swift
Normal file
353
Evergreen/MainWindow/Sidebar/SidebarViewController.swift
Normal file
@ -0,0 +1,353 @@
|
||||
//
|
||||
// SidebarViewController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/26/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import RSTree
|
||||
import DataModel
|
||||
|
||||
@objc class SidebarViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
|
||||
|
||||
@IBOutlet var outlineView: NSOutlineView!
|
||||
var treeController: TreeController!
|
||||
let treeControllerDelegate = SidebarTreeControllerDelegate()
|
||||
|
||||
//MARK: NSViewController
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
treeController = TreeController(delegate: treeControllerDelegate)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(folderChildrenDidChange(_:)), name: NSNotification.Name(rawValue: FolderChildrenDidChangeNotification), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: UserDidAddFeedNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(dataModelDidPerformBatchUpdates(_:)), name: .DataModelDidPerformBatchUpdates, object: nil)
|
||||
|
||||
outlineView.reloadData()
|
||||
}
|
||||
|
||||
//MARK: Notifications
|
||||
|
||||
dynamic func unreadCountDidChange(_ note: Notification) {
|
||||
|
||||
guard let representedObject = note.object else {
|
||||
return
|
||||
}
|
||||
let _ = configureCellsForRepresentedObject(representedObject as AnyObject)
|
||||
}
|
||||
|
||||
dynamic func folderChildrenDidChange(_ note: Notification) {
|
||||
|
||||
rebuildTreeAndReloadDataIfNeeded()
|
||||
}
|
||||
|
||||
dynamic func dataModelDidPerformBatchUpdates(_ notification: Notification) {
|
||||
|
||||
rebuildTreeAndReloadDataIfNeeded()
|
||||
}
|
||||
|
||||
dynamic func userDidAddFeed(_ note: Notification) {
|
||||
|
||||
// Find the feed and select it.
|
||||
|
||||
guard let feed = note.userInfo?[UserDidAddFeedKey] as? Feed else {
|
||||
return
|
||||
}
|
||||
revealAndSelectRepresentedObject(feed)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func delete(_ sender: AnyObject?) {
|
||||
|
||||
if outlineView.selectionIsEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
let nodesToDelete = selectedNodes
|
||||
let selectedRows = outlineView.selectedRowIndexes
|
||||
|
||||
outlineView.beginUpdates()
|
||||
outlineView.removeItems(at: selectedRows, inParent: nil, withAnimation: [.slideDown])
|
||||
outlineView.endUpdates()
|
||||
|
||||
performDataModelBatchUpdates {
|
||||
deleteItemsForNodes(nodesToDelete)
|
||||
}
|
||||
|
||||
treeController.rebuild()
|
||||
}
|
||||
|
||||
// MARK: Navigation
|
||||
|
||||
|
||||
func canGoToNextUnread() -> Bool {
|
||||
|
||||
if let _ = rowContainingNextUnread() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func goToNextUnread() {
|
||||
|
||||
guard let row = rowContainingNextUnread() else {
|
||||
assertionFailure("goToNextUnread called before checking if there is a next unread.")
|
||||
return
|
||||
}
|
||||
|
||||
outlineView.selectRowIndexes(IndexSet([row]), byExtendingSelection: false)
|
||||
|
||||
NSApplication.shared().sendAction(NSSelectorFromString("nextUnread:"), to: nil, from: self)
|
||||
}
|
||||
|
||||
// MARK: NSOutlineViewDelegate
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
|
||||
|
||||
let cell = outlineView.make(withIdentifier: "DataCell", owner: self) as! SidebarCell
|
||||
|
||||
let node = item as! Node
|
||||
configure(cell, node)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func outlineViewSelectionDidChange(_ notification: Notification) {
|
||||
|
||||
// TODO: support multiple selection
|
||||
|
||||
let selectedRow = self.outlineView.selectedRow
|
||||
|
||||
if selectedRow < 0 || selectedRow == NSNotFound {
|
||||
postSidebarSelectionDidChangeNotification(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let selectedNode = self.outlineView.item(atRow: selectedRow) as? Node {
|
||||
postSidebarSelectionDidChangeNotification(NSArray(object: selectedNode.representedObject))
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: NSOutlineViewDataSource
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
|
||||
|
||||
return nodeForItem(item as AnyObject?).numberOfChildNodes
|
||||
}
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
|
||||
|
||||
return nodeForItem(item as AnyObject?).childNodes![index]
|
||||
}
|
||||
|
||||
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
|
||||
|
||||
return nodeForItem(item as AnyObject?).canHaveChildNodes
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Private
|
||||
|
||||
private extension SidebarViewController {
|
||||
|
||||
var selectedNodes: [Node] {
|
||||
get {
|
||||
if let nodes = outlineView.selectedItems as? [Node] {
|
||||
return nodes
|
||||
}
|
||||
return [Node]()
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildTreeAndReloadDataIfNeeded() {
|
||||
|
||||
if !dataModelIsPerformingBatchUpdates() {
|
||||
treeController.rebuild()
|
||||
outlineView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
func postSidebarSelectionDidChangeNotification(_ selectedObjects: NSArray?) {
|
||||
|
||||
var userInfo = [AnyHashable: Any]()
|
||||
if let selectedObjects = selectedObjects {
|
||||
userInfo[objectsKey] = selectedObjects
|
||||
}
|
||||
userInfo[viewKey] = self.outlineView
|
||||
|
||||
NotificationCenter.default.post(name: .SidebarSelectionDidChange, object: self, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func nodeForItem(_ item: AnyObject?) -> Node {
|
||||
|
||||
if item == nil {
|
||||
return treeController.rootNode
|
||||
}
|
||||
return item as! Node
|
||||
}
|
||||
|
||||
func nodeForRow(_ row: Int) -> Node? {
|
||||
|
||||
if row < 0 || row >= outlineView.numberOfRows {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let node = outlineView.item(atRow: row) as? Node {
|
||||
return node
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowHasAtLeastOneUnreadArticle(_ row: Int) -> Bool {
|
||||
|
||||
if let oneNode = nodeForRow(row) {
|
||||
if let unreadCountProvider = oneNode.representedObject as? UnreadCountProvider {
|
||||
if unreadCountProvider.unreadCount > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rowContainingNextUnread() -> Int? {
|
||||
|
||||
let selectedRow = outlineView.selectedRow
|
||||
let numberOfRows = outlineView.numberOfRows
|
||||
var row = selectedRow + 1
|
||||
|
||||
while (row < numberOfRows) {
|
||||
if rowHasAtLeastOneUnreadArticle(row) {
|
||||
return row
|
||||
}
|
||||
row += 1
|
||||
}
|
||||
|
||||
row = 0
|
||||
while (row <= selectedRow) {
|
||||
if rowHasAtLeastOneUnreadArticle(row) {
|
||||
return row
|
||||
}
|
||||
row += 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func configure(_ cell: SidebarCell, _ node: Node) {
|
||||
|
||||
cell.objectValue = node
|
||||
cell.name = nameFor(node)
|
||||
cell.unreadCount = unreadCountFor(node)
|
||||
cell.image = imageFor(node)
|
||||
}
|
||||
|
||||
func imageFor(_ node: Node) -> NSImage? {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func nameFor(_ node: Node) -> String {
|
||||
|
||||
if let displayNameProvider = node.representedObject as? DisplayNameProvider {
|
||||
return displayNameProvider.nameForDisplay
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unreadCountFor(_ node: Node) -> Int {
|
||||
|
||||
if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
|
||||
return unreadCountProvider.unreadCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func availableSidebarCells() -> [SidebarCell] {
|
||||
|
||||
var cells = [SidebarCell]()
|
||||
|
||||
outlineView.enumerateAvailableRowViews { (rowView: NSTableRowView, _: Int) -> Void in
|
||||
|
||||
if let oneSidebarCell = rowView.view(atColumn: 0) as? SidebarCell {
|
||||
cells += [oneSidebarCell]
|
||||
}
|
||||
}
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
func cellsForRepresentedObject(_ representedObject: AnyObject) -> [SidebarCell] {
|
||||
|
||||
let availableCells = availableSidebarCells()
|
||||
return availableCells.filter{ (oneSidebarCell) -> Bool in
|
||||
|
||||
guard let oneNode = oneSidebarCell.objectValue as? Node else {
|
||||
return false
|
||||
}
|
||||
return oneNode.representedObject === representedObject
|
||||
}
|
||||
}
|
||||
|
||||
func configureCellsForRepresentedObject(_ representedObject: AnyObject) -> Bool {
|
||||
|
||||
//Return true if any cells were configured.
|
||||
|
||||
let cells = cellsForRepresentedObject(representedObject)
|
||||
if cells.isEmpty {
|
||||
return false
|
||||
}
|
||||
|
||||
cells.forEach { (oneSidebarCell) in
|
||||
guard let oneNode = oneSidebarCell.objectValue as? Node else {
|
||||
return
|
||||
}
|
||||
configure(oneSidebarCell, oneNode)
|
||||
oneSidebarCell.needsDisplay = true
|
||||
oneSidebarCell.needsLayout = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func revealAndSelectRepresentedObject(_ representedObject: AnyObject) -> Bool {
|
||||
|
||||
return outlineView.revealAndSelectRepresentedObject(representedObject, treeController)
|
||||
}
|
||||
|
||||
func folderParentForNode(_ node: Node) -> Folder? {
|
||||
|
||||
if let folder = node.parent?.representedObject as? Folder {
|
||||
return folder
|
||||
}
|
||||
if let feed = node.representedObject as? Feed {
|
||||
return feed.account
|
||||
}
|
||||
if let folder = node.representedObject as? Folder {
|
||||
return folder.account
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteItemForNode(_ node: Node) {
|
||||
|
||||
if let folder = folderParentForNode(node) {
|
||||
folder.deleteItems([node.representedObject])
|
||||
}
|
||||
}
|
||||
|
||||
func deleteItemsForNodes(_ nodes: [Node]) {
|
||||
|
||||
nodes.forEach { (oneNode) in
|
||||
|
||||
deleteItemForNode(oneNode)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
105
Evergreen/MainWindow/Sidebar/UnreadCountView.swift
Normal file
105
Evergreen/MainWindow/Sidebar/UnreadCountView.swift
Normal file
@ -0,0 +1,105 @@
|
||||
//
|
||||
// UnreadCountView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 11/22/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
private let padding = currentTheme.edgeInsets(forKey: "MainWindow.SourceList.unreadCount.padding")
|
||||
private let cornerRadius = currentTheme.float(forKey: "MainWindow.SourceList.unreadCount.cornerRadius")
|
||||
private let backgroundColor = currentTheme.colorWithAlpha(forKey: "MainWindow.SourceList.unreadCount.backgroundColor")
|
||||
private let textColor = currentTheme.colorWithAlpha(forKey: "MainWindow.SourceList.unreadCount.color")
|
||||
private let textSize = currentTheme.float(forKey: "MainWindow.SourceList.unreadCount.fontSize")
|
||||
private let textFont = NSFont.systemFont(ofSize: textSize, weight: NSFontWeightSemibold)
|
||||
private var textAttributes: [String: AnyObject] = [NSForegroundColorAttributeName: textColor, NSFontAttributeName: textFont, NSKernAttributeName: NSNull()]
|
||||
private var textSizeCache = [Int: NSSize]()
|
||||
|
||||
class UnreadCountView : NSView {
|
||||
|
||||
var unreadCount = 0 {
|
||||
didSet {
|
||||
invalidateIntrinsicContentSize()
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
var unreadCountString: String {
|
||||
get {
|
||||
return unreadCount < 1 ? "" : "\(unreadCount)"
|
||||
}
|
||||
}
|
||||
|
||||
private var intrinsicContentSizeIsValid = false
|
||||
private var _intrinsicContentSize = NSZeroSize
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
get {
|
||||
if !intrinsicContentSizeIsValid {
|
||||
var size = NSZeroSize
|
||||
if unreadCount > 0 {
|
||||
size = textSize()
|
||||
size.width += (padding.left + padding.right)
|
||||
size.height += (padding.top + padding.bottom)
|
||||
}
|
||||
_intrinsicContentSize = size
|
||||
intrinsicContentSizeIsValid = true
|
||||
}
|
||||
return _intrinsicContentSize
|
||||
}
|
||||
}
|
||||
|
||||
override var isFlipped: Bool {
|
||||
get {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func invalidateIntrinsicContentSize() {
|
||||
|
||||
intrinsicContentSizeIsValid = false
|
||||
}
|
||||
|
||||
private func textSize() -> NSSize {
|
||||
|
||||
if unreadCount < 1 {
|
||||
return NSZeroSize
|
||||
}
|
||||
|
||||
if let cachedSize = textSizeCache[unreadCount] {
|
||||
return cachedSize
|
||||
}
|
||||
|
||||
var size = unreadCountString.size(withAttributes: textAttributes)
|
||||
size.height = ceil(size.height)
|
||||
size.width = ceil(size.width)
|
||||
|
||||
textSizeCache[unreadCount] = size
|
||||
return size
|
||||
}
|
||||
|
||||
private func textRect() -> NSRect {
|
||||
|
||||
let size = textSize()
|
||||
var r = NSZeroRect
|
||||
r.size = size
|
||||
r.origin.x = (NSMaxX(bounds) - padding.right) - r.size.width
|
||||
r.origin.y = padding.top
|
||||
return r
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
|
||||
let path = NSBezierPath(roundedRect: bounds, xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
backgroundColor.setFill()
|
||||
path.fill()
|
||||
|
||||
if unreadCount > 0 {
|
||||
unreadCountString.draw(at: textRect().origin, withAttributes: textAttributes)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
160
Evergreen/MainWindow/StatusBar/StatusBarView.swift
Normal file
160
Evergreen/MainWindow/StatusBar/StatusBarView.swift
Normal file
@ -0,0 +1,160 @@
|
||||
//
|
||||
// StatusBarView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 9/17/16.
|
||||
// Copyright © 2016 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import RSCore
|
||||
import DataModel
|
||||
import RSWeb
|
||||
|
||||
final class StatusBarView: NSView {
|
||||
|
||||
@IBOutlet var progressIndicator: NSProgressIndicator!
|
||||
@IBOutlet var progressLabel: NSTextField!
|
||||
@IBOutlet var urlLabel: NSTextField!
|
||||
|
||||
fileprivate var isAnimatingProgress = false
|
||||
fileprivate var article: Article? {
|
||||
didSet {
|
||||
updateURLLabel()
|
||||
}
|
||||
}
|
||||
|
||||
override var isFlipped: Bool {
|
||||
get {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
|
||||
let progressLabelFontSize = progressLabel.font?.pointSize ?? 13.0
|
||||
progressLabel.font = NSFont.monospacedDigitSystemFont(ofSize: progressLabelFontSize, weight: NSFontWeightRegular)
|
||||
progressLabel.stringValue = ""
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(timelineSelectionDidChange(_:)), name: .TimelineSelectionDidChange, object: nil)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
dynamic func progressDidChange(_ notification: Notification) {
|
||||
|
||||
guard let progress = notification.userInfo?[progressKey] as? DownloadProgress else {
|
||||
return
|
||||
}
|
||||
updateProgressIndicator(progress)
|
||||
updateProgressLabel(progress)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
dynamic func timelineSelectionDidChange(_ note: Notification) {
|
||||
|
||||
let timelineView = note.userInfo?[viewKey] as! NSView
|
||||
|
||||
if timelineView.window! === self.window {
|
||||
article = note.userInfo?[articleKey] as? Article
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Drawing
|
||||
|
||||
private let lineColor = NSColor(calibratedWhite: 0.57, alpha: 1.0)
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
|
||||
let path = NSBezierPath()
|
||||
path.lineWidth = 1.0
|
||||
path.move(to: NSPoint(x: NSMinX(bounds), y: NSMinY(bounds) + 0.5))
|
||||
path.line(to: NSPoint(x: NSMaxX(bounds), y: NSMinY(bounds) + 0.5))
|
||||
lineColor.set()
|
||||
path.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private extension StatusBarView {
|
||||
|
||||
// MARK: URL Label
|
||||
|
||||
func updateURLLabel() {
|
||||
|
||||
needsLayout = true
|
||||
|
||||
guard let article = article else {
|
||||
urlLabel.stringValue = ""
|
||||
return
|
||||
}
|
||||
|
||||
let s = preferredLink(for: article)
|
||||
if let s = s {
|
||||
urlLabel.stringValue = (s as NSString).rs_stringByStrippingHTTPOrHTTPSScheme()
|
||||
}
|
||||
else {
|
||||
urlLabel.stringValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Progress
|
||||
|
||||
func stopProgressIfNeeded() {
|
||||
|
||||
if !isAnimatingProgress {
|
||||
return
|
||||
}
|
||||
|
||||
progressIndicator.stopAnimation(self)
|
||||
isAnimatingProgress = false
|
||||
progressIndicator.needsDisplay = true
|
||||
}
|
||||
|
||||
func startProgressIfNeeded() {
|
||||
|
||||
if isAnimatingProgress {
|
||||
return
|
||||
}
|
||||
isAnimatingProgress = true
|
||||
progressIndicator.startAnimation(self)
|
||||
}
|
||||
|
||||
func updateProgressIndicator(_ progress: DownloadProgress) {
|
||||
|
||||
if progress.isComplete {
|
||||
stopProgressIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
startProgressIfNeeded()
|
||||
|
||||
let maxValue = Double(progress.numberOfTasks)
|
||||
if progressIndicator.maxValue != maxValue {
|
||||
progressIndicator.maxValue = maxValue
|
||||
}
|
||||
|
||||
let doubleValue = Double(progress.numberCompleted)
|
||||
if progressIndicator.doubleValue != doubleValue {
|
||||
progressIndicator.doubleValue = doubleValue
|
||||
}
|
||||
}
|
||||
|
||||
func updateProgressLabel(_ progress: DownloadProgress) {
|
||||
|
||||
if progress.isComplete {
|
||||
progressLabel.stringValue = ""
|
||||
return
|
||||
}
|
||||
|
||||
let numberOfTasks = progress.numberOfTasks
|
||||
let numberCompleted = progress.numberCompleted
|
||||
|
||||
let formatString = NSLocalizedString("%@ of %@", comment: "Status bar progress")
|
||||
let s = NSString(format: formatString as NSString, NSNumber(value: numberCompleted), NSNumber(value: numberOfTasks))
|
||||
|
||||
progressLabel.stringValue = s as String
|
||||
}
|
||||
}
|
121
Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift
Normal file
121
Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift
Normal file
@ -0,0 +1,121 @@
|
||||
//
|
||||
// TimelineCellAppearance.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 2/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DB5
|
||||
|
||||
struct TimelineCellAppearance {
|
||||
|
||||
let cellPadding: EdgeInsets
|
||||
|
||||
let feedNameColor: NSColor
|
||||
let feedNameFont: NSFont
|
||||
let faviconFeedNameSpacing: CGFloat
|
||||
let faviconSize = NSSize(width: 16, height: 16)
|
||||
|
||||
let dateColor: NSColor
|
||||
let dateMarginLeft: CGFloat
|
||||
let dateFont: NSFont
|
||||
|
||||
let titleColor: NSColor
|
||||
let titleFont: NSFont
|
||||
let titleBottomMargin: CGFloat
|
||||
|
||||
let textColor: NSColor
|
||||
let textFont: NSFont
|
||||
|
||||
let unreadCircleColor: NSColor
|
||||
let unreadCircleDimension: CGFloat
|
||||
let unreadCircleMarginRight: CGFloat
|
||||
|
||||
let boxLeftMargin: CGFloat
|
||||
|
||||
let gridColor: NSColor
|
||||
|
||||
init(theme: VSTheme, fontSize: FontSize) {
|
||||
|
||||
let actualFontSize = actualFontSizeForFontSize(fontSize)
|
||||
|
||||
cellPadding = theme.edgeInsets(forKey: "MainWindow.Timeline.cell.padding")
|
||||
|
||||
feedNameColor = theme.color(forKey: "MainWindow.Timeline.cell.feedNameColor")
|
||||
feedNameFont = NSFont.systemFont(ofSize: actualFontSize)
|
||||
faviconFeedNameSpacing = theme.float(forKey: "MainWindow.Timeline.cell.faviconFeedNameSpacing")
|
||||
|
||||
dateColor = theme.color(forKey: "MainWindow.Timeline.cell.dateColor")
|
||||
let actualDateFontSize = actualDateFontSizeForFontSize(fontSize)
|
||||
dateFont = NSFont.systemFont(ofSize: actualDateFontSize)
|
||||
dateMarginLeft = theme.float(forKey: "MainWindow.Timeline.cell.dateMarginLeft")
|
||||
|
||||
titleColor = theme.color(forKey: "MainWindow.Timeline.cell.titleColor")
|
||||
titleFont = NSFont.systemFont(ofSize: actualFontSize, weight: NSFontWeightBold)
|
||||
titleBottomMargin = theme.float(forKey: "MainWindow.Timeline.cell.titleMarginBottom")
|
||||
|
||||
textColor = theme.color(forKey: "MainWindow.Timeline.cell.textColor")
|
||||
textFont = NSFont.systemFont(ofSize: actualFontSize)
|
||||
|
||||
unreadCircleColor = theme.color(forKey: "MainWindow.Timeline.cell.unreadCircleColor")
|
||||
unreadCircleDimension = theme.float(forKey: "MainWindow.Timeline.cell.unreadCircleDimension")
|
||||
unreadCircleMarginRight = theme.float(forKey: "MainWindow.Timeline.cell.unreadCircleMarginRight")
|
||||
|
||||
boxLeftMargin = cellPadding.left + unreadCircleDimension + unreadCircleMarginRight
|
||||
|
||||
gridColor = theme.colorWithAlpha(forKey: "MainWindow.Timeline.gridColor")
|
||||
}
|
||||
}
|
||||
|
||||
private let smallFontSize = NSFont.systemFontSize()
|
||||
private let mediumFontSize = smallFontSize + 1.0
|
||||
private let largeFontSize = mediumFontSize + 4.0
|
||||
private let veryLargeFontSize = largeFontSize + 8.0
|
||||
|
||||
private func actualFontSizeForFontSize(_ fontSize: FontSize) -> CGFloat {
|
||||
|
||||
var actualFontSize = smallFontSize
|
||||
|
||||
switch (fontSize) {
|
||||
|
||||
case .small:
|
||||
actualFontSize = smallFontSize
|
||||
case .medium:
|
||||
actualFontSize = mediumFontSize
|
||||
case .large:
|
||||
actualFontSize = largeFontSize
|
||||
case .veryLarge:
|
||||
actualFontSize = veryLargeFontSize
|
||||
}
|
||||
|
||||
return actualFontSize
|
||||
}
|
||||
|
||||
//private let smallDateFontSize = NSFont.systemFontSize() - 2.0
|
||||
//private let mediumDateFontSize = smallDateFontSize + 1.0
|
||||
//private let largeDateFontSize = mediumDateFontSize + 4.0
|
||||
//private let veryLargeDateFontSize = largeDateFontSize + 8.0
|
||||
|
||||
|
||||
private func actualDateFontSizeForFontSize(_ fontSize: FontSize) -> CGFloat {
|
||||
|
||||
return actualFontSizeForFontSize(fontSize)
|
||||
// var actualFontSize = smallDateFontSize
|
||||
//
|
||||
// switch (fontSize) {
|
||||
//
|
||||
// case .small:
|
||||
// actualFontSize = smallDateFontSize
|
||||
// case .medium:
|
||||
// actualFontSize = mediumDateFontSize
|
||||
// case .large:
|
||||
// actualFontSize = largeDateFontSize
|
||||
// case .veryLarge:
|
||||
// actualFontSize = veryLargeDateFontSize
|
||||
// }
|
||||
//
|
||||
// return actualFontSize
|
||||
|
||||
}
|
118
Evergreen/MainWindow/Timeline/Cell/TimelineCellData.swift
Normal file
118
Evergreen/MainWindow/Timeline/Cell/TimelineCellData.swift
Normal file
@ -0,0 +1,118 @@
|
||||
//
|
||||
// TimelineCellData.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 2/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import DataModel
|
||||
|
||||
var attributedTitleCache = [String: NSAttributedString]()
|
||||
var attributedDateCache = [String: NSAttributedString]()
|
||||
var attributedFeedNameCache = [String: NSAttributedString]()
|
||||
|
||||
struct TimelineCellData {
|
||||
|
||||
let title: String
|
||||
let text: String
|
||||
let attributedTitle: NSAttributedString //title + text
|
||||
let dateString: String
|
||||
let attributedDateString: NSAttributedString
|
||||
let feedName: String
|
||||
let attributedFeedName: NSAttributedString
|
||||
let showFeedName: Bool
|
||||
let favicon: NSImage?
|
||||
let read: Bool
|
||||
|
||||
init(article: Article, appearance: TimelineCellAppearance, showFeedName: Bool) {
|
||||
|
||||
self.title = timelineTruncatedTitle(article)
|
||||
self.text = timelineTruncatedSummary(article)
|
||||
|
||||
let attributedTitleCacheKey = "_title: " + self.title + "_text: " + self.text
|
||||
if let s = attributedTitleCache[attributedTitleCacheKey] {
|
||||
self.attributedTitle = s
|
||||
}
|
||||
else {
|
||||
self.attributedTitle = attributedTitleString(title, text, appearance)
|
||||
attributedTitleCache[attributedTitleCacheKey] = self.attributedTitle
|
||||
}
|
||||
|
||||
self.dateString = timelineDateString(article.logicalDatePublished)
|
||||
if let s = attributedDateCache[self.dateString] {
|
||||
self.attributedDateString = s
|
||||
}
|
||||
else {
|
||||
self.attributedDateString = NSAttributedString(string: self.dateString, attributes: [NSForegroundColorAttributeName: appearance.dateColor, NSFontAttributeName: appearance.dateFont])
|
||||
attributedDateCache[self.dateString] = self.attributedDateString
|
||||
}
|
||||
|
||||
if let feed = article.feed {
|
||||
self.feedName = timelineTruncatedFeedName(feed)
|
||||
}
|
||||
else {
|
||||
self.feedName = ""
|
||||
}
|
||||
if let s = attributedFeedNameCache[self.dateString] {
|
||||
self.attributedFeedName = s
|
||||
}
|
||||
else {
|
||||
self.attributedFeedName = NSAttributedString(string: self.feedName, attributes: [NSForegroundColorAttributeName: appearance.feedNameColor, NSFontAttributeName: appearance.feedNameFont])
|
||||
attributedFeedNameCache[self.feedName] = self.attributedFeedName
|
||||
}
|
||||
|
||||
self.showFeedName = showFeedName
|
||||
|
||||
self.favicon = nil
|
||||
|
||||
if let status = article.status {
|
||||
self.read = status.read
|
||||
}
|
||||
else {
|
||||
self.read = false
|
||||
}
|
||||
}
|
||||
|
||||
init() { //Empty
|
||||
|
||||
self.title = ""
|
||||
self.attributedTitle = NSAttributedString(string: "")
|
||||
self.text = ""
|
||||
self.dateString = ""
|
||||
self.attributedDateString = NSAttributedString(string: "")
|
||||
self.feedName = ""
|
||||
self.attributedFeedName = NSAttributedString(string: "")
|
||||
self.showFeedName = false
|
||||
self.favicon = nil
|
||||
self.read = true
|
||||
}
|
||||
|
||||
static func emptyCache() {
|
||||
|
||||
attributedTitleCache = [String: NSAttributedString]()
|
||||
attributedDateCache = [String: NSAttributedString]()
|
||||
attributedFeedNameCache = [String: NSAttributedString]()
|
||||
}
|
||||
}
|
||||
|
||||
let emptyCellData = TimelineCellData()
|
||||
|
||||
private func attributedTitleString(_ title: String, _ text: String, _ appearance: TimelineCellAppearance) -> NSAttributedString {
|
||||
|
||||
if !title.isEmpty && !text.isEmpty {
|
||||
|
||||
let titleMutable = NSMutableAttributedString(string: title, attributes: [NSForegroundColorAttributeName: appearance.titleColor, NSFontAttributeName: appearance.titleFont])
|
||||
let attributedText = NSAttributedString(string: "\n" + text, attributes: [NSForegroundColorAttributeName: appearance.textColor, NSFontAttributeName: appearance.textFont])
|
||||
titleMutable.append(attributedText)
|
||||
return titleMutable
|
||||
}
|
||||
|
||||
if !title.isEmpty && text.isEmpty {
|
||||
return NSAttributedString(string: title, attributes: [NSForegroundColorAttributeName: appearance.titleColor, NSFontAttributeName: appearance.titleFont])
|
||||
}
|
||||
|
||||
return NSAttributedString(string: text, attributes: [NSForegroundColorAttributeName: appearance.textColor, NSFontAttributeName: appearance.textFont])
|
||||
}
|
||||
|
156
Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift
Normal file
156
Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift
Normal file
@ -0,0 +1,156 @@
|
||||
//
|
||||
// TimelineCellLayout.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 2/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import RSTextDrawing
|
||||
import RSCore
|
||||
|
||||
// title/text 1 date
|
||||
// title/text 2
|
||||
// title/text 3
|
||||
// favicon feedname (optional line)
|
||||
|
||||
struct TimelineCellLayout {
|
||||
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let faviconRect: NSRect
|
||||
let feedNameRect: NSRect
|
||||
let dateRect: NSRect
|
||||
let titleRect: NSRect
|
||||
let unreadIndicatorRect: NSRect
|
||||
let paddingBottom: CGFloat
|
||||
|
||||
init(width: CGFloat, faviconRect: NSRect, feedNameRect: NSRect, dateRect: NSRect, titleRect: NSRect, unreadIndicatorRect: NSRect, paddingBottom: CGFloat) {
|
||||
|
||||
self.width = width
|
||||
self.faviconRect = faviconRect
|
||||
self.feedNameRect = feedNameRect
|
||||
self.dateRect = dateRect
|
||||
self.titleRect = titleRect
|
||||
self.unreadIndicatorRect = unreadIndicatorRect
|
||||
self.paddingBottom = paddingBottom
|
||||
|
||||
var height = NSMaxY(dateRect)
|
||||
if feedNameRect != NSZeroRect {
|
||||
height = NSMaxY(feedNameRect)
|
||||
}
|
||||
height = height + paddingBottom
|
||||
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
private func rectForDate(_ cellData: TimelineCellData, _ width: CGFloat, _ appearance: TimelineCellAppearance, _ titleRect: NSRect) -> NSRect {
|
||||
|
||||
let renderer = RSSingleLineRenderer(attributedTitle: cellData.attributedDateString)
|
||||
var r = NSZeroRect
|
||||
r.size = renderer.size
|
||||
// r.origin.y = appearance.cellPadding.top
|
||||
// r.origin.x = width - (appearance.cellPadding.right + r.size.width)
|
||||
|
||||
r.origin.y = NSMaxY(titleRect) + appearance.titleBottomMargin
|
||||
r.origin.x = appearance.boxLeftMargin
|
||||
|
||||
r.size.width = width - (r.origin.x + appearance.cellPadding.right)
|
||||
if r.size.width < 15 {
|
||||
return NSZeroRect
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
private func rectForFeedName(_ cellData: TimelineCellData, _ width: CGFloat, _ appearance: TimelineCellAppearance, _ titleRect: NSRect) -> NSRect {
|
||||
|
||||
if !cellData.showFeedName {
|
||||
return NSZeroRect
|
||||
}
|
||||
|
||||
let renderer = RSSingleLineRenderer(attributedTitle: cellData.attributedFeedName)
|
||||
var r = NSZeroRect
|
||||
r.size = renderer.size
|
||||
r.origin.y = NSMaxY(titleRect) + appearance.titleBottomMargin
|
||||
r.origin.x = appearance.boxLeftMargin
|
||||
|
||||
if let _ = cellData.favicon {
|
||||
r.origin.x += appearance.faviconSize.width + appearance.faviconFeedNameSpacing
|
||||
}
|
||||
|
||||
r.size.width = width - (r.origin.x + appearance.cellPadding.right)
|
||||
|
||||
if r.size.width < 15 {
|
||||
return NSZeroRect
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
private func rectForFavicon(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ feedNameRect: NSRect) -> NSRect {
|
||||
|
||||
guard let _ = cellData.favicon, cellData.showFeedName else {
|
||||
return NSZeroRect
|
||||
}
|
||||
|
||||
var r = NSZeroRect
|
||||
r.size = appearance.faviconSize
|
||||
r.origin.x = appearance.boxLeftMargin
|
||||
|
||||
r = RSRectCenteredVerticallyInRect(r, feedNameRect)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
private func rectsForTitle(_ cellData: TimelineCellData, _ width: CGFloat, _ appearance: TimelineCellAppearance) -> (NSRect, NSRect) {
|
||||
|
||||
var r = NSZeroRect
|
||||
r.origin.x = appearance.boxLeftMargin
|
||||
r.origin.y = appearance.cellPadding.top
|
||||
|
||||
let textWidth = width - (r.origin.x + appearance.cellPadding.right)
|
||||
let renderer = RSMultiLineRenderer(attributedTitle: cellData.attributedTitle)
|
||||
|
||||
let measurements = renderer.measurements(forWidth: textWidth)
|
||||
r.size = NSSize(width: textWidth, height: CGFloat(measurements.height))
|
||||
|
||||
var rline1 = r
|
||||
rline1.size.height = CGFloat(measurements.heightOfFirstLine)
|
||||
|
||||
return (r, rline1)
|
||||
}
|
||||
|
||||
private func rectForUnreadIndicator(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ titleLine1Rect: NSRect) -> NSRect {
|
||||
|
||||
if cellData.read {
|
||||
return NSZeroRect
|
||||
}
|
||||
|
||||
var r = NSZeroRect
|
||||
r.size = NSSize(width: appearance.unreadCircleDimension, height: appearance.unreadCircleDimension)
|
||||
r.origin.x = appearance.cellPadding.left
|
||||
r = RSRectCenteredVerticallyInRect(r, titleLine1Rect)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func timelineCellLayout(_ width: CGFloat, cellData: TimelineCellData, appearance: TimelineCellAppearance) -> TimelineCellLayout {
|
||||
|
||||
// let dateRect = rectForDate(cellData, width, appearance)
|
||||
let (titleRect, titleLine1Rect) = rectsForTitle(cellData, width, appearance)
|
||||
let dateRect = rectForDate(cellData, width, appearance, titleRect)
|
||||
let feedNameRect = rectForFeedName(cellData, width, appearance, titleRect)
|
||||
let faviconRect = rectForFavicon(cellData, appearance, feedNameRect)
|
||||
let unreadIndicatorRect = rectForUnreadIndicator(cellData, appearance, titleLine1Rect)
|
||||
|
||||
return TimelineCellLayout(width: width, faviconRect: faviconRect, feedNameRect: feedNameRect, dateRect: dateRect, titleRect: titleRect, unreadIndicatorRect: unreadIndicatorRect, paddingBottom: appearance.cellPadding.bottom)
|
||||
}
|
||||
|
||||
func timelineCellHeight(_ width: CGFloat, cellData: TimelineCellData, appearance: TimelineCellAppearance) -> CGFloat {
|
||||
|
||||
let layout = timelineCellLayout(width, cellData: cellData, appearance: appearance)
|
||||
return layout.height
|
||||
}
|
162
Evergreen/MainWindow/Timeline/Cell/TimelineStringUtilities.swift
Normal file
162
Evergreen/MainWindow/Timeline/Cell/TimelineStringUtilities.swift
Normal file
@ -0,0 +1,162 @@
|
||||
//
|
||||
// TimelineStringUtilities.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/31/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import DataModel
|
||||
|
||||
private let truncatedFeedNameCache = NSMutableDictionary()
|
||||
private let truncatedTitleCache = NSMutableDictionary()
|
||||
private let normalizedTextCache = NSMutableDictionary()
|
||||
private let textCache = NSMutableDictionary()
|
||||
private let summaryCache = NSMutableDictionary()
|
||||
//private var summaryCache = [String: String]()
|
||||
|
||||
func timelineEmptyCaches() {
|
||||
|
||||
truncatedFeedNameCache.removeAllObjects()
|
||||
truncatedTitleCache.removeAllObjects()
|
||||
normalizedTextCache.removeAllObjects()
|
||||
textCache.removeAllObjects()
|
||||
summaryCache.removeAllObjects()
|
||||
}
|
||||
|
||||
func timelineTruncatedFeedName(_ feed: Feed) -> String {
|
||||
|
||||
let feedName = feed.nameForDisplay
|
||||
if let cachedFeedName = truncatedFeedNameCache[feedName] as? String {
|
||||
return cachedFeedName
|
||||
}
|
||||
|
||||
let maxFeedNameLength = 100
|
||||
if feedName.characters.count < maxFeedNameLength {
|
||||
truncatedFeedNameCache[feedName] = feedName
|
||||
return feedName
|
||||
}
|
||||
|
||||
let s = (feedName as NSString).substring(to: maxFeedNameLength)
|
||||
truncatedFeedNameCache[feedName] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func timelineTruncatedTitle(_ article: Article) -> String {
|
||||
|
||||
guard let title = article.title else {
|
||||
return ""
|
||||
}
|
||||
|
||||
if let cachedTitle = truncatedTitleCache[title] as? String {
|
||||
return cachedTitle
|
||||
}
|
||||
|
||||
var s = title.replacingOccurrences(of: "\n", with: "")
|
||||
s = s.replacingOccurrences(of: "\r", with: "")
|
||||
s = s.replacingOccurrences(of: "\t", with: "")
|
||||
s = s.replacingOccurrences(of: "↦", with: "")
|
||||
s = s.rs_stringByTrimmingWhitespace()
|
||||
|
||||
let maxLength = 1000
|
||||
if s.characters.count < maxLength {
|
||||
truncatedTitleCache[title] = s
|
||||
return s
|
||||
}
|
||||
|
||||
s = (s as NSString).substring(to: maxLength)
|
||||
truncatedTitleCache[title] = s
|
||||
return s
|
||||
}
|
||||
|
||||
func timelineTruncatedSummary(_ article: Article) -> String {
|
||||
|
||||
return timelineSummaryForArticle(article)
|
||||
}
|
||||
|
||||
func timelineNormalizedText(_ text: String) -> String {
|
||||
|
||||
if text.isEmpty {
|
||||
return ""
|
||||
}
|
||||
if let cachedText = normalizedTextCache[text] as? String {
|
||||
return cachedText
|
||||
}
|
||||
|
||||
var s = (text as NSString).rs_stringByTrimmingWhitespace()
|
||||
s = s.rs_stringWithCollapsedWhitespace()
|
||||
|
||||
let result = s as String
|
||||
normalizedTextCache[text] = result
|
||||
return result
|
||||
}
|
||||
|
||||
func timelineNormalizedTextTruncated(_ text: String) -> String {
|
||||
|
||||
if text.isEmpty {
|
||||
return ""
|
||||
}
|
||||
|
||||
if let cachedText = textCache[text] as? String {
|
||||
return cachedText
|
||||
}
|
||||
|
||||
var s: NSString = (text as NSString).rs_stringByDecodingHTMLEntities() as NSString
|
||||
s = s.rs_stringByTrimmingWhitespace() as NSString
|
||||
s = s.rs_stringWithCollapsedWhitespace() as NSString
|
||||
|
||||
let maxLength = 512
|
||||
if s.length > maxLength {
|
||||
s = s.substring(to: maxLength) as NSString
|
||||
}
|
||||
|
||||
textCache[text] = String(s)
|
||||
return s as String
|
||||
}
|
||||
|
||||
|
||||
func timelineSummaryForArticle(_ article: Article) -> String {
|
||||
|
||||
guard let body = article.body else {
|
||||
return ""
|
||||
}
|
||||
|
||||
if let cachedBody = summaryCache[body] as? String {
|
||||
return cachedBody
|
||||
}
|
||||
|
||||
var s = body.rs_string(byStrippingHTML: 300)
|
||||
s = timelineNormalizedText(s)
|
||||
|
||||
summaryCache[body] = s
|
||||
return s
|
||||
}
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private let timeFormatter: DateFormatter = {
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private var token: Int = 0
|
||||
|
||||
func timelineDateString(_ date: Date) -> String {
|
||||
|
||||
if NSCalendar.rs_dateIsToday(date) {
|
||||
return timeFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
164
Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift
Normal file
164
Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift
Normal file
@ -0,0 +1,164 @@
|
||||
//
|
||||
// TimelineTableCellView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/31/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSTextDrawing
|
||||
|
||||
class TimelineTableCellView: NSTableCellView {
|
||||
|
||||
let titleView = RSMultiLineView(frame: NSZeroRect)
|
||||
let unreadIndicatorView = UnreadIndicatorView(frame: NSZeroRect)
|
||||
let dateView = RSSingleLineView(frame: NSZeroRect)
|
||||
let feedNameView = RSSingleLineView(frame: NSZeroRect)
|
||||
|
||||
var cellAppearance: TimelineCellAppearance!
|
||||
var cellData: TimelineCellData! {
|
||||
didSet {
|
||||
updateSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
override var isFlipped: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
var isEmphasized = false {
|
||||
didSet {
|
||||
dateView.emphasized = isEmphasized
|
||||
feedNameView.emphasized = isEmphasized
|
||||
titleView.emphasized = isEmphasized
|
||||
}
|
||||
}
|
||||
|
||||
var isSelected = false {
|
||||
didSet {
|
||||
dateView.selected = isSelected
|
||||
feedNameView.selected = isSelected
|
||||
titleView.selected = isSelected
|
||||
}
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
|
||||
addSubview(titleView)
|
||||
titleView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(unreadIndicatorView)
|
||||
unreadIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
unreadIndicatorView.isHidden = true
|
||||
|
||||
addSubview(dateView)
|
||||
dateView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(feedNameView)
|
||||
feedNameView.translatesAutoresizingMaskIntoConstraints = false;
|
||||
feedNameView.isHidden = true
|
||||
}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
|
||||
super.init(frame: frameRect)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
|
||||
if newSize == self.frame.size {
|
||||
return
|
||||
}
|
||||
|
||||
super.setFrameSize(newSize)
|
||||
updateSubviews()
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
|
||||
updateSubviews()
|
||||
updateAppearance()
|
||||
}
|
||||
|
||||
private func updatedLayoutRects() -> TimelineCellLayout {
|
||||
|
||||
return timelineCellLayout(NSWidth(bounds), cellData: cellData, appearance: cellAppearance)
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
|
||||
resizeSubviews(withOldSize: NSZeroSize)
|
||||
}
|
||||
|
||||
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
||||
|
||||
let layoutRects = updatedLayoutRects()
|
||||
titleView.rs_setFrameIfNotEqual(layoutRects.titleRect)
|
||||
unreadIndicatorView.rs_setFrameIfNotEqual(layoutRects.unreadIndicatorRect)
|
||||
dateView.rs_setFrameIfNotEqual(layoutRects.dateRect)
|
||||
feedNameView.rs_setFrameIfNotEqual(layoutRects.feedNameRect)
|
||||
}
|
||||
|
||||
private func updateTitleView() {
|
||||
|
||||
titleView.attributedStringValue = cellData.attributedTitle
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
private func updateDateView() {
|
||||
|
||||
dateView.attributedStringValue = cellData.attributedDateString
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
private func updateFeedNameView() {
|
||||
|
||||
if cellData.showFeedName {
|
||||
if feedNameView.isHidden {
|
||||
feedNameView.isHidden = false
|
||||
}
|
||||
feedNameView.attributedStringValue = cellData.attributedFeedName
|
||||
}
|
||||
else {
|
||||
if !feedNameView.isHidden {
|
||||
feedNameView.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateUnreadIndicator() {
|
||||
|
||||
if unreadIndicatorView.isHidden != cellData.read {
|
||||
unreadIndicatorView.isHidden = cellData.read
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSubviews() {
|
||||
|
||||
updateTitleView()
|
||||
updateDateView()
|
||||
updateFeedNameView()
|
||||
updateUnreadIndicator()
|
||||
}
|
||||
|
||||
private func updateAppearance() {
|
||||
|
||||
if let rowView = superview as? NSTableRowView {
|
||||
isEmphasized = rowView.isEmphasized
|
||||
isSelected = rowView.isSelected
|
||||
}
|
||||
else {
|
||||
isEmphasized = false
|
||||
isSelected = false
|
||||
}
|
||||
}
|
||||
}
|
28
Evergreen/MainWindow/Timeline/Cell/UnreadIndicatorView.swift
Normal file
28
Evergreen/MainWindow/Timeline/Cell/UnreadIndicatorView.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// UnreadIndicatorView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 2/16/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class UnreadIndicatorView: NSView {
|
||||
|
||||
static let unreadCircleDimension = currentTheme.float(forKey: "MainWindow.Timeline.cell.unreadCircleDimension")
|
||||
|
||||
static let bezierPath: NSBezierPath = {
|
||||
let r = NSRect(x: 0.0, y: 0.0, width: unreadCircleDimension, height: unreadCircleDimension)
|
||||
return NSBezierPath(ovalIn: r)
|
||||
}()
|
||||
|
||||
static let unreadCircleColor = currentTheme.color(forKey: "MainWindow.Timeline.cell.unreadCircleColor")
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
|
||||
UnreadIndicatorView.unreadCircleColor.setFill()
|
||||
UnreadIndicatorView.bezierPath.fill()
|
||||
}
|
||||
|
||||
}
|
78
Evergreen/MainWindow/Timeline/TimelineTableRowView.swift
Normal file
78
Evergreen/MainWindow/Timeline/TimelineTableRowView.swift
Normal file
@ -0,0 +1,78 @@
|
||||
//
|
||||
// TimelineTableRowView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/31/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class TimelineTableRowView : NSTableRowView {
|
||||
|
||||
var cellAppearance: TimelineCellAppearance!
|
||||
|
||||
// override var interiorBackgroundStyle: NSBackgroundStyle {
|
||||
// return .Light
|
||||
// }
|
||||
|
||||
private var cellView: TimelineTableCellView? {
|
||||
get {
|
||||
for oneSubview in subviews {
|
||||
if let foundView = oneSubview as? TimelineTableCellView {
|
||||
return foundView
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override var isEmphasized: Bool {
|
||||
didSet {
|
||||
if let cellView = cellView {
|
||||
cellView.isEmphasized = isEmphasized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var isSelected: Bool {
|
||||
didSet {
|
||||
if let cellView = cellView {
|
||||
cellView.isSelected = isSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var gridRect: NSRect {
|
||||
get {
|
||||
return NSMakeRect(floor(cellAppearance.boxLeftMargin), NSMaxY(bounds) - 1.0, NSWidth(bounds), 1)
|
||||
}
|
||||
}
|
||||
|
||||
override func drawSeparator(in dirtyRect: NSRect) {
|
||||
|
||||
let path = NSBezierPath()
|
||||
let originX = floor(cellAppearance.boxLeftMargin)
|
||||
let destinationX = ceil(NSMaxX(bounds))
|
||||
let y = floor(NSMaxY(bounds)) - 0.5
|
||||
path.move(to: NSPoint(x: originX, y: y))
|
||||
path.line(to: NSPoint(x: destinationX, y: y))
|
||||
|
||||
cellAppearance.gridColor.set()
|
||||
path.stroke()
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
|
||||
super.draw(dirtyRect)
|
||||
|
||||
if !isSelected && !isNextRowSelected {
|
||||
drawSeparator(in: dirtyRect)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateGridRect() {
|
||||
|
||||
setNeedsDisplay(gridRect)
|
||||
}
|
||||
}
|
44
Evergreen/MainWindow/Timeline/TimelineTableView.swift
Normal file
44
Evergreen/MainWindow/Timeline/TimelineTableView.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// TimelineTableView.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 10/11/16.
|
||||
// Copyright © 2016 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class TimelineTableView: NSTableView {
|
||||
|
||||
weak var keyboardDelegate: KeyboardDelegate?
|
||||
|
||||
//MARK: NSResponder
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
|
||||
if let keyboardDelegate = keyboardDelegate {
|
||||
if keyboardDelegate.handleKeydownEvent(event, sender: self) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func viewWillStartLiveResize() {
|
||||
|
||||
if let scrollView = self.enclosingScrollView {
|
||||
scrollView.hasVerticalScroller = false
|
||||
}
|
||||
super.viewWillStartLiveResize()
|
||||
}
|
||||
|
||||
override func viewDidEndLiveResize() {
|
||||
|
||||
if let scrollView = self.enclosingScrollView {
|
||||
scrollView.hasVerticalScroller = true
|
||||
}
|
||||
super.viewDidEndLiveResize()
|
||||
}
|
||||
|
||||
}
|
625
Evergreen/MainWindow/Timeline/TimelineViewController.swift
Normal file
625
Evergreen/MainWindow/Timeline/TimelineViewController.swift
Normal file
@ -0,0 +1,625 @@
|
||||
//
|
||||
// TimelineViewController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/26/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSTextDrawing
|
||||
import RSTree
|
||||
import DataModel
|
||||
import LocalAccount
|
||||
|
||||
let timelineFontSizeKVOKey = "values." + TimelineFontSizeKey
|
||||
|
||||
class TimelineViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource, KeyboardDelegate {
|
||||
|
||||
@IBOutlet var tableView: TimelineTableView!
|
||||
var didRegisterForNotifications = false
|
||||
var fontSize: FontSize = timelineFontSize() {
|
||||
didSet {
|
||||
fontSizeDidChange()
|
||||
}
|
||||
}
|
||||
var cellAppearance: TimelineCellAppearance!
|
||||
|
||||
private var articles = [Article]() {
|
||||
didSet {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private var representedObjects: [AnyObject]? {
|
||||
didSet {
|
||||
if !representedObjectArraysAreEqual(oldValue, representedObjects) {
|
||||
fetchArticles()
|
||||
if articles.count > 0 {
|
||||
tableView.scrollRowToVisible(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var showFeedNames: Bool {
|
||||
|
||||
// if let _ = node?.representedObject as? Feed {
|
||||
return false
|
||||
// }
|
||||
// return true
|
||||
}
|
||||
|
||||
var selectedArticles: [Article] {
|
||||
get {
|
||||
return articlesForIndexes(tableView.selectedRowIndexes)
|
||||
}
|
||||
}
|
||||
|
||||
private var oneSelectedArticle: Article? {
|
||||
get {
|
||||
return selectedArticles.count == 1 ? selectedArticles.first : nil
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
||||
cellAppearance = TimelineCellAppearance(theme: currentTheme, fontSize: fontSize)
|
||||
tableView.rowHeight = calculateRowHeight()
|
||||
|
||||
tableView.target = self
|
||||
tableView.doubleAction = #selector(openArticleInBrowser(_:))
|
||||
|
||||
tableView.keyboardDelegate = self
|
||||
|
||||
if !didRegisterForNotifications {
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sidebarSelectionDidChange(_:)), name: .SidebarSelectionDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(articleStatusesDidChange(_:)), name: .ArticleStatusesDidChange, object: nil)
|
||||
|
||||
NSUserDefaultsController.shared().addObserver(self, forKeyPath:timelineFontSizeKVOKey, options: NSKeyValueObservingOptions(rawValue: 0), context: nil)
|
||||
|
||||
didRegisterForNotifications = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: KVO
|
||||
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
|
||||
if let keyPath = keyPath {
|
||||
|
||||
switch (keyPath) {
|
||||
|
||||
case timelineFontSizeKVOKey:
|
||||
fontSizeInDefaultsDidChange()
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
}
|
||||
|
||||
// MARK: Appearance Change
|
||||
|
||||
private func fontSizeDidChange() {
|
||||
|
||||
cellAppearance = TimelineCellAppearance(theme: currentTheme, fontSize: fontSize)
|
||||
let updatedRowHeight = calculateRowHeight()
|
||||
if tableView.rowHeight != updatedRowHeight {
|
||||
tableView.rowHeight = updatedRowHeight
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func markAllAsRead() {
|
||||
|
||||
if articles.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
let articlesSet = NSMutableSet()
|
||||
articlesSet.addObjects(from: articles)
|
||||
markArticles(articlesSet, statusKey: .read, flag: true)
|
||||
|
||||
reloadCellsForArticles(articles)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
func openArticleInBrowser(_ sender: AnyObject) {
|
||||
|
||||
guard let article = oneSelectedArticle else {
|
||||
return
|
||||
}
|
||||
if let link = preferredLink(for: article) {
|
||||
openInBrowser(link)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func toggleStatusOfSelectedArticles(_ sender: AnyObject) {
|
||||
|
||||
guard !selectedArticles.isEmpty else {
|
||||
return
|
||||
}
|
||||
let articles = selectedArticles
|
||||
var markAsRead = true
|
||||
if articles.first!.status.read {
|
||||
markAsRead = false
|
||||
}
|
||||
|
||||
markArticles(NSSet(array: articles), statusKey: .read, flag: markAsRead)
|
||||
}
|
||||
|
||||
@IBAction func markSelectedArticlesAsRead(_ sender: AnyObject) {
|
||||
|
||||
markArticles(NSSet(array: selectedArticles), statusKey: .read, flag: true)
|
||||
}
|
||||
|
||||
@IBAction func markSelectedArticlesAsUnread(_ sender: AnyObject) {
|
||||
|
||||
markArticles(NSSet(array: selectedArticles), statusKey: .read, flag: false)
|
||||
}
|
||||
|
||||
// MARK: Navigation
|
||||
|
||||
func goToNextUnread() {
|
||||
|
||||
guard let ix = indexOfNextUnreadArticle() else {
|
||||
return
|
||||
}
|
||||
tableView.rs_selectRow(ix)
|
||||
tableView.scrollTo(row: ix)
|
||||
// tableView.rs_selectRowAndScrollToVisible(ix)
|
||||
}
|
||||
|
||||
func canGoToNextUnread() -> Bool {
|
||||
|
||||
guard let _ = indexOfNextUnreadArticle() else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func canMarkAllAsRead() -> Bool {
|
||||
|
||||
for oneArticle in articles {
|
||||
if !oneArticle.status.read {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indexOfNextUnreadArticle() -> Int? {
|
||||
|
||||
if articles.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ix = tableView.selectedRow
|
||||
while(true) {
|
||||
|
||||
ix = ix + 1
|
||||
if ix >= articles.count {
|
||||
break
|
||||
}
|
||||
let article = articleAtRow(ix)!
|
||||
if !article.status.read {
|
||||
return ix
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
func sidebarSelectionDidChange(_ note: Notification) {
|
||||
|
||||
let sidebarView = note.userInfo?[viewKey] as! NSView
|
||||
|
||||
if sidebarView.window! === tableView.window {
|
||||
representedObjects = note.userInfo?[objectsKey] as? [AnyObject]
|
||||
}
|
||||
}
|
||||
|
||||
func articleStatusesDidChange(_ note: Notification) {
|
||||
|
||||
guard let articles = note.userInfo?[articlesKey] as? NSSet else {
|
||||
return
|
||||
}
|
||||
|
||||
reloadCellsForArticles(articles.allObjects as! [Article])
|
||||
}
|
||||
|
||||
func fontSizeInDefaultsDidChange() {
|
||||
|
||||
TimelineCellData.emptyCache()
|
||||
RSSingleLineRenderer.emptyCache()
|
||||
RSMultiLineRenderer.emptyCache()
|
||||
|
||||
let updatedFontSize = timelineFontSize()
|
||||
if updatedFontSize != self.fontSize {
|
||||
self.fontSize = updatedFontSize
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: KeyboardDelegate
|
||||
|
||||
func handleKeydownEvent(_ event: NSEvent, sender: AnyObject) -> Bool {
|
||||
|
||||
guard !event.rs_keyIsModified() else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let ch = event.rs_unmodifiedCharacterString() else {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasSelectedArticle = hasAtLeastOneSelectedArticle
|
||||
var keyHandled = false
|
||||
|
||||
var shouldOpenInBrowser = false
|
||||
|
||||
switch(ch) {
|
||||
|
||||
case "\n":
|
||||
shouldOpenInBrowser = true
|
||||
keyHandled = true
|
||||
case "\r":
|
||||
shouldOpenInBrowser = true
|
||||
keyHandled = true
|
||||
|
||||
case "r":
|
||||
markSelectedArticlesAsRead(sender)
|
||||
keyHandled = true
|
||||
|
||||
case "u":
|
||||
markSelectedArticlesAsUnread(sender)
|
||||
keyHandled = true
|
||||
|
||||
default:
|
||||
keyHandled = false
|
||||
}
|
||||
|
||||
if !keyHandled {
|
||||
let chUnichar = event.rs_unmodifiedCharacter()
|
||||
|
||||
switch(chUnichar) {
|
||||
|
||||
case keypadEnter:
|
||||
shouldOpenInBrowser = true
|
||||
keyHandled = true
|
||||
|
||||
default:
|
||||
keyHandled = false
|
||||
}
|
||||
}
|
||||
|
||||
if shouldOpenInBrowser && hasSelectedArticle {
|
||||
openArticleInBrowser(self)
|
||||
}
|
||||
|
||||
return keyHandled
|
||||
}
|
||||
|
||||
// MARK: Reloading Data
|
||||
|
||||
private func cellForRowView(_ rowView: NSView) -> NSView? {
|
||||
|
||||
for oneView in rowView.subviews where oneView is TimelineTableCellView {
|
||||
return oneView
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reloadCellsForArticles(_ articles: [Article]) {
|
||||
|
||||
let indexes = indexesForArticles(articles)
|
||||
tableView.reloadData(forRowIndexes: indexes, columnIndexes: NSIndexSet(index: 0) as IndexSet)
|
||||
}
|
||||
|
||||
// MARK: Articles
|
||||
|
||||
private func indexesForArticles(_ articles: [Article]) -> IndexSet {
|
||||
|
||||
var indexes = IndexSet()
|
||||
|
||||
articles.forEach { (article) in
|
||||
let oneIndex = rowForArticle(article)
|
||||
if oneIndex != NSNotFound {
|
||||
indexes.insert(oneIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return indexes
|
||||
}
|
||||
|
||||
private func articlesForIndexes(_ indexes: IndexSet) -> [Article] {
|
||||
|
||||
return indexes.flatMap{ (oneIndex) -> Article? in
|
||||
return articleAtRow(oneIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private func articleAtRow(_ row: Int) -> Article? {
|
||||
|
||||
if row < 0 || row == NSNotFound || row > articles.count - 1 {
|
||||
return nil
|
||||
}
|
||||
return articles[row]
|
||||
}
|
||||
|
||||
private func rowForArticle(_ article: Article) -> Int {
|
||||
|
||||
if let index = articles.index(where: { (oneArticle) -> Bool in
|
||||
return oneArticle === article
|
||||
}) {
|
||||
return index
|
||||
}
|
||||
|
||||
return NSNotFound
|
||||
}
|
||||
|
||||
func selectedArticle() -> Article? {
|
||||
|
||||
return articleAtRow(tableView.selectedRow)
|
||||
}
|
||||
|
||||
// MARK: Sorting Articles
|
||||
|
||||
private func articleComparator(_ article1: Article, article2: Article) -> Bool {
|
||||
|
||||
return article1.logicalDatePublished.compare(article2.logicalDatePublished) == .orderedDescending
|
||||
}
|
||||
|
||||
// MARK: Fetching Articles
|
||||
|
||||
private func fetchArticles() {
|
||||
|
||||
guard let representedObjects = representedObjects else {
|
||||
if !articles.isEmpty {
|
||||
articles = [Article]()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var accountsDictionary = [String: [AnyObject]]()
|
||||
|
||||
func addToAccountArray(accountID: String, object: AnyObject) {
|
||||
|
||||
if let accountArray = accountsDictionary[accountID] {
|
||||
if !accountArray.contains(where: { $0 === object }) {
|
||||
accountsDictionary[accountID] = accountArray + [object]
|
||||
}
|
||||
}
|
||||
else {
|
||||
accountsDictionary[accountID] = [object]
|
||||
}
|
||||
}
|
||||
|
||||
for oneObject in representedObjects {
|
||||
|
||||
if let oneFeed = oneObject as? Feed {
|
||||
addToAccountArray(accountID: oneFeed.account.identifier, object: oneFeed)
|
||||
}
|
||||
else if let oneFolder = oneObject as? Folder, let accountID = oneFolder.account?.identifier {
|
||||
addToAccountArray(accountID: accountID, object: oneFolder)
|
||||
}
|
||||
}
|
||||
|
||||
var fetchedArticles = [Article]()
|
||||
for (accountID, objects) in accountsDictionary {
|
||||
|
||||
guard let oneAccount = AccountManager.sharedInstance.existingAccountWithIdentifier(accountID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let oneFetchedArticles = oneAccount.fetchArticles(for: objects)
|
||||
for oneFetchedArticle in oneFetchedArticles {
|
||||
if !fetchedArticles.contains(where: { $0 === oneFetchedArticle }) {
|
||||
fetchedArticles += [oneFetchedArticle]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchedArticles.sort(by: articleComparator)
|
||||
|
||||
if !articleArraysAreIdentical(array1: articles, array2: fetchedArticles) {
|
||||
articles = fetchedArticles
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Cell Configuring
|
||||
|
||||
private func calculateRowHeight() -> CGFloat {
|
||||
|
||||
let prototypeArticle = LocalArticle(account: AccountManager.sharedInstance.localAccount, feedID: "prototype", articleID: "prototype")
|
||||
prototypeArticle.title = "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
|
||||
|
||||
let prototypeArticleStatus = LocalArticleStatus(articleID: "prototype", read: false, starred: false, userDeleted: false, dateArrived: Date())
|
||||
prototypeArticle.status = prototypeArticleStatus
|
||||
|
||||
let prototypeCellData = TimelineCellData(article: prototypeArticle, appearance: cellAppearance, showFeedName: false)
|
||||
let height = timelineCellHeight(100, cellData: prototypeCellData, appearance: cellAppearance)
|
||||
return height
|
||||
}
|
||||
|
||||
private func configureTimelineCell(_ cell: TimelineTableCellView, article: Article) {
|
||||
|
||||
cell.objectValue = article
|
||||
cell.cellData = TimelineCellData(article: article, appearance: cellAppearance, showFeedName: showFeedNames)
|
||||
}
|
||||
|
||||
private func makeTimelineCellEmpty(_ cell: TimelineTableCellView) {
|
||||
|
||||
cell.objectValue = nil
|
||||
cell.cellData = emptyCellData
|
||||
}
|
||||
|
||||
// MARK: NSTableViewDataSource
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int {
|
||||
|
||||
return articles.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
|
||||
|
||||
return articleAtRow(row)
|
||||
}
|
||||
|
||||
// MARK: NSTableViewDelegate
|
||||
|
||||
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
|
||||
|
||||
let rowView: TimelineTableRowView = tableView.make(withIdentifier: "timelineRow", owner: self) as! TimelineTableRowView
|
||||
rowView.cellAppearance = cellAppearance
|
||||
return rowView
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
|
||||
let cell: TimelineTableCellView = tableView.make(withIdentifier: "timelineCell", owner: self) as! TimelineTableCellView
|
||||
cell.cellAppearance = cellAppearance
|
||||
|
||||
if let article = articleAtRow(row) {
|
||||
configureTimelineCell(cell, article: article)
|
||||
}
|
||||
else {
|
||||
makeTimelineCellEmpty(cell)
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
private func postTimelineSelectionDidChangeNotification(_ selectedArticle: Article?) {
|
||||
|
||||
var userInfo = [String: AnyObject]()
|
||||
if let article = selectedArticle {
|
||||
userInfo[articleKey] = article
|
||||
}
|
||||
userInfo[viewKey] = self.tableView
|
||||
|
||||
NotificationCenter.default.post(name: .TimelineSelectionDidChange, object: self, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
|
||||
tableView.redrawGrid()
|
||||
|
||||
let selectedRow = tableView.selectedRow
|
||||
|
||||
if selectedRow < 0 || selectedRow == NSNotFound || tableView.numberOfSelectedRows != 1 {
|
||||
postTimelineSelectionDidChangeNotification(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let selectedArticle = articleAtRow(selectedRow) {
|
||||
let articleSet = NSSet(array: [selectedArticle])
|
||||
if (!selectedArticle.status.read) {
|
||||
markArticles(articleSet, statusKey: .read, flag: true)
|
||||
}
|
||||
postTimelineSelectionDidChangeNotification(selectedArticle)
|
||||
}
|
||||
else {
|
||||
postTimelineSelectionDidChangeNotification(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func representedObjectArraysAreEqual(_ objects1: [AnyObject]?, _ objects2: [AnyObject]?) -> Bool {
|
||||
|
||||
if objects1 == nil && objects2 == nil {
|
||||
return true
|
||||
}
|
||||
guard let objects1 = objects1, let objects2 = objects2 else {
|
||||
return false
|
||||
}
|
||||
if objects1.count != objects2.count {
|
||||
return false
|
||||
}
|
||||
|
||||
var ix = 0
|
||||
for oneObject in objects1 {
|
||||
if oneObject !== objects2[ix] {
|
||||
return false
|
||||
}
|
||||
ix += 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension TimelineViewController {
|
||||
|
||||
var hasAtLeastOneSelectedArticle: Bool {
|
||||
get {
|
||||
return self.tableView.selectedRow != -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSTableView {
|
||||
|
||||
func scrollTo(row: Int) {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView else {
|
||||
return
|
||||
}
|
||||
let documentVisibleRect = scrollView.documentVisibleRect
|
||||
|
||||
let r = rect(ofRow: row)
|
||||
if NSContainsRect(documentVisibleRect, r) {
|
||||
return
|
||||
}
|
||||
|
||||
let rMidY = NSMidY(r)
|
||||
var scrollPoint = NSZeroPoint;
|
||||
let extraHeight = 150
|
||||
scrollPoint.y = floor(rMidY - (documentVisibleRect.size.height / 2.0)) + CGFloat(extraHeight)
|
||||
scrollPoint.y = max(scrollPoint.y, 0)
|
||||
|
||||
let maxScrollPointY = frame.size.height - documentVisibleRect.size.height
|
||||
scrollPoint.y = min(maxScrollPointY, scrollPoint.y)
|
||||
|
||||
let clipView = scrollView.contentView
|
||||
|
||||
let rClipView = NSMakeRect(scrollPoint.x, scrollPoint.y, NSWidth(clipView.bounds), NSHeight(clipView.bounds))
|
||||
|
||||
clipView.animator().bounds = rClipView
|
||||
}
|
||||
|
||||
func visibleRowViews() -> [TimelineTableRowView]? {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView, numberOfRows > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let range = rows(in: scrollView.documentVisibleRect)
|
||||
let ixMax = numberOfRows - 1
|
||||
let ixStart = min(range.location, ixMax)
|
||||
let ixEnd = min(((range.location + range.length) - 1), ixMax)
|
||||
|
||||
var visibleRows = [TimelineTableRowView]()
|
||||
|
||||
for ixRow in ixStart...ixEnd {
|
||||
if let oneRowView = rowView(atRow: ixRow, makeIfNecessary: false) as? TimelineTableRowView {
|
||||
visibleRows += [oneRowView]
|
||||
}
|
||||
}
|
||||
|
||||
return visibleRows.isEmpty ? nil : visibleRows
|
||||
}
|
||||
|
||||
func redrawGrid() {
|
||||
|
||||
visibleRowViews()?.forEach { $0.invalidateGridRect() }
|
||||
}
|
||||
}
|
182
Evergreen/Preferences/PreferencesWindowController.swift
Normal file
182
Evergreen/Preferences/PreferencesWindowController.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// PreferencesWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/1/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
private struct PreferencesToolbarItemSpec {
|
||||
|
||||
let identifier: String // Toolbar item identifier and view controller identifier in storyboard
|
||||
let name: String
|
||||
let imageName: String
|
||||
}
|
||||
|
||||
private let toolbarItemIdentifierGeneral = "General"
|
||||
|
||||
class PreferencesWindowController : NSWindowController, NSToolbarDelegate {
|
||||
|
||||
private let windowFrameName = "Preferences"
|
||||
fileprivate var viewControllers = [String: NSViewController]()
|
||||
fileprivate let toolbarItemSpecs: [PreferencesToolbarItemSpec] = {
|
||||
var specs = [PreferencesToolbarItemSpec]()
|
||||
specs += [PreferencesToolbarItemSpec(identifier: toolbarItemIdentifierGeneral, name: NSLocalizedString("General", comment: "Preferences"), imageName: NSImageNamePreferencesGeneral)]
|
||||
return specs
|
||||
}()
|
||||
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
let toolbar = NSToolbar(identifier: "PreferencesToolbar")
|
||||
toolbar.delegate = self
|
||||
toolbar.autosavesConfiguration = false
|
||||
toolbar.allowsUserCustomization = false
|
||||
toolbar.displayMode = .iconAndLabel
|
||||
toolbar.selectedItemIdentifier = toolbarItemSpecs.first!.identifier
|
||||
|
||||
window?.showsToolbarButton = false
|
||||
window?.toolbar = toolbar
|
||||
|
||||
window?.setFrameAutosaveName(windowFrameName)
|
||||
|
||||
switchToViewAtIndex(0)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
func toolbarItemClicked(_ sender: AnyObject) {
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: NSToolbarDelegate
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: String, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
|
||||
guard let toolbarItemSpec = toolbarItemSpecs.first(where: { $0.identifier == itemIdentifier }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let toolbarItem = NSToolbarItem(itemIdentifier: toolbarItemSpec.identifier)
|
||||
toolbarItem.action = #selector(toolbarItemClicked(_:))
|
||||
toolbarItem.target = self
|
||||
toolbarItem.label = toolbarItemSpec.name
|
||||
toolbarItem.paletteLabel = toolbarItem.label
|
||||
toolbarItem.image = NSImage(named: toolbarItemSpec.imageName)
|
||||
|
||||
return toolbarItem
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
|
||||
|
||||
return toolbarItemSpecs.map { $0.identifier }
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
|
||||
|
||||
return toolbarDefaultItemIdentifiers(toolbar)
|
||||
}
|
||||
|
||||
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [String] {
|
||||
|
||||
return toolbarDefaultItemIdentifiers(toolbar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PreferencesWindowController {
|
||||
|
||||
var currentView: NSView? {
|
||||
get {
|
||||
return window?.contentView?.subviews.first
|
||||
}
|
||||
}
|
||||
|
||||
func toolbarItemSpec(for identifier: String) -> PreferencesToolbarItemSpec? {
|
||||
|
||||
return toolbarItemSpecs.first(where: { $0.identifier == identifier })
|
||||
}
|
||||
|
||||
func switchToViewAtIndex(_ index: Int) {
|
||||
|
||||
let identifier = toolbarItemSpecs[index].identifier
|
||||
switchToView(identifier: identifier)
|
||||
}
|
||||
|
||||
func switchToView(identifier: String) {
|
||||
|
||||
guard let toolbarItemSpec = toolbarItemSpec(for: identifier) else {
|
||||
assertionFailure("Preferences window: no toolbarItemSpec matching \(identifier).")
|
||||
return
|
||||
}
|
||||
|
||||
guard let newViewController = viewController(identifier: identifier) else {
|
||||
assertionFailure("Preferences window: no view controller matching \(identifier).")
|
||||
return
|
||||
}
|
||||
|
||||
if newViewController.view == currentView {
|
||||
return
|
||||
}
|
||||
|
||||
newViewController.view.nextResponder = newViewController
|
||||
newViewController.nextResponder = window!.contentView
|
||||
|
||||
window!.title = toolbarItemSpec.name
|
||||
|
||||
resizeWindow(toFitView: newViewController.view)
|
||||
|
||||
if let currentView = currentView {
|
||||
window!.contentView?.replaceSubview(currentView, with: newViewController.view)
|
||||
}
|
||||
else {
|
||||
window!.contentView?.addSubview(newViewController.view)
|
||||
}
|
||||
|
||||
window!.makeFirstResponder(newViewController.view)
|
||||
}
|
||||
|
||||
func viewController(identifier: String) -> NSViewController? {
|
||||
|
||||
if let cachedViewController = viewControllers[identifier] {
|
||||
return cachedViewController
|
||||
}
|
||||
|
||||
let storyboard = NSStoryboard(name: "Preferences", bundle: nil)
|
||||
guard let viewController = storyboard.instantiateController(withIdentifier: identifier) as? NSViewController else {
|
||||
assertionFailure("Unknown preferences view controller: \(identifier)")
|
||||
return nil
|
||||
}
|
||||
|
||||
viewControllers[identifier] = viewController
|
||||
return viewController
|
||||
}
|
||||
|
||||
func resizeWindow(toFitView view: NSView) {
|
||||
|
||||
let viewFrame = view.frame
|
||||
let windowFrame = window!.frame
|
||||
let contentViewFrame = window!.contentView!.frame
|
||||
|
||||
let deltaHeight = NSHeight(contentViewFrame) - NSHeight(viewFrame)
|
||||
let heightForWindow = NSHeight(windowFrame) - deltaHeight
|
||||
let windowOriginY = NSMinY(windowFrame)// + deltaHeight
|
||||
|
||||
var updatedWindowFrame = windowFrame
|
||||
updatedWindowFrame.size.height = heightForWindow
|
||||
updatedWindowFrame.origin.y = windowOriginY
|
||||
updatedWindowFrame.size.width = NSWidth(viewFrame)
|
||||
|
||||
var updatedViewFrame = viewFrame
|
||||
updatedViewFrame.origin = NSZeroPoint
|
||||
if viewFrame != updatedViewFrame {
|
||||
view.frame = updatedViewFrame
|
||||
}
|
||||
|
||||
if windowFrame != updatedWindowFrame {
|
||||
window!.setFrame(updatedWindowFrame, display: true, animate: true)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
//
|
||||
// IndeterminateProgressWindowController.swift
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 8/28/16.
|
||||
// Copyright © 2016 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class IndeterminateProgressWindowController: NSWindowController {
|
||||
|
||||
@IBOutlet var messageLabel: NSTextField!
|
||||
@IBOutlet var progressIndicator: NSProgressIndicator!
|
||||
dynamic var message = ""
|
||||
|
||||
convenience init(message: String) {
|
||||
|
||||
self.init(windowNibName: "IndeterminateProgressWindow")
|
||||
self.message = message
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
progressIndicator.startAnimation(self)
|
||||
}
|
||||
}
|
||||
|
||||
func runIndeterminateProgressWithMessage(_ message: String) {
|
||||
|
||||
let windowController = IndeterminateProgressWindowController(message: message)
|
||||
NSApplication.shared().runModal(for: windowController.window!)
|
||||
}
|
||||
|
||||
func stopIndeterminateProgress() {
|
||||
|
||||
NSApplication.shared().stopModal()
|
||||
}
|
154
Evergreen/Resources/DefaultFeeds.plist
Normal file
154
Evergreen/Resources/DefaultFeeds.plist
Normal file
@ -0,0 +1,154 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<array>
|
||||
<dict>
|
||||
<key>note</key>
|
||||
<string>By Brent Simmons, Evergreen developer</string>
|
||||
<key>editedName</key>
|
||||
<string>Inessential</string>
|
||||
<key>home</key>
|
||||
<string>http://inessential.com/</string>
|
||||
<key>url</key>
|
||||
<string>http://inessential.com/xml/rss.xml</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://daringfireball.net/</string>
|
||||
<key>editedName</key>
|
||||
<string>Daring Fireball</string>
|
||||
<key>url</key>
|
||||
<string>http://daringfireball.net/feeds/main</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>https://www.natashatherobot.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>Natasha the Robot</string>
|
||||
<key>url</key>
|
||||
<string>https://www.natashatherobot.com/feed/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://www.imore.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>iMore</string>
|
||||
<key>url</key>
|
||||
<string>http://www.imore.com/rss.xml</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://becky.coffee/</string>
|
||||
<key>note</key>
|
||||
<string>iOS developer & corgi enthusiast</string>
|
||||
<key>editedName</key>
|
||||
<string>Becky Hansmeyer</string>
|
||||
<key>url</key>
|
||||
<string>http://beckyhansmeyer.com/feed/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://shapeof.com/</string>
|
||||
<key>note</key>
|
||||
<string>By Gus Mueller, Acorn developer</string>
|
||||
<key>editedName</key>
|
||||
<string>The Shape of Everything</string>
|
||||
<key>url</key>
|
||||
<string>http://shapeof.com/rss.xml</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://redqueencoder.com/</string>
|
||||
<key>note</key>
|
||||
<string>Janie Clayton</string>
|
||||
<key>editedName</key>
|
||||
<string>The Red Queen Coder</string>
|
||||
<key>url</key>
|
||||
<string>http://redqueencoder.com/feed/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>https://medium.com/@jaimeejaimee</string>
|
||||
<key>editedName</key>
|
||||
<string>jaimeejaimee</string>
|
||||
<key>note</key>
|
||||
<string>Jaimee Newberry</string>
|
||||
<key>url</key>
|
||||
<string>https://medium.com/feed/@jaimeejaimee</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://sixcolors.com</string>
|
||||
<key>note</key>
|
||||
<string>By Jason Snell & friends</string>
|
||||
<key>editedName</key>
|
||||
<string>Six Colors</string>
|
||||
<key>url</key>
|
||||
<string>http://feedpress.me/sixcolors</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://www.loopinsight.com/</string>
|
||||
<key>note</key>
|
||||
<string>Jim Dalrymple and Dave Mark</string>
|
||||
<key>editedName</key>
|
||||
<string>Loop Insight</string>
|
||||
<key>url</key>
|
||||
<string>http://www.loopinsight.com/feed/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://katiefloyd.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>Katie Floyd</string>
|
||||
<key>url</key>
|
||||
<string>http://feed.katiefloyd.com/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://ashleynh.me/</string>
|
||||
<key>editedName</key>
|
||||
<string>Ashley Nelson-Hornstein</string>
|
||||
<key>url</key>
|
||||
<string>http://blog.ashleynh.me/rss/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://ranchero.com/evergreen/</string>
|
||||
<key>editedName</key>
|
||||
<string>Evergreen News</string>
|
||||
<key>url</key>
|
||||
<string>http://ranchero.com/evergreen/feed.json</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://scripting.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>Scripting News</string>
|
||||
<key>note</key>
|
||||
<string>Dave Winer</string>
|
||||
<key>url</key>
|
||||
<string>http://scripting.com/rss.xml</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://ericasadun.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>Erica Sadun</string>
|
||||
<key>note</key>
|
||||
<string>Lots of Swift goodness.</string>
|
||||
<key>url</key>
|
||||
<string>http://ericasadun.com/feed/</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>home</key>
|
||||
<string>http://onefoottsunami.com/</string>
|
||||
<key>editedName</key>
|
||||
<string>One Foot Tsunami</string>
|
||||
<key>note</key>
|
||||
<string>Paul Kafasis</string>
|
||||
<key>url</key>
|
||||
<string>http://onefoottsunami.com/feed/atom/</string>
|
||||
</dict>
|
||||
</array>
|
||||
</plist>
|
86
Evergreen/Resources/styleSheet.css
Normal file
86
Evergreen/Resources/styleSheet.css
Normal file
@ -0,0 +1,86 @@
|
||||
body {
|
||||
color: #444;
|
||||
background-color: white;
|
||||
margin-top: 42px;
|
||||
margin-bottom: 100px;
|
||||
margin-left: 80px;
|
||||
margin-right: 80px;
|
||||
font-family: -apple-system;
|
||||
font-size: 18px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a, a:link, a:visited {
|
||||
color: #015cdc;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
#articleDateline {
|
||||
color: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-bottom: 6px;
|
||||
padding-top: 6px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
#articleDateline a:link, #articleDateline a:visited {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
#articleDescription {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
h1 {
|
||||
line-height: 1.15em;
|
||||
font-weight: medium;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: -apple-system, "Helvetica Neue"
|
||||
}
|
||||
code, pre {
|
||||
font-family: "SF Mono", Menlo, "Courier New", Courier, monospace;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*Block ads and junk*/
|
||||
|
||||
iframe[src*="feedads"],
|
||||
iframe[src*="doubleclick"],
|
||||
iframe[src*="plusone.google"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
a[href*=".ads."],
|
||||
a[href*="feedads"],
|
||||
a[href*="feedburner"],
|
||||
a[href*="doubleclick"],
|
||||
a[href*="//ads."],
|
||||
a[href*="api.tweetmeme"],
|
||||
a[href*="delicious.com/post?"],
|
||||
a[href*="digg.com/submit?"],
|
||||
a[href*="google.com/bookmarks/mark?"],
|
||||
a[href*="posterous.com/share?"],
|
||||
a[href*="tumblr.com/share?"],
|
||||
a[href*="linkedin.com/shareArticle?"],
|
||||
a[href*="facebook.com/share.php?"],
|
||||
a[href*="http://twitter.com/home?"],
|
||||
a[href*="addtoany.com/share_save"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
img[src*=".ads."],
|
||||
img[src*="//ads."],
|
||||
img[src*="doubleclick"],
|
||||
img[src*="feedads"],
|
||||
img[src*="feedburner"],
|
||||
img[src*="share-buttons"] {
|
||||
display: none !important;
|
||||
}
|
3
Evergreen/Resources/template.html
Normal file
3
Evergreen/Resources/template.html
Normal file
@ -0,0 +1,3 @@
|
||||
<div id="articleTitle"><h1>[[newsitem_title]]</h1></div>
|
||||
<div id="articleDateline">[[feedlink_withfavicon]] • <span class="articleDate">[[date_medium]]</span></div>
|
||||
<div id="articleDescription">[[newsitem_description]]</div>
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// AccountProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/17/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// ArticleProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// ArticleStatusProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// FeedProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// FolderProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/17/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// UnreadCountProtocol.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/8/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// DiskDictionaryConstants.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/9/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalAccount.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalArticle.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
@ -25,7 +25,7 @@ public final class LocalArticle: NSObject, Article {
|
||||
get {
|
||||
if _body == nil, let d = bodyData {
|
||||
// print(title)
|
||||
if let s = NSString(data: d, encoding: String.Encoding.utf8.rawValue) as? String {
|
||||
if let s = NSString(data: d, encoding: String.Encoding.utf8.rawValue) as String? {
|
||||
_body = s
|
||||
bodyData = nil
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalArticleCache.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 5/9/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalArticleStatus.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalDatabase.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/20/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalFeed.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalFolder.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 4/23/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// LocalStatusesManager.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 5/8/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -71,6 +71,10 @@ typedef struct {
|
||||
+ (NSString *)rs_stringWithNumberOfTabs:(NSInteger)numberOfTabs;
|
||||
- (NSString *)rs_stringByPrependingNumberOfTabs:(NSInteger)numberOfTabs;
|
||||
|
||||
// Remove leading http:// or https://
|
||||
|
||||
- (NSString *)rs_stringByStrippingHTTPOrHTTPSScheme;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
@ -366,5 +366,13 @@ NSString *RSStringReplaceAll(NSString *stringToSearch, NSString *searchFor, NSSt
|
||||
}
|
||||
|
||||
|
||||
- (NSString *)rs_stringByStrippingHTTPOrHTTPSScheme {
|
||||
|
||||
NSString *s = [self rs_stringByStrippingPrefix:@"http://" caseSensitive:NO];
|
||||
s = [s rs_stringByStrippingPrefix:@"https://" caseSensitive:NO];
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@end
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// Node.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 7/21/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
|
@ -1,6 +1,6 @@
|
||||
//
|
||||
// TreeController.swift
|
||||
// Rainier
|
||||
// Evergreen
|
||||
//
|
||||
// Created by Brent Simmons on 5/29/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
|
Loading…
x
Reference in New Issue
Block a user