diff --git a/MacDown/Code/Application/MPMainController.m b/MacDown/Code/Application/MPMainController.m index 1471fb43..1bf4ca80 100644 --- a/MacDown/Code/Application/MPMainController.m +++ b/MacDown/Code/Application/MPMainController.m @@ -24,6 +24,9 @@ static NSString * const kMPTreatLastSeenStampKey = @"treatLastSeenStamp"; +static NSString * const kMPViewMenuTitle = @"View"; +static NSString * const kMPFollowSystemAppearanceMenuItemTitle = + @"Follow System Appearance"; NS_INLINE void MPOpenBundledFile(NSString *resource, NSString *extension) @@ -97,7 +100,7 @@ NS_INLINE void treat() } -@interface MPMainController () +@interface MPMainController () @property (readonly) NSWindowController *preferencesWindowController; @end @@ -112,6 +115,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification setEventHandler:self andSelector:@selector(openUrlSchemeAppleEvent:withReplyEvent:) forEventClass:kInternetEventClass andEventID:kAEGetURL]; + [self installAppearanceThemeMenuItem]; } // Open a file from a browser with url of the form : @@ -216,6 +220,26 @@ - (IBAction)openGitHub:(id)sender [[NSWorkspace sharedWorkspace] openURL:url]; } +- (IBAction)toggleAppearanceThemesFollowSystem:(id)sender +{ + MPPreferences *preferences = self.preferences; + preferences.appearanceThemesFollowSystem = + !preferences.appearanceThemesFollowSystem; +} + + +#pragma mark - NSMenuItemValidation + +- (BOOL)validateMenuItem:(NSMenuItem *)menuItem +{ + if (menuItem.action == @selector(toggleAppearanceThemesFollowSystem:)) + { + menuItem.state = self.preferences.appearanceThemesFollowSystem ? + NSControlStateValueOn : NSControlStateValueOff; + } + return YES; +} + #pragma mark - Override @@ -307,6 +331,28 @@ - (void)copyFiles } } +- (void)installAppearanceThemeMenuItem +{ + NSMenuItem *viewItem = [NSApp.mainMenu itemWithTitle:kMPViewMenuTitle]; + NSMenu *viewMenu = viewItem.submenu; + if (!viewMenu) + return; + + SEL action = @selector(toggleAppearanceThemesFollowSystem:); + for (NSMenuItem *item in viewMenu.itemArray) + { + if (item.action == action) + return; + } + + [viewMenu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *item = + [[NSMenuItem alloc] initWithTitle:kMPFollowSystemAppearanceMenuItemTitle + action:action keyEquivalent:@""]; + item.target = self; + [viewMenu addItem:item]; +} + - (void)openPendingFiles { NSDocumentController *c = [NSDocumentController sharedDocumentController]; diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 75cc0fea..aa8cb019 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -37,6 +37,8 @@ #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; +static NSString * const kMPAppleInterfaceThemeChangedNotification = + @"AppleInterfaceThemeChangedNotification"; NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) @@ -76,6 +78,8 @@ @"editorHorizontalInset", @"editorVerticalInset", @"editorWidthLimited", @"editorMaximumWidth", @"editorLineSpacing", @"editorOnRight", @"editorStyleName", @"editorShowWordCount", + @"appearanceThemesFollowSystem", @"editorLightStyleName", + @"editorDarkStyleName", @"editorScrollsPastEnd", @"htmlMathJax", @"htmlMathJaxInlineDollar", nil ]; @@ -535,6 +539,9 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller [center addObserver:self selector:@selector(previewBoundsDidChange:) name:NSViewBoundsDidChangeNotification object:self.preview.enclosingScrollView.contentView]; + [[NSDistributedNotificationCenter defaultCenter] + addObserver:self selector:@selector(applicationAppearanceDidChange:) + name:kMPAppleInterfaceThemeChangedNotification object:nil]; self.needsToUnregister = YES; @@ -654,6 +661,7 @@ - (void)close } [[NSNotificationCenter defaultCenter] removeObserver:self]; + [[NSDistributedNotificationCenter defaultCenter] removeObserver:self]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; @@ -1323,7 +1331,7 @@ - (BOOL)rendererRendersTOC:(MPRenderer *)renderer - (NSString *)rendererStyleName:(MPRenderer *)renderer { - return self.preferences.htmlStyleName; + return self.preferences.effectiveHtmlStyleName; } - (BOOL)rendererDetectsFrontMatter:(MPRenderer *)renderer @@ -1398,7 +1406,7 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html // Check if CSS style or highlighting theme has changed. // If either changed, we must do a full reload to update with new CSS links. - NSString *newStyleName = self.preferences.htmlStyleName; + NSString *newStyleName = self.preferences.effectiveHtmlStyleName; NSString *newHighlightingTheme = self.preferences.htmlHighlightingThemeName; BOOL stylesChanged = !MPAreNilableStringsEqual(self.currentStyleName, newStyleName) || !MPAreNilableStringsEqual(self.currentHighlightingThemeName, newHighlightingTheme); @@ -1689,6 +1697,15 @@ - (void)didRequestPreviewReload:(NSNotification *)notification [self render:nil]; } +- (void)applicationAppearanceDidChange:(NSNotification *)notification +{ + if (!self.preferences.appearanceThemesFollowSystem) + return; + + [self setupEditor:nil]; + [self.renderer renderIfPreferencesChanged]; +} + - (void)previewBoundsDidChange:(NSNotification *)notification { // Issue #342: Only trigger reverse sync when the user is explicitly scrolling @@ -1757,7 +1774,7 @@ - (IBAction)exportHtml:(id)sender MPExportPanelAccessoryViewController *controller = [[MPExportPanelAccessoryViewController alloc] init]; - controller.stylesIncluded = (BOOL)self.preferences.htmlStyleName; + controller.stylesIncluded = (BOOL)self.preferences.effectiveHtmlStyleName; controller.highlightingIncluded = self.preferences.htmlSyntaxHighlighting; panel.accessoryView = controller.view; @@ -2159,6 +2176,9 @@ - (void)setupEditor:(NSString *)changedKey if (!changedKey || [changedKey isEqualToString:@"editorBaseFontInfo"] || [changedKey isEqualToString:@"editorStyleName"] + || [changedKey isEqualToString:@"appearanceThemesFollowSystem"] + || [changedKey isEqualToString:@"editorLightStyleName"] + || [changedKey isEqualToString:@"editorDarkStyleName"] || [changedKey isEqualToString:@"editorLineSpacing"]) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; @@ -2192,7 +2212,7 @@ - (void)setupEditor:(NSString *)changedKey self.highlighter.styles = nil; [self.highlighter readClearTextStylesFromTextView]; - NSString *themeName = [self.preferences.editorStyleName copy]; + NSString *themeName = [self.preferences.effectiveEditorStyleName copy]; if (themeName.length) { NSString *path = MPThemePathForName(themeName); @@ -2200,7 +2220,9 @@ - (void)setupEditor:(NSString *)changedKey [self.highlighter applyStylesFromStylesheet:themeString withErrorHandler: ^(NSArray *errorMessages) { - self.preferences.editorStyleName = nil; + [self.preferences setEditorStyleName:nil + forDarkAppearance: + self.preferences.usesDarkSystemAppearance]; }]; } diff --git a/MacDown/Code/Preferences/MPEditorPreferencesViewController.m b/MacDown/Code/Preferences/MPEditorPreferencesViewController.m index 865fd87e..181b2aeb 100644 --- a/MacDown/Code/Preferences/MPEditorPreferencesViewController.m +++ b/MacDown/Code/Preferences/MPEditorPreferencesViewController.m @@ -76,7 +76,7 @@ - (void)loadThemes [self.themeSelect addItemWithTitle:@""]; [self.themeSelect addItemsWithTitles:itemTitles]; - NSString *title = [self.preferences.editorStyleName copy]; + NSString *title = [self.preferences.effectiveEditorStyleName copy]; if (title.length) [self.themeSelect selectItemWithTitle:title]; @@ -123,10 +123,17 @@ - (IBAction)changeTheme:(NSPopUpButton *)sender NSString *title = sender.selectedItem.title; // Special case: the first (empty) item. No stylesheets will be used. - if (!title.length) - self.preferences.editorStyleName = nil; + NSString *styleName = title.length ? title : nil; + if (self.preferences.appearanceThemesFollowSystem) + { + [self.preferences setEditorStyleName:styleName + forDarkAppearance: + self.preferences.usesDarkSystemAppearance]; + } else - self.preferences.editorStyleName = title; + { + self.preferences.editorStyleName = styleName; + } } - (IBAction)invokeStylesheetFunction:(NSSegmentedControl *)sender diff --git a/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m b/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m index 976e204b..c3e6d8e5 100644 --- a/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m +++ b/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m @@ -61,10 +61,17 @@ - (IBAction)changeStylesheet:(NSPopUpButton *)sender NSString *title = sender.selectedItem.title; // Special case: the first (empty) item. No stylesheets will be used. - if (!title.length) - self.preferences.htmlStyleName = nil; + NSString *styleName = title.length ? title : nil; + if (self.preferences.appearanceThemesFollowSystem) + { + [self.preferences setHtmlStyleName:styleName + forDarkAppearance: + self.preferences.usesDarkSystemAppearance]; + } else - self.preferences.htmlStyleName = title; + { + self.preferences.htmlStyleName = styleName; + } } - (IBAction)changeHighlightingTheme:(NSPopUpButton *)sender @@ -153,7 +160,7 @@ - (void)loadStylesheets [self.stylesheetSelect addItemWithTitle:@""]; [self.stylesheetSelect addItemsWithTitles:itemTitles]; - NSString *title = self.preferences.htmlStyleName; + NSString *title = self.preferences.effectiveHtmlStyleName; if (title.length) [self.stylesheetSelect selectItemWithTitle:title]; diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 3a249e3a..45e63a09 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -43,6 +43,9 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) BOOL editorSyncScrolling; @property (assign) BOOL editorSmartHome; @property (assign) NSString *editorStyleName; +@property (assign) BOOL appearanceThemesFollowSystem; +@property (assign) NSString *editorLightStyleName; +@property (assign) NSString *editorDarkStyleName; @property (assign) CGFloat editorHorizontalInset; @property (assign) CGFloat editorVerticalInset; @property (assign) CGFloat editorLineSpacing; @@ -61,6 +64,8 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) NSString *htmlTemplateName; @property (assign) NSString *htmlStyleName; +@property (assign) NSString *htmlLightStyleName; +@property (assign) NSString *htmlDarkStyleName; @property (assign) BOOL htmlDetectFrontMatter; @property (assign) BOOL htmlTaskList; @property (assign) BOOL htmlHardWrap; @@ -80,8 +85,17 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (readonly) CGFloat editorBaseFontSize; @property (nonatomic, assign) NSFont *editorBaseFont; @property (readonly) NSString *editorUnorderedListMarker; +@property (readonly) NSString *effectiveEditorStyleName; +@property (readonly) NSString *effectiveHtmlStyleName; - (instancetype)init; +- (BOOL)usesDarkSystemAppearance; +- (NSString *)editorStyleNameForDarkAppearance:(BOOL)darkAppearance; +- (NSString *)htmlStyleNameForDarkAppearance:(BOOL)darkAppearance; +- (void)setEditorStyleName:(NSString *)styleName + forDarkAppearance:(BOOL)darkAppearance; +- (void)setHtmlStyleName:(NSString *)styleName + forDarkAppearance:(BOOL)darkAppearance; // Convinience methods. @property (nonatomic, assign) NSArray *filesToOpen; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index c3d9cd5a..9816c835 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -32,7 +32,9 @@ typedef NS_ENUM(NSUInteger, MPUnorderedListMarkerType) static CGFloat const kMPDefaultEditorLineSpacing = 3.0; static BOOL const kMPDefaultEditorSyncScrolling = YES; static NSString * const kMPDefaultEditorThemeName = @"Tomorrow+"; +static NSString * const kMPDefaultEditorDarkThemeName = @"Mou Night+"; static NSString * const kMPDefaultHtmlStyleName = @"GitHub2"; +static NSString * const kMPDefaultHtmlDarkStyleName = @"Github2 (dark)"; @implementation MPPreferences @@ -242,6 +244,9 @@ - (void)migratePreferencesFromLegacyBundleIdentifierIfNeeded @dynamic editorSyncScrolling; @dynamic editorSmartHome; @dynamic editorStyleName; +@dynamic appearanceThemesFollowSystem; +@dynamic editorLightStyleName; +@dynamic editorDarkStyleName; @dynamic editorHorizontalInset; @dynamic editorVerticalInset; @dynamic editorLineSpacing; @@ -260,6 +265,8 @@ - (void)migratePreferencesFromLegacyBundleIdentifierIfNeeded @dynamic htmlTemplateName; @dynamic htmlStyleName; +@dynamic htmlLightStyleName; +@dynamic htmlDarkStyleName; @dynamic htmlDetectFrontMatter; @dynamic htmlTaskList; @dynamic htmlHardWrap; @@ -318,6 +325,69 @@ - (NSString *)editorUnorderedListMarker } } +- (BOOL)usesDarkSystemAppearance +{ + NSAppearance *appearance = NSApp.effectiveAppearance; + if (!appearance) + appearance = [NSAppearance currentAppearance]; + + NSString *match = [appearance bestMatchFromAppearancesWithNames: + @[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; + return [match isEqualToString:NSAppearanceNameDarkAqua]; +} + +- (NSString *)editorStyleNameForDarkAppearance:(BOOL)darkAppearance +{ + if (!self.appearanceThemesFollowSystem) + return self.editorStyleName; + + NSString *styleName = + darkAppearance ? self.editorDarkStyleName : self.editorLightStyleName; + return styleName.length ? styleName : self.editorStyleName; +} + +- (NSString *)htmlStyleNameForDarkAppearance:(BOOL)darkAppearance +{ + if (!self.appearanceThemesFollowSystem) + return self.htmlStyleName; + + NSString *styleName = + darkAppearance ? self.htmlDarkStyleName : self.htmlLightStyleName; + return styleName.length ? styleName : self.htmlStyleName; +} + +- (void)setEditorStyleName:(NSString *)styleName + forDarkAppearance:(BOOL)darkAppearance +{ + if (darkAppearance) + self.editorDarkStyleName = styleName; + else + self.editorLightStyleName = styleName; + self.editorStyleName = styleName; +} + +- (void)setHtmlStyleName:(NSString *)styleName + forDarkAppearance:(BOOL)darkAppearance +{ + if (darkAppearance) + self.htmlDarkStyleName = styleName; + else + self.htmlLightStyleName = styleName; + self.htmlStyleName = styleName; +} + +- (NSString *)effectiveEditorStyleName +{ + return [self editorStyleNameForDarkAppearance: + [self usesDarkSystemAppearance]]; +} + +- (NSString *)effectiveHtmlStyleName +{ + return [self htmlStyleNameForDarkAppearance: + [self usesDarkSystemAppearance]]; +} + - (NSArray *)filesToOpen { return [self.userDefaults objectForKey:kMPFilesToOpenKey @@ -406,7 +476,12 @@ - (void)loadDefaultPreferences self.editorVerticalInset = kMPDefaultEditorVerticalInset; self.editorLineSpacing = kMPDefaultEditorLineSpacing; self.editorSyncScrolling = kMPDefaultEditorSyncScrolling; + self.appearanceThemesFollowSystem = NO; + self.editorLightStyleName = kMPDefaultEditorThemeName; + self.editorDarkStyleName = kMPDefaultEditorDarkThemeName; self.htmlStyleName = kMPDefaultHtmlStyleName; + self.htmlLightStyleName = kMPDefaultHtmlStyleName; + self.htmlDarkStyleName = kMPDefaultHtmlDarkStyleName; self.htmlDefaultDirectoryUrl = [NSURL fileURLWithPath:NSHomeDirectory() isDirectory:YES]; } @@ -437,6 +512,14 @@ - (void)loadDefaultUserDefaults self.extensionStrikethough = YES; if (![defaults objectForKey:@"editorAutoSave"]) self.editorAutoSave = YES; + if (![defaults objectForKey:@"editorLightStyleName"]) + self.editorLightStyleName = self.editorStyleName ?: kMPDefaultEditorThemeName; + if (![defaults objectForKey:@"editorDarkStyleName"]) + self.editorDarkStyleName = kMPDefaultEditorDarkThemeName; + if (![defaults objectForKey:@"htmlLightStyleName"]) + self.htmlLightStyleName = self.htmlStyleName ?: kMPDefaultHtmlStyleName; + if (![defaults objectForKey:@"htmlDarkStyleName"]) + self.htmlDarkStyleName = kMPDefaultHtmlDarkStyleName; // Apply preference migrations using version-based system. [self applyPreferencesMigrations]; diff --git a/MacDownTests/MPPreferencesTests.m b/MacDownTests/MPPreferencesTests.m index 10d1461e..fc8944d1 100644 --- a/MacDownTests/MPPreferencesTests.m +++ b/MacDownTests/MPPreferencesTests.m @@ -12,6 +12,13 @@ @interface MPPreferencesTests : XCTestCase @property MPPreferences *preferences; @property NSDictionary *oldFontInfo; +@property BOOL oldAppearanceThemesFollowSystem; +@property NSString *oldEditorStyleName; +@property NSString *oldEditorLightStyleName; +@property NSString *oldEditorDarkStyleName; +@property NSString *oldHtmlStyleName; +@property NSString *oldHtmlLightStyleName; +@property NSString *oldHtmlDarkStyleName; @end @@ -22,12 +29,28 @@ - (void)setUp [super setUp]; self.preferences = [MPPreferences sharedInstance]; self.oldFontInfo = [self.preferences.editorBaseFontInfo copy]; + self.oldAppearanceThemesFollowSystem = + self.preferences.appearanceThemesFollowSystem; + self.oldEditorStyleName = [self.preferences.editorStyleName copy]; + self.oldEditorLightStyleName = [self.preferences.editorLightStyleName copy]; + self.oldEditorDarkStyleName = [self.preferences.editorDarkStyleName copy]; + self.oldHtmlStyleName = [self.preferences.htmlStyleName copy]; + self.oldHtmlLightStyleName = [self.preferences.htmlLightStyleName copy]; + self.oldHtmlDarkStyleName = [self.preferences.htmlDarkStyleName copy]; } - (void)tearDown { - // Only restore font info which is what the original test modified + // Restore shared singleton state touched by these tests. self.preferences.editorBaseFontInfo = self.oldFontInfo; + self.preferences.appearanceThemesFollowSystem = + self.oldAppearanceThemesFollowSystem; + self.preferences.editorStyleName = self.oldEditorStyleName; + self.preferences.editorLightStyleName = self.oldEditorLightStyleName; + self.preferences.editorDarkStyleName = self.oldEditorDarkStyleName; + self.preferences.htmlStyleName = self.oldHtmlStyleName; + self.preferences.htmlLightStyleName = self.oldHtmlLightStyleName; + self.preferences.htmlDarkStyleName = self.oldHtmlDarkStyleName; [self.preferences synchronize]; [super tearDown]; } @@ -215,6 +238,61 @@ - (void)testHighlightingThemeNamePersistence [self.preferences synchronize]; } +- (void)testAppearanceThemePairsUseSingleThemeWhenDisabled +{ + self.preferences.appearanceThemesFollowSystem = NO; + self.preferences.editorStyleName = @"Tomorrow+"; + self.preferences.editorLightStyleName = @"Solarized (Light)+"; + self.preferences.editorDarkStyleName = @"Mou Night+"; + self.preferences.htmlStyleName = @"GitHub2"; + self.preferences.htmlLightStyleName = @"Solarized (Light)"; + self.preferences.htmlDarkStyleName = @"Solarized (Dark)"; + + XCTAssertEqualObjects( + [self.preferences editorStyleNameForDarkAppearance:YES], + @"Tomorrow+"); + XCTAssertEqualObjects( + [self.preferences htmlStyleNameForDarkAppearance:YES], + @"GitHub2"); +} + +- (void)testAppearanceThemePairsSelectLightAndDarkThemes +{ + self.preferences.appearanceThemesFollowSystem = YES; + self.preferences.editorStyleName = @"Tomorrow+"; + self.preferences.editorLightStyleName = @"Solarized (Light)+"; + self.preferences.editorDarkStyleName = @"Mou Night+"; + self.preferences.htmlStyleName = @"GitHub2"; + self.preferences.htmlLightStyleName = @"Solarized (Light)"; + self.preferences.htmlDarkStyleName = @"Solarized (Dark)"; + + XCTAssertEqualObjects( + [self.preferences editorStyleNameForDarkAppearance:NO], + @"Solarized (Light)+"); + XCTAssertEqualObjects( + [self.preferences editorStyleNameForDarkAppearance:YES], + @"Mou Night+"); + XCTAssertEqualObjects( + [self.preferences htmlStyleNameForDarkAppearance:NO], + @"Solarized (Light)"); + XCTAssertEqualObjects( + [self.preferences htmlStyleNameForDarkAppearance:YES], + @"Solarized (Dark)"); +} + +- (void)testAppearanceThemePairSettersUpdateCurrentFallback +{ + [self.preferences setEditorStyleName:@"Mou Night+" + forDarkAppearance:YES]; + [self.preferences setHtmlStyleName:@"Solarized (Dark)" + forDarkAppearance:YES]; + + XCTAssertEqualObjects(self.preferences.editorDarkStyleName, @"Mou Night+"); + XCTAssertEqualObjects(self.preferences.editorStyleName, @"Mou Night+"); + XCTAssertEqualObjects(self.preferences.htmlDarkStyleName, @"Solarized (Dark)"); + XCTAssertEqualObjects(self.preferences.htmlStyleName, @"Solarized (Dark)"); +} + #pragma mark - Singleton Tests