diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index fe07b64..ba756f5 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -14,6 +14,11 @@ 544FBD4521064AEB008A260C /* Python.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 544FBD4421064AEB008A260C /* Python.framework */; }; 544FBD4721064B2F008A260C /* getFeed.py in Resources */ = {isa = PBXBuildFile; fileRef = 544FBD4621064B2F008A260C /* getFeed.py */; }; 544FBD4921064DF0008A260C /* feedparser521.py in Resources */ = {isa = PBXBuildFile; fileRef = 544FBD4821064DF0008A260C /* feedparser521.py */; }; + 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 */; }; 54ACC28121061B3B0020715F /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 54ACC28021061B3B0020715F /* AppDelegate.m */; }; 54ACC28621061B3C0020715F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28521061B3C0020715F /* Assets.xcassets */; }; 54ACC28921061B3C0020715F /* Main.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54ACC28721061B3C0020715F /* Main.xib */; }; @@ -35,6 +40,13 @@ 544FBD4421064AEB008A260C /* Python.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Python.framework; path = System/Library/Frameworks/Python.framework; sourceTree = SDKROOT; }; 544FBD4621064B2F008A260C /* getFeed.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = getFeed.py; sourceTree = ""; usesTabs = 0; }; 544FBD4821064DF0008A260C /* feedparser521.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = feedparser521.py; sourceTree = ""; usesTabs = 0; }; + 546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SettingsFeeds.h; sourceTree = ""; }; + 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */ = {isa = PBXFileReference; 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; lastKnownFileType = sourcecode.c.h; path = SettingsGeneral.h; sourceTree = ""; }; + 546FC44121189975007CC3A3 /* SettingsGeneral.m */ = {isa = PBXFileReference; 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 = ""; }; 54ACC27C21061B3B0020715F /* baRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = baRSS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 54ACC27F21061B3B0020715F /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 54ACC28021061B3B0020715F /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -69,6 +81,32 @@ name = Frameworks; sourceTree = ""; }; + 546FC44521189ADC007CC3A3 /* Settings Tabs */ = { + isa = PBXGroup; + children = ( + 546FC44021189975007CC3A3 /* SettingsGeneral.h */, + 546FC44121189975007CC3A3 /* SettingsGeneral.m */, + 546FC44221189975007CC3A3 /* SettingsGeneral.xib */, + 546FC43B21188AD5007CC3A3 /* SettingsFeeds.h */, + 546FC43C21188AD5007CC3A3 /* SettingsFeeds.m */, + 546FC43E21188C78007CC3A3 /* SettingsFeeds.xib */, + ); + path = "Settings Tabs"; + sourceTree = ""; + }; + 546FC44D2118B357007CC3A3 /* Preferences */ = { + isa = PBXGroup; + children = ( + 54ACC29621061FBA0020715F /* Preferences.h */, + 54ACC29721061FBA0020715F /* Preferences.m */, + 546FC4462118A8E6007CC3A3 /* Preferences.xib */, + 546FC44521189ADC007CC3A3 /* Settings Tabs */, + 544B01182114B41200386E5C /* ModalSheet.h */, + 544B01192114B41200386E5C /* ModalSheet.m */, + ); + path = Preferences; + sourceTree = ""; + }; 549369F421091E6D001AF895 /* python */ = { isa = PBXGroup; children = ( @@ -107,12 +145,9 @@ 54ACC28021061B3B0020715F /* AppDelegate.m */, 54ACC29321061E270020715F /* NewsController.h */, 54ACC29421061E270020715F /* NewsController.m */, - 54ACC29621061FBA0020715F /* Preferences.h */, - 54ACC29721061FBA0020715F /* Preferences.m */, - 544B01182114B41200386E5C /* ModalSheet.h */, - 544B01192114B41200386E5C /* ModalSheet.m */, 54209E922117325100F3B5EF /* DrawImage.h */, 54209E932117325100F3B5EF /* DrawImage.m */, + 546FC44D2118B357007CC3A3 /* Preferences */, 54ACC28521061B3C0020715F /* Assets.xcassets */, 54ACC28721061B3C0020715F /* Main.xib */, 54ACC28A21061B3C0020715F /* Info.plist */, @@ -188,7 +223,10 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 546FC4472118A8E6007CC3A3 /* Preferences.xib in Resources */, 54ACC28621061B3C0020715F /* Assets.xcassets in Resources */, + 546FC44421189975007CC3A3 /* SettingsGeneral.xib in Resources */, + 546FC43F21188C78007CC3A3 /* SettingsFeeds.xib in Resources */, 544FBD4921064DF0008A260C /* feedparser521.py in Resources */, 544FBD4721064B2F008A260C /* getFeed.py in Resources */, 54ACC28921061B3C0020715F /* Main.xib in Resources */, @@ -204,11 +242,13 @@ files = ( 54F39C2E210BE1F700AEE730 /* DBv1.xcdatamodeld in Sources */, 544B011D2114EE9100386E5C /* AppHook.m in Sources */, + 546FC44321189975007CC3A3 /* SettingsGeneral.m in Sources */, 54ACC29521061E270020715F /* NewsController.m in Sources */, 54ACC28C21061B3C0020715F /* main.m in Sources */, 54ACC28121061B3B0020715F /* AppDelegate.m in Sources */, 544B011A2114B41200386E5C /* ModalSheet.m in Sources */, 54ACC29821061FBA0020715F /* Preferences.m in Sources */, + 546FC43D21188AD5007CC3A3 /* SettingsFeeds.m in Sources */, 1968E0AE14B8E8A90E194980 /* PyHandler.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */, ); @@ -232,6 +272,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -279,7 +320,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -290,6 +331,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -331,7 +373,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.12; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; }; diff --git a/baRSS/AppDelegate.h b/baRSS/AppDelegate.h index f02b188..c1ffa2b 100644 --- a/baRSS/AppDelegate.h +++ b/baRSS/AppDelegate.h @@ -25,5 +25,7 @@ @interface AppDelegate : NSObject @property (readonly, strong) NSPersistentContainer *persistentContainer; + +- (void)preferencesClosed; @end diff --git a/baRSS/AppDelegate.m b/baRSS/AppDelegate.m index 26cf493..9d2212e 100644 --- a/baRSS/AppDelegate.m +++ b/baRSS/AppDelegate.m @@ -23,10 +23,12 @@ #import "AppDelegate.h" #import "PyHandler.h" #import "DrawImage.h" +#import "Preferences.h" @interface AppDelegate () -@property (strong) NSStatusItem *statusItem; @property (weak) IBOutlet NSMenu *statusMenu; +@property (strong) NSStatusItem *statusItem; +@property (strong) Preferences *prefWindow; @end @implementation AppDelegate @@ -46,6 +48,17 @@ [PyHandler shutdown]; } +- (IBAction)openPreferences:(id)sender { + if (!self.prefWindow) + self.prefWindow = [[Preferences alloc] initWithWindowNibName:@"Preferences"]; + [NSApp activateIgnoringOtherApps:YES]; + [self.prefWindow showWindow:nil]; +} + +- (void)preferencesClosed { + self.prefWindow = nil; +} + #pragma mark - Core Data stack @synthesize persistentContainer = _persistentContainer; diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 78868da..5278eea 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -27,30 +27,31 @@ static NSEventModifierFlags fnKeyFlags = NSEventModifierFlagShift | NSEventModif @implementation AppHook - (void) sendEvent:(NSEvent *)event { if ([event type] == NSEventTypeKeyDown) { + if (!event.characters || event.characters.length == 0) { + [super sendEvent:event]; + return; + } NSEventModifierFlags flags = (event.modifierFlags & fnKeyFlags); // ignore caps lock, etc. unichar key = [event.characters characterAtIndex:0]; // charactersIgnoringModifiers if (flags == NSEventModifierFlagCommand) { switch (key) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wundeclared-selector" - case 'z': if ([self sendAction:@selector(undo:) to:nil from:self]) return; break; -#pragma clang diagnostic pop case 'x': if ([self sendAction:@selector(cut:) to:nil from:self]) return; break; case 'c': if ([self sendAction:@selector(copy:) to:nil from:self]) return; break; case 'v': if ([self sendAction:@selector(paste:) to:nil from:self]) return; break; case 'a': if ([self sendAction:@selector(selectAll:) to:nil from:self]) return; break; case 'q': if ([self sendAction:@selector(terminate:) to:nil from:self]) return; break; case 'w': if ([self sendAction:@selector(performClose:) to:nil from:self]) return; break; - } - } else if (flags == (NSEventModifierFlagCommand | NSEventModifierFlagShift)) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" + case 'z': if ([self sendAction:@selector(undo:) to:nil from:self]) return; break; + } + } else if (flags == (NSEventModifierFlagCommand | NSEventModifierFlagShift)) { if (key == 'z') { if ([self sendAction:@selector(redo:) to:nil from:self]) return; } } else { - if (key == 13 || key == 3) { // Enter / Return key + if (key == NSEnterCharacter || key == NSCarriageReturnCharacter) { if ([self sendAction:@selector(enterPressed:) to:nil from:self]) return; } diff --git a/baRSS/Base.lproj/Main.xib b/baRSS/Base.lproj/Main.xib index 335bf8c..6c548be 100644 --- a/baRSS/Base.lproj/Main.xib +++ b/baRSS/Base.lproj/Main.xib @@ -3,7 +3,6 @@ - @@ -23,26 +22,26 @@ - + - + - + - + @@ -51,632 +50,8 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - diff --git a/baRSS/DrawImage.m b/baRSS/DrawImage.m index 1ccb3a7..0f04680 100644 --- a/baRSS/DrawImage.m +++ b/baRSS/DrawImage.m @@ -96,8 +96,9 @@ CGContextDrawLinearGradient(c, gradient, CGPointMake(0, s), CGPointMake(s, 0), 0); CGGradientRelease(gradient); CFRelease(colors); + } else { + CGContextFillPath(c); } - CGContextFillPath(c); CGContextSetFillColorWithColor(c, [self.barsColor CGColor]); } CGContextAddPath(c, bars); diff --git a/baRSS/ModalSheet.m b/baRSS/ModalSheet.m deleted file mode 100644 index f773d95..0000000 --- a/baRSS/ModalSheet.m +++ /dev/null @@ -1,113 +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" - -#define BETWEEN(x,min,max) (x < min ? min : x > max ? max : x) - - -#pragma mark - ModalSheet - -@interface ModalSheet() -@property (weak) IBOutlet NSView *content; -@end - -@implementation ModalSheet -- (void)setFormContent:(NSView *)subcontent { - [self.content.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; - - CGFloat heightDiff = subcontent.frame.size.height - self.content.frame.size.height; - NSRect oldFrame = self.frame; - oldFrame.size.height += heightDiff; - [self setFrame:oldFrame display:NO]; - - [subcontent setFrameSize:NSMakeSize(self.content.frame.size.width, subcontent.frame.size.height)]; - [self.content addSubview:subcontent]; - [self recalculateKeyViewLoop]; - - self.minSize = NSMakeSize(323 + 40, self.frame.size.height); - self.maxSize = NSMakeSize(1200, self.frame.size.height); -} -- (IBAction)didTapDoneButton:(id)sender { - [self.sheetParent endSheet:self returnCode:NSModalResponseOK]; -} -- (IBAction)didTapCancelButton:(id)sender { - [self.sheetParent endSheet:self returnCode:NSModalResponseAbort]; -} -@end - - -#pragma mark - ModalFeedEdit - - -@implementation ModalFeedEdit -- (void)setDefaultValues { - self.url.stringValue = @""; - self.title.stringValue = @""; - self.refreshNum.intValue = 30; - [self.refreshUnit selectItemAtIndex:1]; -} -- (void)setURL:(NSString*)url name:(NSString*)name refreshNum:(int32_t)num unit:(int16_t)unit { - self.url.objectValue = url; - self.title.objectValue = name; - self.refreshNum.intValue = num; - [self.refreshUnit selectItemAtIndex:BETWEEN(unit, 0, self.refreshUnit.numberOfItems - 1)]; -} -+ (NSString*)stringForRefreshNum:(int32_t)num unit:(int16_t)unit { - return [NSString stringWithFormat:@"%d%c", num, [@"smhdw" characterAtIndex:(NSUInteger)BETWEEN(unit, 0, 4)]]; -} -@end - - -#pragma mark - ModalGroupEdit - - -@implementation ModalGroupEdit -- (void)setDefaultValues { - self.title.stringValue = @"New Group"; -} -- (void)setGroupName:(NSString*)name { - self.title.objectValue = name; -} -@end - - -#pragma mark - StrictUIntFormatter - - -@implementation StrictUIntFormatter -- (NSString *)stringForObjectValue:(id)obj { - return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]]; -} -- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error { - *obj = [[NSNumber numberWithInt:[string intValue]] stringValue]; - return YES; -} -- (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/NewsController.h b/baRSS/NewsController.h index 9067b0c..f08d98f 100644 --- a/baRSS/NewsController.h +++ b/baRSS/NewsController.h @@ -22,11 +22,6 @@ #import -@interface NewsController : NSTreeController -- (IBAction)addFeed:(NSButton *)sender; -- (IBAction)addGroup:(NSButton *)sender; -- (IBAction)addSeparator:(NSButton *)sender; +@interface NewsController : NSObject -- (NSString*)copyDescriptionOfSelectedItems; -- (void)openModalForSelection; @end diff --git a/baRSS/NewsController.m b/baRSS/NewsController.m index afedf2a..7a68527 100644 --- a/baRSS/NewsController.m +++ b/baRSS/NewsController.m @@ -22,67 +22,17 @@ #import "NewsController.h" #import "PyHandler.h" -#import "Preferences.h" #import "DBv1+CoreDataModel.h" -#import "ModalSheet.h" -#import "DrawImage.h" @interface NewsController () -@property (weak) IBOutlet Preferences *preferencesWindow; -@property (weak) IBOutlet ModalFeedEdit *viewModalEditFeed; -@property (weak) IBOutlet ModalGroupEdit *viewModalEditGroup; -@property (weak) IBOutlet NSOutlineView *outlineView; - -@property (strong) NSArray *currentlyDraggedNodes; @end @implementation NewsController -// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data... -static NSString *dragNodeType = @"baRSS-feed-drag"; - -- (void)awakeFromNib { - [super awakeFromNib]; - // Set the outline view to accept the custom drag type AbstractTreeNodeType... - [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; - [self setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; -} - -- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { - FeedConfig *f = [(NSTreeNode*)item representedObject]; - bool isFeed = (f.type == 1); - bool isSeperator = (f.type == 2); - 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) { - cellView.textField.stringValue = (!isFeed ? @"" : [ModalFeedEdit stringForRefreshNum:f.refreshNum unit:f.refreshUnit]); - } else if (isSeperator) { - return cellView; // the refresh cell is already skipped with the above if condition - } else { - cellView.textField.objectValue = f.name; - if (f.type == 0) { - cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder]; - } else { - // TODO: load icon - static NSImage *defaultRSSIcon; - if (!defaultRSSIcon) - defaultRSSIcon = [[[RSSIcon iconWithSize:cellView.imageView.frame.size] autoGradient] image]; - - cellView.imageView.image = defaultRSSIcon; - } - } - if (isFeed) // also for refresh column - cellView.textField.textColor = (f.refreshNum == 0 ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]); - return cellView; -} - - (IBAction)pauseUpdates:(NSMenuItem *)sender { NSLog(@"pause"); } + - (IBAction)updateAllFeeds:(NSMenuItem *)sender { NSLog(@"update all"); NSDictionary * obj = [PyHandler getFeed:@"https://feeds.feedburner.com/simpledesktops" withEtag:nil andModified:nil]; @@ -120,216 +70,4 @@ static NSString *dragNodeType = @"baRSS-feed-drag"; NSLog(@"all unread"); } -#pragma mark - Insert & Edit Feed Items - -- (IBAction)addFeed:(id)sender { - [self showModalForFeedConfig:nil isGroupEdit:NO]; -} - -- (IBAction)addGroup:(id)sender { - [self showModalForFeedConfig:nil isGroupEdit:YES]; -} - -- (IBAction)addSeparator:(id)sender { - [self.managedObjectContext.undoManager beginUndoGrouping]; - FeedConfig *sp = [self insertSortedItemAtSelection]; - sp.name = @"---"; - sp.type = 2; - [self.managedObjectContext.undoManager endUndoGrouping]; -} - -- (void)remove:(id)sender { - [self.managedObjectContext.undoManager beginUndoGrouping]; - for (NSIndexPath *path in self.selectionIndexPaths) - [self incrementIndicesBy:-1 forSubsequentNodes:path]; - [super remove:sender]; - [self.managedObjectContext.undoManager endUndoGrouping]; -} - -- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { - if (sender.clickedRow == -1) - return; // ignore clicks on column headers and where no row was selected - - FeedConfig *fc = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject]; - [self showModalForFeedConfig:fc isGroupEdit:YES]; // yes will be overwritten anyway -} - -- (void)openModalForSelection { - [self showModalForFeedConfig:self.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway -} - -- (void)showModalForFeedConfig:(FeedConfig*)obj isGroupEdit:(bool)group { - bool existingItem = [obj isKindOfClass:[FeedConfig class]]; - if (existingItem) { - if (obj.type == 2) return; // Separator - group = (obj.type == 0); - if (group) [self.viewModalEditGroup setGroupName:obj.name]; - else [self.viewModalEditFeed setURL:obj.url name:obj.name refreshNum:obj.refreshNum unit:obj.refreshUnit]; - } else { - if (group) [self.viewModalEditGroup setDefaultValues]; - else [self.viewModalEditFeed setDefaultValues]; - } - NSView *content = (group ? self.viewModalEditGroup : self.viewModalEditFeed); - [self.preferencesWindow presentModal:content completion:^(NSModalResponse returnCode) { - if (returnCode == NSModalResponseOK) { - [self.managedObjectContext.undoManager beginUndoGrouping]; - FeedConfig *item = obj; - if (!existingItem) { // create new item - item = [self insertSortedItemAtSelection]; - item.type = (group ? 0 : 1); - } - - if (group) { - item.name = self.viewModalEditGroup.title.stringValue; - } else { - item.name = self.viewModalEditFeed.title.stringValue; - item.url = self.viewModalEditFeed.url.stringValue; - item.refreshNum = self.viewModalEditFeed.refreshNum.intValue; - item.refreshUnit = (int16_t)self.viewModalEditFeed.refreshUnit.indexOfSelectedItem; - } - [self.managedObjectContext.undoManager endUndoGrouping]; - [self rearrangeObjects]; - } - }]; -} - -- (FeedConfig*)insertSortedItemAtSelection { - NSIndexPath *selectedIndex = [self selectionIndexPath]; - NSIndexPath *insertIndex = selectedIndex; - - FeedConfig *selected = [[[self arrangedObjects] descendantNodeAtIndexPath:selectedIndex] representedObject]; - NSUInteger lastIndex = selected.children.count; - bool groupSelected = (selected.type == 0); - - if (!groupSelected) { - lastIndex = (NSUInteger)selected.sortIndex + 1; // insert after selection - insertIndex = [insertIndex indexPathByRemovingLastIndex]; - [self incrementIndicesBy:+1 forSubsequentNodes:selectedIndex]; - --selected.sortIndex; // insert after selection - } - - FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.managedObjectContext]; - [self insertObject:newItem atArrangedObjectIndexPath:[insertIndex indexPathByAddingIndex:lastIndex]]; - // First insert, then parent, else troubles - newItem.sortIndex = (int32_t)lastIndex; - newItem.parent = (groupSelected ? selected : selected.parent); - return newItem; -} - -#pragma mark - Import & Export of Data - -- (NSString*)copyDescriptionOfSelectedItems { - NSMutableString *str = [[NSMutableString alloc] init]; - for (FeedConfig *item in self.selectedObjects) { - [self traverseChildren:item appendString:str indentation:0]; - } - [[NSPasteboard generalPasteboard] clearContents]; - [[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString]; - NSLog(@"%@", str); - return str; -} - -- (void)traverseChildren:(FeedConfig*)obj appendString:(NSMutableString*)str indentation:(int)indent { - for (int i = indent; i > 0; i--) { - [str appendString:@" "]; - } - switch (obj.type) { - case 0: [str appendFormat:@"%@:\n", obj.name]; break; // Group - case 2: [str appendString:@"-------------\n"]; break; // Separator - default: [str appendFormat:@"%@ (%@) - %@\n", obj.name, obj.url, [ModalFeedEdit stringForRefreshNum:obj.refreshNum unit:obj.refreshUnit]]; - } - for (FeedConfig *child in obj.children) { - [self traverseChildren:child appendString:str indentation:indent + 1]; - } -} - -- (void)incrementIndicesBy:(int)val forSubsequentNodes:(NSIndexPath*)path { - NSIndexPath *parentPath = [path indexPathByRemovingLastIndex]; - NSTreeNode *root = [self arrangedObjects]; - if (parentPath.length > 0) - root = [root descendantNodeAtIndexPath:parentPath]; - - for (NSUInteger i = [path indexAtPosition:path.length - 1]; i < root.childNodes.count; i++) { - ((FeedConfig*)[root.childNodes[i] representedObject]).sortIndex += val; - } -} - -#pragma mark - Dragging Support, Data Source Delegate - -- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard { - [self.managedObjectContext.undoManager beginUndoGrouping]; - [pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self]; - [pboard setString:@"dragging" forType:dragNodeType]; - self.currentlyDraggedNodes = items; - return YES; -} - -- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation { - self.currentlyDraggedNodes = nil; - [self.managedObjectContext.undoManager endUndoGrouping]; - if ([self.managedObjectContext hasChanges]) { - NSError *err; - [self.managedObjectContext save:&err]; - if (err) NSLog(@"Error: %@", err); - } -} - -- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id )info item:(id)item childIndex:(NSInteger)index { - NSArray *dstChildren = [item childNodes]; - if (!item || !dstChildren) - dstChildren = [self arrangedObjects].childNodes; - - bool isFolderDrag = (index == -1); - NSUInteger insertIndex = (isFolderDrag ? dstChildren.count : (NSUInteger)index); - // index where the items will be moved to, but not final since items above can vanish - NSIndexPath *dest = [item indexPath]; - if (!dest) dest = [NSIndexPath indexPathWithIndex:insertIndex]; - else dest = [dest indexPathByAddingIndex:insertIndex]; - - // decrement index for every item that is dragged from the same location (above the destination) - NSUInteger updateIndex = insertIndex; - for (NSTreeNode *node in self.currentlyDraggedNodes) { - NSIndexPath *nodesPath = [node indexPath]; - if ([[nodesPath indexPathByRemovingLastIndex] isEqualTo:[dest indexPathByRemovingLastIndex]] && - insertIndex > [nodesPath indexAtPosition:nodesPath.length - 1]) - { - --updateIndex; - } - } - - // decrement sort indices at source - for (NSTreeNode *node in self.currentlyDraggedNodes) - [self incrementIndicesBy:-1 forSubsequentNodes:[node indexPath]]; - // increment sort indices at destination - if (!isFolderDrag) - [self incrementIndicesBy:(int)self.currentlyDraggedNodes.count forSubsequentNodes:dest]; - - // move items - [self moveNodes:self.currentlyDraggedNodes toIndexPath:dest]; - - // set sort indices for dragged items - for (NSUInteger i = 0; i < self.currentlyDraggedNodes.count; i++) { - FeedConfig *fc = [self.currentlyDraggedNodes[i] representedObject]; - fc.sortIndex = (int32_t)(updateIndex + i); - } - return YES; -} - -- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id )info proposedItem:(id)item proposedChildIndex:(NSInteger)index { - FeedConfig *fc = [(NSTreeNode*)item representedObject]; - if (index == -1 && fc.type != 0) { // if drag is on specific item and that item isnt a group - return NSDragOperationNone; - } - - NSTreeNode *parent = item; - while (parent != nil) { - for (NSTreeNode *node in self.currentlyDraggedNodes) { - if (parent == node) - return NSDragOperationNone; // cannot move items into a child of its own - } - parent = [parent parentNode]; - } - return NSDragOperationGeneric; -} - @end diff --git a/baRSS/Preferences.m b/baRSS/Preferences.m deleted file mode 100644 index 1f437eb..0000000 --- a/baRSS/Preferences.m +++ /dev/null @@ -1,95 +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 "Preferences.h" -#import "NewsController.h" -#import "ModalSheet.h" - -@interface Preferences () -@property (weak) IBOutlet NSView *viewGeneral; -@property (weak) IBOutlet NSView *viewFeeds; -@property (weak) IBOutlet NewsController *newsController; -@end - -@implementation Preferences - -- (void)awakeFromNib { - [super awakeFromNib]; - NSUInteger idx = (NSUInteger)[[NSUserDefaults standardUserDefaults] integerForKey:@"preferencesTab"]; - if (idx >= self.toolbar.items.count) - idx = 0; - [self tabClicked:self.toolbar.items[idx]]; -} - -- (IBAction)tabClicked:(NSToolbarItem *)sender { - self.contentView = nil; - if ([sender.itemIdentifier isEqualToString:@"tabGeneral"]) - self.contentView = self.viewGeneral; - else if ([sender.itemIdentifier isEqualToString:@"tabFeeds"]) - self.contentView = self.viewFeeds; - - self.toolbar.selectedItemIdentifier = sender.itemIdentifier; - [self recalculateKeyViewLoop]; - [self setInitialFirstResponder:self.contentView]; - - [[NSUserDefaults standardUserDefaults] setInteger:(NSInteger)[self.toolbar.items indexOfObject:sender] - forKey:@"preferencesTab"]; -} - -- (void)undo:(id)sender { - [self.newsController.managedObjectContext.undoManager undo]; - [self.newsController rearrangeObjects]; // update ordering -} - -- (void)redo:(id)sender { - [self.newsController.managedObjectContext.undoManager redo]; - [self.newsController rearrangeObjects]; // update ordering -} - -- (void)copy:(id)sender { - [self.newsController copyDescriptionOfSelectedItems]; -} - -- (void)enterPressed:(id)sender { - [self.newsController openModalForSelection]; -} - -- (BOOL)respondsToSelector:(SEL)aSelector { - bool isFeedView = (self.contentView == self.viewFeeds) && !self.attachedSheet; - // Only if 'Feeds' Tab is selected & no open modal sheet & NSOutlineView has focus - if (aSelector == @selector(enterPressed:) || aSelector == @selector(copy:)) { - bool outlineHasFocus = [[self firstResponder] isKindOfClass:[NSOutlineView class]]; - return isFeedView && outlineHasFocus && (self.newsController.selectedNodes.count > 0); - } else if (aSelector == @selector(undo:)) { - return isFeedView && [self.newsController.managedObjectContext.undoManager canUndo]; - } else if (aSelector == @selector(redo:)) { - return isFeedView && [self.newsController.managedObjectContext.undoManager canRedo]; - } - return [super respondsToSelector:aSelector]; -} - -- (void)presentModal:(NSView*)view completion:(void (^ __nullable)(NSModalResponse returnCode))handler { - [self.modalSheet setFormContent:view]; - [self beginSheet:self.modalSheet completionHandler:handler]; -} - -@end diff --git a/baRSS/ModalSheet.h b/baRSS/Preferences/ModalSheet.h similarity index 95% rename from baRSS/ModalSheet.h rename to baRSS/Preferences/ModalSheet.h index 0963232..e91403c 100644 --- a/baRSS/ModalSheet.h +++ b/baRSS/Preferences/ModalSheet.h @@ -22,8 +22,8 @@ #import -@interface ModalSheet : NSWindow -- (void)setFormContent:(NSView *)subcontent; +@interface ModalSheet : NSPanel ++ (instancetype)modalWithView:(NSView*)content; @end diff --git a/baRSS/Preferences/ModalSheet.m b/baRSS/Preferences/ModalSheet.m new file mode 100644 index 0000000..aa30050 --- /dev/null +++ b/baRSS/Preferences/ModalSheet.m @@ -0,0 +1,150 @@ +// +// 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" + +#define BETWEEN(x,min,max) (x < min ? min : x > max ? max : x) + + +#pragma mark - ModalSheet + +@implementation ModalSheet + +- (void)didTapDoneButton:(id)sender { [self closeWithResponse:NSModalResponseOK]; } +- (void)didTapCancelButton:(id)sender { [self closeWithResponse:NSModalResponseAbort]; } + +- (void)closeWithResponse:(NSModalResponse)response { + // 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]; +} + ++ (instancetype)modalWithView:(NSView*)content { + static const int padWindow = 20; + static const int padButtons = 12; + static const int minWidth = 320; + static const int maxWidth = 1200; + NSInteger prevWidth = [[NSUserDefaults standardUserDefaults] integerForKey:@"modalSheetWidth"]; + + NSRect cFrame = NSMakeRect(padWindow, padWindow, BETWEEN(prevWidth, minWidth, maxWidth), content.frame.size.height); + NSRect wFrame = CGRectInset(cFrame, -padWindow, -padWindow); + + NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskFullSizeContentView; + ModalSheet *sheet = [[super alloc] initWithContentRect:wFrame styleMask:style backing:NSBackingStoreBuffered defer:NO]; + + // Respond buttons + NSButton *btnDone = [NSButton buttonWithTitle:NSLocalizedString(@"Done", nil) target:sheet action:@selector(didTapDoneButton:)]; + btnDone.keyEquivalent = @"\r"; // Enter / Return + btnDone.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin; + + NSButton *btnCancel = [NSButton buttonWithTitle:NSLocalizedString(@"Cancel", nil) target:sheet action:@selector(didTapCancelButton:)]; + btnCancel.keyEquivalent = [NSString stringWithFormat:@"%c", 0x1b]; // ESC + btnCancel.autoresizingMask = NSViewMinXMargin | NSViewMaxYMargin; + + NSRect align = [btnDone alignmentRectForFrame:btnDone.frame]; + align.origin.x = wFrame.size.width - align.size.width - padWindow; + align.origin.y = padWindow; + [btnDone setFrameOrigin:[btnDone frameForAlignmentRect:align].origin]; + + align.origin.x -= [btnCancel alignmentRectForFrame:btnCancel.frame].size.width + padButtons; + [btnCancel setFrameOrigin:[btnCancel frameForAlignmentRect:align].origin]; + + // this is equivalent, however I'm not sure if these values will change in a future OS +// [btnDone setFrameOrigin:NSMakePoint(wFrame.size.width - btnDone.frame.size.width - 12, 13)]; // =20 with alignment +// [btnCancel setFrameOrigin:NSMakePoint(btnDone.frame.origin.x - btnCancel.frame.size.width, 13)]; + + // add all UI elements to the window view + content.frame = cFrame; + [sheet.contentView addSubview:content]; + [sheet.contentView addSubview:btnDone]; + [sheet.contentView addSubview:btnCancel]; + + // add respond buttons to the window height + wFrame.size.height += align.size.height + padButtons; + [sheet setContentSize:wFrame.size]; + + // constraints on resizing + sheet.minSize = NSMakeSize(minWidth + 2 * padWindow, wFrame.size.height); + sheet.maxSize = NSMakeSize(maxWidth, wFrame.size.height); + return sheet; +} +@end + + +#pragma mark - ModalFeedEdit + + +@implementation ModalFeedEdit +- (void)setDefaultValues { + self.url.stringValue = @""; + self.title.stringValue = @""; + self.refreshNum.intValue = 30; + [self.refreshUnit selectItemAtIndex:1]; +} +- (void)setURL:(NSString*)url name:(NSString*)name refreshNum:(int32_t)num unit:(int16_t)unit { + self.url.objectValue = url; + self.title.objectValue = name; + self.refreshNum.intValue = num; + [self.refreshUnit selectItemAtIndex:BETWEEN(unit, 0, self.refreshUnit.numberOfItems - 1)]; +} ++ (NSString*)stringForRefreshNum:(int32_t)num unit:(int16_t)unit { + return [NSString stringWithFormat:@"%d%c", num, [@"smhdw" characterAtIndex:(NSUInteger)BETWEEN(unit, 0, 4)]]; +} +@end + + +#pragma mark - ModalGroupEdit + + +@implementation ModalGroupEdit +- (void)setDefaultValues { + self.title.stringValue = @"New Group"; +} +- (void)setGroupName:(NSString*)name { + self.title.objectValue = name; +} +@end + + +#pragma mark - StrictUIntFormatter + + +@implementation StrictUIntFormatter +- (NSString *)stringForObjectValue:(id)obj { + return [NSString stringWithFormat:@"%d", [[NSString stringWithFormat:@"%@", obj] intValue]]; +} +- (BOOL)getObjectValue:(out id _Nullable __autoreleasing *)obj forString:(NSString *)string errorDescription:(out NSString *__autoreleasing _Nullable *)error { + *obj = [[NSNumber numberWithInt:[string intValue]] stringValue]; + return YES; +} +- (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.h b/baRSS/Preferences/Preferences.h similarity index 84% rename from baRSS/Preferences.h rename to baRSS/Preferences/Preferences.h index 22e1869..a49d040 100644 --- a/baRSS/Preferences.h +++ b/baRSS/Preferences/Preferences.h @@ -22,10 +22,5 @@ #import -@class ModalSheet; - -@interface Preferences : NSWindow -@property (weak) IBOutlet ModalSheet *modalSheet; - -- (void)presentModal:(NSView*)view completion:(void (^ __nullable)(NSModalResponse returnCode))handler; +@interface Preferences : NSWindowController @end diff --git a/baRSS/Preferences/Preferences.m b/baRSS/Preferences/Preferences.m new file mode 100644 index 0000000..5e714be --- /dev/null +++ b/baRSS/Preferences/Preferences.m @@ -0,0 +1,82 @@ +// +// 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 "Preferences.h" +#import "SettingsFeeds.h" +#import "SettingsGeneral.h" +#import "AppDelegate.h" + + +@interface Preferences () +@property (weak) IBOutlet SettingsGeneral *settingsGeneral; +@property (weak) IBOutlet SettingsFeeds *settingsFeeds; +@end + +@implementation Preferences + +- (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]]; +} + +- (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; + } + + 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"]; +} + +- (void)windowWillClose:(NSNotification *)notification { + [(AppDelegate*)[NSApp delegate] preferencesClosed]; +} + +@end + + + +@interface NonRespondingWindow : NSWindow +@end + +@implementation NonRespondingWindow +- (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:)) { +#pragma clang diagnostic pop + return NO; + } + return [super respondsToSelector:aSelector]; +} +@end diff --git a/baRSS/Preferences/Preferences.xib b/baRSS/Preferences/Preferences.xib new file mode 100644 index 0000000..e098700 --- /dev/null +++ b/baRSS/Preferences/Preferences.xib @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/baRSS/Preferences/Settings Tabs/SettingsFeeds.h b/baRSS/Preferences/Settings Tabs/SettingsFeeds.h new file mode 100644 index 0000000..f4b06b9 --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsFeeds.h @@ -0,0 +1,27 @@ +// +// 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 + +@interface SettingsFeeds : NSViewController + +@end diff --git a/baRSS/Preferences/Settings Tabs/SettingsFeeds.m b/baRSS/Preferences/Settings Tabs/SettingsFeeds.m new file mode 100644 index 0000000..bf22f3c --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsFeeds.m @@ -0,0 +1,344 @@ +// +// 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 "SettingsFeeds.h" +#import "DBv1+CoreDataModel.h" +#import "ModalSheet.h" +#import "DrawImage.h" +#import "AppDelegate.h" + +@interface SettingsFeeds () +@property (weak) IBOutlet ModalFeedEdit *viewModalEditFeed; +@property (weak) IBOutlet ModalGroupEdit *viewModalEditGroup; +@property (weak) IBOutlet NSOutlineView *outlineView; +@property (weak) IBOutlet NSTreeController *dataStore; + +@property (strong) NSArray *currentlyDraggedNodes; +@property (strong) NSUndoManager *undoManager; +@end + +@implementation SettingsFeeds + +// Declare a string constant for the drag type - to be used when writing and retrieving pasteboard data... +static NSString *dragNodeType = @"baRSS-feed-drag"; + +- (void)viewDidLoad { + [super viewDidLoad]; + self.dataStore.managedObjectContext = [(AppDelegate*)[NSApp delegate] persistentContainer].viewContext; + self.undoManager = self.dataStore.managedObjectContext.undoManager; + [self.outlineView registerForDraggedTypes:[NSArray arrayWithObject:dragNodeType]]; + [self.dataStore setSortDescriptors:[NSArray arrayWithObject:[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:YES]]]; +} + +- (IBAction)addFeed:(id)sender { + [self showModalForFeedConfig:nil isGroupEdit:NO]; +} + +- (IBAction)addGroup:(id)sender { + [self showModalForFeedConfig:nil isGroupEdit:YES]; +} + +- (IBAction)addSeparator:(id)sender { + [self.undoManager beginUndoGrouping]; + FeedConfig *sp = [self insertSortedItemAtSelection]; + sp.name = @"---"; + sp.type = 2; + [self.undoManager endUndoGrouping]; +} + +- (IBAction)remove:(id)sender { + [self.undoManager beginUndoGrouping]; + for (NSIndexPath *path in self.dataStore.selectionIndexPaths) + [self incrementIndicesBy:-1 forSubsequentNodes:path]; + [self.dataStore remove:sender]; + [self.undoManager endUndoGrouping]; +} + +- (IBAction)doubleClickOutlineView:(NSOutlineView*)sender { + if (sender.clickedRow == -1) + return; // ignore clicks on column headers and where no row was selected + + FeedConfig *fc = [(NSTreeNode*)[sender itemAtRow:sender.clickedRow] representedObject]; + [self showModalForFeedConfig:fc isGroupEdit:YES]; // yes will be overwritten anyway +} + + +#pragma mark - Insert & Edit Feed Items + + +- (void)openModalForSelection { + [self showModalForFeedConfig:self.dataStore.selectedObjects.firstObject isGroupEdit:YES]; // yes will be overwritten anyway +} + +- (void)showModalForFeedConfig:(FeedConfig*)obj isGroupEdit:(bool)group { + bool existingItem = [obj isKindOfClass:[FeedConfig class]]; + if (existingItem) { + if (obj.type == 2) return; // Separator + group = (obj.type == 0); + if (group) [self.viewModalEditGroup setGroupName:obj.name]; + else [self.viewModalEditFeed setURL:obj.url name:obj.name refreshNum:obj.refreshNum unit:obj.refreshUnit]; + } else { + if (group) [self.viewModalEditGroup setDefaultValues]; + else [self.viewModalEditFeed setDefaultValues]; + } + NSView *content = (group ? self.viewModalEditGroup : self.viewModalEditFeed); + [self.view.window beginSheet:[ModalSheet modalWithView:content] completionHandler:^(NSModalResponse returnCode) { + if (returnCode == NSModalResponseOK) { + FeedConfig *item = obj; + if (!existingItem) { // create new item + item = [self insertSortedItemAtSelection]; + item.type = (group ? 0 : 1); + } + if (group) { + if (![item.name isEqualToString: self.viewModalEditGroup.title.stringValue]) + item.name = self.viewModalEditGroup.title.stringValue; + } else { + if (![item.name isEqualToString: self.viewModalEditFeed.title.stringValue]) + item.name = self.viewModalEditFeed.title.stringValue; + if (![item.url isEqualToString:self.viewModalEditFeed.url.stringValue]) + item.url = self.viewModalEditFeed.url.stringValue; + if (item.refreshNum != self.viewModalEditFeed.refreshNum.intValue) + item.refreshNum = self.viewModalEditFeed.refreshNum.intValue; + if (item.refreshUnit != self.viewModalEditFeed.refreshUnit.indexOfSelectedItem) + item.refreshUnit = (int16_t)self.viewModalEditFeed.refreshUnit.indexOfSelectedItem; + } + [self.dataStore rearrangeObjects]; + } + }]; +} + +- (FeedConfig*)insertSortedItemAtSelection { + NSIndexPath *selectedIndex = [self.dataStore selectionIndexPath]; + NSIndexPath *insertIndex = selectedIndex; + + FeedConfig *selected = [[[self.dataStore arrangedObjects] descendantNodeAtIndexPath:selectedIndex] representedObject]; + NSUInteger lastIndex = selected.children.count; + bool groupSelected = (selected.type == 0); + + if (!groupSelected) { + lastIndex = (NSUInteger)selected.sortIndex + 1; // insert after selection + insertIndex = [insertIndex indexPathByRemovingLastIndex]; + [self incrementIndicesBy:+1 forSubsequentNodes:selectedIndex]; + --selected.sortIndex; // insert after selection + } + + FeedConfig *newItem = [[FeedConfig alloc] initWithEntity:FeedConfig.entity insertIntoManagedObjectContext:self.dataStore.managedObjectContext]; + [self.dataStore insertObject:newItem atArrangedObjectIndexPath:[insertIndex indexPathByAddingIndex:lastIndex]]; + // First insert, then parent, else troubles + newItem.sortIndex = (int32_t)lastIndex; + newItem.parent = (groupSelected ? selected : selected.parent); + return newItem; +} + + +#pragma mark - Import & Export of Data + + +- (void)incrementIndicesBy:(int)val forSubsequentNodes:(NSIndexPath*)path { + NSIndexPath *parentPath = [path indexPathByRemovingLastIndex]; + NSTreeNode *root = [self.dataStore arrangedObjects]; + if (parentPath.length > 0) + root = [root descendantNodeAtIndexPath:parentPath]; + + for (NSUInteger i = [path indexAtPosition:path.length - 1]; i < root.childNodes.count; i++) { + ((FeedConfig*)[root.childNodes[i] representedObject]).sortIndex += val; + } +} + + +#pragma mark - Dragging Support, Data Source Delegate + + +- (BOOL)outlineView:(NSOutlineView *)outlineView writeItems:(NSArray *)items toPasteboard:(NSPasteboard *)pboard { + [self.undoManager beginUndoGrouping]; + [pboard declareTypes:[NSArray arrayWithObject:dragNodeType] owner:self]; + [pboard setString:@"dragging" forType:dragNodeType]; + self.currentlyDraggedNodes = items; + return YES; +} + +- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session endedAtPoint:(NSPoint)screenPoint operation:(NSDragOperation)operation { + self.currentlyDraggedNodes = nil; + [self.undoManager endUndoGrouping]; + if ([self.dataStore.managedObjectContext hasChanges]) { + NSError *err; + [self.dataStore.managedObjectContext save:&err]; + if (err) NSLog(@"Error: %@", err); + } +} + +- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id )info item:(id)item childIndex:(NSInteger)index { + NSArray *dstChildren = [item childNodes]; + if (!item || !dstChildren) + dstChildren = [self.dataStore arrangedObjects].childNodes; + + bool isFolderDrag = (index == -1); + NSUInteger insertIndex = (isFolderDrag ? dstChildren.count : (NSUInteger)index); + // index where the items will be moved to, but not final since items above can vanish + NSIndexPath *dest = [item indexPath]; + if (!dest) dest = [NSIndexPath indexPathWithIndex:insertIndex]; + else dest = [dest indexPathByAddingIndex:insertIndex]; + + // decrement index for every item that is dragged from the same location (above the destination) + NSUInteger updateIndex = insertIndex; + for (NSTreeNode *node in self.currentlyDraggedNodes) { + NSIndexPath *nodesPath = [node indexPath]; + if ([[nodesPath indexPathByRemovingLastIndex] isEqualTo:[dest indexPathByRemovingLastIndex]] && + insertIndex > [nodesPath indexAtPosition:nodesPath.length - 1]) + { + --updateIndex; + } + } + + // decrement sort indices at source + for (NSTreeNode *node in self.currentlyDraggedNodes) + [self incrementIndicesBy:-1 forSubsequentNodes:[node indexPath]]; + // increment sort indices at destination + if (!isFolderDrag) + [self incrementIndicesBy:(int)self.currentlyDraggedNodes.count forSubsequentNodes:dest]; + + // move items + [self.dataStore moveNodes:self.currentlyDraggedNodes toIndexPath:dest]; + + // set sort indices for dragged items + for (NSUInteger i = 0; i < self.currentlyDraggedNodes.count; i++) { + FeedConfig *fc = [self.currentlyDraggedNodes[i] representedObject]; + fc.sortIndex = (int32_t)(updateIndex + i); + } + return YES; +} + +- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id )info proposedItem:(id)item proposedChildIndex:(NSInteger)index { + FeedConfig *fc = [(NSTreeNode*)item representedObject]; + if (index == -1 && fc.type != 0) { // if drag is on specific item and that item isnt a group + return NSDragOperationNone; + } + + NSTreeNode *parent = item; + while (parent != nil) { + for (NSTreeNode *node in self.currentlyDraggedNodes) { + if (parent == node) + return NSDragOperationNone; // cannot move items into a child of its own + } + parent = [parent parentNode]; + } + return NSDragOperationGeneric; +} + + +#pragma mark - Data Source Delegate + + +- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { + FeedConfig *f = [(NSTreeNode*)item representedObject]; + bool isFeed = (f.type == 1); + bool isSeperator = (f.type == 2); + 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) { + cellView.textField.stringValue = (!isFeed ? @"" : [ModalFeedEdit stringForRefreshNum:f.refreshNum unit:f.refreshUnit]); + } else if (isSeperator) { + return cellView; // the refresh cell is already skipped with the above if condition + } else { + cellView.textField.objectValue = f.name; + if (f.type == 0) { + cellView.imageView.image = [NSImage imageNamed:NSImageNameFolder]; + } else { + // TODO: load icon + static NSImage *defaultRSSIcon; + if (!defaultRSSIcon) + defaultRSSIcon = [[[RSSIcon iconWithSize:cellView.imageView.frame.size] autoGradient] image]; + + cellView.imageView.image = defaultRSSIcon; + } + } + if (isFeed) // also for refresh column + cellView.textField.textColor = (f.refreshNum == 0 ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]); + return cellView; +} + + +#pragma mark - Keyboard Commands: undo, redo, copy, enter + + +- (BOOL)respondsToSelector:(SEL)aSelector { + if (aSelector == @selector(enterPressed:) || aSelector == @selector(copy:)) { + bool outlineHasFocus = [[self.view.window firstResponder] isKindOfClass:[NSOutlineView class]]; + return outlineHasFocus && (self.dataStore.selectedNodes.count > 0); + } else if (aSelector == @selector(undo:)) { + return [self.undoManager canUndo]; + } else if (aSelector == @selector(redo:)) { + return [self.undoManager canRedo]; + } + return [super respondsToSelector:aSelector]; +} + +- (void)undo:(id)sender { + [self.undoManager undo]; + [self.dataStore rearrangeObjects]; // update ordering +} + +- (void)redo:(id)sender { + [self.undoManager redo]; + [self.dataStore rearrangeObjects]; // update ordering +} + +- (void)enterPressed:(id)sender { + [self openModalForSelection]; +} + +- (void)copy:(id)sender { + NSMutableString *str = [[NSMutableString alloc] init]; + NSMutableArray *items = [NSMutableArray arrayWithArray:self.dataStore.selectedObjects]; + while (items.count > 0) { + [self traverseChildren:items[0] appendString:str indentation:0 onSelection:items]; + } + [[NSPasteboard generalPasteboard] clearContents]; + [[NSPasteboard generalPasteboard] setString:str forType:NSPasteboardTypeString]; + NSLog(@"%@", str); +} + +- (void)traverseChildren:(FeedConfig*)obj appendString:(NSMutableString*)str indentation:(int)indent onSelection:(NSMutableArray*)arr { + for (NSUInteger i = 0; i < arr.count; i++) { + if (obj == arr[i]) { + [arr removeObjectAtIndex:i]; + break; + } + } + for (int i = indent; i > 0; i--) { + [str appendString:@" "]; + } + switch (obj.type) { + case 0: [str appendFormat:@"%@:\n", obj.name]; break; // Group + case 2: [str appendString:@"-------------\n"]; break; // Separator + default: [str appendFormat:@"%@ (%@) - %@\n", obj.name, obj.url, [ModalFeedEdit stringForRefreshNum:obj.refreshNum unit:obj.refreshUnit]]; + } + for (FeedConfig *child in obj.children) { + [self traverseChildren:child appendString:str indentation:indent + 1 onSelection:arr]; + } +} + +@end diff --git a/baRSS/Preferences/Settings Tabs/SettingsFeeds.xib b/baRSS/Preferences/Settings Tabs/SettingsFeeds.xib new file mode 100644 index 0000000..d24ceea --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsFeeds.xib @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/baRSS/Preferences/Settings Tabs/SettingsGeneral.h b/baRSS/Preferences/Settings Tabs/SettingsGeneral.h new file mode 100644 index 0000000..9abae2a --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsGeneral.h @@ -0,0 +1,27 @@ +// +// 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 + +@interface SettingsGeneral : NSViewController + +@end diff --git a/baRSS/Preferences/Settings Tabs/SettingsGeneral.m b/baRSS/Preferences/Settings Tabs/SettingsGeneral.m new file mode 100644 index 0000000..50b7499 --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsGeneral.m @@ -0,0 +1,33 @@ +// +// 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 "SettingsGeneral.h" + +@implementation SettingsGeneral + +- (void)viewDidLoad { + [super viewDidLoad]; +} + +// TODO: show list of installed browsers and make menu choosable + +@end diff --git a/baRSS/Preferences/Settings Tabs/SettingsGeneral.xib b/baRSS/Preferences/Settings Tabs/SettingsGeneral.xib new file mode 100644 index 0000000..86d0a63 --- /dev/null +++ b/baRSS/Preferences/Settings Tabs/SettingsGeneral.xib @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +