From 8e712cae2054cfb7beb1851593ffc7c4849cc0a6 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 2 Jul 2019 11:10:34 +0200 Subject: [PATCH] Refactoring Interface Builder UI to code equivalent --- CHANGELOG.md | 6 + baRSS.xcodeproj/project.pbxproj | 128 ++- baRSS/AppHook.m | 17 +- baRSS/Core Data/Feed+Ext.m | 8 +- baRSS/Core Data/StoreCoordinator.m | 2 +- baRSS/Helper/FeedDownload.m | 5 +- baRSS/Helper/NSDate+Ext.h | 11 + baRSS/Helper/NSDate+Ext.m | 62 ++ baRSS/Helper/NSView+Ext.h | 101 +++ baRSS/Helper/NSView+Ext.m | 353 ++++++++ baRSS/Helper/Statistics.m | 188 ----- baRSS/Info.plist | 2 +- baRSS/Preferences/About Tab/SettingsAbout.h | 26 + baRSS/Preferences/About Tab/SettingsAbout.m | 32 + .../Preferences/About Tab/SettingsAboutView.h | 27 + .../Preferences/About Tab/SettingsAboutView.m | 87 ++ .../Appearance Tab/SettingsAppearance.h | 27 + .../Appearance Tab/SettingsAppearance.m | 53 ++ .../Appearance Tab/SettingsAppearanceView.h | 29 + .../Appearance Tab/SettingsAppearanceView.m | 88 ++ baRSS/Preferences/Feeds Tab/ModalFeedEdit.h | 1 + baRSS/Preferences/Feeds Tab/ModalFeedEdit.m | 164 ++-- baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib | 158 ---- .../Preferences/Feeds Tab/ModalFeedEditView.h | 45 + .../Preferences/Feeds Tab/ModalFeedEditView.m | 120 +++ baRSS/Preferences/Feeds Tab/OpmlExport.m | 74 +- .../Feeds Tab/RefreshStatisticsView.h} | 14 +- .../Feeds Tab/RefreshStatisticsView.m | 117 +++ baRSS/Preferences/Feeds Tab/SettingsFeeds.h | 9 + baRSS/Preferences/Feeds Tab/SettingsFeeds.m | 186 +++-- baRSS/Preferences/Feeds Tab/SettingsFeeds.xib | 250 ------ .../Preferences/Feeds Tab/SettingsFeedsView.h | 48 ++ .../Preferences/Feeds Tab/SettingsFeedsView.m | 254 ++++++ .../Preferences/General Tab/SettingsGeneral.h | 4 +- .../Preferences/General Tab/SettingsGeneral.m | 31 +- .../General Tab/SettingsGeneral.xib | 487 ----------- .../General Tab/SettingsGeneralView.h | 35 + .../General Tab/SettingsGeneralView.m | 51 ++ baRSS/Preferences/{ => Helper}/ModalSheet.h | 1 - baRSS/Preferences/Helper/ModalSheet.m | 109 +++ .../{General Tab => Helper}/UserPrefs.h | 0 .../{General Tab => Helper}/UserPrefs.m | 0 baRSS/Preferences/ModalSheet.m | 141 ---- baRSS/Preferences/Preferences.h | 3 +- baRSS/Preferences/Preferences.m | 125 +-- baRSS/Preferences/Preferences.xib | 783 ------------------ baRSS/Status Bar Menu/BarStatusItem.m | 2 +- 47 files changed, 2072 insertions(+), 2392 deletions(-) create mode 100644 baRSS/Helper/NSView+Ext.h create mode 100644 baRSS/Helper/NSView+Ext.m delete mode 100644 baRSS/Helper/Statistics.m create mode 100644 baRSS/Preferences/About Tab/SettingsAbout.h create mode 100644 baRSS/Preferences/About Tab/SettingsAbout.m create mode 100644 baRSS/Preferences/About Tab/SettingsAboutView.h create mode 100644 baRSS/Preferences/About Tab/SettingsAboutView.m create mode 100644 baRSS/Preferences/Appearance Tab/SettingsAppearance.h create mode 100644 baRSS/Preferences/Appearance Tab/SettingsAppearance.m create mode 100644 baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h create mode 100644 baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m delete mode 100644 baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib create mode 100644 baRSS/Preferences/Feeds Tab/ModalFeedEditView.h create mode 100644 baRSS/Preferences/Feeds Tab/ModalFeedEditView.m rename baRSS/{Helper/Statistics.h => Preferences/Feeds Tab/RefreshStatisticsView.h} (75%) create mode 100644 baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m delete mode 100644 baRSS/Preferences/Feeds Tab/SettingsFeeds.xib create mode 100644 baRSS/Preferences/Feeds Tab/SettingsFeedsView.h create mode 100644 baRSS/Preferences/Feeds Tab/SettingsFeedsView.m delete mode 100644 baRSS/Preferences/General Tab/SettingsGeneral.xib create mode 100644 baRSS/Preferences/General Tab/SettingsGeneralView.h create mode 100644 baRSS/Preferences/General Tab/SettingsGeneralView.m rename baRSS/Preferences/{ => Helper}/ModalSheet.h (97%) create mode 100644 baRSS/Preferences/Helper/ModalSheet.m rename baRSS/Preferences/{General Tab => Helper}/UserPrefs.h (100%) rename baRSS/Preferences/{General Tab => Helper}/UserPrefs.m (100%) delete mode 100644 baRSS/Preferences/ModalSheet.m delete mode 100644 baRSS/Preferences/Preferences.xib diff --git a/CHANGELOG.md b/CHANGELOG.md index 47cdb26..0928d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project does NOT adhere to [Semantic Versioning](https://semver.org/spe ## [Unreleased] +### Fixed +- Changed error message text when user cancels creation of new feed item +- Comparing existing articles with nonexistent guid and link + +### Changed +- Interface builder files replaced with code equivalent ## [0.9.4] - 2019-04-02 diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index 57bdb29..835ff82 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -10,19 +10,20 @@ 540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 540F704421B6C16C0022E69D /* FeedMeta+Ext.m */; }; 54195883218A061100581B79 /* Feed+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195882218A061100581B79 /* Feed+Ext.m */; }; 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54195885218E1BDB00581B79 /* NSMenu+Ext.m */; }; + 541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */ = {isa = PBXBuildFile; fileRef = 541C67C22255470B004D2CE6 /* SettingsAppearance.m */; }; 54209E942117325100F3B5EF /* DrawImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 54209E932117325100F3B5EF /* DrawImage.m */; }; - 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */ = {isa = PBXBuildFile; fileRef = 544936FA21F1E66100DEE9AA /* Statistics.m */; }; 544B011A2114B41200386E5C /* ModalSheet.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B01192114B41200386E5C /* ModalSheet.m */; }; 544B011D2114EE9100386E5C /* AppHook.m in Sources */ = {isa = PBXBuildFile; fileRef = 544B011C2114EE9100386E5C /* AppHook.m */; }; 544DCCB9212A2B4D002DBC46 /* RSXML.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; }; 544DCCBA212A2B4D002DBC46 /* RSXML.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 544DCCB8212A2B4D002DBC46 /* RSXML.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 544DCCBE212A2B6F002DBC46 /* RSXML.framework.dSYM in CopyFiles */ = {isa = PBXBuildFile; fileRef = 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */; }; + 546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; }; + 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; }; + 546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; }; 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */; }; - 546FC43F21188C78007CC3A3 /* SettingsFeeds.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */; }; 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */ = {isa = PBXBuildFile; fileRef = 546FC44121189975007CC3A3 /* SettingsGeneral.m */; }; - 546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC44221189975007CC3A3 /* SettingsGeneral.xib */; }; - 546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 546FC4462118A8E6007CC3A3 /* Preferences.xib */; }; 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */; }; + 5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */; }; 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */ = {isa = PBXBuildFile; fileRef = 5496B510214D6275003ED4ED /* UserPrefs.m */; }; 54A07A7F220E04CF00082C51 /* NSFetchRequest+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A7E220E04CF00082C51 /* NSFetchRequest+Ext.m */; }; 54A07A82220E723D00082C51 /* MapUnreadTotal.m in Sources */ = {isa = PBXBuildFile; fileRef = 54A07A81220E723D00082C51 /* MapUnreadTotal.m */; }; @@ -30,11 +31,14 @@ 54ACC28C21061B3C0020715F /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28B21061B3C0020715F /* main.m */; }; 54ACC29521061E270020715F /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29421061E270020715F /* FeedDownload.m */; }; 54ACC29821061FBA0020715F /* Preferences.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC29721061FBA0020715F /* Preferences.m */; }; + 54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B51703226DC339006C1B29 /* ModalFeedEditView.m */; }; + 54B517072270E990006C1B29 /* NSView+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B517062270E92A006C1B29 /* NSView+Ext.m */; }; 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749D92204A85C0022CC6D /* BarStatusItem.m */; }; 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */; }; 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */ = {isa = PBXBuildFile; fileRef = 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */; }; + 54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */; }; 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E8831E211B509D00064188 /* ModalFeedEdit.m */; }; - 54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54E8831F211B509D00064188 /* ModalFeedEdit.xib */; }; + 54E9CF32225914300023696F /* SettingsAbout.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9CF31225914300023696F /* SettingsAbout.m */; }; 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28221061B3B0020715F /* DBv1.xcdatamodeld */; }; 54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F6025C21C1D4170006D338 /* OpmlExport.m */; }; 54FE73D021220DEC003EAC65 /* StoreCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FE73CF21220DEC003EAC65 /* StoreCoordinator.m */; }; @@ -74,25 +78,28 @@ 54195884218E1BDB00581B79 /* NSMenu+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSMenu+Ext.h"; sourceTree = ""; }; 54195885218E1BDB00581B79 /* NSMenu+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSMenu+Ext.m"; sourceTree = ""; }; 541958872190FF1200581B79 /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; + 541C67C12255470B004D2CE6 /* SettingsAppearance.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAppearance.h; sourceTree = ""; }; + 541C67C22255470B004D2CE6 /* SettingsAppearance.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearance.m; sourceTree = ""; }; 54209E922117325100F3B5EF /* DrawImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DrawImage.h; sourceTree = ""; }; 54209E932117325100F3B5EF /* DrawImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DrawImage.m; sourceTree = ""; }; - 544936F921F1E66100DEE9AA /* Statistics.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Statistics.h; sourceTree = ""; }; - 544936FA21F1E66100DEE9AA /* Statistics.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Statistics.m; sourceTree = ""; }; 544B01182114B41200386E5C /* ModalSheet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalSheet.h; sourceTree = ""; }; 544B01192114B41200386E5C /* ModalSheet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalSheet.m; sourceTree = ""; }; 544B011B2114EE9100386E5C /* AppHook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppHook.h; sourceTree = ""; }; 544B011C2114EE9100386E5C /* AppHook.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppHook.m; sourceTree = ""; }; 544DCCB8212A2B4D002DBC46 /* RSXML.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RSXML.framework; path = Carthage/Build/Mac/RSXML.framework; sourceTree = ""; }; 544DCCBD212A2B6F002DBC46 /* RSXML.framework.dSYM */ = {isa = PBXFileReference; lastKnownFileType = wrapper.dsym; name = RSXML.framework.dSYM; path = Carthage/Build/Mac/RSXML.framework.dSYM; sourceTree = ""; }; + 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = ""; }; + 546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = ""; }; + 546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = ""; }; + 546A6A2E22C585580034E806 /* SettingsAboutView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAboutView.h; sourceTree = ""; }; 546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsFeeds.h; sourceTree = ""; }; 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsFeeds.m; sourceTree = ""; }; - 546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsFeeds.xib; sourceTree = ""; }; 546FC44021189975007CC3A3 /* SettingsGeneral.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsGeneral.h; sourceTree = ""; }; 546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneral.m; sourceTree = ""; }; - 546FC44221189975007CC3A3 /* SettingsGeneral.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SettingsGeneral.xib; sourceTree = ""; }; - 546FC4462118A8E6007CC3A3 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = ""; }; 5477D34C21233C62002BA27F /* FeedGroup+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedGroup+Ext.h"; sourceTree = ""; }; 5477D34D21233C62002BA27F /* FeedGroup+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedGroup+Ext.m"; sourceTree = ""; }; + 5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeedsView.h; sourceTree = ""; }; + 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsFeedsView.m; sourceTree = ""; }; 54892F1D2235285700271CBA /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 5496B50F214D6275003ED4ED /* UserPrefs.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UserPrefs.h; sourceTree = ""; }; 5496B510214D6275003ED4ED /* UserPrefs.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserPrefs.m; sourceTree = ""; }; @@ -109,15 +116,24 @@ 54ACC29421061E270020715F /* FeedDownload.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = ""; }; 54ACC29621061FBA0020715F /* Preferences.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Preferences.h; sourceTree = ""; }; 54ACC29721061FBA0020715F /* Preferences.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Preferences.m; sourceTree = ""; }; + 54B51702226DC339006C1B29 /* ModalFeedEditView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ModalFeedEditView.h; sourceTree = ""; }; + 54B51703226DC339006C1B29 /* ModalFeedEditView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEditView.m; sourceTree = ""; }; + 54B517052270E8C6006C1B29 /* NSView+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSView+Ext.h"; sourceTree = ""; }; + 54B517062270E92A006C1B29 /* NSView+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSView+Ext.m"; sourceTree = ""; }; 54B749D82204A85C0022CC6D /* BarStatusItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BarStatusItem.h; sourceTree = ""; }; 54B749D92204A85C0022CC6D /* BarStatusItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BarStatusItem.m; sourceTree = ""; }; 54B749DE220635BE0022CC6D /* FeedArticle+Ext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FeedArticle+Ext.h"; sourceTree = ""; }; 54B749DF220635CD0022CC6D /* FeedArticle+Ext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FeedArticle+Ext.m"; sourceTree = ""; }; 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+Ext.h"; sourceTree = ""; }; 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+Ext.m"; sourceTree = ""; }; + 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RefreshStatisticsView.h; sourceTree = ""; }; + 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RefreshStatisticsView.m; sourceTree = ""; }; + 54D857D022802309001BA1C8 /* SettingsGeneralView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsGeneralView.h; sourceTree = ""; }; + 54D857D122802309001BA1C8 /* SettingsGeneralView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsGeneralView.m; sourceTree = ""; }; 54E8831D211B509D00064188 /* ModalFeedEdit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ModalFeedEdit.h; sourceTree = ""; }; 54E8831E211B509D00064188 /* ModalFeedEdit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ModalFeedEdit.m; sourceTree = ""; }; - 54E8831F211B509D00064188 /* ModalFeedEdit.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ModalFeedEdit.xib; sourceTree = ""; }; + 54E9CF30225914300023696F /* SettingsAbout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsAbout.h; sourceTree = ""; }; + 54E9CF31225914300023696F /* SettingsAbout.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsAbout.m; sourceTree = ""; }; 54F6025B21C1D4170006D338 /* OpmlExport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpmlExport.h; sourceTree = ""; }; 54F6025C21C1D4170006D338 /* OpmlExport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OpmlExport.m; sourceTree = ""; }; 54FE73CE21220DEC003EAC65 /* StoreCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StoreCoordinator.h; sourceTree = ""; }; @@ -160,10 +176,10 @@ 54209E932117325100F3B5EF /* DrawImage.m */, 54ACC29321061E270020715F /* FeedDownload.h */, 54ACC29421061E270020715F /* FeedDownload.m */, - 544936F921F1E66100DEE9AA /* Statistics.h */, - 544936FA21F1E66100DEE9AA /* Statistics.m */, 54BB048721FD2AB500C303A5 /* NSDate+Ext.h */, 54BB048821FD2AB500C303A5 /* NSDate+Ext.m */, + 54B517052270E8C6006C1B29 /* NSView+Ext.h */, + 54B517062270E92A006C1B29 /* NSView+Ext.m */, ); path = Helper; sourceTree = ""; @@ -177,28 +193,16 @@ name = Frameworks; sourceTree = ""; }; - 546FC44521189ADC007CC3A3 /* General Tab */ = { - isa = PBXGroup; - children = ( - 5496B50F214D6275003ED4ED /* UserPrefs.h */, - 5496B510214D6275003ED4ED /* UserPrefs.m */, - 546FC44021189975007CC3A3 /* SettingsGeneral.h */, - 546FC44121189975007CC3A3 /* SettingsGeneral.m */, - 546FC44221189975007CC3A3 /* SettingsGeneral.xib */, - ); - path = "General Tab"; - sourceTree = ""; - }; 546FC44D2118B357007CC3A3 /* Preferences */ = { isa = PBXGroup; children = ( + 54E9CF2F225913850023696F /* Helper */, 54ACC29621061FBA0020715F /* Preferences.h */, 54ACC29721061FBA0020715F /* Preferences.m */, - 546FC4462118A8E6007CC3A3 /* Preferences.xib */, - 544B01182114B41200386E5C /* ModalSheet.h */, - 544B01192114B41200386E5C /* ModalSheet.m */, - 546FC44521189ADC007CC3A3 /* General Tab */, + 54D857CF228022AB001BA1C8 /* General Tab */, 54E88323211B542E00064188 /* Feeds Tab */, + 54D857D3228035D4001BA1C8 /* Appearance Tab */, + 54D857D72280C367001BA1C8 /* About Tab */, ); path = Preferences; sourceTree = ""; @@ -259,21 +263,69 @@ path = baRSS; sourceTree = ""; }; + 54D857CF228022AB001BA1C8 /* General Tab */ = { + isa = PBXGroup; + children = ( + 546FC44021189975007CC3A3 /* SettingsGeneral.h */, + 546FC44121189975007CC3A3 /* SettingsGeneral.m */, + 54D857D022802309001BA1C8 /* SettingsGeneralView.h */, + 54D857D122802309001BA1C8 /* SettingsGeneralView.m */, + ); + path = "General Tab"; + sourceTree = ""; + }; + 54D857D3228035D4001BA1C8 /* Appearance Tab */ = { + isa = PBXGroup; + children = ( + 541C67C12255470B004D2CE6 /* SettingsAppearance.h */, + 541C67C22255470B004D2CE6 /* SettingsAppearance.m */, + 546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */, + 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */, + ); + path = "Appearance Tab"; + sourceTree = ""; + }; + 54D857D72280C367001BA1C8 /* About Tab */ = { + isa = PBXGroup; + children = ( + 54E9CF30225914300023696F /* SettingsAbout.h */, + 54E9CF31225914300023696F /* SettingsAbout.m */, + 546A6A2E22C585580034E806 /* SettingsAboutView.h */, + 546A6A2D22C585580034E806 /* SettingsAboutView.m */, + ); + path = "About Tab"; + sourceTree = ""; + }; 54E88323211B542E00064188 /* Feeds Tab */ = { isa = PBXGroup; children = ( 546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */, 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */, - 546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */, + 5478DF02225A7AE200D30C64 /* SettingsFeedsView.h */, + 5478DF03225A7AE200D30C64 /* SettingsFeedsView.m */, 54E8831D211B509D00064188 /* ModalFeedEdit.h */, 54E8831E211B509D00064188 /* ModalFeedEdit.m */, - 54E8831F211B509D00064188 /* ModalFeedEdit.xib */, + 54B51702226DC339006C1B29 /* ModalFeedEditView.h */, + 54B51703226DC339006C1B29 /* ModalFeedEditView.m */, + 54D857CC227C5785001BA1C8 /* RefreshStatisticsView.h */, + 54D857CD227C5785001BA1C8 /* RefreshStatisticsView.m */, 54F6025B21C1D4170006D338 /* OpmlExport.h */, 54F6025C21C1D4170006D338 /* OpmlExport.m */, ); path = "Feeds Tab"; sourceTree = ""; }; + 54E9CF2F225913850023696F /* Helper */ = { + isa = PBXGroup; + children = ( + 5496B50F214D6275003ED4ED /* UserPrefs.h */, + 5496B510214D6275003ED4ED /* UserPrefs.m */, + 544B01182114B41200386E5C /* ModalSheet.h */, + 544B01192114B41200386E5C /* ModalSheet.m */, + ); + path = Helper; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -343,11 +395,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */, - 54E88321211B509D00064188 /* ModalFeedEdit.xib in Resources */, 54ACC28621061B3C0020715F /* Assets.xcassets in Resources */, - 546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */, - 546FC43F21188C78007CC3A3 /* SettingsFeeds.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -378,14 +426,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 54B51704226DC339006C1B29 /* ModalFeedEditView.m in Sources */, + 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */, + 54E9CF32225914300023696F /* SettingsAbout.m in Sources */, 54B749E0220636200022CC6D /* FeedArticle+Ext.m in Sources */, 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */, 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, + 546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */, 54ACC29521061E270020715F /* FeedDownload.m in Sources */, 54BB048921FD2AB500C303A5 /* NSDate+Ext.m in Sources */, 5477D34E21233C62002BA27F /* FeedGroup+Ext.m in Sources */, - 544936FB21F1E66100DEE9AA /* Statistics.m in Sources */, + 5478DF04225A7AE200D30C64 /* SettingsFeedsView.m in Sources */, 540F704521B6C16C0022E69D /* FeedMeta+Ext.m in Sources */, 54ACC28C21061B3C0020715F /* main.m in Sources */, 54FE73D3212316CD003EAC65 /* BarMenu.m in Sources */, @@ -395,6 +447,10 @@ 54195886218E1BDB00581B79 /* NSMenu+Ext.m in Sources */, 54F6025D21C1D4170006D338 /* OpmlExport.m in Sources */, 5496B511214D6275003ED4ED /* UserPrefs.m in Sources */, + 546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */, + 54B517072270E990006C1B29 /* NSView+Ext.m in Sources */, + 54D857CE227C5785001BA1C8 /* RefreshStatisticsView.m in Sources */, + 541C67C32255470B004D2CE6 /* SettingsAppearance.m in Sources */, 54B749DA2204A85C0022CC6D /* BarStatusItem.m in Sources */, 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 616621a..2cd8cdf 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -26,7 +26,7 @@ #import "Preferences.h" @interface AppHook() -@property (strong) Preferences *prefWindow; +@property (strong) NSWindowController *prefWindow; @end @implementation AppHook @@ -76,9 +76,7 @@ /// Called whenever the user activates the preferences (either through menu click or hotkey). - (void)openPreferences { if (!self.prefWindow) { - self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; - self.prefWindow.window.title = [NSString stringWithFormat:@"%@ %@", NSProcessInfo.processInfo.processName, - NSLocalizedString(@"Preferences", nil)]; + self.prefWindow = [[NSWindowController alloc] initWithWindow:[Preferences window]]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(preferencesClosed:) name:NSWindowWillCloseNotification object:self.prefWindow.window]; } [NSApp activateIgnoringOtherApps:YES]; @@ -196,12 +194,13 @@ static NSEventModifierFlags fnKeyFlags = NSEventModifierFlagShift | NSEventModif if ([self sendAction:@selector(redo:) to:nil from:self]) return; } - } else { - if (key == NSEnterCharacter || key == NSCarriageReturnCharacter) { - if ([self sendAction:@selector(enterPressed:) to:nil from:self]) - return; - } } +// else { +// if (key == NSEnterCharacter || key == NSCarriageReturnCharacter) { +// if ([self sendAction:@selector(enterPressed:) to:nil from:self]) +// return; +// } +// } #pragma clang diagnostic pop } [super sendEvent:event]; diff --git a/baRSS/Core Data/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m index f88cc42..5577de3 100644 --- a/baRSS/Core Data/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -189,8 +189,8 @@ BOOL linkIsNil = (searchLink == nil); BOOL guidIsNil = (searchGuid == nil); for (FeedArticle *art in localSet) { - if ((linkIsNil && art.link == nil) || [art.link isEqualToString:searchLink]) { - if ((guidIsNil && art.guid == nil) || [art.guid isEqualToString:searchGuid]) + if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) { + if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid])) return art; } } @@ -206,8 +206,8 @@ BOOL linkIsNil = (searchLink == nil); BOOL guidIsNil = (searchGuid == nil); for (RSParsedArticle *art in remoteSet) { - if ((linkIsNil && art.link == nil) || [art.link isEqualToString:searchLink]) { - if ((guidIsNil && art.guid == nil) || [art.guid isEqualToString:searchGuid]) + if ((linkIsNil && art.link == nil) || (!linkIsNil && [art.link isEqualToString:searchLink])) { + if ((guidIsNil && art.guid == nil) || (!guidIsNil && [art.guid isEqualToString:searchGuid])) return art; } } diff --git a/baRSS/Core Data/StoreCoordinator.m b/baRSS/Core Data/StoreCoordinator.m index d475fd4..1afd672 100644 --- a/baRSS/Core Data/StoreCoordinator.m +++ b/baRSS/Core Data/StoreCoordinator.m @@ -107,7 +107,7 @@ fr.propertiesToFetch = @[ @"indexPath" ]; [fr addFunctionExpression:@"sum:" onKeyPath:@"articles.unread" name:@"unread" type:NSInteger32AttributeType]; [fr addFunctionExpression:@"count:" onKeyPath:@"articles.unread" name:@"total" type:NSInteger32AttributeType]; - return [fr fetchAllRows: [self getMainContext]]; + return (NSArray*)[fr fetchAllRows: [self getMainContext]]; } diff --git a/baRSS/Helper/FeedDownload.m b/baRSS/Helper/FeedDownload.m index f5f6ac7..cabaae0 100644 --- a/baRSS/Helper/FeedDownload.m +++ b/baRSS/Helper/FeedDownload.m @@ -266,8 +266,11 @@ static BOOL _nextUpdateIsForced = NO; dispatch_sync(dispatch_get_main_queue(), ^{ // sync! (thread is already in background) chosenURL = askUser(parsedMeta); }); - if (!chosenURL || chosenURL.length == 0) + if (!chosenURL || chosenURL.length == 0) { + // User canceled operation, show appropriate error message + *err = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Operation canceled.", nil)}]; return NO; + } [self parseFeedRequest:[self newRequestURL:chosenURL] xmlBlock:nil feedBlock:block]; return YES; } feedBlock:block]; diff --git a/baRSS/Helper/NSDate+Ext.h b/baRSS/Helper/NSDate+Ext.h index 058d7af..d35557d 100644 --- a/baRSS/Helper/NSDate+Ext.h +++ b/baRSS/Helper/NSDate+Ext.h @@ -33,6 +33,12 @@ typedef NS_ENUM(int32_t, TimeUnitType) { }; @interface NSDate (Ext) ++ (NSString*)dayStringISO8601; ++ (NSString*)dayStringLocalized; +@end + + +@interface NSDate (Interval) + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag; + (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag; @end @@ -43,3 +49,8 @@ typedef NS_ENUM(int32_t, TimeUnitType) { + (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag; + (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit; @end + + +@interface NSDate (Statistics) ++ (NSDictionary*)refreshIntervalStatistics:(NSArray *)list; +@end diff --git a/baRSS/Helper/NSDate+Ext.m b/baRSS/Helper/NSDate+Ext.m index 27e5051..ddf90da 100644 --- a/baRSS/Helper/NSDate+Ext.m +++ b/baRSS/Helper/NSDate+Ext.m @@ -38,6 +38,23 @@ static const TimeUnitType _values[] = { @implementation NSDate (Ext) +/// @return Day as string in iso format: @c YYYY-MM-DD'T'hh:mm:ss'Z' ++ (NSString*)dayStringISO8601 { + return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]]; +// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]]; +// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day]; +} + +/// @return Day as string in localized short format, e.g., @c DD.MM.YY ++ (NSString*)dayStringLocalized { + return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle]; +} + +@end + + +@implementation NSDate (Interval) + /// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h. + (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag { if (flag) { @@ -139,3 +156,48 @@ static const TimeUnitType _values[] = { } @end + + +@implementation NSDate (Statistics) + +/** + @return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest} + */ ++ (NSDictionary*)refreshIntervalStatistics:(NSArray *)list { + if (!list || list.count == 0) + return nil; + + NSDate *earliest = [NSDate distantFuture]; + NSDate *latest = [NSDate distantPast]; + NSDate *prev = nil; + NSMutableArray *differences = [NSMutableArray array]; + for (NSDate *d in list) { + if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull + continue; + earliest = [d earlierDate:earliest]; + latest = [d laterDate:latest]; + if (prev) { + int dif = abs((int)[d timeIntervalSinceDate:prev]); + [differences addObject:[NSNumber numberWithInt:dif]]; + } + prev = d; + } + if (differences.count == 0) + return nil; + + [differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]]; + + NSUInteger i = (differences.count/2); + NSNumber *median = differences[i]; + if ((differences.count % 2) == 0) { // even feed count, use median of two values + median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2]; + } + return @{@"min" : differences.firstObject, + @"max" : differences.lastObject, + @"avg" : [differences valueForKeyPath:@"@avg.self"], + @"median" : median, + @"earliest" : earliest, + @"latest" : latest }; +} + +@end diff --git a/baRSS/Helper/NSView+Ext.h b/baRSS/Helper/NSView+Ext.h new file mode 100644 index 0000000..fa7f9ef --- /dev/null +++ b/baRSS/Helper/NSView+Ext.h @@ -0,0 +1,101 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +/***/ static const CGFloat PAD_WIN = 20; // window padding +/***/ static const CGFloat PAD_L = 16; +/***/ static const CGFloat PAD_M = 8; +/***/ static const CGFloat PAD_S = 4; +/***/ static const CGFloat PAD_XS = 2; + +/***/ static const CGFloat HEIGHT_LABEL = 17; +/***/ static const CGFloat HEIGHT_LABEL_SMALL = 14; +/***/ static const CGFloat HEIGHT_INPUTFIELD = 21; +/***/ static const CGFloat HEIGHT_BUTTON = 21; +/***/ static const CGFloat HEIGHT_INLINEBUTTON = 16; +/***/ static const CGFloat HEIGHT_POPUP = 21; +/***/ static const CGFloat HEIGHT_SPINNER = 16; +/***/ static const CGFloat HEIGHT_CHECKBOX = 14; + +/// Static variable to calculate origin center coordinate in its @c superview. The value of this var isn't used. +static const CGFloat CENTER = -0.015625; + +/// Calculate @c origin.y going down from the top border of its @c superview +NS_INLINE CGFloat YFromTop(NSView *view) { return NSHeight(view.superview.frame) - NSMinY(view.frame) - view.alignmentRectInsets.bottom; } + + +/* + Allmost all methods return @c self to allow method chaining + */ + +@interface NSView (Ext) +// UI: TextFields ++ (NSTextField*)label:(NSString*)text; ++ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w; ++ (NSView*)labelColumn:(NSArray*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad; +// UI: Buttons ++ (NSButton*)button:(NSString*)text; ++ (NSButton*)buttonImageSquare:(nonnull NSImageName)name; ++ (NSButton*)buttonIcon:(NSImage*)img size:(CGFloat)size; ++ (NSButton*)inlineButton:(NSString*)text; ++ (NSPopUpButton*)popupButton:(CGFloat)w; +// UI: Others ++ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size; ++ (NSButton*)checkbox:(BOOL)flag; ++ (NSProgressIndicator*)activitySpinner; ++ (NSView*)radioGroup:(NSArray*)entries target:(id)target action:(nonnull SEL)action; ++ (NSView*)radioGroup:(NSArray*)entries; +// UI: Enclosing Container +- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect; ++ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad; +// Insert UI elements in parent view +- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y; +- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y; +- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y; +- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y; +// Modify existing UI elements +- (instancetype)sizableWidthAndHeight; +- (instancetype)sizeToRight:(CGFloat)rightPadding; +- (instancetype)sizeWidthToFit; +- (instancetype)tooltip:(NSString*)tt; +// Debugging +- (instancetype)colorLayer:(NSColor*)color; ++ (NSView*)redCube:(CGFloat)size; +@end + + +@interface NSControl (Ext) +- (instancetype)action:(SEL)selector target:(id)target; +- (instancetype)large; +- (instancetype)small; +- (instancetype)tiny; +- (instancetype)bold; +- (instancetype)textRight; +- (instancetype)textCenter; +@end + + +@interface NSTextField (Ext) +- (instancetype)gray; +- (instancetype)selectable; +@end diff --git a/baRSS/Helper/NSView+Ext.m b/baRSS/Helper/NSView+Ext.m new file mode 100644 index 0000000..c3eb017 --- /dev/null +++ b/baRSS/Helper/NSView+Ext.m @@ -0,0 +1,353 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "NSView+Ext.h" + +@implementation NSView (Ext) + +#pragma mark - UI: TextFields - + +/// Create label with non-editable text. Ensures uniform fontsize and text color. @c 17px height. ++ (NSTextField*)label:(NSString*)text { + NSTextField *label = [NSTextField labelWithString:text]; + [label setFrameSize: NSMakeSize(0, HEIGHT_LABEL)]; + label.font = [NSFont systemFontOfSize: NSFont.systemFontSize]; + label.textColor = [NSColor controlTextColor]; + label.lineBreakMode = NSLineBreakByTruncatingTail; +// label.backgroundColor = [NSColor yellowColor]; +// label.drawsBackground = YES; + return [label sizeWidthToFit]; +} + +/// Create input text field with placeholder text. @c 21px height. ++ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w { + NSTextField *input = [NSTextField textFieldWithString:@""]; + [input setFrameSize: NSMakeSize(w, HEIGHT_INPUTFIELD)]; + input.alignment = NSTextAlignmentJustified; + input.placeholderString = placeholder; + input.font = [NSFont systemFontOfSize: NSFont.systemFontSize]; + input.textColor = [NSColor controlTextColor]; + return input; +} + +/// Create view with @c NSTextField subviews with right-aligned and row-centered text from @c labels. ++ (NSView*)labelColumn:(NSArray*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad { + CGFloat w = 0, y = 0; + CGFloat off = (h - HEIGHT_LABEL) / 2; + NSView *parent = [[NSView alloc] init]; + for (NSUInteger i = 0; i < labels.count; i++) { + NSTextField *lbl = [[NSView label:labels[i]] placeIn:parent xRight:0 yTop:y + off]; + if (w < NSWidth(lbl.frame)) + w = NSWidth(lbl.frame); + y += h + pad; + } + [parent setFrameSize: NSMakeSize(w, y - pad)]; + return parent; +} + + +#pragma mark - UI: Buttons - + + +/// Create button. @c 21px height. ++ (NSButton*)button:(NSString*)text { + NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_BUTTON)]; + btn.font = [NSFont systemFontOfSize:NSFont.systemFontSize]; + btn.bezelStyle = NSBezelStyleRounded; + btn.title = text; + return [btn sizeWidthToFit]; +} + +/// Create @c NSBezelStyleSmallSquare image button. @c 25x21px ++ (NSButton*)buttonImageSquare:(nonnull NSImageName)name { + NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 25, HEIGHT_BUTTON)]; + btn.bezelStyle = NSBezelStyleSmallSquare; + btn.image = [NSImage imageNamed:name]; + if (!btn.image) btn.title = name; // fallback to text + return btn; +} + +/// Create pure image button with no border. ++ (NSButton*)buttonIcon:(NSImage*)img size:(CGFloat)size { + NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, size, size)]; + btn.bezelStyle = NSBezelStyleRounded; + btn.bordered = NO; + btn.image = img; + return btn; +} + +/// Create gray inline button with rounded corners. @c 16px height. ++ (NSButton*)inlineButton:(NSString*)text { + NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_INLINEBUTTON)]; + btn.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightBold]; + btn.bezelStyle = NSBezelStyleInline; + btn.controlSize = NSControlSizeSmall; + btn.title = text; + return [btn sizeWidthToFit]; +} + +/// Create empty drop down button. @c 21px height. ++ (NSPopUpButton*)popupButton:(CGFloat)w { + return [[NSPopUpButton alloc] initWithFrame: NSMakeRect(0, 0, w, HEIGHT_POPUP) pullsDown:NO]; +} + + +#pragma mark - UI: Others - + + +/// Create @c ImageView with square @c size ++ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size { + NSImageView *imgView = [[NSImageView alloc] initWithFrame: NSMakeRect(0, 0, size, size)]; + if (name) imgView.image = [NSImage imageNamed:name]; + return imgView; +} + +/// Create checkbox. @c 14px height. ++ (NSButton*)checkbox:(BOOL)flag { + NSButton *check = [NSButton checkboxWithTitle:@"" target:nil action:nil]; + check.title = @""; // needed, otherwise will print "Button" + check.frame = NSMakeRect(0, 0, HEIGHT_CHECKBOX, HEIGHT_CHECKBOX); + check.state = (flag? NSControlStateValueOn : NSControlStateValueOff); + return check; +} + +/// Create progress spinner. @c 16px size. ++ (NSProgressIndicator*)activitySpinner { + NSProgressIndicator *spin = [[NSProgressIndicator alloc] initWithFrame: NSMakeRect(0, 0, HEIGHT_SPINNER, HEIGHT_SPINNER)]; + spin.indeterminate = YES; + spin.displayedWhenStopped = NO; + spin.style = NSProgressIndicatorSpinningStyle; + spin.controlSize = NSControlSizeSmall; + return spin; +} + +/// Create grouping view with vertically, left-aligned radio buttons. Action is identical for all buttons (grouping). ++ (NSView*)radioGroup:(NSArray*)entries target:(id)target action:(nonnull SEL)action { + if (entries.count == 0) + return nil; + CGFloat w = 0, h = 0; + NSView *parent = [[NSView alloc] init]; + for (NSUInteger i = entries.count; i > 0; i--) { + NSButton *btn = [NSButton radioButtonWithTitle:entries[i-1] target:target action:action]; + btn.tag = (NSInteger)i-1; + if (btn.tag == 0) + btn.state = NSControlStateValueOn; + if (w < NSWidth(btn.frame)) // find max width (before alignmentRect:) + w = NSWidth(btn.frame); + [btn placeIn:parent x:0 y:h]; + h += NSHeight([btn alignmentRectForFrame:btn.frame]) + PAD_XS; + } + [parent setFrameSize: NSMakeSize(w, h - PAD_XS)]; + return parent; +} + +/// Same as @c radioGroup:target:action: but using dummy action to ignore radio button click events. ++ (NSView*)radioGroup:(NSArray*)entries { + return [self radioGroup:entries target:self action:@selector(donothing)]; +} + +/// Solely used to group radio buttons ++ (void)donothing {} + + +#pragma mark - UI: Enclosing Container - + + +/// Insert @c scrollView, remove @c self from current view and set as @c documentView for the newly created scroll view. +- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect { + NSScrollView *scroll = [[[NSScrollView alloc] initWithFrame:rect] sizableWidthAndHeight]; + scroll.borderType = NSBezelBorder; + scroll.hasVerticalScroller = YES; + scroll.horizontalScrollElasticity = NSScrollElasticityNone; + [self addSubview:scroll]; + + if (content.superview) [content removeFromSuperview]; // remove if added already (e.g., helper methods above) + content.frame = NSMakeRect(0, 0, scroll.contentSize.width, scroll.contentSize.height); + scroll.documentView = content; + return scroll; +} + +/// Create view with @c NSTextField label in front of the view. ++ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad { + NSView *parent = [[NSView alloc] initWithFrame: NSZeroRect]; + NSTextField *label = [NSView label:str]; + [label placeIn:parent x:pad yTop:pad]; + [other placeIn:parent x:pad + NSWidth(label.frame) yTop:pad]; + [parent setFrameSize: NSMakeSize(NSMaxX(other.frame), NSHeight(other.frame) + 2 * pad)]; + return parent; +} + + +#pragma mark - Insert UI elements in parent view - + + +/** + Set frame origin and insert @c self in @c parent view with @c frameForAlignmentRect:. + You may use @c CENTER to automatically calculate midpoint in parent view. + The @c autoresizingMask will be set accordingly. + */ +- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y { + SetCenterableOrigin(self, parent, x, y); + if (x == CENTER) self.autoresizingMask |= NSViewMinXMargin | NSViewMaxXMargin; + if (y == CENTER) self.autoresizingMask |= NSViewMinYMargin | NSViewMaxYMargin; + self.frame = [self frameForAlignmentRect:self.frame]; + [parent addSubview:self]; + return self; +} + +/// Same as @c placeIn:x:y: but measure position from top instead of bottom. Also sets @c autoresizingMask. +- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y { + return [[self placeIn:parent x:x y:NSHeight(parent.frame) - NSHeight(self.frame) - y] alignTop]; +} + +/// Same as @c placeIn:x:y: but measure position from right instead of left. Also sets @c autoresizingMask. +- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y { + return [[self placeIn:parent x:NSWidth(parent.frame) - NSWidth(self.frame) - x y:y] alignRight]; +} + +/// Set origin by measuring from top right (@c CENTER is not allowed here). Also sets @c autoresizingMask. +- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y { + [self setFrameOrigin: NSMakePoint(NSWidth(parent.frame) - NSWidth(self.frame) - x, + NSHeight(parent.frame) - NSHeight(self.frame) - y)]; + self.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin; + self.frame = [self frameForAlignmentRect:self.frame]; + [parent addSubview:self]; + return self; +} + + +#pragma mark - Modify existing UI elements - + + +// Aligned Frame Origins +// pad - view.alignmentRectInsets.left; +// pad - view.alignmentRectInsets.bottom; +// NSWidth(view.superview.frame) - NSWidth(view.frame) - pad + view.alignmentRectInsets.right; +// NSHeight(view.superview.frame) - NSHeight(view.frame) - pad + view.alignmentRectInsets.top; + +/// Modify @c .autoresizingMask; Clear @c NSViewMaxYMargin flag and set @c NSViewMinYMargin +- (instancetype)alignTop { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxYMargin) | NSViewMinYMargin; return self; } + +/// Modify @c .autoresizingMask; Clear @c NSViewMaxXMargin flag and set @c NSViewMinXMargin +- (instancetype)alignRight { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxXMargin) | NSViewMinXMargin; return self; } + +/// Modify @c .autoresizingMask; Add @c NSViewWidthSizable @c | @c NSViewHeightSizable flags +- (instancetype)sizableWidthAndHeight { self.autoresizingMask |= NSViewWidthSizable | NSViewHeightSizable; return self; } + +/// Extend frame in its @c superview and stick to right with padding. Adds @c NSViewWidthSizable to @c autoresizingMask +- (instancetype)sizeToRight:(CGFloat)rightPadding { + SetFrameWidth(self, NSWidth(self.superview.frame) - NSMinX(self.frame) - rightPadding + self.alignmentRectInsets.right); + self.autoresizingMask |= NSViewWidthSizable; + return self; +} + +/// Set @c width to @c fittingSize.width but keep original height. +- (instancetype)sizeWidthToFit { + SetFrameWidth(self, self.fittingSize.width); + return self; +} + +/// Set @c tooltip and @c accessibilityTitle of view and return self +- (instancetype)tooltip:(NSString*)tt { + self.toolTip = tt; + if (self.accessibilityLabel.length == 0) + self.accessibilityLabel = tt; + return self; +} + +/// Helper method to get y origin point (from top) while respecting @c alignmentRectInsets and view sizes +NS_INLINE void SetCenterableOrigin(NSView *view, NSView *parent, CGFloat x, CGFloat y) { + if (x == CENTER) x = (NSWidth(parent.frame) - NSWidth(view.frame)) / 2; + if (y == CENTER) y = (NSHeight(parent.frame) - NSHeight(view.frame)) / 2; + [view setFrameOrigin: NSMakePoint(x, y)]; +} + +/// Helper method to set frame width and keep same height +NS_INLINE void SetFrameWidth(NSView *view, CGFloat w) { + [view setFrameSize: NSMakeSize(w, NSHeight(view.frame))]; +} + + +#pragma mark - Debugging - + + +/// Set background color on @c .layer +- (instancetype)colorLayer:(NSColor*)color { + self.layer = [CALayer layer]; + self.layer.backgroundColor = color.CGColor; + return self; +} + ++ (NSView*)redCube:(CGFloat)size { + return [[[NSView alloc] initWithFrame: NSMakeRect(0, 0, size, size)] colorLayer:NSColor.redColor]; +} + +@end + + +#pragma mark - NSControl specific - + + +@implementation NSControl (Ext) + +/// Set @c target and @c action simultaneously +- (instancetype)action:(SEL)selector target:(id)target { + self.action = selector; + self.target = target; + return self; +} + +/// Set system font with current @c pointSize @c + @c 2. A label will be @c 19px height. +- (instancetype)large { SetFontAndResize(self, [NSFont systemFontOfSize: self.font.pointSize + 2]); return self; } + +/// Set system font with @c smallSystemFontSize and perform @c sizeToFit. A label will be @c 14px height. +- (instancetype)small { SetFontAndResize(self, [NSFont systemFontOfSize: NSFont.smallSystemFontSize]); return self; } + +/// Set monospaced font with @c labelFontSize regular and perform @c sizeToFit. A label will be @c 13px height. +- (instancetype)tiny { SetFontAndResize(self, [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightRegular]); return self; } + +/// Set system bold font with current @c pointSize +- (instancetype)bold { SetFontAndResize(self, [NSFont boldSystemFontOfSize: self.font.pointSize]); return self; } + +/// Set @c .alignment to @c NSTextAlignmentRight +- (instancetype)textRight { self.alignment = NSTextAlignmentRight; return self; } + +/// Set @c .alignment to @c NSTextAlignmentCenter +- (instancetype)textCenter { self.alignment = NSTextAlignmentCenter; return self; } + +/// Helper method to set new font, subsequently run @c sizeToFit +NS_INLINE void SetFontAndResize(NSControl *control, NSFont *font) { + control.font = font; [control sizeToFit]; +} + +@end + + +@implementation NSTextField (Ext) + +/// Set text color to @c systemGrayColor +- (instancetype)gray { self.textColor = [NSColor systemGrayColor]; return self; } + +/// Set @c .selectable to @c YES +- (instancetype)selectable { self.selectable = YES; return self; } + +@end diff --git a/baRSS/Helper/Statistics.m b/baRSS/Helper/Statistics.m deleted file mode 100644 index f761d64..0000000 --- a/baRSS/Helper/Statistics.m +++ /dev/null @@ -1,188 +0,0 @@ -// -// The MIT License (MIT) -// Copyright (c) 2019 Oleg Geier -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -// of the Software, and to permit persons to whom the Software is furnished to do -// so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -#import "Statistics.h" -#import "NSDate+Ext.h" - -@implementation Statistics - -#pragma mark - Generate Refresh Interval Statistics - -/** - @return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest} - */ -+ (NSDictionary*)refreshInterval:(NSArray *)list { - if (!list || list.count == 0) - return nil; - - NSDate *earliest = [NSDate distantFuture]; - NSDate *latest = [NSDate distantPast]; - NSDate *prev = nil; - NSMutableArray *differences = [NSMutableArray array]; - for (NSDate *d in list) { - if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull - continue; - earliest = [d earlierDate:earliest]; - latest = [d laterDate:latest]; - if (prev) { - int dif = abs((int)[d timeIntervalSinceDate:prev]); - [differences addObject:[NSNumber numberWithInt:dif]]; - } - prev = d; - } - if (differences.count == 0) - return nil; - - [differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]]; - - NSUInteger i = (differences.count/2); - NSNumber *median = differences[i]; - if ((differences.count % 2) == 0) { // even feed count, use median of two values - median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2]; - } - return @{@"min" : differences.firstObject, - @"max" : differences.lastObject, - @"avg" : [differences valueForKeyPath:@"@avg.self"], - @"median" : median, - @"earliest" : earliest, - @"latest" : latest }; -} - - -#pragma mark - Feed Statistics UI - -/** - Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date. - - @param info The dictionary generated with @c -refreshInterval: - @param count Article count. - @param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:. - If not disable button border and display as bold inline text. - @return Centered view without autoresizing. - */ -+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id)callback { - NSString *lbl = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count]; - if (!info || info.count == 0) - return [self grayLabel:lbl]; - - // Subview with 4 button (min, max, avg, median) - NSView *buttonsView = [[NSView alloc] init]; - NSPoint origin = NSZeroPoint; - for (NSString *str in @[@"min", @"max", @"avg", @"median"]) { - NSString *title = [str stringByAppendingString:@":"]; - NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback]; - [v setFrameOrigin:origin]; - [buttonsView addSubview:v]; - origin.x += NSWidth(v.frame); - } - [buttonsView setFrameSize:NSMakeSize(origin.x, NSHeight(buttonsView.subviews.firstObject.frame))]; - - // Subview with article count and latest article date - NSDate *lastUpdate = [info valueForKey:@"latest"]; - NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle]; - NSTextField *dateView = [self grayLabel:[lbl stringByAppendingFormat:@" (latest: %@)", mod]]; - - // Feed wasn't updated in a while ... - if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) { - NSMutableAttributedString *as = dateView.attributedStringValue.mutableCopy; - [as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(lbl.length, as.length - lbl.length)]; - [dateView setAttributedStringValue:as]; - } - - // Calculate offset and align both horizontally centered - CGFloat maxWidth = NSWidth(buttonsView.frame); - if (maxWidth < NSWidth(dateView.frame)) - maxWidth = NSWidth(dateView.frame); - [buttonsView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(buttonsView.frame)), 0)]; - [dateView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(dateView.frame)), NSHeight(buttonsView.frame))]; - - // Dump both into single parent view and make that view centered during resize - NSView *parent = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, maxWidth, NSMaxY(dateView.frame))]; - parent.autoresizingMask = NSViewMinXMargin | NSViewMaxXMargin;// | NSViewMinYMargin | NSViewMaxYMargin; - parent.autoresizesSubviews = NO; -// parent.layer = [CALayer layer]; -// parent.layer.backgroundColor = [NSColor systemYellowColor].CGColor; - [parent addSubview:dateView]; - [parent addSubview:buttonsView]; - return parent; -} - -/** - Create view with duration button, e.g., '3.4h' and label infornt of it. - */ -+ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id)callback { - static const int buttonPadding = 5; - NSButton *button = [self grayInlineButton:value]; - if (callback) { - button.target = callback; - button.action = @selector(refreshIntervalButtonClicked:); - } else { - button.bordered = NO; - button.enabled = NO; - } - NSTextField *label; - if (title && title.length > 0) { - label = [self grayLabel:title]; - [label setFrameOrigin:NSMakePoint(0, button.alignmentRectInsets.bottom + 0.5f*(NSHeight(button.frame) - NSHeight(label.frame)))]; - } - [button setFrameOrigin:NSMakePoint(NSWidth(label.frame), 0)]; - - CGFloat maxHeight = NSHeight(button.frame); - if (maxHeight < NSHeight(label.frame)) - maxHeight = NSHeight(label.frame); - - NSView *parent = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, NSMaxX(button.frame) + buttonPadding, maxHeight + buttonPadding)]; - [parent addSubview:label]; - [parent addSubview:button]; - return parent; -} - -/** - @return Rounded, gray inline button with tag equal to refresh interval. - */ -+ (NSButton*)grayInlineButton:(NSNumber*)num { - NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil]; - button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold]; - button.bezelStyle = NSBezelStyleInline; - button.controlSize = NSControlSizeSmall; - TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES]; - button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval - [button sizeToFit]; - return button; -} - -/** - @return Simple Label with smaller gray text, non-editable. - */ -+ (NSTextField*)grayLabel:(NSString*)text { - NSTextField *label = [NSTextField textFieldWithString:text]; - label.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightRegular]; - label.textColor = [NSColor systemGrayColor]; - label.drawsBackground = NO; - label.selectable = NO; - label.editable = NO; - label.bezeled = NO; - [label sizeToFit]; - return label; -} - -@end diff --git a/baRSS/Info.plist b/baRSS/Info.plist index b3bfdf2..bf2575a 100644 --- a/baRSS/Info.plist +++ b/baRSS/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 1208 + 7288 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/baRSS/Preferences/About Tab/SettingsAbout.h b/baRSS/Preferences/About Tab/SettingsAbout.h new file mode 100644 index 0000000..40c2532 --- /dev/null +++ b/baRSS/Preferences/About Tab/SettingsAbout.h @@ -0,0 +1,26 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@interface SettingsAbout : NSViewController +@end diff --git a/baRSS/Preferences/About Tab/SettingsAbout.m b/baRSS/Preferences/About Tab/SettingsAbout.m new file mode 100644 index 0000000..58d651a --- /dev/null +++ b/baRSS/Preferences/About Tab/SettingsAbout.m @@ -0,0 +1,32 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsAbout.h" +#import "SettingsAboutView.h" + +@implementation SettingsAbout + +- (void)loadView { + self.view = [SettingsAboutView new]; +} + +@end diff --git a/baRSS/Preferences/About Tab/SettingsAboutView.h b/baRSS/Preferences/About Tab/SettingsAboutView.h new file mode 100644 index 0000000..da11dac --- /dev/null +++ b/baRSS/Preferences/About Tab/SettingsAboutView.h @@ -0,0 +1,27 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@interface SettingsAboutView : NSView +@end + diff --git a/baRSS/Preferences/About Tab/SettingsAboutView.m b/baRSS/Preferences/About Tab/SettingsAboutView.m new file mode 100644 index 0000000..597492e --- /dev/null +++ b/baRSS/Preferences/About Tab/SettingsAboutView.m @@ -0,0 +1,87 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsAboutView.h" +#import "NSView+Ext.h" + +@implementation SettingsAboutView + +- (instancetype)init { + self = [super initWithFrame: NSZeroRect]; + NSDictionary *infoDict = [[NSBundle mainBundle] infoDictionary]; + NSString *name = infoDict[@"CFBundleName"]; + NSString *version = [NSString stringWithFormat:NSLocalizedString(@"Version %@", nil), infoDict[@"CFBundleShortVersionString"]]; +#if DEBUG + version = [version stringByAppendingFormat:@" (%@)", infoDict[@"CFBundleVersion"]]; +#endif + + // Application icon image (top-centered) + NSImageView *logo = [[NSView imageView:NSImageNameApplicationIcon size:64] placeIn:self x:CENTER yTop:PAD_M]; + // Add app name + NSTextField *lblN = [[[[NSView label:name] large] bold] placeIn:self x:CENTER yTop: YFromTop(logo) + PAD_M]; + // Add version info + NSTextField *lblV = [[[[NSView label:version] small] selectable] placeIn:self x:CENTER yTop: YFromTop(lblN) + PAD_S]; + + // Add rtf document + NSTextView *tv = [[NSTextView new] sizableWidthAndHeight]; + tv.textContainerInset = NSMakeSize(0, 15); + tv.alignment = NSTextAlignmentCenter; + tv.editable = NO; // but selectable + [tv.textStorage setAttributedString:[self rtfDocument]]; + [self wrapContent:tv inScrollView:NSMakeRect(-1, 20, NSWidth(self.frame) + 2, NSMinY(lblV.frame) - PAD_M - 20)]; + return self; +} + +/// Construct attributed string by concatenating snippets of text. +- (NSMutableAttributedString*)rtfDocument { + NSMutableAttributedString *mas = [NSMutableAttributedString new]; + [mas beginEditing]; + [self str:mas add:@"Programming\n" bold:YES]; + [self str:mas add:@"Oleg Geier\n\n" bold:NO]; + [self str:mas add:@"Source Code available\n" bold:YES]; + [self str:mas add:@"github.com" link:@"https://github.com/relikd/baRSS"]; + [self str:mas add:@" (MIT License)\nor " bold:NO]; + [self str:mas add:@"gitlab.com" link:@"https://gitlab.com/relikd/baRSS"]; + [self str:mas add:@" (MIT License)\n\n" bold:NO]; + [self str:mas add:@"3rd-Party Libraries\n" bold:YES]; + [self str:mas add:@"RSXML" link:@"https://github.com/relikd/RSXML"]; + [self str:mas add:@" (MIT License)" bold:NO]; + [mas endEditing]; + return mas; +} + +/// Helper method to insert attributed (bold) text +- (void)str:(NSMutableAttributedString*)parent add:(NSString*)text bold:(BOOL)flag { + NSFont *font = [NSFont systemFontOfSize:NSFont.systemFontSize weight:(flag ? NSFontWeightMedium : NSFontWeightLight)]; + [parent appendAttributedString:[[NSAttributedString alloc] initWithString:NonLocalized(text) attributes:@{ NSFontAttributeName : font }]]; +} + +/// Helper method to insert attributed hyperlink text +- (void)str:(NSMutableAttributedString*)parent add:(NSString*)text link:(NSString*)url { + [self str:parent add:text bold:NO]; + [parent addAttribute:NSLinkAttributeName value:url range:NSMakeRange(parent.length - text.length, text.length)]; +} + +__attribute__((annotate("returns_localized_nsstring"))) +static inline NSString *NonLocalized(NSString *s) { return s; } + +@end diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearance.h b/baRSS/Preferences/Appearance Tab/SettingsAppearance.h new file mode 100644 index 0000000..8c516f8 --- /dev/null +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearance.h @@ -0,0 +1,27 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@interface SettingsAppearance : NSViewController +- (void)didSelectCheckbox:(NSButton*)sender; +@end diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearance.m b/baRSS/Preferences/Appearance Tab/SettingsAppearance.m new file mode 100644 index 0000000..c67da71 --- /dev/null +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearance.m @@ -0,0 +1,53 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsAppearance.h" +#import "SettingsAppearanceView.h" +#import "AppHook.h" +#import "BarStatusItem.h" + + +@implementation SettingsAppearance + +- (void)loadView { + self.view = [SettingsAppearanceView new]; + for (NSButton *button in self.view.subviews) { + if ([button isKindOfClass:[NSButton class]]) { // for all checkboxes + [button setAction:@selector(didSelectCheckbox:)]; + [button setTarget:self]; + } + } +} + +#pragma mark - Checkbox Callback Method + +/// Sync new value with UserDefaults and update status bar icon +- (void)didSelectCheckbox:(NSButton*)sender { + BOOL state = (sender.state == NSControlStateValueOn); + [[NSUserDefaults standardUserDefaults] setBool:state forKey:sender.identifier]; + if ([sender.identifier isEqualToString:@"globalUnreadCount"] || + [sender.identifier isEqualToString:@"globalTintMenuBarIcon"]) { + [[(AppHook*)NSApp statusItem] updateBarIcon]; + } +} + +@end diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h new file mode 100644 index 0000000..046e2b5 --- /dev/null +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h @@ -0,0 +1,29 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@class SettingsAppearance; + +@interface SettingsAppearanceView : NSView +@end + diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m new file mode 100644 index 0000000..ee775b8 --- /dev/null +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m @@ -0,0 +1,88 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsAppearanceView.h" +#import "NSView+Ext.h" +#import "DrawImage.h" +#import "UserPrefs.h" + +@interface SettingsAppearanceView() +@property (assign) NSUInteger row; +@end + + +/***/ static const CGFloat IconSize = 18; +/***/ static const CGFloat colWidth = (IconSize + PAD_M); // checkbox column width + + +@implementation SettingsAppearanceView + +- (instancetype)init { + self = [super initWithFrame: NSZeroRect]; + self.row = 0; + // Insert matrix header (the three icons) + [self head:0 tooltip:NSLocalizedString(@"Show in menu bar", nil) class:[SettingsIconGlobal class]]; + [self head:1 tooltip:NSLocalizedString(@"Show in group menu", nil) class:[SettingsIconGroup class]]; + [self head:2 tooltip:NSLocalizedString(@"Show in feed menu", nil) class:[RSSIcon class]]; + // Generate checkbox matrix (checkbox state, X: default ON, O: default OFF, blank: hidden) + [self entry:"X " label:NSLocalizedString(@"Tint menu bar icon on unread", nil)]; + [self entry:"X " label:NSLocalizedString(@"Update all feeds", nil)]; + [self entry:"XXX" label:NSLocalizedString(@"Open all unread", nil)]; + [self entry:"XXX" label:NSLocalizedString(@"Mark all read", nil)]; + [self entry:"XXX" label:NSLocalizedString(@"Mark all unread", nil)]; + [self entry:"XXX" label:NSLocalizedString(@"Number of unread items", nil)]; + [self entry:" X" label:NSLocalizedString(@"Tick mark unread items", nil)]; + [[self entry:" O" label:NSLocalizedString(@"Short article names", nil)] tooltip:NSLocalizedString(@"Truncate article title after 60 characters", nil)]; + [[self entry:" O" label:NSLocalizedString(@"Limit number of articles", nil)] tooltip:NSLocalizedString(@"Display at most 40 articles in feed menu", nil)]; + return self; +} + +/// Helper method for matrix table header icons +- (void)head:(int)x tooltip:(NSString*)ttip class:(Class)cls { + [[[[cls alloc] initWithFrame:NSMakeRect(0, 0, IconSize, IconSize)] tooltip:ttip] placeIn:self x:PAD_WIN + x * colWidth yTop:PAD_WIN]; +} + +/// Create new entry with 1-3 checkboxes and a descriptive label +- (NSTextField*)entry:(char*)m label:(NSString*)text { + static const char* scope[] = { "global", "group", "feed" }; + static const char* ident[] = { "TintMenuBarIcon", "UpdateAll", "OpenUnread", "MarkRead", "MarkUnread", "UnreadCount", "TickMark", "ShortNames", "LimitArticles" }; + CGFloat y = PAD_WIN + IconSize + PAD_S + self.row * (PAD_S + HEIGHT_LABEL); + + // Add checkboxes: row 0 - 8, col 0 - 2 + for (NSUInteger col = 0; col < 3; col++) { + NSString *key = [NSString stringWithFormat:@"%s%s", scope[col], ident[self.row]]; + BOOL state; + switch (m[col]) { + case 'X': state = [UserPrefs defaultYES:key]; break; + case 'O': state = [UserPrefs defaultNO: key]; break; + default: continue; // ignore blanks + } + NSButton *check = [[NSView checkbox:state] placeIn:self x:PAD_WIN + col * colWidth + 2 yTop:y + 2]; // 2px checkbox offset + check.identifier = key; + check.accessibilityLabel = [text stringByAppendingFormat:@" (%s)", scope[col]]; // TODO: localize: global, group, feed + } + self.row += 1; + // Add label + return [[[NSView label:text] placeIn:self x:PAD_WIN + 3 * colWidth yTop:y] sizeToRight:PAD_WIN]; +} + +@end diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h index 9af1928..b78fc04 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.h @@ -33,6 +33,7 @@ @interface ModalFeedEdit : ModalEditDialog +- (void)didClickWarningButton:(NSButton*)sender; @end @interface ModalGroupEdit : ModalEditDialog diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m index 997fe86..590b708 100644 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.m @@ -26,8 +26,10 @@ #import "Feed+Ext.h" #import "FeedMeta+Ext.h" #import "FeedGroup+Ext.h" -#import "Statistics.h" +#import "ModalFeedEditView.h" +#import "RefreshStatisticsView.h" #import "NSDate+Ext.h" +#import "NSView+Ext.h" #pragma mark - ModalEditDialog - @@ -65,36 +67,28 @@ @interface ModalFeedEdit() -@property (weak) IBOutlet NSTextField *url; -@property (weak) IBOutlet NSTextField *name; -@property (weak) IBOutlet NSTextField *refreshNum; -@property (weak) IBOutlet NSPopUpButton *refreshUnit; -@property (weak) IBOutlet NSProgressIndicator *spinnerURL; -@property (weak) IBOutlet NSProgressIndicator *spinnerName; -@property (weak) IBOutlet NSButton *warningIndicator; -@property (weak) IBOutlet NSPopover *warningPopover; -@property (strong) NSView *statisticsView; +@property (strong) IBOutlet ModalFeedEditView *view; // override + +@property (strong) RefreshStatisticsView *statisticsView; @property (copy) NSString *previousURL; // check if changed and avoid multiple download @property (copy) NSString *httpDate; @property (copy) NSString *httpEtag; @property (copy) NSString *faviconURL; -@property (strong) NSImage *favicon; @property (strong) NSError *feedError; // download error or xml parser error @property (strong) RSParsedFeed *feedResult; // parsed result @property (assign) BOOL didDownloadFeed; // check if feed articles need update @end @implementation ModalFeedEdit +@dynamic view; /// Init feed edit dialog with default values. -- (void)viewDidLoad { - [super viewDidLoad]; +- (void)loadView { + self.view = [[ModalFeedEditView alloc] initWithController:self]; self.previousURL = @""; - self.refreshNum.intValue = 30; - [NSDate populateUnitsMenu:self.refreshUnit selected:TimeUnitMinutes]; - self.warningIndicator.image = nil; - [self.warningIndicator.cell setHighlightsBy:NSNoCellMask]; + self.view.refreshNum.intValue = 30; + [NSDate populateUnitsMenu:self.view.refreshUnit selected:TimeUnitMinutes]; [self populateTextFields:self.feedGroup]; } @@ -103,11 +97,11 @@ */ - (void)populateTextFields:(FeedGroup*)fg { if (!fg || [fg hasChanges]) return; // hasChanges is true only if newly created - self.name.objectValue = fg.name; - self.url.objectValue = fg.feed.meta.url; - self.previousURL = self.url.stringValue; - self.warningIndicator.image = [fg.feed iconImage16]; - [NSDate setInterval:fg.feed.meta.refresh forPopup:self.refreshUnit andField:self.refreshNum animate:NO]; + self.view.name.objectValue = fg.name; + self.view.url.objectValue = fg.feed.meta.url; + self.previousURL = self.view.url.stringValue; + self.view.favicon.image = [fg.feed iconImage16]; + [NSDate setInterval:fg.feed.meta.refresh forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:NO]; [self statsForCoreDataObject]; } @@ -119,15 +113,15 @@ */ - (void)applyChangesToCoreDataObject { Feed *feed = self.feedGroup.feed; - [self.feedGroup setNameIfChanged:self.name.stringValue]; + [self.feedGroup setNameIfChanged:self.view.name.stringValue]; FeedMeta *meta = feed.meta; [meta setUrlIfChanged:self.previousURL]; - [meta setRefreshAndSchedule:[NSDate intervalForPopup:self.refreshUnit andField:self.refreshNum]]; + [meta setRefreshAndSchedule:[NSDate intervalForPopup:self.view.refreshUnit andField:self.view.refreshNum]]; // updateTimer will be scheduled once preferences is closed if (self.didDownloadFeed) { [meta setEtag:self.httpEtag modified:self.httpDate]; [feed updateWithRSS:self.feedResult postUnreadCountChange:YES]; - [feed setIconImage:self.favicon]; + [feed setIconImage:self.view.favicon.image]; } } @@ -137,20 +131,20 @@ */ - (void)preDownload { [self.modalSheet setDoneEnabled:NO]; // prevent user from closing the dialog during download - [self.spinnerURL startAnimation:nil]; - [self.spinnerName startAnimation:nil]; - self.warningIndicator.image = nil; + [self.view.spinnerURL startAnimation:nil]; + [self.view.spinnerName startAnimation:nil]; + self.view.favicon.image = nil; + self.view.warningButton.hidden = YES; self.didDownloadFeed = NO; // Assuming the user has not changed title since the last fetch. // Reset to "" because after download it will be pre-filled with new feed title - if ([self.name.stringValue isEqualToString:self.feedResult.title]) { - self.name.stringValue = @""; + if ([self.view.name.stringValue isEqualToString:self.feedResult.title]) { + self.view.name.stringValue = @""; } self.feedResult = nil; self.feedError = nil; self.httpEtag = nil; self.httpDate = nil; - self.favicon = nil; self.faviconURL = nil; } @@ -192,8 +186,8 @@ for (RSHTMLMetadataFeedLink *fl in list) { [menu addItemWithTitle:fl.title action:nil keyEquivalent:@""]; } - NSPoint belowURL = NSMakePoint(0,self.url.frame.size.height); - if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.url]) { + NSPoint belowURL = NSMakePoint(0, NSHeight(self.view.url.frame)); + if ([menu popUpMenuPositioningItem:nil atLocation:belowURL inView:self.view.url]) { NSInteger idx = [menu indexOfItem:menu.highlightedItem]; if (idx < 0) idx = 0; // User hit enter without selection. Assume first item, because PopUpMenu did return YES! return [list objectAtIndex:(NSUInteger)idx].link; @@ -210,22 +204,25 @@ if (self.modalSheet.didCloseAndCancel) return; // 1. Stop spinner animation for name field. (keep spinner for URL running until favicon downloaded) - [self.spinnerName stopAnimation:nil]; + [self.view.spinnerName stopAnimation:nil]; // 2. If URL was redirected, replace original text field value with new one. (e.g., https redirect) if (responseURL.length > 0 && ![responseURL isEqualToString:self.previousURL]) { self.previousURL = responseURL; - self.url.stringValue = responseURL; + self.view.url.stringValue = responseURL; } // 3. Copy parsed feed title to text field. (only if user hasn't set anything else yet) NSString *parsedTitle = self.feedResult.title; - if (parsedTitle.length > 0 && [self.name.stringValue isEqualToString:@""]) { - self.name.stringValue = parsedTitle; // no damage to replace an empty string + if (parsedTitle.length > 0 && [self.view.name.stringValue isEqualToString:@""]) { + self.view.name.stringValue = parsedTitle; // no damage to replace an empty string } // TODO: user preference to automatically select refresh interval (selection: None,min,max,avg,median) [self statsForDownloadObject]; // 4. Continue with favicon download (or finish with error) - if (self.feedError) { - [self finishDownloadWithFavicon:[NSImage imageNamed:NSImageNameCaution]]; + BOOL hasError = (self.feedError != nil); + self.view.favicon.hidden = hasError; + self.view.warningButton.hidden = !hasError; + if (hasError) { + [self finishDownloadWithFavicon]; } else { if (!self.faviconURL) self.faviconURL = self.feedResult.link; @@ -234,8 +231,8 @@ [FeedDownload downloadFavicon:self.faviconURL finished:^(NSImage * _Nullable img) { if (self.modalSheet.didCloseAndCancel) return; - self.favicon = img; - [self finishDownloadWithFavicon:img]; + self.view.favicon.image = img; + [self finishDownloadWithFavicon]; }]; } } @@ -244,12 +241,10 @@ The last step of the download process. Stop spinning animation set favivon image preview (right of url bar) and re-enable 'Done' button. */ -- (void)finishDownloadWithFavicon:(NSImage*)img { +- (void)finishDownloadWithFavicon { if (self.modalSheet.didCloseAndCancel) return; - [self.warningIndicator.cell setHighlightsBy: (self.feedError ? NSContentsCellMask : NSNoCellMask)]; - self.warningIndicator.image = img; - [self.spinnerURL stopAnimation:nil]; + [self.view.spinnerURL stopAnimation:nil]; [self.modalSheet setDoneEnabled:YES]; } @@ -275,24 +270,22 @@ /// Generate statistics UI with buttons to quickly select refresh unit and duration. - (void)appendViewWithFeedStatistics:(NSArray*)dates count:(NSUInteger)count { - static const CGFloat statsPadding = 15.f; CGFloat prevHeight = 0.f; if (self.statisticsView != nil) { - prevHeight = self.statisticsView.frame.size.height + statsPadding; + prevHeight = NSHeight(self.statisticsView.frame) + PAD_L; [self.statisticsView removeFromSuperview]; self.statisticsView = nil; } - NSDictionary *stats = [Statistics refreshInterval:dates]; - NSView *v = [Statistics viewForRefreshInterval:stats articleCount:count callback:self]; - [[self getModalSheet] extendContentViewBy:v.frame.size.height + statsPadding - prevHeight]; - [v setFrameOrigin:NSMakePoint(0.5f*(NSWidth(self.view.frame) - NSWidth(v.frame)), 0)]; - [self.view addSubview:v]; - self.statisticsView = v; + + NSDictionary *stats = [NSDate refreshIntervalStatistics:dates]; + RefreshStatisticsView *rsv = [[RefreshStatisticsView alloc] initWithRefreshInterval:stats articleCount:count callback:self]; + [[self getModalSheet] extendContentViewBy:NSHeight(rsv.frame) + PAD_L - prevHeight]; + self.statisticsView = [rsv placeIn:self.view x:CENTER y:0]; } -/// Callback method for @c Statistics @c +viewForRefreshInterval:articleCount:callback: +/// Callback method @c RefreshStatisticsView - (void)refreshIntervalButtonClicked:(NSButton *)sender { - [NSDate setInterval:(Interval)sender.tag forPopup:self.refreshUnit andField:self.refreshNum animate:YES]; + [NSDate setInterval:(Interval)sender.tag forPopup:self.view.refreshUnit andField:self.view.refreshNum animate:YES]; } @@ -301,8 +294,8 @@ /// Window delegate will be only called on button 'Done'. - (BOOL)windowShouldClose:(NSWindow *)sender { - if (![self.previousURL isEqualToString:self.url.stringValue]) { - [[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.url]; + if (![self.previousURL isEqualToString:self.view.url.stringValue]) { + [[NSNotificationCenter defaultCenter] postNotificationName:NSControlTextDidEndEditingNotification object:self.view.url]; return NO; } return YES; @@ -310,30 +303,26 @@ /// Whenever the user finished entering the url (return key or focus change) perform a download request. - (void)controlTextDidEndEditing:(NSNotification *)obj { - if (obj.object == self.url) { - if (![self.previousURL isEqualToString:self.url.stringValue]) { - self.previousURL = self.url.stringValue; + if (obj.object == self.view.url) { + if (![self.previousURL isEqualToString:self.view.url.stringValue]) { + self.previousURL = self.view.url.stringValue; [self downloadRSS]; } } } /// Warning button next to url text field. Will be visible if an error occurs during download. -- (IBAction)didClickWarningButton:(NSButton*)sender { +- (void)didClickWarningButton:(NSButton*)sender { if (!self.feedError) return; - NSString *str = self.feedError.localizedDescription; - NSTextField *tf = self.warningPopover.contentViewController.view.subviews.firstObject; - tf.maximumNumberOfLines = 7; - tf.objectValue = str; + self.view.warningText.objectValue = self.feedError.localizedDescription; + NSSize newSize = self.view.warningText.fittingSize; // width is limited by the textfield's preferred width + newSize.width += 2 * self.view.warningText.frame.origin.x; // the padding + newSize.height += 2 * self.view.warningText.frame.origin.y; - NSSize newSize = tf.fittingSize; // width is limited by the textfield's preferred width - newSize.width += 2 * tf.frame.origin.x; // the padding - newSize.height += 2 * tf.frame.origin.y; - - [self.warningPopover showRelativeToRect:sender.bounds ofView:sender preferredEdge:NSRectEdgeMinY]; - [self.warningPopover setContentSize:newSize]; + self.view.warningPopover.contentSize = newSize; + [self.view.warningPopover showRelativeToRect:sender.bounds ofView:sender preferredEdge:NSRectEdgeMinY]; } @end @@ -351,41 +340,10 @@ } /// Set one single @c NSTextField as entire view. Populate with default value and placeholder. - (void)loadView { - NSTextField *tf = [NSTextField textFieldWithString:NSLocalizedString(@"New Group", nil)]; - tf.placeholderString = NSLocalizedString(@"New Group", nil); - tf.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; - self.view = tf; + self.view = [[NSView inputField:NSLocalizedString(@"New Group Name", nil) width:0] sizeToRight:0]; } /// Edit of group finished. Save changes to core data object and perform save operation on delegate. - (void)applyChangesToCoreDataObject { [self.feedGroup setNameIfChanged:((NSTextField*)self.view).stringValue]; } @end - - -#pragma mark - StrictUIntFormatter - - - -@interface StrictUIntFormatter : NSFormatter -@end - -@implementation StrictUIntFormatter -/// Display object as integer formatted string. -- (NSString *)stringForObjectValue:(id)obj { - return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]]; -} -/// Parse any pasted input as integer. -- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error { - *obj = [[NSNumber numberWithInt:[string intValue]] stringValue]; - return YES; -} -/// Only digits, no other character allowed -- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error { - for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) { - unichar c = [*partialStringPtr characterAtIndex:i]; - if (c < '0' || c > '9') - return NO; - } - return YES; -} -@end diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib b/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib deleted file mode 100644 index caea2dd..0000000 --- a/baRSS/Preferences/Feeds Tab/ModalFeedEdit.xib +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Couldn't load Feed -An additional line -and a third - - - - - - - - - - - - diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h new file mode 100644 index 0000000..bbd58bc --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.h @@ -0,0 +1,45 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@class ModalFeedEdit; + +@interface ModalFeedEditView : NSView +@property (weak) IBOutlet NSTextField *url; +@property (weak) IBOutlet NSProgressIndicator *spinnerURL; +@property (weak) IBOutlet NSImageView *favicon; + +@property (weak) IBOutlet NSTextField *name; +@property (weak) IBOutlet NSProgressIndicator *spinnerName; + +@property (weak) IBOutlet NSTextField *refreshNum; +@property (weak) IBOutlet NSPopUpButton *refreshUnit; + +@property (weak) IBOutlet NSButton *warningButton; +@property NSPopover *warningPopover; +@property (weak) IBOutlet NSTextField *warningText; + +- (instancetype)initWithController:(ModalFeedEdit*)controller NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; +- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE; +@end diff --git a/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m new file mode 100644 index 0000000..bf87bd9 --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/ModalFeedEditView.m @@ -0,0 +1,120 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "ModalFeedEditView.h" +#import "ModalFeedEdit.h" +#import "NSView+Ext.h" + +@interface StrictUIntFormatter : NSFormatter +@end + +@implementation ModalFeedEditView + +- (instancetype)initWithController:(ModalFeedEdit*)controller { + NSArray *lbls = @[NSLocalizedString(@"URL", nil), + NSLocalizedString(@"Name", nil), + NSLocalizedString(@"Refresh", nil)]; + NSView *labels = [NSView labelColumn:lbls rowHeight:HEIGHT_INPUTFIELD padding:PAD_S]; + + + self = [super initWithFrame:NSMakeRect(0, 0, 0, NSHeight(labels.frame))]; + self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + + CGFloat x = NSWidth(labels.frame) + PAD_S; + static const CGFloat rowHeight = PAD_S + HEIGHT_INPUTFIELD; + [labels placeIn:self x:0 yTop:0]; + + // 1. row + self.url = [[[NSView inputField:@"https://example.org/feed.rss" width:0] placeIn:self x:x yTop:0] sizeToRight:PAD_S + 18]; + self.spinnerURL = [[NSView activitySpinner] placeIn:self xRight:1 yTop:2.5]; + self.favicon = [[[NSView imageView:nil size:18] tooltip:NSLocalizedString(@"Favicon", nil)] placeIn:self xRight:0 yTop:1.5]; + NSTextField *errorDesc = [self warningPopoverContentView]; + self.warningPopover = [self warningPopoverControllerWith:errorDesc]; + self.warningText = errorDesc; // after added to parent view, otherwise will be released immediatelly (weak ivar) + self.warningButton = [[[[NSView buttonIcon:[NSImage imageNamed:NSImageNameCaution] size:18] action:@selector(didClickWarningButton:) target:nil] // up the responder chain + tooltip:NSLocalizedString(@"Click here to show failure reason", nil)] + placeIn:self xRight:0 yTop:1.5]; + // 2. row + self.name = [[[NSView inputField:NSLocalizedString(@"Example Title", nil) width:0] placeIn:self x:x yTop:rowHeight] sizeToRight:PAD_S + 18]; + self.spinnerName = [[NSView activitySpinner] placeIn:self xRight:1 yTop:rowHeight + 2.5]; + // 3. row + self.refreshNum = [[NSView inputField:@"30" width:85] placeIn:self x:x yTop:2*rowHeight]; + self.refreshUnit = [[NSView popupButton:120] placeIn:self x:NSMaxX(self.refreshNum.frame) + PAD_M yTop:2*rowHeight]; + + // initial state + self.url.delegate = controller; + self.warningButton.hidden = YES; + self.refreshNum.formatter = [StrictUIntFormatter new]; // see below ... + //[self.warningButton.cell setHighlightsBy:(error ? NSContentsCellMask : NSNoCellMask)]; + return self; +} + +/// User visible error description text (after click on warning button) +- (NSTextField*)warningPopoverContentView { + NSTextField *txt = [[[NSView label:@""] selectable] sizableWidthAndHeight]; + [txt setFrameSize: NSMakeSize(300, 100)]; + txt.lineBreakMode = NSLineBreakByWordWrapping; + txt.maximumNumberOfLines = 7; + return txt; +} + +/// Prepare popover controller to display download errors +- (NSPopover*)warningPopoverControllerWith:(NSTextField*)content { + NSPopover *pop = [[NSPopover alloc] init]; + pop.behavior = NSPopoverBehaviorTransient; + pop.contentViewController = [[NSViewController alloc] init]; + + pop.contentViewController.view = [[NSView alloc] initWithFrame:content.frame]; + [pop.contentViewController.view addSubview:content]; + + content.frame = NSInsetRect(content.frame, 4, 2); + content.preferredMaxLayoutWidth = NSWidth(content.frame); + return pop; +} + +@end + + +#pragma mark - StrictUIntFormatter - + + +@implementation StrictUIntFormatter +/// Display object as integer formatted string. +- (NSString *)stringForObjectValue:(id)obj { + return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]]; +} +/// Parse any pasted input as integer. +- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error { + *obj = [[NSNumber numberWithInt:[string intValue]] stringValue]; + return YES; +} +/// Only digits, no other character allowed +- (BOOL)isPartialStringValid:(NSString *__autoreleasing _Nonnull *)partialStringPtr proposedSelectedRange:(NSRangePointer)proposedSelRangePtr originalString:(NSString *)origString originalSelectedRange:(NSRange)origSelRange errorDescription:(NSString *__autoreleasing _Nullable *)error { + for (NSUInteger i = 0; i < [*partialStringPtr length]; i++) { + unichar c = [*partialStringPtr characterAtIndex:i]; + if (c < '0' || c > '9') + return NO; + } + return YES; +} +@end + diff --git a/baRSS/Preferences/Feeds Tab/OpmlExport.m b/baRSS/Preferences/Feeds Tab/OpmlExport.m index ecbeab1..7cf5824 100644 --- a/baRSS/Preferences/Feeds Tab/OpmlExport.m +++ b/baRSS/Preferences/Feeds Tab/OpmlExport.m @@ -26,6 +26,8 @@ #import "StoreCoordinator.h" #import "FeedDownload.h" #import "Constants.h" +#import "NSDate+Ext.h" +#import "NSView+Ext.h" @implementation OpmlExport @@ -54,12 +56,12 @@ /// Display Save File Panel to select export destination. All feeds from core data will be exported. + (void)showExportDialog:(NSWindow*)window withContext:(NSManagedObjectContext*)moc { NSSavePanel *sp = [NSSavePanel savePanel]; - sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [self currentDayAsStringISO8601:NO]]; + sp.nameFieldStringValue = [NSString stringWithFormat:@"baRSS feeds %@", [NSDate dayStringLocalized]]; sp.allowedFileTypes = @[@"opml"]; sp.allowsOtherFileTypes = YES; - NSView *radioView = [self radioGroupCreate:@[NSLocalizedString(@"Hierarchical", nil), - NSLocalizedString(@"Flattened", nil)]]; - sp.accessoryView = [self viewByPrependingLabel:NSLocalizedString(@"Export format:", nil) toView:radioView]; + NSView *radioView = [NSView radioGroup:@[NSLocalizedString(@"Hierarchical", nil), + NSLocalizedString(@"Flattened", nil)]]; + sp.accessoryView = [NSView wrapView:radioView withLabel:NSLocalizedString(@"Export format:", nil) padding:PAD_M]; [sp beginSheetModalForWindow:window completionHandler:^(NSModalResponse result) { if (result == NSModalResponseOK) { @@ -94,8 +96,8 @@ alert.informativeText = NSLocalizedString(@"Do you want to append or replace existing items?", nil); [alert addButtonWithTitle:NSLocalizedString(@"Import", nil)]; [alert addButtonWithTitle:NSLocalizedString(@"Cancel", nil)]; - alert.accessoryView = [self radioGroupCreate:@[NSLocalizedString(@"Append", nil), - NSLocalizedString(@"Overwrite", nil)]]; + alert.accessoryView = [NSView radioGroup:@[NSLocalizedString(@"Append", nil), + NSLocalizedString(@"Overwrite", nil)]]; if ([alert runModal] == NSAlertFirstButtonReturn) { return [self radioGroupSelection:alert.accessoryView]; @@ -196,7 +198,7 @@ NSXMLElement *head = [NSXMLElement elementWithName:@"head"]; head.children = @[[NSXMLElement elementWithName:@"title" stringValue:@"baRSS feeds"], [NSXMLElement elementWithName:@"ownerName" stringValue:@"baRSS"], - [NSXMLElement elementWithName:@"dateCreated" stringValue:[self currentDayAsStringISO8601:YES]] ]; + [NSXMLElement elementWithName:@"dateCreated" stringValue:[NSDate dayStringISO8601]] ]; NSXMLElement *body = [NSXMLElement elementWithName:@"body"]; for (FeedGroup *item in list) { @@ -247,15 +249,6 @@ #pragma mark - Helper -/// @param flag If @c YES use long internet format for opml file. If @c NO use short format as filename. -+ (NSString*)currentDayAsStringISO8601:(BOOL)flag { - if (flag) - return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]]; -// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]]; -// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day]; - return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle]; -} - /// Count items where @c xmlURL key is set. + (NSUInteger)recursiveNumberOfFeeds:(RSOPMLItem*)document { if ([document attributeForKey:OPMLXMLURLKey]) { @@ -269,34 +262,6 @@ } } -/// Solely used to group radio buttons -+ (void)donothing {} - -/// Create a new view with as many @c NSRadioButton items as there are strings. Buttons @c tag is equal to the array index. -+ (NSView*)radioGroupCreate:(NSArray*)titles { - if (titles.count == 0) - return nil; - - NSRect viewRect = NSMakeRect(0, 0, 0, 8); - NSInteger idx = (NSInteger)titles.count; - NSView *v = [[NSView alloc] init]; - for (NSString *title in titles.reverseObjectEnumerator) { - idx -= 1; - NSButton *btn = [NSButton radioButtonWithTitle:title target:self action:@selector(donothing)]; - btn.tag = idx; - btn.frame = NSOffsetRect(btn.frame, 0, viewRect.size.height); - viewRect.size.height += btn.frame.size.height + 2; // 2px padding - if (viewRect.size.width < btn.frame.size.width) - viewRect.size.width = btn.frame.size.width; - [v addSubview:btn]; - if (idx == 0) - btn.state = NSControlStateValueOn; - } - viewRect.size.height += 6; // 8 - 2px padding - v.frame = viewRect; - return v; -} - /// Loop over all subviews and find the @c NSButton that is selected. + (NSInteger)radioGroupSelection:(NSView*)view { for (NSButton *btn in view.subviews) { @@ -307,25 +272,4 @@ return -1; } -/// @return New view with @c NSTextField label in the top left corner and @c radioView on the right side. -+ (NSView*)viewByPrependingLabel:(NSString*)str toView:(NSView*)radioView { - NSTextField *label = [NSTextField textFieldWithString:str]; - label.editable = NO; - label.selectable = NO; - label.bezeled = NO; - label.drawsBackground = NO; - - NSRect fL = label.frame; - NSRect fR = radioView.frame; - fL.origin.y += fR.size.height - fL.size.height - 8; - fR.origin.x += fL.size.width; - label.frame = fL; - radioView.frame = fR; - - NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, NSMaxX(fR), NSMaxY(fR))]; - [view addSubview:label]; - [view addSubview:radioView]; - return view; -} - @end diff --git a/baRSS/Helper/Statistics.h b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.h similarity index 75% rename from baRSS/Helper/Statistics.h rename to baRSS/Preferences/Feeds Tab/RefreshStatisticsView.h index ddd49a9..c790379 100644 --- a/baRSS/Helper/Statistics.h +++ b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.h @@ -24,15 +24,13 @@ @protocol RefreshIntervalButtonDelegate @required -/** - The interval-unit combination is stored as follows: - :: @c sender.tag @c >> @c 3 (Refresh Interval) - :: @c sender.tag @c & @c 0x7 (Refresh Unit, where 0: seconds and 4: weeks) - */ +/// @c sender.tag is refresh interval in seconds - (void)refreshIntervalButtonClicked:(NSButton*)sender; @end -@interface Statistics : NSObject -+ (NSDictionary*)refreshInterval:(NSArray *)list; -+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id)callback; + +@interface RefreshStatisticsView : NSView +- (instancetype)initWithRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id)callback NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; +- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE; @end diff --git a/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m new file mode 100644 index 0000000..248d91a --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/RefreshStatisticsView.m @@ -0,0 +1,117 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "RefreshStatisticsView.h" +#import "NSDate+Ext.h" +#import "NSView+Ext.h" + +@implementation RefreshStatisticsView + +/** + Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date. + + @param info The dictionary generated with @c -refreshInterval: + @param count Article count. + @param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:. + If not disable button border and display as bold inline text. + @return Centered view without autoresizing. + */ +- (instancetype)initWithRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id)callback { + self = [super initWithFrame:NSZeroRect]; + self.autoresizesSubviews = NO; + + NSTextField *dateView = [self viewForArticlesCount:count latest:info]; + if (!info || info.count == 0) { + [self setFrameSize:dateView.frame.size]; + [dateView placeIn:self x:0 y:0]; + } else { + NSArray *arr = @[GrayLabel(NSLocalizedString(@"min:", nil)), [self createInlineButton:info[@"min"] callback:callback], + GrayLabel(NSLocalizedString(@"max:", nil)), [self createInlineButton:info[@"max"] callback:callback], + GrayLabel(NSLocalizedString(@"avg:", nil)), [self createInlineButton:info[@"avg"] callback:callback], + GrayLabel(NSLocalizedString(@"median:", nil)), [self createInlineButton:info[@"median"] callback:callback]]; + NSView *buttonsView = [self placeViewsHorizontally:arr]; + + CGFloat w = NSWidth(buttonsView.frame); + if (w < NSWidth(dateView.frame)) + w = NSWidth(dateView.frame); + [self setFrameSize:NSMakeSize(w, NSHeight(buttonsView.frame) + PAD_M + NSHeight(dateView.frame))]; + + [dateView placeIn:self x:CENTER yTop:0]; + [buttonsView placeIn:self x:CENTER y:0]; + } + return self; +} + +/// TextField with article count and latest article date. +- (NSTextField*)viewForArticlesCount:(NSUInteger)count latest:(nullable NSDictionary*)info { + NSString *text = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count]; + if (!info || info.count == 0) { + return GrayLabel(text); + } + NSDate *lastUpdate = [info valueForKey:@"latest"]; + NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle]; + NSTextField *label = GrayLabel([text stringByAppendingFormat:NSLocalizedString(@" (latest: %@)", nil), mod]); + + // Feed wasn't updated in a while ... + if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) { + NSMutableAttributedString *as = label.attributedStringValue.mutableCopy; + [as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(text.length, as.length - text.length)]; + [label setAttributedStringValue:as]; // red colored date + } + return label; +} + +/// Label with smaller gray text, non-editable. @c 13px height. +NS_INLINE NSTextField* GrayLabel(NSString *text) { + return [[[NSView label:text] tiny] gray]; +} + +/// Inline button with tag equal to refresh interval. @c 16px height. +- (NSButton*)createInlineButton:(NSNumber*)num callback:(nullable id)callback { + NSButton *button = [NSView inlineButton:[NSDate stringForInterval:num.intValue rounded:YES]]; + TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES]; + button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded interval + // TODO: accessibility title: readable interval string + if (callback) { + [button action:@selector(refreshIntervalButtonClicked:) target:callback]; + } else { + button.bordered = NO; + button.enabled = NO; + } + return button; +} + +/// Helper method to arrange all views in a horizontal line (vertically centered). +- (NSView*)placeViewsHorizontally:(NSArray*)views { + CGFloat w = 0; + NSView *parent = [[NSView alloc] initWithFrame: NSZeroRect]; + for (NSView *v in views) { + BOOL isButton = [v isKindOfClass:[NSButton class]]; + [v setFrameOrigin:NSMakePoint(w, (isButton ? 0 : 2))]; + [parent addSubview:v]; + w += NSWidth(v.frame) + (isButton ? PAD_M : 0); + } + [parent setFrameSize:NSMakeSize(w - PAD_M, 16)]; + return parent; +} + +@end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.h b/baRSS/Preferences/Feeds Tab/SettingsFeeds.h index b69691a..92432f0 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.h +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.h @@ -24,5 +24,14 @@ /** Manages the NSOutlineView and Feed creation and editing */ @interface SettingsFeeds : NSViewController +@property (strong) NSTreeController *dataStore; +- (void)editSelectedItem; +- (void)doubleClickOutlineView:(NSOutlineView*)sender; +- (void)addFeed; +- (void)addGroup; +- (void)addSeparator; +- (void)remove:(id)sender; +- (void)openImportDialog; +- (void)openExportDialog; @end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m index 26241ef..7225364 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds.m @@ -28,12 +28,10 @@ #import "FeedGroup+Ext.h" #import "OpmlExport.h" #import "FeedDownload.h" +#import "SettingsFeedsView.h" @interface SettingsFeeds () -@property (weak) IBOutlet NSOutlineView *outlineView; -@property (weak) IBOutlet NSTreeController *dataStore; -@property (weak) IBOutlet NSProgressIndicator *spinner; -@property (weak) IBOutlet NSTextField *spinnerLabel; +@property (strong) SettingsFeedsView *view; // override super @property (strong) NSArray *currentlyDraggedNodes; @property (strong) NSUndoManager *undoManager; @@ -43,23 +41,20 @@ @end @implementation SettingsFeeds +@dynamic view; // TODO: drag-n-drop feeds to opml file? // Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data... static NSString *dragNodeType = @"baRSS-feed-drag"; +- (void)loadView { + [self initCoreDataStore]; + self.view = [[SettingsFeedsView alloc] initWithController:self]; + [self.view.outline registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; +} + - (void)viewDidLoad { [super viewDidLoad]; - [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; - [self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; - - self.undoManager = [[NSUndoManager alloc] init]; - self.undoManager.groupsByEvent = NO; - self.undoManager.levelsOfUndo = 30; - - self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; - self.dataStore.managedObjectContext.undoManager = self.undoManager; - // Register for notifications [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedUpdated object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(feedUpdated:) name:kNotificationFeedIconUpdated object:nil]; @@ -70,12 +65,35 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [[NSNotificationCenter defaultCenter] removeObserver:self]; } +- (void)initCoreDataStore { + self.undoManager = [[NSUndoManager alloc] init]; + self.undoManager.groupsByEvent = NO; + self.undoManager.levelsOfUndo = 30; + + self.dataStore = [[NSTreeController alloc] init]; + self.dataStore.managedObjectContext = [StoreCoordinator createChildContext]; + self.dataStore.managedObjectContext.undoManager = self.undoManager; + self.dataStore.childrenKeyPath = @"children"; + self.dataStore.leafKeyPath = @"type"; + self.dataStore.entityName = @"FeedGroup"; + self.dataStore.objectClass = [FeedGroup class]; + self.dataStore.fetchPredicate = [NSPredicate predicateWithFormat:@"parent == nil"]; + self.dataStore.sortDescriptors = [NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]; + + NSError *error; + BOOL ok = [self.dataStore fetchWithRequest:nil merge:NO error:&error]; + if (!ok || error) { + [[NSApplication sharedApplication] presentError:error]; + } +} + #pragma mark - Activity Spinner & Status Info /// Initialize status info timer - (void)viewWillAppear { + [self.dataStore rearrangeObjects]; // needed to scroll outline view to top (if prefs open on another tab) self.intervalFormatter = [[NSDateComponentsFormatter alloc] init]; self.intervalFormatter.unitsStyle = NSDateComponentsFormatterUnitsStyleShort; // e.g., '30 min' self.intervalFormatter.maximumUnitCount = 1; @@ -99,7 +117,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; if (date) { double nextFire = fabs(date.timeIntervalSinceNow); if (nextFire > 1e9) { // distance future, over 31 years - self.spinnerLabel.stringValue = @""; + self.view.status.stringValue = @""; return; } if (nextFire > 60) { // update 1/min @@ -108,7 +126,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; nextFire = 1; // update 1/sec } NSString *str = [self.intervalFormatter stringFromTimeInterval: date.timeIntervalSinceNow]; - self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), str]; + self.view.status.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Next update in %@", nil), str]; [self.timerStatusInfo setFireDate:[NSDate dateWithTimeIntervalSinceNow: nextFire]]; } } @@ -116,18 +134,18 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; /// Start ( @c c @c > @c 0 ) or stop ( @c c @c = @c 0 ) activity spinner. Also, sets status info. - (void)activateSpinner:(NSInteger)c { if (c == 0) { - [self.spinner stopAnimation:nil]; - self.spinnerLabel.stringValue = @""; + [self.view.spinner stopAnimation:nil]; + self.view.status.stringValue = @""; [self.timerStatusInfo fire]; } else { [self.timerStatusInfo setFireDate:[NSDate distantFuture]]; - [self.spinner startAnimation:nil]; + [self.view.spinner startAnimation:nil]; if (c == 1) { // exactly one feed - self.spinnerLabel.stringValue = NSLocalizedString(@"Updating 1 feed …", nil); + self.view.status.stringValue = NSLocalizedString(@"Updating 1 feed …", nil); } else if (c < 0) { // unknown number of feeds - self.spinnerLabel.stringValue = NSLocalizedString(@"Updating feeds …", nil); + self.view.status.stringValue = NSLocalizedString(@"Updating feeds …", nil); } else { - self.spinnerLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c]; + self.view.status.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Updating %lu feeds …", nil), c]; } } } @@ -207,25 +225,39 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; #pragma mark - UI Button Interaction +/// Open clicked or selected item for editing. +- (void)editSelectedItem { + FeedGroup *chosen = [self clickedItem]; + if (!chosen) chosen = self.dataStore.selectedObjects.firstObject; + [self showModalForFeedGroup:chosen isGroupEdit:YES]; // yes will be overwritten anyway +} + +/// Open clicked item for editing. +- (void)doubleClickOutlineView:(NSOutlineView*)sender { + FeedGroup *fg = [self clickedItem]; + if (!fg) return; + [self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway +} + /// Add feed button. -- (IBAction)addFeed:(id)sender { +- (void)addFeed { [self showModalForFeedGroup:nil isGroupEdit:NO]; } /// Add group button. -- (IBAction)addGroup:(id)sender { +- (void)addGroup { [self showModalForFeedGroup:nil isGroupEdit:YES]; } /// Add separator button. -- (IBAction)addSeparator:(id)sender { +- (void)addSeparator { [self beginCoreDataChange]; [self insertFeedGroupAtSelection:SEPARATOR].name = @"---"; [self endCoreDataChangeShouldUndo:NO]; } /// Remove feed button. User has selected one or more item in outline view. -- (IBAction)remove:(id)sender { +- (void)remove:(id)sender { [self beginCoreDataChange]; NSArray *parentNodes = [self.dataStore.selectedNodes valueForKeyPath:@"parentNode"]; [self.dataStore remove:sender]; @@ -236,31 +268,12 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; } -/// Open user selected item for editing. -- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { - if (sender.clickedRow == -1) - return; // ignore clicks on column headers and where no row was selected - FeedGroup *fg = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject]; - [self showModalForFeedGroup:fg isGroupEdit:YES]; // yes will be overwritten anyway +- (void)openImportDialog { + [OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; } -/// Share menu button. Currently only import & export feeds as OPML. -- (IBAction)shareMenu:(NSButton*)sender { - if (!sender.menu) { - sender.menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Import / Export menu", nil)]; - sender.menu.autoenablesItems = NO; - [sender.menu addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:nil keyEquivalent:@""].tag = 101; - [sender.menu addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:nil keyEquivalent:@""].tag = 102; - // TODO: Add menus for online sync? email export? etc. - } - if ([sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0,sender.frame.size.height) inView:sender]) { - NSInteger tag = sender.menu.highlightedItem.tag; - if (tag == 101) { - [OpmlExport showImportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; - } else if (tag == 102) { - [OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; - } - } +- (void)openExportDialog { + [OpmlExport showExportDialog:self.view.window withContext:self.dataStore.managedObjectContext]; } @@ -320,7 +333,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; - (NSIndexPath*)indexPathForInsertAtNode:(NSTreeNode*)node { if (!node) { // append to root return [NSIndexPath indexPathWithIndex:[self.dataStore arrangedObjects].childNodes.count]; // or 0 to append at front - } else if ([self.outlineView isItemExpanded:node]) { // append to group (if open) + } else if ([self.view.outline isItemExpanded:node]) { // append to group (if open) return [node.indexPath indexPathByAddingIndex:0]; // or 'selection.childNodes.count' to append at end } else { // append before / after selected item NSIndexPath *pth = node.indexPath; @@ -328,6 +341,7 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; NSUInteger lastIdx = [pth indexAtPosition:pth.length - 1]; return [[pth indexPathByRemovingLastIndex] indexPathByAddingIndex:lastIdx + 1]; } + // TODO: always append to end } /// Loop over all descendants and update @c sortIndex @c (FeedGroup) as well as all @c indexPath @c (Feed) @@ -401,48 +415,59 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; #pragma mark - Data Source Delegate +// Data source is handled by bindings anyway. These methods can be ignored +- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item { return 0; } +- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item { return YES; } +- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item { return nil; } +- (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item { return nil; } + /// Populate @c NSOutlineView data cells with core data object values. -- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { - FeedGroup *fg = [(NSTreeNode*)item representedObject]; - BOOL isSeperator = (fg.type == SEPARATOR); - BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"]; - - NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed")); - // owner is nil to prohibit repeated awakeFromNib calls - NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil]; - - if (isRefreshColumn) { - NSString *str = [fg refreshString]; - cellView.textField.stringValue = str; - cellView.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]); - } else if (isSeperator) { - return cellView; // refresh cell already skipped with the above if condition - } else { - cellView.textField.objectValue = fg.name; - cellView.imageView.image = fg.iconImage16; +- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item { + NSUserInterfaceItemIdentifier ident = tableColumn.identifier; + if (ident == CustomCellName) { + FeedGroup *fg = [item representedObject]; + if (fg.type == SEPARATOR) + ident = CustomCellSeparator; } - return cellView; + NSTableCellView *v = [outlineView makeViewWithIdentifier:ident owner:self]; + if (v) return v; + if (ident == CustomCellName) return [NameColumnCell new]; + if (ident == CustomCellRefresh) return [RefreshColumnCell new]; + if (ident == CustomCellSeparator) return [SeparatorColumnCell new]; + return nil; +} + +/// @return User clicked cell item or @c nil if user did not click on a cell. +- (FeedGroup*)clickedItem { + NSOutlineView *ov = self.view.outline; + return [(NSTreeNode*)[ov itemAtRow:ov.clickedRow] representedObject]; } #pragma mark - Keyboard Commands: undo, redo, copy, enter +/// Also look for commands right click menu of outline view +- (void)keyDown:(NSEvent *)event { + if (![self.view.outline.menu performKeyEquivalent:event]) { + [super keyDown:event]; + } +} + /// Returning @c NO will result in a Action-Not-Available-Buzzer sound - (BOOL)respondsToSelector:(SEL)aSelector { if (aSelector == @selector(undo:)) return [self.undoManager canUndo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating]; if (aSelector == @selector(redo:)) return [self.undoManager canRedo] && self.undoManager.groupingLevel == 0 && ![FeedDownload isUpdating]; - if (aSelector == @selector(copy:) || aSelector == @selector(enterPressed:)) { - BOOL outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]]; - BOOL hasSelection = (self.dataStore.selectedNodes.count > 0); - if (!outlineHasFocus || !hasSelection) - return NO; - if (aSelector == @selector(copy:)) - return YES; - // can edit only if selection is not a separator - return (((FeedGroup*)self.dataStore.selectedNodes.firstObject.representedObject).type != SEPARATOR); + if (aSelector == @selector(copy:) || aSelector == @selector(remove:)) + return self.dataStore.selectedNodes.count > 0; + if (aSelector == @selector(editSelectedItem)) { + FeedGroup *chosen = [self clickedItem]; + if (!chosen) chosen = self.dataStore.selectedObjects.firstObject; + if (chosen && chosen.type != SEPARATOR) + return YES; // can edit only if selection is not a separator + return NO; } return [super respondsToSelector:aSelector]; } @@ -459,11 +484,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; [self saveWithUnpredictableChange]; } -/// User pressed enter; open edit dialog for selected item. -- (void)enterPressed:(id)sender { - [self showModalForFeedGroup:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway -} - /// Copy human readable description of selected nodes to clipboard. - (void)copy:(id)sender { NSMutableString *str = [[NSMutableString alloc] init]; diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib b/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib deleted file mode 100644 index 127856e..0000000 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds.xib +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.h b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.h new file mode 100644 index 0000000..7e9704e --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.h @@ -0,0 +1,48 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@class SettingsFeeds; + +@interface SettingsFeedsView : NSView +@property (weak) IBOutlet NSOutlineView *outline; +@property (weak) IBOutlet NSTextField *status; +@property (weak) IBOutlet NSProgressIndicator *spinner; + +- (instancetype)initWithController:(SettingsFeeds*)delegate NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; +- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE; +@end + + +@interface NameColumnCell : NSTableCellView +extern NSUserInterfaceItemIdentifier const CustomCellName; +@end + +@interface RefreshColumnCell : NSTableCellView +extern NSUserInterfaceItemIdentifier const CustomCellRefresh; +@end + +@interface SeparatorColumnCell : NSTableCellView +extern NSUserInterfaceItemIdentifier const CustomCellSeparator; +@end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m new file mode 100644 index 0000000..8755b9a --- /dev/null +++ b/baRSS/Preferences/Feeds Tab/SettingsFeedsView.m @@ -0,0 +1,254 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsFeedsView.h" +#import "StoreCoordinator.h" +#import "FeedGroup+Ext.h" +#import "FeedMeta+Ext.h" +#import "DrawImage.h" +#import "SettingsFeeds.h" +#import "NSDate+Ext.h" +#import "NSView+Ext.h" + + +@interface SettingsFeedsView() +@property (weak) SettingsFeeds *controller; +@end + +@implementation SettingsFeedsView + +- (instancetype)initWithController:(SettingsFeeds*)delegate { + self = [super initWithFrame:NSZeroRect]; + if (self) { + self.controller = delegate; // make sure its first + self.outline = [self generateOutlineView]; // uses self.controller + [self wrapContent:self.outline inScrollView:NSMakeRect(0, 20, NSWidth(self.frame), NSHeight(self.frame) - 20)]; + self.outline.menu = [self generateCommandsMenu]; + [self.outline.menu.itemArray makeObjectsPerformSelector:@selector(setTarget:) withObject:delegate]; + CGFloat x = [self generateButtons]; // uses self.controller and self.outline + // Setup status text field ('Next update in X min.' or 'Updating X feeds ...') + self.status = [[[[[[NSView label:@""] small] gray] textCenter] placeIn:self x:x + PAD_L y:3.5] sizeToRight:PAD_L]; + self.spinner = [[NSView activitySpinner] placeIn:self xRight:2 y:2]; + } + return self; +} + +/** + Setup @c self.outline + @note Requires @c self.controller + */ +- (NSOutlineView*)generateOutlineView { + // Generate outline view + NSOutlineView *o = [[NSOutlineView alloc] init]; + o.columnAutoresizingStyle = NSTableViewFirstColumnOnlyAutoresizingStyle; + o.usesAlternatingRowBackgroundColors = YES; + o.allowsMultipleSelection = YES; + o.allowsColumnReordering = NO; + o.allowsColumnSelection = NO; + o.allowsEmptySelection = YES; + //o.intercellSpacing = NSMakeSize(3, 2); + o.rowHeight = 18; + + [self setOutlineColumns:o]; + + // Setup action and bindings + SettingsFeeds *sf = self.controller; + o.delegate = sf; + o.dataSource = sf; + o.target = sf; + o.doubleAction = @selector(doubleClickOutlineView:); + + [o bind:NSContentBinding toObject:sf.dataStore withKeyPath:@"arrangedObjects" options:nil]; // @{NSAlwaysPresentsApplicationModalAlertsBindingOption:@YES} + [o bind:NSSelectionIndexPathsBinding toObject:sf.dataStore withKeyPath:@"selectionIndexPaths" options:nil]; + return o; +} + +/// Generate table columns 'Name' and 'Refresh' +- (void)setOutlineColumns:(NSOutlineView*)outline { + NSTableColumn *colName = [[NSTableColumn alloc] initWithIdentifier:CustomCellName]; + colName.title = NSLocalizedString(@"Name", nil); + colName.width = 10000; + colName.maxWidth = 10000; + colName.resizingMask = NSTableColumnAutoresizingMask; + [outline addTableColumn:colName]; + + NSTableColumn *colRefresh = [[NSTableColumn alloc] initWithIdentifier:CustomCellRefresh]; + colRefresh.title = NSLocalizedString(@"Refresh", nil); + colRefresh.width = 50; + colRefresh.resizingMask = NSTableColumnNoResizing; + [outline addTableColumn:colRefresh]; + + for (NSTableColumn *col in outline.tableColumns) { + col.headerCell.title = [NSString stringWithFormat:@" %@", col.title]; + NSDictionary *attr = @{ NSFontAttributeName: [NSFont systemFontOfSize:NSFont.smallSystemFontSize weight:NSFontWeightMedium] }; + col.headerCell.attributedStringValue = [[NSAttributedString alloc] initWithString:col.title attributes:attr]; + } + outline.outlineTableColumn = colName; +} + +/// Setup right click menu (also used for hotkeys). +- (NSMenu*)generateCommandsMenu { + NSMenu *m = [[NSMenu alloc] initWithTitle:@""]; + [m addItemWithTitle:NSLocalizedString(@"Edit Item", nil) action:@selector(editSelectedItem) keyEquivalent:[NSString stringWithFormat:@"%c", NSCarriageReturnCharacter]].keyEquivalentModifierMask = 0; + [m addItemWithTitle:NSLocalizedString(@"Delete Item(s)", nil) action:@selector(remove:) keyEquivalent:[NSString stringWithFormat:@"%c", NSBackspaceCharacter]]; + [m addItem:[NSMenuItem separatorItem]]; // index: 2 + [m addItemWithTitle:NSLocalizedString(@"New Feed", nil) action:@selector(addFeed) keyEquivalent:@"n"]; + [m addItemWithTitle:NSLocalizedString(@"New Group", nil) action:@selector(addGroup) keyEquivalent:@"g"]; + [m addItemWithTitle:NSLocalizedString(@"New Separator", nil) action:@selector(addSeparator) keyEquivalent:@""]; + [m addItem:[NSMenuItem separatorItem]]; // index: 6 + [m addItemWithTitle:NSLocalizedString(@"Import Feeds …", nil) action:@selector(openImportDialog) keyEquivalent:@""]; + [m addItemWithTitle:NSLocalizedString(@"Export Feeds …", nil) action:@selector(openExportDialog) keyEquivalent:@""]; + [m addItem:[NSMenuItem separatorItem]]; // index: 9 +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wundeclared-selector" + [m addItemWithTitle:NSLocalizedString(@"Undo", nil) action:@selector(undo:) keyEquivalent:@"z"]; + [m addItemWithTitle:NSLocalizedString(@"Redo", nil) action:@selector(redo:) keyEquivalent:@"Z"]; +#pragma clang diagnostic pop + return m; +} + +/** + Setup the bottom button bar. (e.g., add, remove, edit, export, import, etc.) + @note Requires @c self.controller and @c self.outline + + @return Max x-value of last button frame + */ +- (CGFloat)generateButtons { + NSButton *add = [[NSView buttonImageSquare:NSImageNameAddTemplate] tooltip:NSLocalizedString(@"Add new item", nil)]; + NSButton *del = [[NSView buttonImageSquare:NSImageNameRemoveTemplate] tooltip:NSLocalizedString(@"Delete selected item(s)", nil)]; + NSButton *share = [[NSView buttonImageSquare:NSImageNameShareTemplate] tooltip:NSLocalizedString(@"Import or export data", nil)]; + + [self button:add copyActions:3 to:5]; + [self button:del copyActions:1 to:1]; + [self button:share copyActions:7 to:8]; // TODO: Add menus for online sync? email export? etc. + + [add placeIn:self x:0 y:0]; + [del placeIn:self x:24 y:0]; + [share placeIn:self x:2 * 24 + PAD_L y:0]; + + NSTreeController *tc = self.controller.dataStore; + [add bind:NSEnabledBinding toObject:tc withKeyPath:@"canInsert" options:nil]; + [del bind:NSEnabledBinding toObject:tc withKeyPath:@"canRemove" options:nil]; + return NSMaxX(share.frame); +} + +/** + Duplicate right click menu actions to button + @note Requires @c self.outline +*/ +- (void)button:(NSButton*)btn copyActions:(NSInteger)start to:(NSInteger)end { + if (start < 0 || start > end || end >= self.outline.menu.numberOfItems) { + NSAssert(NO, @"Invalid index, can't copy command menu items."); + return; + } + if (start == end) { + // copy menu item action to button action + NSMenuItem *source = [self.outline.menu itemAtIndex:start]; + [btn action:source.action target:source.target]; + btn.keyEquivalent = source.keyEquivalent; + btn.keyEquivalentModifierMask = source.keyEquivalentModifierMask; + } else { + // create drop down menu with all options + btn.menu = [[NSMenu alloc] initWithTitle:@""]; + [btn action:@selector(openButtonMenu:) target:self]; + for (NSInteger i = start; i <= end; i++) { + [btn.menu addItem:[[self.outline.menu itemAtIndex:i] copy]]; + } + } +} + +/// Show drop down menu even for left click. +- (void)openButtonMenu:(NSButton*)sender { + //[NSMenu popUpContextMenu:sender.menu withEvent:[NSApp currentEvent] forView:sender]; + [sender.menu popUpMenuPositioningItem:nil atLocation:NSMakePoint(0, NSHeight(sender.frame)) inView:sender]; +} + +@end + + +#pragma mark - Custom Outline View Cells - + + +/** + First outline view column, with textfield and feed icon + */ +@implementation NameColumnCell +/// Identifier for cell with @c .imageView (feed icon) and @c .textField (feed title) +NSUserInterfaceItemIdentifier const CustomCellName = @"NameColumnCell"; + +- (instancetype)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + self.identifier = CustomCellName; + self.imageView = [[NSView imageView:nil size:16] placeIn:self x:1 yTop:1]; + self.textField = [[[NSView label:@""] placeIn:self x:25 yTop:0] sizeToRight:0]; + return self; +} + +- (void)setObjectValue:(FeedGroup*)fg { + self.textField.objectValue = fg.name; + self.imageView.image = fg.iconImage16; +} + +@end + + +/** + Second outline view column, either refresh string or empty + */ +@implementation RefreshColumnCell +/// Identifier for cell with @c .textField (refresh string or empty) +NSUserInterfaceItemIdentifier const CustomCellRefresh = @"RefreshColumnCell"; + +- (instancetype)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + self.identifier = CustomCellRefresh; + self.textField = [[[[NSView label:@""] textRight] placeIn:self x:0 yTop:0] sizeToRight:0]; + return self; +} + +- (void)setObjectValue:(FeedGroup*)fg { + NSString *str = [fg refreshString]; + self.textField.objectValue = str; + // TODO: accessibility title: readable interval string + self.textField.textColor = (str.length > 1 ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]); +} + +@end + + +/** + First outline view column, separator line + */ +@implementation SeparatorColumnCell +/// Identifier for cell with line separator +NSUserInterfaceItemIdentifier const CustomCellSeparator = @"SeparatorColumnCell"; + +- (instancetype)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + self.identifier = CustomCellSeparator; + [[[[DrawSeparator alloc] initWithFrame:self.frame] placeIn:self x:0 y:0] sizableWidthAndHeight]; + return self; +} + +- (void)setObjectValue:(FeedGroup*)fg { /* do nothing */ } + +@end diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.h b/baRSS/Preferences/General Tab/SettingsGeneral.h index 58396e5..eb000b8 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.h +++ b/baRSS/Preferences/General Tab/SettingsGeneral.h @@ -23,5 +23,7 @@ #import @interface SettingsGeneral : NSViewController -@property (assign) IBOutlet NSView *appearanceView; +- (void)fixCache:(NSButton *)sender; +- (void)changeHttpApplication:(NSPopUpButton *)sender; +- (void)changeDefaultRSSReader:(NSPopUpButton *)sender; @end diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index b8b1706..5f441d5 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -21,33 +21,32 @@ // SOFTWARE. #import "SettingsGeneral.h" -#import "AppHook.h" -#import "BarStatusItem.h" #import "UserPrefs.h" #import "StoreCoordinator.h" #import "Constants.h" +#import "SettingsGeneralView.h" @interface SettingsGeneral() -@property (weak) IBOutlet NSPopUpButton *popupHttpApplication; -@property (weak) IBOutlet NSPopUpButton *popupDefaultRSSReader; +@property (strong) IBOutlet SettingsGeneralView *view; // override @end @implementation SettingsGeneral +@dynamic view; -- (void)viewDidLoad { - [super viewDidLoad]; +- (void)loadView { + self.view = [[SettingsGeneralView alloc] initWithController:self]; // Default http application for opening the feed urls - [self generateMenuForPopup:self.popupHttpApplication withScheme:@"https"]; - [self.popupHttpApplication insertItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application") atIndex:0]; - [self selectBundleID:[UserPrefs getHttpApplication] inPopup:self.popupHttpApplication]; + [self generateMenuForPopup:self.view.popupHttpApplication withScheme:@"https"]; + [self.view.popupHttpApplication insertItemWithTitle:NSLocalizedString(@"System Default", @"Default web browser application") atIndex:0]; + [self selectBundleID:[UserPrefs getHttpApplication] inPopup:self.view.popupHttpApplication]; // Default RSS Reader application - [self generateMenuForPopup:self.popupDefaultRSSReader withScheme:@"feed"]; - [self selectBundleID:[self defaultBundleIdForScheme:@"feed"] inPopup:self.popupDefaultRSSReader]; + [self generateMenuForPopup:self.view.popupDefaultRSSReader withScheme:@"feed"]; + [self selectBundleID:[self defaultBundleIdForScheme:@"feed"] inPopup:self.view.popupDefaultRSSReader]; } #pragma mark - UI interaction with IBAction -- (IBAction)fixCache:(NSButton *)sender { +- (void)fixCache:(NSButton *)sender { NSUInteger deleted = [StoreCoordinator deleteUnreferenced]; [StoreCoordinator restoreFeedIndexPaths]; [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationTotalUnreadCountReset object:nil]; @@ -58,15 +57,11 @@ [alert runModal]; } -- (IBAction)changeMenuBarIconSetting:(NSButton*)sender { - [[(AppHook*)NSApp statusItem] updateBarIcon]; -} - -- (IBAction)changeHttpApplication:(NSPopUpButton *)sender { +- (void)changeHttpApplication:(NSPopUpButton *)sender { [UserPrefs setHttpApplication:sender.selectedItem.representedObject]; } -- (IBAction)changeDefaultRSSReader:(NSPopUpButton *)sender { +- (void)changeDefaultRSSReader:(NSPopUpButton *)sender { if ([self setDefaultRSSApplication:sender.selectedItem.representedObject] == NO) { // in case anything went wrong, restore previous selection [self selectBundleID:[self defaultBundleIdForScheme:@"feed"] inPopup:sender]; diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.xib b/baRSS/Preferences/General Tab/SettingsGeneral.xib deleted file mode 100644 index e441834..0000000 --- a/baRSS/Preferences/General Tab/SettingsGeneral.xib +++ /dev/null @@ -1,487 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/baRSS/Preferences/General Tab/SettingsGeneralView.h b/baRSS/Preferences/General Tab/SettingsGeneralView.h new file mode 100644 index 0000000..cbe438b --- /dev/null +++ b/baRSS/Preferences/General Tab/SettingsGeneralView.h @@ -0,0 +1,35 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import + +@class SettingsGeneral; + +@interface SettingsGeneralView : NSView +@property (weak) IBOutlet NSPopUpButton* popupHttpApplication; +@property (weak) IBOutlet NSPopUpButton* popupDefaultRSSReader; + +- (instancetype)initWithController:(SettingsGeneral*)controller NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; +- (nullable instancetype)initWithCoder:(NSCoder *)decoder NS_UNAVAILABLE; +@end + diff --git a/baRSS/Preferences/General Tab/SettingsGeneralView.m b/baRSS/Preferences/General Tab/SettingsGeneralView.m new file mode 100644 index 0000000..07d30cf --- /dev/null +++ b/baRSS/Preferences/General Tab/SettingsGeneralView.m @@ -0,0 +1,51 @@ +// +// The MIT License (MIT) +// Copyright (c) 2019 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "SettingsGeneralView.h" +#import "SettingsGeneral.h" +#import "NSView+Ext.h" + +@implementation SettingsGeneralView + +- (instancetype)initWithController:(SettingsGeneral*)controller { + self = [super initWithFrame:NSZeroRect]; + + NSArray *lbls = @[NSLocalizedString(@"Open URLs with:", nil), + NSLocalizedString(@"Default RSS Reader:", nil)]; + NSView *labels = [[NSView labelColumn:lbls rowHeight:HEIGHT_POPUP padding:PAD_M] placeIn:self x:PAD_WIN yTop:PAD_WIN]; + CGFloat x = NSMaxX(labels.frame) + PAD_S; + + self.popupHttpApplication = [[self createPopup:x top: PAD_WIN + 1] action:@selector(changeHttpApplication:) target:controller]; + self.popupDefaultRSSReader = [[self createPopup:x top: YFromTop(self.popupHttpApplication) + PAD_M] action:@selector(changeDefaultRSSReader:) target:controller]; + + // Add fix cache button + [[[[NSView button:NSLocalizedString(@"Fix Cache", nil)] action:@selector(fixCache:) target:controller] + tooltip:NSLocalizedString(@"Will remove unreferenced feed entries", nil)] placeIn:self xRight:PAD_WIN y:PAD_WIN]; + return self; +} + +/// Helper method to create sizable popup button +- (NSPopUpButton*)createPopup:(CGFloat)x top:(CGFloat)y { + return [[[NSView popupButton:0] placeIn:self x:x yTop:y] sizeToRight:PAD_WIN]; +} + +@end diff --git a/baRSS/Preferences/ModalSheet.h b/baRSS/Preferences/Helper/ModalSheet.h similarity index 97% rename from baRSS/Preferences/ModalSheet.h rename to baRSS/Preferences/Helper/ModalSheet.h index 309d6f7..e13f71e 100644 --- a/baRSS/Preferences/ModalSheet.h +++ b/baRSS/Preferences/Helper/ModalSheet.h @@ -23,7 +23,6 @@ #import @interface ModalSheet : NSPanel -@property (readonly) BOOL didCloseAndSave; @property (readonly) BOOL didCloseAndCancel; - (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag NS_UNAVAILABLE; diff --git a/baRSS/Preferences/Helper/ModalSheet.m b/baRSS/Preferences/Helper/ModalSheet.m new file mode 100644 index 0000000..274de8d --- /dev/null +++ b/baRSS/Preferences/Helper/ModalSheet.m @@ -0,0 +1,109 @@ +// +// The MIT License (MIT) +// Copyright (c) 2018 Oleg Geier +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#import "ModalSheet.h" +#import "NSView+Ext.h" + +@interface ModalSheet() +@property (assign) BOOL respondToShouldClose; +@end + +@implementation ModalSheet + +/// Designated initializer. 'Done' and 'Cancel' buttons will be added automatically. +- (instancetype)initWithView:(NSView*)content { + static const NSInteger minWidth = 320; + static const NSInteger maxWidth = 1200; + static const CGFloat contentOffsetY = PAD_WIN + HEIGHT_BUTTON + PAD_L; + + NSInteger w = [[NSUserDefaults standardUserDefaults] integerForKey:@"modalSheetWidth"]; + if (w < minWidth) w = minWidth; + else if (w > maxWidth) w = maxWidth; + + CGFloat h = NSHeight(content.frame); + [content setFrameSize: NSMakeSize(w, h)]; + + // after content size, increase to window size + w += 2 * PAD_WIN; + h += PAD_WIN + contentOffsetY; // the second PAD_WIN is already in contentOffsetY + + NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView; + self = [super initWithContentRect:NSMakeRect(0, 0, w, h) styleMask:style backing:NSBackingStoreBuffered defer:NO]; + [content placeIn:self.contentView x:PAD_WIN y:contentOffsetY]; + + // Restrict resizing to width only + self.minSize = NSMakeSize(minWidth + 2 * PAD_WIN, h); + self.maxSize = NSMakeSize(maxWidth + 2 * PAD_WIN, h); + + // Add default interaction buttons + NSButton *btnDone = [self createButton:NSLocalizedString(@"Done", nil) atX:PAD_WIN]; + NSButton *btnCancel = [self createButton:NSLocalizedString(@"Cancel", nil) atX:w - NSMinX(btnDone.frame) + PAD_M]; + btnDone.tag = 42; // mark 'Done' button + btnDone.keyEquivalent = @"\r"; // Enter / Return + btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC + return self; +} + +/// Helper method to create bottom-right aligned button. +- (NSButton*)createButton:(NSString*)text atX:(CGFloat)x { + return [[[NSView button:text] action:@selector(didTapButton:) target:self] placeIn:self.contentView xRight:x y:PAD_WIN]; +} + +/// Manually disable 'Done' button if a task is still running. +- (void)setDoneEnabled:(BOOL)accept { + ((NSButton*)[self.contentView viewWithTag:42]).enabled = accept; +} + +/// Sets bool for future usage +- (void)setDelegate:(id)delegate { + [super setDelegate:delegate]; + self.respondToShouldClose = [delegate respondsToSelector:@selector(windowShouldClose:)]; +} + +/** + Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button. + In the later case set @c .didCloseAndCancel @c = @c YES + */ +- (void)didTapButton:(NSButton*)sender { + BOOL successful = (sender.tag == 42); // 'Done' button + if (successful && self.respondToShouldClose && ![self.delegate windowShouldClose:self]) { + return; + } + _didCloseAndCancel = !successful; + // Save modal view width for next time + CGFloat w = NSWidth(self.contentView.frame) - 2 * PAD_WIN; + [[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"]; + // Remove subviews to avoid _NSKeyboardFocusClipView issues + [self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; + [self.sheetParent endSheet:self returnCode:(successful ? NSModalResponseOK : NSModalResponseCancel)]; +} + +/// Resize modal window by @c dy. Makes room for additional content. Use negative values to shrink window. +- (void)extendContentViewBy:(CGFloat)dy { + self.minSize = NSMakeSize(self.minSize.width, self.minSize.height + dy); + self.maxSize = NSMakeSize(self.maxSize.width, self.maxSize.height + dy); + NSRect r = self.frame; + r.size.height += dy; + [self setFrame:r display:YES animate:YES]; +} + +@end diff --git a/baRSS/Preferences/General Tab/UserPrefs.h b/baRSS/Preferences/Helper/UserPrefs.h similarity index 100% rename from baRSS/Preferences/General Tab/UserPrefs.h rename to baRSS/Preferences/Helper/UserPrefs.h diff --git a/baRSS/Preferences/General Tab/UserPrefs.m b/baRSS/Preferences/Helper/UserPrefs.m similarity index 100% rename from baRSS/Preferences/General Tab/UserPrefs.m rename to baRSS/Preferences/Helper/UserPrefs.m diff --git a/baRSS/Preferences/ModalSheet.m b/baRSS/Preferences/ModalSheet.m deleted file mode 100644 index a65b3a5..0000000 --- a/baRSS/Preferences/ModalSheet.m +++ /dev/null @@ -1,141 +0,0 @@ -// -// The MIT License (MIT) -// Copyright (c) 2018 Oleg Geier -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -// of the Software, and to permit persons to whom the Software is furnished to do -// so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -#import "ModalSheet.h" - -@interface ModalSheet() -@property (weak) NSButton *btnDone; -@property (assign) BOOL respondToShouldClose; -@end - -@implementation ModalSheet -@synthesize didCloseAndSave = _didCloseAndSave, didCloseAndCancel = _didCloseAndCancel; - -/// User did click the 'Done' button. -- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; } -/// User did click the 'Cancel' button. -- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseCancel]; } -/// Manually disable 'Done' button if a task is still running. -- (void)setDoneEnabled:(BOOL)accept { self.btnDone.enabled = accept; } - -- (void)setDelegate:(id)delegate { - [super setDelegate:delegate]; - self.respondToShouldClose = [delegate respondsToSelector:@selector(windowShouldClose:)]; -} - -/** - Called after user has clicked the 'Done' (Return) or 'Cancel' (Esc) button. - Flags controller as being closed @c .closeInitiated @c = @c YES. - And removes all subviews (clean up). - */ -- (void)closeWithResponse:(NSModalResponse)response { - if (response == NSModalResponseOK && self.respondToShouldClose && ![self.delegate windowShouldClose:self]) { - return; - } - _didCloseAndSave = (response == NSModalResponseOK); - _didCloseAndCancel = (response != NSModalResponseOK); - // store modal view width and remove subviews to avoid _NSKeyboardFocusClipView issues - // first object is always the view of the modal dialog - CGFloat w = self.contentView.subviews.firstObject.frame.size.width; - [[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)w forKey:@"modalSheetWidth"]; - [self.contentView.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; - [self.sheetParent endSheet:self returnCode:response]; -} - -/** - Designated initializer for @c ModalSheet. 'Done' and 'Cancel' button will be added automatically. - - @param content @c NSView will be displayed in dialog box. - */ -- (instancetype)initWithView:(NSView*)content { - static const int padWindow = 20; - static const int minWidth = 320; - static const int maxWidth = 1200; - - NSInteger prevWidth = [[NSUserDefaults standardUserDefaults] integerForKey:@"modalSheetWidth"]; - if (prevWidth < minWidth) prevWidth = minWidth; - else if (prevWidth > maxWidth) prevWidth = maxWidth; - - NSSize contentSize = NSMakeSize(prevWidth, content.frame.size.height); - [content setFrameSize:contentSize]; - - NSSize wSize = NSMakeSize(contentSize.width + 2 * padWindow, contentSize.height + 2 * padWindow); - - NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView; - self = [super initWithContentRect:NSMakeRect(0, 0, wSize.width, wSize.height) styleMask:style backing:NSBackingStoreBuffered defer:NO]; - if (self) { - NSButton *btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:self action:@selector(didTapDoneButton:)]; - NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(didTapCancelButton:)]; - btnDone.keyEquivalent = @"\r"; // Enter / Return - btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC - - // Make room for buttons - wSize.height += btnDone.frame.size.height; - [self setContentSize:wSize]; - - // Restrict resizing to width only (after setContentSize:) - self.minSize = NSMakeSize(minWidth + 2 * padWindow, wSize.height); - self.maxSize = NSMakeSize(maxWidth + 2 * padWindow, wSize.height); - - // Content view (set origin after setContentSize:) - [content setFrameOrigin:NSMakePoint(padWindow, wSize.height - padWindow - contentSize.height)]; - [self.contentView addSubview:content]; - - // Respond buttons - [self placeButtons:@[btnDone, btnCancel] inBottomRightCornerWithPadding:padWindow]; - [self.contentView addSubview:btnCancel]; - [self.contentView addSubview:btnDone]; - self.btnDone = btnDone; - } - return self; -} - -/** - Buttons will stick to the right margin and bottom margin when resizing. Also sets autoresizingMask. - - @param buttons First item is rightmost button. Next buttons will be appended left of that button and so on. - @param padding Distance between button and right / bottom edge. - */ -- (void)placeButtons:(NSArray *)buttons inBottomRightCornerWithPadding:(int)padding { - NSEdgeInsets edge = buttons.firstObject.alignmentRectInsets; - NSPoint p = NSMakePoint(self.contentView.frame.size.width - padding + edge.right, padding - edge.bottom); - for (NSButton *btn in buttons) { - p.x -= btn.frame.size.width; - [btn setFrameOrigin:p]; - btn.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin; - } -} - -/** - Resize modal window by @c dy. Makes room for additional content. Use negative values to shrink window. - */ -- (void)extendContentViewBy:(CGFloat)dy { - self.minSize = NSMakeSize(self.minSize.width, self.minSize.height + dy); - self.maxSize = NSMakeSize(self.maxSize.width, self.maxSize.height + dy); - NSRect r = self.frame; - r.size.height += dy; - [self setFrame:r display:YES animate:YES]; -} - -@end - - diff --git a/baRSS/Preferences/Preferences.h b/baRSS/Preferences/Preferences.h index a49d040..a79f3e4 100644 --- a/baRSS/Preferences/Preferences.h +++ b/baRSS/Preferences/Preferences.h @@ -22,5 +22,6 @@ #import -@interface Preferences : NSWindowController +@interface Preferences : NSWindow ++ (instancetype)window; @end diff --git a/baRSS/Preferences/Preferences.m b/baRSS/Preferences/Preferences.m index ef178b1..35fb46f 100644 --- a/baRSS/Preferences/Preferences.m +++ b/baRSS/Preferences/Preferences.m @@ -21,74 +21,95 @@ // SOFTWARE. #import "Preferences.h" -#import "SettingsFeeds.h" #import "SettingsGeneral.h" +#import "SettingsFeeds.h" +#import "SettingsAppearance.h" +#import "SettingsAbout.h" - -@interface Preferences () -@property (weak) IBOutlet SettingsGeneral *settingsGeneral; -@property (weak) IBOutlet SettingsFeeds *settingsFeeds; -@property (weak) IBOutlet NSView *aboutView; -@property (weak) IBOutlet NSTextField *lblAppName; -@property (weak) IBOutlet NSTextField *lblAppVersion; +/// Managing individual tabs in application preferences +@interface PrefTabs : NSTabViewController @end +@implementation PrefTabs + +- (instancetype)init { + self = [super init]; + if (self) { + self.tabStyle = NSTabViewControllerTabStyleToolbar; + self.transitionOptions = NSViewControllerTransitionNone; + + NSTabViewItem *flexibleWidth = [[NSTabViewItem alloc] initWithIdentifier:NSToolbarFlexibleSpaceItemIdentifier]; + flexibleWidth.viewController = [NSViewController new]; + + self.tabViewItems = @[ + TabItem(NSImageNamePreferencesGeneral, NSLocalizedString(@"General", nil), [SettingsGeneral class]), + TabItem(NSImageNameUserAccounts, NSLocalizedString(@"Feeds", nil), [SettingsFeeds class]), + TabItem(NSImageNameFontPanel, NSLocalizedString(@"Appearance", nil), [SettingsAppearance class]), + flexibleWidth, + TabItem(NSImageNameInfo, NSLocalizedString(@"About", nil), [SettingsAbout class]), + ]; + + NSInteger index = [[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"]; + if (index > 0 || (NSUInteger)index < self.tabViewItems.count) + self.selectedTabViewItemIndex = index; + } + return self; +} + +/// Helper method to generate tab item with image, label, and controller. +NS_INLINE NSTabViewItem* TabItem(NSImageName imageName, NSString *text, Class class) { + NSTabViewItem *item = [NSTabViewItem tabViewItemWithViewController: [class new]]; + item.image = [NSImage imageNamed:imageName]; + item.label = text; + return item; +} + +/// Delegate method, store last selected tab to user preferences +- (void)tabView:(NSTabView*)tabView didSelectTabViewItem:(nullable NSTabViewItem*)tabViewItem { + [super tabView:tabView didSelectTabViewItem:tabViewItem]; + NSInteger prevIndex = [[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"]; + NSInteger newIndex = self.selectedTabViewItemIndex; + if (prevIndex != newIndex) + [[NSUserDefaults standardUserDefaults] setInteger:newIndex forKey:@"preferencesTab"]; +} + +@end + + @implementation Preferences -/// Restore tab selection from previous session -- (void)windowDidLoad { - [super windowDidLoad]; - NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"]; - if (idx >= self.window.toolbar.items.count) - idx = 0; - [self tabClicked:self.window.toolbar.items[idx]]; -} - -/// Replace content view according to selected tab -- (IBAction)tabClicked:(NSToolbarItem *)sender { - self.window.contentView = nil; - if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) { - self.window.contentView = self.settingsGeneral.view; - } else if ([sender.itemIdentifier isEqualToString:@"tabFeeds"]) { - self.window.contentView = self.settingsFeeds.view; - } else if ([sender.itemIdentifier isEqualToString:@"tabAppearance"]) { - if (self.settingsGeneral.view.frame.size.width > 0) { - // using side effect when reading settingsGeneral.view -> will load appearanceView too. - // TODO: generate view programmatically - self.window.contentView = nil; - } - self.window.contentView = self.settingsGeneral.appearanceView; - } else if ([sender.itemIdentifier isEqualToString:@"tabAbout"]) { - NSDictionary *infoDict = [[NSBundle mainBundle] infoDictionary]; - self.lblAppName.objectValue = infoDict[@"CFBundleName"]; - self.lblAppVersion.stringValue = [NSString stringWithFormat:NSLocalizedString(@"Version %@", nil), infoDict[@"CFBundleShortVersionString"]]; - self.window.contentView = self.aboutView; ++ (instancetype)window { + NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable | NSWindowStyleMaskUnifiedTitleAndToolbar; + Preferences *w = [[Preferences alloc] initWithContentRect:NSMakeRect(0, 0, 320, 327) styleMask:style backing:NSBackingStoreBuffered defer:YES]; + w.contentMinSize = NSMakeSize(320, 327); + w.windowController.shouldCascadeWindows = YES; + w.title = [NSString stringWithFormat:NSLocalizedString(@"%@ Preferences", nil), NSProcessInfo.processInfo.processName]; + w.contentViewController = [PrefTabs new]; + w.delegate = w; + NSWindowPersistableFrameDescriptor prevFrame = [[NSUserDefaults standardUserDefaults] stringForKey:@"prefWindow"]; + if (!prevFrame) { + [w setContentSize:NSMakeSize(320, 327)]; + [w center]; + } else { + [w setFrameFromString:prevFrame]; } - - self.window.toolbar.selectedItemIdentifier = sender.itemIdentifier; - [self.window recalculateKeyViewLoop]; - [self.window setInitialFirstResponder:self.window.contentView]; - - NSInteger selectedIndex = (NSInteger)[self.window.toolbar.items indexOfObject:sender]; - [[NSUserDefaults standardUserDefaults] setInteger:selectedIndex forKey:@"preferencesTab"]; + return w; } -@end +- (void)windowWillClose:(NSNotification *)notification { + [[NSUserDefaults standardUserDefaults] setObject:self.stringWithSavedFrame forKey:@"prefWindow"]; +} - -/// A window that does not respond to Cmd-C, Cmd-Z, Cmd-Shift-Z and Enter-pressed events. -@interface NonRespondingWindow : NSWindow -@end - -@implementation NonRespondingWindow +/// Do not respond to Cmd-Z and Cmd-Shift-Z. Will be handled in subview controllers. - (BOOL)respondsToSelector:(SEL)aSelector { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" - if (aSelector == @selector(enterPressed:) || aSelector == @selector(copy:) - || aSelector == @selector(undo:) || aSelector == @selector(redo:)) { + if (aSelector == @selector(undo:) || aSelector == @selector(redo:)) { #pragma clang diagnostic pop return NO; } return [super respondsToSelector:aSelector]; } + @end + diff --git a/baRSS/Preferences/Preferences.xib b/baRSS/Preferences/Preferences.xib deleted file mode 100644 index d7d2adc..0000000 --- a/baRSS/Preferences/Preferences.xib +++ /dev/null @@ -1,783 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Cg - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Oleg Geier - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Cg - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (MIT License) -or - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - (MIT License) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -Cg - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/baRSS/Status Bar Menu/BarStatusItem.m b/baRSS/Status Bar Menu/BarStatusItem.m index 6b23bd4..579bdef 100644 --- a/baRSS/Status Bar Menu/BarStatusItem.m +++ b/baRSS/Status Bar Menu/BarStatusItem.m @@ -123,7 +123,7 @@ self.statusItem.title = @""; } BOOL hasNet = [FeedDownload allowNetworkConnection]; - if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"tintMenuBarIcon"]) { + if (self.unreadCountTotal > 0 && hasNet && [UserPrefs defaultYES:@"globalTintMenuBarIcon"]) { self.statusItem.image = [RSSIcon systemBarIcon:16 tint:[NSColor rssOrange] noConnection:!hasNet]; } else { self.statusItem.image = [RSSIcon systemBarIcon:16 tint:nil noConnection:!hasNet];