diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index f8dc9fc6..cb6763b8 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 03224E1B02E36783D5307F4A /* MPDocumentIOTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 35FB5AC5D9A67BFB58A9430F /* MPDocumentIOTests.m */; }; 0A33AF1E979F50E868FEE7DC /* libPods-MacDownCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2375876A05ADEB6544CB4EB /* libPods-MacDownCore.a */; }; 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */; }; + A1B2C3D4E5F67890ABCDEF12 /* MPZoomTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */; }; 197TESTS0B00000000197RSTB /* MPRendererStateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 197TESTS0F00000000197RSTF /* MPRendererStateTests.m */; }; 1F002A23195B3DAE008B8D93 /* MPRenderer.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F002A22195B3DAE008B8D93 /* MPRenderer.m */; }; 1F0D9D65194AC7CF008E1856 /* NSString+Lookup.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F0D9D5F194AC7CF008E1856 /* NSString+Lookup.m */; }; @@ -119,6 +120,7 @@ BA2B9EC2E95175092A97B41E /* MPResourceWatcherSet.m in Sources */ = {isa = PBXBuildFile; fileRef = E6D070A6E080254A17B3B197 /* MPResourceWatcherSet.m */; }; CCD97578A2886FFE73BD96F7 /* MPMathJaxRenderingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6811F39B13C649FC86C4CE5B /* MPMathJaxRenderingTests.m */; }; CHKBOXTGL0001BUILDFILER /* MPCheckboxToggleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */; }; + PRVZMTST0001BUILDFILER /* MPPreviewZoomTests.m in Sources */ = {isa = PBXBuildFile; fileRef = PRVZMTST0001FILEREFID /* MPPreviewZoomTests.m */; }; D29776CC6E7EB5B4AA2E0537 /* MPFileWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = A81247E840EB5C07669FF165 /* MPFileWatcher.m */; }; D3877A6637DE48017448C8DB /* MPRendererTestHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E2FC4B9389A012264DC6214 /* MPRendererTestHelpers.m */; }; DCD310373F0CCB56C7DFD720 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2FEC7D0105F2CD0464F4B6AA /* Quartz.framework */; }; @@ -590,6 +592,7 @@ 5C2E23A49DBCCD5CFA3B8104 /* libPods-MacDownQuickLook.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MacDownQuickLook.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CE5C37476A61D05FCC741F9 /* MPQuickLookPreferences.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = MacDownCore/MPQuickLookPreferences.h; sourceTree = SOURCE_ROOT; }; 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = MPPaneToggleTests.m; sourceTree = ""; }; + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = MPZoomTests.m; sourceTree = ""; }; 5F23020AD4494E700E72C264 /* MPRendererTestHelpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPRendererTestHelpers.h; sourceTree = ""; }; 615588251EB35DEE6204B94E /* MPResourceWatcherSet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPResourceWatcherSet.h; sourceTree = ""; }; 6811F39B13C649FC86C4CE5B /* MPMathJaxRenderingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPMathJaxRenderingTests.m; sourceTree = ""; }; @@ -626,6 +629,7 @@ CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPCheckboxToggleTests.m; sourceTree = ""; }; D30FDB4BDFBD13C1577ECFB4 /* MacDownCore.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = MacDownCore/MacDownCore.h; sourceTree = SOURCE_ROOT; }; D80F0F27E815934603CB0A05 /* MacDownQuickLook.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MacDownQuickLook.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + PRVZMTST0001FILEREFID /* MPPreviewZoomTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPPreviewZoomTests.m; sourceTree = ""; }; DDDB87873110C02114439C2C /* MPFileWatcher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPFileWatcher.h; sourceTree = ""; }; E64879065D20A4124AD56945 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = MacDownQuickLook/Info.plist; sourceTree = SOURCE_ROOT; }; E6D070A6E080254A17B3B197 /* MPResourceWatcherSet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPResourceWatcherSet.m; sourceTree = ""; }; @@ -1086,6 +1090,7 @@ 197TESTS0F00000000197RSTF /* MPRendererStateTests.m */, C2B84BF8A8BC4F4B871646F8 /* MPScrollSyncTests.m */, CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */, + PRVZMTST0001FILEREFID /* MPPreviewZoomTests.m */, ISSUE285SMARTQTFILEREF /* MPSmartQuoteTests.m */, 1FFEB3261972DAB400B2254F /* MPHTMLTabularizeTests.m */, 1FFEB32F19ABCD1500B2254F /* MPMarkdownRenderingTests.m */, @@ -1119,6 +1124,7 @@ QLTST00002PREF00000M /* MPQuickLookPreferencesTests.m */, QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */, 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */, + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */, ); path = MacDownTests; sourceTree = ""; @@ -1779,6 +1785,7 @@ 03224E1B02E36783D5307F4A /* MPDocumentIOTests.m in Sources */, B9A8DE030E3748EB899BD45E /* MPScrollSyncTests.m in Sources */, CHKBOXTGL0001BUILDFILER /* MPCheckboxToggleTests.m in Sources */, + PRVZMTST0001BUILDFILER /* MPPreviewZoomTests.m in Sources */, ISSUE285SMARTQTBUILDFILE /* MPSmartQuoteTests.m in Sources */, 1F51C9A5194565050015A96F /* MPPreferencesTests.m in Sources */, 1FF1420419A8A24800CF8A6A /* MPUtilityTests.m in Sources */, @@ -1820,6 +1827,7 @@ QLBLD00005PREFTESTSRC /* MPQuickLookPreferencesTests.m in Sources */, QLBLD00006PVCTESTSRC0 /* MPPreviewViewControllerTests.m in Sources */, 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */, + A1B2C3D4E5F67890ABCDEF12 /* MPZoomTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MacDown/Code/Application/MPToolbarController.m b/MacDown/Code/Application/MPToolbarController.m index e19313f1..0e9faa3f 100644 --- a/MacDown/Code/Application/MPToolbarController.m +++ b/MacDown/Code/Application/MPToolbarController.m @@ -7,6 +7,7 @@ // #import "MPToolbarController.h" +#import "MPPreferences.h" // Because we're creating selectors for methods which aren't in this class #pragma GCC diagnostic ignored "-Wundeclared-selector" @@ -14,6 +15,16 @@ static CGFloat itemWidth = 37; +// Document-zoom presets must match MPDocumentZoomLevels() in MPDocument.m. +static NSArray *MPToolbarDocumentZoomLevels(void) +{ + static NSArray *levels = nil; + static dispatch_once_t token; + dispatch_once(&token, ^{ + levels = @[@0.5, @0.75, @0.9, @1.0, @1.1, @1.25, @1.5, @2.0, @3.0]; + }); + return levels; +} @implementation MPToolbarController @@ -33,24 +44,60 @@ @implementation MPToolbarController * created, so we can't set target = self.document at construction time. */ NSMutableDictionary *standaloneItemActions; + + /** + * Weak reference to the zoom popup so we can re-sync its selected item + * when the preference changes from elsewhere (menu, keyboard shortcut). + */ + __weak NSPopUpButton *_zoomPopUp; } - (id)init { self = [super init]; - + if (!self) { return nil; } - + self->toolbarItemIdentifierObjectDictionary = [NSMutableDictionary new]; self->standaloneItemActions = [NSMutableDictionary new]; [self setupToolbarItems]; - + + // Observe NSUserDefaults so the popup's selection reflects external + // changes (View menu actions, ⌘+/⌘-/⌘0). Using KVO on the standard + // defaults avoids threading a sync callback through MPDocument. + [[NSUserDefaults standardUserDefaults] + addObserver:self + forKeyPath:@"documentZoomLevel" + options:NSKeyValueObservingOptionNew + context:NULL]; + return self; } +- (void)dealloc +{ + @try { + [[NSUserDefaults standardUserDefaults] + removeObserver:self forKeyPath:@"documentZoomLevel"]; + } @catch (NSException *exception) { + // removeObserver may throw if not registered; ignore on teardown. + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context +{ + if ([keyPath isEqualToString:@"documentZoomLevel"]) + { + [self syncDocumentZoomDisplay]; + } +} + #pragma mark - Private @@ -97,10 +144,14 @@ - (void)setupToolbarItems @[ toggleEditorMenuItem, togglePreviewMenuItem ] - ] + ], + [self toolbarItemDocumentZoomPopUpWithIdentifier:@"document-zoom" label:NSLocalizedString(@"Zoom", @"Preview pane zoom toolbar item")] ]; - + self->toolbarItemIdentifiers = [self toolbarItemIdentifiersFromItemsArray:self->toolbarItems]; + + // Reflect the persisted preference once everything is wired up. + [self syncDocumentZoomDisplay]; } /** @@ -400,13 +451,80 @@ - (NSToolbarItem *)toolbarItemDropDownWithIdentifier:(NSString *)itemIdentifier [[popupButton lastItem] setTarget:self]; [[popupButton lastItem] setAction:@selector(dropdownMenuItemClicked:)]; } - + toolbarItem.view = popupButton; - + [self->toolbarItemIdentifierObjectDictionary setObject:toolbarItem forKey:itemIdentifier]; - + return toolbarItem; } +/** + * Factory method for the document-zoom popup. Unlike the layout dropdown + * this is a regular (non-pull-down) NSPopUpButton: the currently selected + * item is shown as the button label so the user sees the active zoom + * percentage at a glance. Each menu item is wired to + * -selectDocumentZoom: on the document, with the target zoom level + * (NSNumber) attached as the item's representedObject. + */ +- (NSToolbarItem *)toolbarItemDocumentZoomPopUpWithIdentifier:(NSString *)itemIdentifier label:(NSString *)label +{ + NSToolbarItem *toolbarItem = [[NSToolbarItem alloc] initWithItemIdentifier:itemIdentifier]; + toolbarItem.label = label; + toolbarItem.paletteLabel = label; + toolbarItem.toolTip = label; + + NSPopUpButton *popupButton = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 0, 70, 27) pullsDown:NO]; + popupButton.bezelStyle = NSBezelStyleTexturedRounded; + popupButton.focusRingType = NSFocusRingTypeDefault; + + NSArray *levels = MPToolbarDocumentZoomLevels(); + for (NSNumber *level in levels) + { + NSString *title = [NSString stringWithFormat:@"%.0f%%", level.doubleValue * 100.0]; + [popupButton addItemWithTitle:title]; + NSMenuItem *added = [popupButton lastItem]; + added.representedObject = level; + added.target = self.document; + added.action = @selector(selectDocumentZoom:); + } + + toolbarItem.view = popupButton; + + [self->toolbarItemIdentifierObjectDictionary setObject:toolbarItem forKey:itemIdentifier]; + _zoomPopUp = popupButton; + + return toolbarItem; +} + +/** + * Update the popup's selection to match the current document-zoom + * preference. If the current preference matches a preset (within + * epsilon), that item is selected. Otherwise the popup falls back to + * the closest preset so the button always shows a sensible label. + */ +- (void)syncDocumentZoomDisplay +{ + NSPopUpButton *popup = _zoomPopUp; + if (!popup) + return; + + CGFloat current = [MPPreferences sharedInstance].documentZoomLevel; + NSArray *levels = MPToolbarDocumentZoomLevels(); + + NSUInteger nearestIdx = 0; + CGFloat bestDiff = CGFLOAT_MAX; + for (NSUInteger i = 0; i < levels.count; i++) + { + CGFloat diff = fabs(levels[i].doubleValue - current); + if (diff < bestDiff) + { + bestDiff = diff; + nearestIdx = i; + } + } + [popup selectItemAtIndex:(NSInteger)nearestIdx]; +} + @end diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 312f5c09..1e4e3d36 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -27,4 +27,14 @@ */ + (NSString *)toggleCheckboxAtIndex:(NSUInteger)index inMarkdown:(NSString *)markdown; +/** + * Set the shared document zoom to the level represented by the sender's + * representedObject (NSNumber). Sender may be an NSMenuItem or + * NSPopUpButton; the toolbar dropdown uses this entry point. + */ +- (IBAction)selectDocumentZoom:(id)sender; +- (IBAction)zoomIn:(id)sender; +- (IBAction)zoomOut:(id)sender; +- (IBAction)resetZoom:(id)sender; + @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index bdc56477..d2300654 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -38,6 +38,9 @@ static NSString * const kMPDefaultAutosaveName = @"Untitled"; +static const CGFloat kMPMinZoom = 0.5; +static const CGFloat kMPMaxZoom = 3.0; + NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) { @@ -77,12 +80,28 @@ @"editorWidthLimited", @"editorMaximumWidth", @"editorLineSpacing", @"editorOnRight", @"editorStyleName", @"editorShowWordCount", @"editorScrollsPastEnd", @"editorShowsInvisibleCharacters", - @"htmlMathJax", @"htmlMathJaxInlineDollar", nil + @"htmlMathJax", @"htmlMathJaxInlineDollar", + @"documentZoomLevel", nil ]; }); return keys; } +/** + * Ordered list of document zoom multipliers used by ⌘+/⌘- and the + * toolbar dropdown. Kept as a single source of truth so the popup and the + * snap-step helper cannot drift apart. + */ +NS_INLINE NSArray *MPDocumentZoomLevels() +{ + static NSArray *levels = nil; + static dispatch_once_t token; + dispatch_once(&token, ^{ + levels = @[@0.5, @0.75, @0.9, @1.0, @1.1, @1.25, @1.5, @2.0, @3.0]; + }); + return levels; +} + NS_INLINE NSString *MPRectStringForAutosaveName(NSString *name) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; @@ -285,6 +304,8 @@ typedef NS_ENUM(NSInteger, MPReferenceKind) { // Store file content in initializer until nib is loaded. @property (copy) NSString *loadedString; +@property CGFloat zoomMultiplier; + - (void)scaleWebview; - (void)syncScrollers; - (void)syncScrollersReverse; @@ -310,6 +331,9 @@ - (void)applyEditorStartInPreviewModePreference; // Issue #441: Settle / re-sync the panes when Sync Panes is toggled mid-session. - (void)handleSyncScrollingEnabled; - (void)handleSyncScrollingDisabled; +// Preview zoom helpers +- (void)applyPreviewZoom; +- (void)stepDocumentZoomDirection:(NSInteger)direction; // Commit 8 (gap 9): MathJax generation counter accessor (used by tests via category) - (NSUInteger)mathJaxRenderGeneration; @@ -564,7 +588,6 @@ - (instancetype)init self.volumeLocalityChecker = ^BOOL(NSString *path) { return [MPFileWatcher pathIsOnLocalVolume:path]; }; - return self; } @@ -1032,7 +1055,21 @@ - (BOOL)validateUserInterfaceItem:(id)item { BOOL result = [super validateUserInterfaceItem:item]; SEL action = item.action; - if (action == @selector(toggleToolbar:)) + + // Zoom menu validation + if (action == @selector(zoomIn:)) + { + return self.zoomMultiplier < kMPMaxZoom; + } + else if (action == @selector(zoomOut:)) + { + return self.zoomMultiplier > kMPMinZoom; + } + else if (action == @selector(resetZoom:)) + { + return fabs(self.zoomMultiplier - 1.0) > 0.001; + } + else if (action == @selector(toggleToolbar:)) { NSMenuItem *it = ((NSMenuItem *)item); it.title = self.toolbarVisible ? @@ -1088,6 +1125,10 @@ - (BOOL)validateUserInterfaceItem:(id)item ? NSControlStateValueOn : NSControlStateValueOff; return self.editor != nil; } + else if (action == @selector(selectDocumentZoom:)) + { + return YES; + } return result; } @@ -1330,6 +1371,13 @@ - (void)webView:(WebView *)sender didFinishLoadForFrame:(WebFrame *)frame // Issue #16: Invoke deferred operation handlers after render completes [self invokeRenderCompletionHandlers]; + + // Re-apply the preview pane page-size multiplier. WebKit resets the + // multiplier when a new document loads, so each finished mainFrame load + // needs to restore the user's preference. Restrict to mainFrame so + // subframe (e.g. iframe) loads do not stomp the top-level zoom. + if (frame == sender.mainFrame) + [self applyPreviewZoom]; } - (void)webView:(WebView *)sender didFailLoadWithError:(NSError *)error @@ -1718,6 +1766,12 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html // Fall back to full reload [self.preview.mainFrame loadHTMLString:html baseURL:baseUrl]; + // Re-apply preview zoom immediately. The WebKit page-size multiplier + // is reset by a fresh load; calling it now (in addition to the + // didFinishLoadForFrame callback) shortens the visible window where + // the preview could briefly render at 100% before our preference + // takes effect. + [self applyPreviewZoom]; self.currentBaseUrl = baseUrl; self.currentStyleName = newStyleName; self.currentHighlightingThemeName = newHighlightingTheme; @@ -2061,6 +2115,12 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object } else if (object == [NSUserDefaults standardUserDefaults]) { + // Document zoom is shared by every open window and drives both panes. + if ([keyPath isEqualToString:@"documentZoomLevel"]) + { + [self applyCurrentZoom]; + return; + } if (self.highlighter.isActive) [self setupEditor:keyPath]; [self redrawDivider]; @@ -2450,7 +2510,10 @@ - (IBAction)togglePreviewPane:(id)sender - (IBAction)toggleEditorPane:(id)sender { + BOOL wasVisible = self.editorVisible; [self toggleSplitterCollapsingEditorPane:YES]; + if (self.editorVisible != wasVisible) + self.preferences.editorStartInPreviewMode = !self.editorVisible; } - (IBAction)toggleAutoSave:(id)sender @@ -2649,32 +2712,7 @@ - (void)setupEditor:(NSString *)changedKey || [changedKey isEqualToString:@"editorStyleName"] || [changedKey isEqualToString:@"editorLineSpacing"]) { - NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - style.lineSpacing = self.preferences.editorLineSpacing; - - // Configure tab stops to match 4-space tab width (fixes #195) - NSFont *font = [self.preferences.editorBaseFont copy]; - if (font) - { - NSDictionary *attrs = @{NSFontAttributeName: font}; - CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; - CGFloat tabInterval = spaceWidth * 4; - - NSMutableArray *tabStops = [NSMutableArray array]; - for (NSInteger i = 1; i <= 100; i++) - { - NSTextTab *tab = [[NSTextTab alloc] - initWithTextAlignment:NSTextAlignmentLeft - location:tabInterval * i - options:@{}]; - [tabStops addObject:tab]; - } - style.tabStops = tabStops; - } - - self.editor.defaultParagraphStyle = [style copy]; - if (font) - self.editor.font = font; + [self applyEditorFontAndParagraphStyle]; self.editor.textColor = nil; self.editor.backgroundColor = [NSColor clearColor]; self.highlighter.styles = nil; @@ -2823,30 +2861,100 @@ - (void)redrawDivider } } +- (CGFloat)previewScale +{ + if (self.preferences.previewZoomRelativeToBaseFontSize) + { + CGFloat fontSize = self.preferences.editorBaseFontSize; + if (fontSize > 0.0) + { + static const CGFloat defaultSize = 14.0; + return (fontSize / defaultSize) * self.zoomMultiplier; + } + } + return self.zoomMultiplier; +} + +- (CGFloat)zoomMultiplier +{ + CGFloat level = self.preferences.documentZoomLevel; + return level > 0.0 ? level : 1.0; +} + +- (void)setZoomMultiplier:(CGFloat)zoomMultiplier +{ + self.preferences.documentZoomLevel = + MIN(MAX(zoomMultiplier, kMPMinZoom), kMPMaxZoom); +} + - (void)scaleWebview { - if (!self.preferences.previewZoomRelativeToBaseFontSize) + if (!self.preview) return; - CGFloat fontSize = self.preferences.editorBaseFontSize; - if (fontSize <= 0.0) - return; + CGFloat scale = [self previewScale]; + [self.preview setPageSizeMultiplier:(float)scale]; +} - static const CGFloat defaultSize = 14.0; - CGFloat scale = fontSize / defaultSize; - -#if 0 - // Sadly, this doesn’t work correctly. - // It looks fine, but selections are offset relative to the mouse cursor. - NSScrollView *previewScrollView = - self.preview.mainFrame.frameView.documentView.enclosingScrollView; - NSClipView *previewContentView = previewScrollView.contentView; - [previewContentView scaleUnitSquareToSize:NSMakeSize(scale, scale)]; - [previewContentView setNeedsDisplay:YES]; -#else - // Warning: this is private webkit API and NOT App Store-safe! - [self.preview setPageSizeMultiplier:scale]; -#endif +- (NSFont *)zoomedEditorFont +{ + NSFont *baseFont = self.preferences.editorBaseFont; + if (!baseFont) + return nil; + CGFloat zoomedSize = baseFont.pointSize * self.zoomMultiplier; + return [NSFont fontWithDescriptor:baseFont.fontDescriptor size:zoomedSize]; +} + +- (void)applyEditorFontAndParagraphStyle +{ + NSFont *font = [[self zoomedEditorFont] copy]; + + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.lineSpacing = self.preferences.editorLineSpacing; + + // Configure tab stops to match 4-space tab width (fixes #195) + if (font) + { + NSDictionary *attrs = @{NSFontAttributeName: font}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat tabInterval = spaceWidth * 4; + + NSMutableArray *tabStops = [NSMutableArray array]; + for (NSInteger i = 1; i <= 100; i++) + { + NSTextTab *tab = [[NSTextTab alloc] + initWithTextAlignment:NSTextAlignmentLeft + location:tabInterval * i + options:@{}]; + [tabStops addObject:tab]; + } + style.tabStops = tabStops; + } + + self.editor.defaultParagraphStyle = [style copy]; + if (font) + self.editor.font = font; +} + +- (IBAction)zoomIn:(id)sender +{ + [self stepDocumentZoomDirection:+1]; +} + +- (IBAction)zoomOut:(id)sender +{ + [self stepDocumentZoomDirection:-1]; +} + +- (IBAction)resetZoom:(id)sender +{ + self.preferences.documentZoomLevel = 1.0; +} + +- (void)applyCurrentZoom +{ + [self applyEditorFontAndParagraphStyle]; + [self scaleWebview]; } /** @@ -4058,4 +4166,92 @@ - (void)reloadFromDisk [self startFileWatching]; } + +#pragma mark - Document zoom + +/** + * Re-apply preview page zoom after WebKit reloads its main frame. + */ +- (void)applyPreviewZoom +{ + [self scaleWebview]; +} + +/** + * Step the shared document zoom by one preset in the requested direction. + * @param direction +1 to zoom in, -1 to zoom out. + * + * If the current zoom matches a preset (within epsilon), step from that + * preset. Otherwise snap to the nearest preset on the requested side: + * zooming in snaps up to the smallest preset greater than the current + * value; zooming out snaps down to the largest preset less than current. + * Beeps when already at the bound. + */ +- (void)stepDocumentZoomDirection:(NSInteger)direction +{ + NSArray *levels = MPDocumentZoomLevels(); + CGFloat current = self.preferences.documentZoomLevel; + if (current <= 0) current = 1.0; + + // Find index of nearest preset to the current zoom. + NSUInteger nearestIdx = 0; + CGFloat bestDiff = CGFLOAT_MAX; + for (NSUInteger i = 0; i < levels.count; i++) + { + CGFloat diff = fabs(levels[i].doubleValue - current); + if (diff < bestDiff) + { + bestDiff = diff; + nearestIdx = i; + } + } + + NSInteger targetIdx; + const CGFloat eps = 1e-6; + if (fabs(levels[nearestIdx].doubleValue - current) < eps) + { + targetIdx = (NSInteger)nearestIdx + direction; + } + else if (direction > 0) + { + // Snap up to the smallest preset > current. + targetIdx = (NSInteger)nearestIdx; + if (levels[nearestIdx].doubleValue < current) + targetIdx++; + } + else + { + // Snap down to the largest preset < current. + targetIdx = (NSInteger)nearestIdx; + if (levels[nearestIdx].doubleValue > current) + targetIdx--; + } + + if (targetIdx < 0 || targetIdx >= (NSInteger)levels.count) + { + NSBeep(); + return; + } + self.preferences.documentZoomLevel = levels[(NSUInteger)targetIdx].doubleValue; +} + +- (IBAction)selectDocumentZoom:(id)sender +{ + // Sender is an NSPopUpButton (toolbar) or NSMenuItem (future menu). + // Both carry the target level as an NSNumber in representedObject. + NSNumber *level = nil; + if ([sender isKindOfClass:[NSMenuItem class]]) + { + level = [(NSMenuItem *)sender representedObject]; + } + else if ([sender isKindOfClass:[NSPopUpButton class]]) + { + level = [[(NSPopUpButton *)sender selectedItem] representedObject]; + } + if ([level isKindOfClass:[NSNumber class]]) + { + self.preferences.documentZoomLevel = level.doubleValue; + } +} + @end diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 8c1d91c4..3ed1bd3f 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -59,6 +59,7 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) NSInteger editorUnorderedListMarkerType; @property (assign) BOOL previewZoomRelativeToBaseFontSize; +@property (assign) CGFloat documentZoomLevel; @property (assign) NSString *htmlTemplateName; @property (assign) NSString *htmlStyleName; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index c0979f44..76add6ff 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -258,6 +258,7 @@ - (void)migratePreferencesFromLegacyBundleIdentifierIfNeeded @dynamic editorUnorderedListMarkerType; @dynamic previewZoomRelativeToBaseFontSize; +@dynamic documentZoomLevel; @dynamic htmlTemplateName; @dynamic htmlStyleName; @@ -410,6 +411,7 @@ - (void)loadDefaultPreferences self.htmlStyleName = kMPDefaultHtmlStyleName; self.htmlDefaultDirectoryUrl = [NSURL fileURLWithPath:NSHomeDirectory() isDirectory:YES]; + self.documentZoomLevel = 1.0; } /** Load default preferences when the app launches. @@ -439,6 +441,13 @@ - (void)loadDefaultUserDefaults if (![defaults objectForKey:@"editorAutoSave"]) self.editorAutoSave = YES; + // Defensive default for document zoom level. Migration v6 also handles + // this, but this branch protects against any path that bypasses the + // migration code (e.g. a stale user defaults blob that already has a + // higher MPMigrationVersion but lacks this key). + if (![defaults objectForKey:@"documentZoomLevel"]) + self.documentZoomLevel = 1.0; + // Apply preference migrations using version-based system. [self applyPreferencesMigrations]; } @@ -456,6 +465,7 @@ - (void)loadDefaultUserDefaults * hide YAML front matter by default (Issue #307) * - Version 4: Clear stale split view autosave (Issue #309) * - Version 5: Auto-save preference default + * - Version 6: Document zoom level default (100%) */ - (NSInteger)effectiveMigrationVersion { @@ -494,7 +504,7 @@ - (NSInteger)effectiveMigrationVersion */ - (void)applyPreferencesMigrations { - static NSInteger const kMPCurrentMigrationVersion = 5; + static NSInteger const kMPCurrentMigrationVersion = 6; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSInteger currentVersion = [self effectiveMigrationVersion]; @@ -551,6 +561,16 @@ - (void)applyPreferencesMigrations self.editorAutoSave = YES; } + // Migration Version 6: Document zoom level default + // Establish a 100% baseline shared by the editor and preview panes. + // Without this, existing users would inherit 0.0 (the implicit default + // for a CGFloat NSNumber-backed preference), which would zero out the + // preview on first launch after upgrade. + if (currentVersion < 6) + { + self.documentZoomLevel = 1.0; + } + // Update to current version [defaults setInteger:kMPCurrentMigrationVersion forKey:@"MPMigrationVersion"]; } diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 23e7fe18..6f927ff1 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -416,6 +416,25 @@ + + + + + + + + + + + + + + + + + + + @@ -456,7 +475,8 @@ - + + @@ -466,7 +486,8 @@ - + + @@ -521,7 +542,8 @@ - + + diff --git a/MacDownTests/MPPaneToggleTests.m b/MacDownTests/MPPaneToggleTests.m index 6893ee5f..e1f6f91b 100644 --- a/MacDownTests/MPPaneToggleTests.m +++ b/MacDownTests/MPPaneToggleTests.m @@ -43,6 +43,7 @@ @implementation MockMenuItem @interface MPPaneToggleTests : XCTestCase @property (strong) MPDocument *document; +@property BOOL originalStartInPreviewMode; @end @@ -51,12 +52,17 @@ @implementation MPPaneToggleTests - (void)setUp { [super setUp]; + MPPreferences *preferences = [MPPreferences sharedInstance]; + self.originalStartInPreviewMode = preferences.editorStartInPreviewMode; + preferences.editorStartInPreviewMode = NO; self.document = [[MPDocument alloc] init]; } - (void)tearDown { self.document = nil; + [MPPreferences sharedInstance].editorStartInPreviewMode = + self.originalStartInPreviewMode; [super tearDown]; } @@ -83,6 +89,40 @@ - (void)testTogglePreviewPaneIBActionDoesNotCrash @"togglePreviewPane: should not crash"); } +- (void)testEditorPaneChoiceIsRemembered +{ + MPPreferences *preferences = [MPPreferences sharedInstance]; + BOOL originalEditorOnRight = preferences.editorOnRight; + + @try { + MPDocumentSplitView *splitView = [[MPDocumentSplitView alloc] + initWithFrame:NSMakeRect(0, 0, 800, 600)]; + splitView.vertical = YES; + NSView *editor = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 399, 600)]; + WebView *preview = [[WebView alloc] + initWithFrame:NSMakeRect(400, 0, 400, 600)]; + [splitView addSubview:editor]; + [splitView addSubview:preview]; + + self.document.splitView = splitView; + self.document.editorContainer = editor; + self.document.preview = preview; + preferences.editorOnRight = NO; + + [self.document toggleEditorPane:nil]; + XCTAssertTrue(preferences.editorStartInPreviewMode, + @"Hiding the editor should be remembered for the next window"); + + [self.document toggleEditorPane:nil]; + XCTAssertFalse(preferences.editorStartInPreviewMode, + @"Restoring the editor should update the remembered choice"); + } + @finally { + preferences.editorOnRight = originalEditorOnRight; + } +} + #pragma mark - Menu Validation Tests diff --git a/MacDownTests/MPPreferencesTests.m b/MacDownTests/MPPreferencesTests.m index a7917e9f..dcc9930e 100644 --- a/MacDownTests/MPPreferencesTests.m +++ b/MacDownTests/MPPreferencesTests.m @@ -582,10 +582,10 @@ - (void)testFreshInstallGetsCurrentMigrationVersion // Create new preferences instance MPPreferences *prefs = [[MPPreferences alloc] init]; - // Fresh install should set migration version to current (5) + // Fresh install should set migration version to current (6) NSInteger version = [defaults integerForKey:@"MPMigrationVersion"]; - XCTAssertEqual(version, 5, - @"Fresh installation should set migration version to 5"); + XCTAssertEqual(version, 6, + @"Fresh installation should set migration version to 6"); // Intra-emphasis should be disabled XCTAssertFalse(prefs.extensionIntraEmphasis, @@ -639,10 +639,10 @@ - (void)testMigrationFromLegacySubstitutionFlagOnly // Trigger initialization MPPreferences *prefs = [[MPPreferences alloc] init]; - // Migration version should be updated to 5 + // Migration version should be updated to 6 NSInteger version = [defaults integerForKey:@"MPMigrationVersion"]; - XCTAssertEqual(version, 5, - @"Migration version should be updated to 5"); + XCTAssertEqual(version, 6, + @"Migration version should be updated to 6"); // Version 2 migration: task list should be enabled XCTAssertTrue(prefs.htmlTaskList, @@ -703,10 +703,10 @@ - (void)testMigrationFromBothLegacyFlags // Trigger initialization MPPreferences *prefs = [[MPPreferences alloc] init]; - // Migration version should be updated to 5 + // Migration version should be updated to 6 NSInteger version = [defaults integerForKey:@"MPMigrationVersion"]; - XCTAssertEqual(version, 5, - @"Migration version should be updated to 5"); + XCTAssertEqual(version, 6, + @"Migration version should be updated to 6"); // Version 3 migration: intra-emphasis should be disabled XCTAssertFalse(prefs.extensionIntraEmphasis, @@ -847,10 +847,10 @@ - (void)testExistingUserAtVersion2GetsMigrated XCTAssertFalse(prefs.extensionIntraEmphasis, @"Version 3 migration should disable intra-emphasis for existing users"); - // Migration version should be updated to 5 + // Migration version should be updated to 6 NSInteger version = [defaults integerForKey:@"MPMigrationVersion"]; - XCTAssertEqual(version, 5, - @"Migration version should be updated to 5 after migration"); + XCTAssertEqual(version, 6, + @"Migration version should be updated to 6 after migration"); // Restore original values if (originalVersion) @@ -997,10 +997,10 @@ - (void)testAutoSaveMigrationDefaultsToYes XCTAssertTrue(prefs.editorAutoSave, @"Migration should default editorAutoSave to YES for existing users"); - // Migration version should be updated to 5 + // Migration version should be updated to 6 NSInteger version = [defaults integerForKey:@"MPMigrationVersion"]; - XCTAssertEqual(version, 5, - @"Migration version should be updated to 5"); + XCTAssertEqual(version, 6, + @"Migration version should be updated to 6"); // Restore original values if (originalVersion) diff --git a/MacDownTests/MPPreviewZoomTests.m b/MacDownTests/MPPreviewZoomTests.m new file mode 100644 index 00000000..edf5ae76 --- /dev/null +++ b/MacDownTests/MPPreviewZoomTests.m @@ -0,0 +1,143 @@ +// +// MPPreviewZoomTests.m +// MacDown 3000 +// +// Tests for the document zoom feature: preset snap-step semantics, +// default level, clamping at the bounds, and the resetZoom: +// reset action. These tests exercise the preference/snap logic in +// isolation from the WebView. The integration with WebView +// setPageSizeMultiplier: is covered manually because the page-size +// multiplier is a private WebKit API that is meaningful only when a +// real WebView is rendering loaded HTML. +// + +#import +#import "MPPreferences.h" +#import "MPDocument.h" + + +// Expose the private snap-step helper so tests can drive the snap logic +// directly without instantiating the full nib. The implementation lives +// in MPDocument.m; this category just makes the selector visible. +@interface MPDocument (MPPreviewZoomTests) +- (void)stepDocumentZoomDirection:(NSInteger)direction; +@end + + +@interface MPPreviewZoomTests : XCTestCase +@property (strong) MPDocument *document; +@property (assign) CGFloat originalZoomLevel; +@end + + +@implementation MPPreviewZoomTests + +- (void)setUp +{ + [super setUp]; + self.originalZoomLevel = [MPPreferences sharedInstance].documentZoomLevel; + // Instantiate MPDocument directly. The IBOutlet `preview` will be + // nil, which is fine: applyPreviewZoom guards against a nil preview + // and the snap-step logic operates on the preference only. + self.document = [[MPDocument alloc] init]; +} + +- (void)tearDown +{ + // Restore the previous preference so the user's persisted setting is + // not perturbed by the test run. + [MPPreferences sharedInstance].documentZoomLevel = self.originalZoomLevel; + [[MPPreferences sharedInstance] synchronize]; + self.document = nil; + [super tearDown]; +} + +#pragma mark - Default + +/** + * After fresh-install initialization, documentZoomLevel must be 1.0. + * loadDefaultUserDefaults sets the defensive default, and the migration + * path also sets 1.0 for upgrades. Either way, an existing test + * environment must observe a non-zero (specifically 1.0) zoom level. + */ +- (void)testDefaultZoomLevelIsOne +{ + // Re-initialize a fresh instance — sharedInstance has already run + // initialization at the start of the test process. + MPPreferences *prefs = [MPPreferences sharedInstance]; + XCTAssertEqualWithAccuracy(prefs.documentZoomLevel, 1.0, 1e-9, + @"Default preview zoom level should be 1.0"); +} + +#pragma mark - Stepping at preset boundaries + +- (void)testStepUpFromOneHundredGoesTo110 +{ + [MPPreferences sharedInstance].documentZoomLevel = 1.0; + [self.document stepDocumentZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 1.1, 1e-9, + @"Step up from 100%% should land on 110%%"); +} + +- (void)testStepDownFromOneHundredGoesTo90 +{ + [MPPreferences sharedInstance].documentZoomLevel = 1.0; + [self.document stepDocumentZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 0.9, 1e-9, + @"Step down from 100%% should land on 90%%"); +} + +#pragma mark - Clamping at bounds + +- (void)testStepUpAtMaxIsClamped +{ + [MPPreferences sharedInstance].documentZoomLevel = 3.0; + [self.document stepDocumentZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 3.0, 1e-9, + @"Stepping up at the max preset must not change the level"); +} + +- (void)testStepDownAtMinIsClamped +{ + [MPPreferences sharedInstance].documentZoomLevel = 0.5; + [self.document stepDocumentZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 0.5, 1e-9, + @"Stepping down at the min preset must not change the level"); +} + +#pragma mark - Snap-from-off-preset + +- (void)testSnapFromOffPresetUpRoundsUp +{ + [MPPreferences sharedInstance].documentZoomLevel = 1.05; + [self.document stepDocumentZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 1.1, 1e-9, + @"Step up from 105%% should snap to the next preset above (110%%)"); +} + +- (void)testSnapFromOffPresetDownRoundsDown +{ + [MPPreferences sharedInstance].documentZoomLevel = 1.05; + [self.document stepDocumentZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 1.0, 1e-9, + @"Step down from 105%% should snap to the next preset below (100%%)"); +} + +#pragma mark - Actual size action + +- (void)testActualSizeResets +{ + [MPPreferences sharedInstance].documentZoomLevel = 1.5; + [self.document resetZoom:nil]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].documentZoomLevel, + 1.0, 1e-9, + @"resetZoom: must reset zoom level to 1.0"); +} + +@end diff --git a/MacDownTests/MPToolbarControllerTests.m b/MacDownTests/MPToolbarControllerTests.m index f0b33c38..6c0d525c 100644 --- a/MacDownTests/MPToolbarControllerTests.m +++ b/MacDownTests/MPToolbarControllerTests.m @@ -148,8 +148,8 @@ - (void)testAllowedIdentifiersContainAllCustomItems - (void)testAllowedIdentifiersTotalCount { NSArray *allowed = [self.controller toolbarAllowedItemIdentifiers:nil]; - // 14 custom + 3 system (flexible space, space, separator) - XCTAssertEqual(allowed.count, 17, + // 15 custom + 3 system (flexible space, space, separator) + XCTAssertEqual(allowed.count, 18, @"Allowed identifiers should have 17 items: " @"14 custom + flexible space + space + separator"); } @@ -272,8 +272,8 @@ - (void)testSelectableIdentifiersContainAllCustomItems - (void)testSelectableIdentifiersCount { NSArray *selectable = [self.controller toolbarSelectableItemIdentifiers:nil]; - XCTAssertEqual(selectable.count, 14, - @"Selectable identifiers should have exactly 14 items (custom only, no system items)"); + XCTAssertEqual(selectable.count, 15, + @"Selectable identifiers should have exactly 15 items (custom only, no system items)"); } - (void)testSelectableIdentifiersAcceptsNilToolbar @@ -347,8 +347,8 @@ - (void)testDefaultIdentifiersContainFlexibleSpaces - (void)testDefaultIdentifiersTotalCount { NSArray *defaults = [self.controller toolbarDefaultItemIdentifiers:nil]; - XCTAssertEqual(defaults.count, 16, - @"Default toolbar should have 16 items: 11 custom + 5 flexible spaces"); + XCTAssertEqual(defaults.count, 17, + @"Default toolbar should have 17 items: 12 custom + 5 flexible spaces"); } - (void)testDefaultIdentifiersDoNotContainFixedSpaces @@ -378,7 +378,8 @@ - (void)testDefaultIdentifiersExactOrder NSToolbarFlexibleSpaceItemIdentifier, @"copy-html", NSToolbarFlexibleSpaceItemIdentifier, - @"layout" + @"layout", + @"document-zoom" ]; XCTAssertEqual(defaults.count, expected.count, diff --git a/MacDownTests/MPZoomTests.m b/MacDownTests/MPZoomTests.m new file mode 100644 index 00000000..9519d652 --- /dev/null +++ b/MacDownTests/MPZoomTests.m @@ -0,0 +1,523 @@ +// +// MPZoomTests.m +// MacDownTests +// +// Tests for the per-document zoom feature in MPDocument +// (zoomIn:/zoomOut:/resetZoom: actions, zoomMultiplier property, +// and the zoom-aware font and tab-stop code paths). +// + +#import +#import "MPDocument.h" +#import "MPEditorView.h" +#import "MPPreferences.h" + +#pragma mark - Testing Category + +@interface MPDocument (ZoomTesting) +@property CGFloat zoomMultiplier; +@property (unsafe_unretained) IBOutlet MPEditorView *editor; +- (IBAction)zoomIn:(id)sender; +- (IBAction)zoomOut:(id)sender; +- (IBAction)resetZoom:(id)sender; +- (void)applyCurrentZoom; +- (void)setupEditor:(NSString *)changedKey; +- (CGFloat)previewScale; +@end + +#pragma mark - Mock Menu Item + +// Separate class from MPPaneToggleTests.m's MockMenuItem to avoid duplicate symbol. +@interface MockZoomMenuItem : NSMenuItem +@end + +@implementation MockZoomMenuItem +@end + +#pragma mark - Test Case + +@interface MPZoomTests : XCTestCase +@property (strong) MPDocument *document; +@property (assign) CGFloat originalZoomLevel; +@end + +@implementation MPZoomTests + +- (void)setUp +{ + [super setUp]; + self.originalZoomLevel = [MPPreferences sharedInstance].documentZoomLevel; + [MPPreferences sharedInstance].documentZoomLevel = 1.0; + self.document = [[MPDocument alloc] init]; +} + +- (void)tearDown +{ + [MPPreferences sharedInstance].documentZoomLevel = self.originalZoomLevel; + self.document = nil; + [super tearDown]; +} + + +#pragma mark - Zoom Multiplier Basics + +/** + * A new document should start with zoomMultiplier of 1.0. + */ +- (void)testZoomMultiplierDefaultsToOne +{ + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.0, 0.001, + @"New document should default to zoom multiplier of 1.0"); +} + +/** + * After calling zoomIn:nil the multiplier should increase by 0.1 (to 1.1). + */ +- (void)testZoomInIncrementsMultiplier +{ + [self.document zoomIn:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.1, 0.001, + @"zoomIn: should increment multiplier by 0.1"); +} + +/** + * After calling zoomOut:nil the multiplier should decrease by 0.1 (to 0.9). + */ +- (void)testZoomOutDecrementsMultiplier +{ + [self.document zoomOut:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 0.9, 0.001, + @"zoomOut: should decrement multiplier by 0.1"); +} + +/** + * Setting multiplier to 2.0 then calling resetZoom:nil should restore 1.0. + */ +- (void)testResetZoomSetsMultiplierToOne +{ + self.document.zoomMultiplier = 2.0; + [self.document resetZoom:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.0, 0.001, + @"resetZoom: should restore multiplier to 1.0"); +} + +/** + * When already at kMPMaxZoom (3.0), zoomIn: should be a no-op. + */ +- (void)testZoomInAtMaxZoomIsNoOp +{ + self.document.zoomMultiplier = 3.0; + [self.document zoomIn:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 3.0, 0.001, + @"zoomIn: at maximum zoom should not increase multiplier"); +} + +/** + * When already at kMPMinZoom (0.5), zoomOut: should be a no-op. + */ +- (void)testZoomOutAtMinZoomIsNoOp +{ + self.document.zoomMultiplier = 0.5; + [self.document zoomOut:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 0.5, 0.001, + @"zoomOut: at minimum zoom should not decrease multiplier"); +} + +/** + * 25 zoomIn, then 50 zoomOut, then 25 zoomIn must keep multiplier within [0.5, 3.0]. + */ +- (void)testRapidZoomInOut +{ + for (int i = 0; i < 25; i++) + [self.document zoomIn:nil]; + for (int i = 0; i < 50; i++) + [self.document zoomOut:nil]; + for (int i = 0; i < 25; i++) + [self.document zoomIn:nil]; + + CGFloat m = self.document.zoomMultiplier; + XCTAssertGreaterThanOrEqual(m, 0.5 - 0.001, + @"Rapid zoom sequence must not go below minimum (0.5)"); + XCTAssertLessThanOrEqual(m, 3.0 + 0.001, + @"Rapid zoom sequence must not exceed maximum (3.0)"); +} + + +#pragma mark - Menu Validation + +/** + * validateUserInterfaceItem: should return YES for zoomIn: at default zoom (1.0). + */ +- (void)testZoomInMenuValidationEnabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom In" + action:@selector(zoomIn:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Zoom In should be enabled at default zoom (1.0)"); +} + +/** + * validateUserInterfaceItem: should return YES for zoomOut: at default zoom (1.0). + */ +- (void)testZoomOutMenuValidationEnabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom Out" + action:@selector(zoomOut:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Zoom Out should be enabled at default zoom (1.0)"); +} + +/** + * validateUserInterfaceItem: should return NO for resetZoom: at default zoom (1.0), + * because there is nothing to reset. + */ +- (void)testResetZoomMenuValidationDisabledAtDefault +{ + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Reset Zoom" + action:@selector(resetZoom:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Reset Zoom should be disabled when multiplier is already 1.0"); +} + +/** + * validateUserInterfaceItem: should return YES for resetZoom: when multiplier != 1.0. + */ +- (void)testResetZoomMenuValidationEnabledWhenZoomed +{ + self.document.zoomMultiplier = 1.5; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Reset Zoom" + action:@selector(resetZoom:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertTrue(result, @"Reset Zoom should be enabled when multiplier is not 1.0"); +} + +/** + * validateUserInterfaceItem: should return NO for zoomIn: when at maximum zoom (3.0). + */ +- (void)testZoomInMenuValidationDisabledAtMaxZoom +{ + self.document.zoomMultiplier = 3.0; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom In" + action:@selector(zoomIn:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Zoom In should be disabled at maximum zoom (3.0)"); +} + +/** + * validateUserInterfaceItem: should return NO for zoomOut: when at minimum zoom (0.5). + */ +- (void)testZoomOutMenuValidationDisabledAtMinZoom +{ + self.document.zoomMultiplier = 0.5; + MockZoomMenuItem *item = [[MockZoomMenuItem alloc] initWithTitle:@"Zoom Out" + action:@selector(zoomOut:) + keyEquivalent:@""]; + BOOL result = [self.document validateUserInterfaceItem:item]; + XCTAssertFalse(result, @"Zoom Out should be disabled at minimum zoom (0.5)"); +} + + +#pragma mark - Preference Observer Tests + +/** + * Calling setupEditor: while zoomed should not crash. + * nil changedKey exercises the full setup path. + */ +- (void)testSetupEditorDoesNotCrashWhileZoomed +{ + self.document.zoomMultiplier = 2.0; + XCTAssertNoThrow([self.document setupEditor:nil], + @"setupEditor:nil should not crash when zoomed to 2.0"); +} + +/** + * Calling setupEditor: for a line-spacing change while zoomed should not crash. + */ +- (void)testSetupEditorDoesNotCrashForLineSpacingChangeWhileZoomed +{ + self.document.zoomMultiplier = 1.3; + XCTAssertNoThrow([self.document setupEditor:@"editorLineSpacing"], + @"setupEditor:editorLineSpacing should not crash when zoomed"); +} + +/** + * Calling setupEditor: for a style change while zoomed should not crash. + */ +- (void)testSetupEditorDoesNotCrashForStyleChangeWhileZoomed +{ + self.document.zoomMultiplier = 1.3; + XCTAssertNoThrow([self.document setupEditor:@"editorStyleName"], + @"setupEditor:editorStyleName should not crash when zoomed"); +} + +/** + * After zooming to 1.5 and calling applyCurrentZoom, a subsequent + * setupEditor:editorBaseFontInfo must not revert the editor font to the + * unzoomed base size. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testSetupEditorPreservesZoomedFontSize +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testSetupEditorPreservesZoomedFontSize - editor outlet is nil (headless)"); + return; + } + + // Zoom to 1.5x and apply. + self.document.zoomMultiplier = 1.5; + [self.document applyCurrentZoom]; + + CGFloat zoomedPointSize = self.document.editor.font.pointSize; + + // Simulate a preference change that triggers font re-application. + [self.document setupEditor:@"editorBaseFontInfo"]; + + CGFloat afterSetupPointSize = self.document.editor.font.pointSize; + + XCTAssertEqualWithAccuracy(afterSetupPointSize, zoomedPointSize, 0.1, + @"setupEditor: must not revert the editor font to " + @"the unzoomed base size after applyCurrentZoom has run"); +} + +/** + * After zooming, a preference change (setupEditor:), and another zoomIn:, + * the multiplier should advance to the next persisted preset. + * This exercises the multiplier state across a full zoom -> preference -> zoom cycle. + */ +- (void)testZoomThenPreferenceChangeThenZoomAgain +{ + self.document.zoomMultiplier = 1.5; + XCTAssertNoThrow([self.document setupEditor:@"editorBaseFontInfo"], + @"setupEditor: should not throw while zoomed"); + + [self.document zoomIn:nil]; + + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 2.0, 0.001, + @"After zoom(1.5) -> setupEditor -> zoomIn, " + @"multiplier should advance to the 2.0 preset"); +} + + +#pragma mark - Tab Stop Calculation Tests + +/** + * Pure math test: a font at 2x size must produce a wider space character and + * therefore a wider tab interval than the same font at base size. + * This test does not require a window controller and always runs. + */ +- (void)testZoomedFontProducesDifferentTabWidth +{ + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + XCTAssertNotNil(baseFont, @"editorBaseFont must not be nil"); + + CGFloat baseSize = baseFont.pointSize; + CGFloat zoomedSize = baseSize * 2.0; + + NSFont *zoomedFont = [NSFont fontWithName:baseFont.fontName size:zoomedSize]; + XCTAssertNotNil(zoomedFont, @"Could not create zoomed font"); + + NSDictionary *baseAttrs = @{NSFontAttributeName: baseFont}; + NSDictionary *zoomedAttrs = @{NSFontAttributeName: zoomedFont}; + + CGFloat baseSpaceWidth = [@" " sizeWithAttributes:baseAttrs].width; + CGFloat zoomedSpaceWidth = [@" " sizeWithAttributes:zoomedAttrs].width; + + XCTAssertGreaterThan(zoomedSpaceWidth, baseSpaceWidth, + @"Space character must be wider in a larger font"); + + CGFloat baseTabInterval = baseSpaceWidth * 4.0; + CGFloat zoomedTabInterval = zoomedSpaceWidth * 4.0; + + XCTAssertGreaterThan(zoomedTabInterval, baseTabInterval, + @"Tab interval (4 spaces) must be wider at 2x zoom than at base size"); +} + +/** + * After zooming to 2.0 and applying, the first tab stop should match the + * tab interval computed from the zoomed font, not the base font. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testTabStopsReflectZoomedFontSize +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testTabStopsReflectZoomedFontSize - editor outlet is nil (headless)"); + return; + } + + if (self.document.editor.defaultParagraphStyle.tabStops.count == 0) { + NSLog(@"Skipping testTabStopsReflectZoomedFontSize - no tab stops (headless)"); + return; + } + + // Zoom to 2.0 and apply. + self.document.zoomMultiplier = 2.0; + [self.document applyCurrentZoom]; + + // Trigger the code path that recomputes tab stops. + [self.document setupEditor:@"editorBaseFontInfo"]; + + // Compute the expected tab interval from the zoomed font. + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + CGFloat zoomedSize = baseFont.pointSize * 2.0; + NSFont *zoomedFont = [NSFont fontWithName:baseFont.fontName size:zoomedSize]; + NSDictionary *attrs = @{NSFontAttributeName: zoomedFont}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat expectedTabInterval = spaceWidth * 4.0; + + NSArray *tabStops = self.document.editor.defaultParagraphStyle.tabStops; + XCTAssertGreaterThan(tabStops.count, 0U, @"There should be at least one tab stop"); + + NSTextTab *firstTab = tabStops[0]; + + XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, + @"First tab stop must reflect zoomed font size, " + @"not the unzoomed base font size"); +} + +/** + * CONDITIONAL test: at default zoom (1.0), tab stops should match the interval + * computed from the base font. This is a green test that verifies the baseline + * behaviour is correct before any zoom is applied. + * + * Skips in headless environments where the editor outlet is nil. + */ +- (void)testTabStopsAtDefaultZoomMatchBaseFont +{ + [self.document makeWindowControllers]; + + if (!self.document.editor) { + NSLog(@"Skipping testTabStopsAtDefaultZoomMatchBaseFont - editor outlet is nil (headless)"); + return; + } + + if (self.document.editor.defaultParagraphStyle.tabStops.count == 0) { + NSLog(@"Skipping testTabStopsAtDefaultZoomMatchBaseFont - no tab stops (headless)"); + return; + } + + // Ensure default zoom. + self.document.zoomMultiplier = 1.0; + [self.document setupEditor:@"editorBaseFontInfo"]; + + NSFont *baseFont = [MPPreferences sharedInstance].editorBaseFont; + NSDictionary *attrs = @{NSFontAttributeName: baseFont}; + CGFloat spaceWidth = [@" " sizeWithAttributes:attrs].width; + CGFloat expectedTabInterval = spaceWidth * 4.0; + + NSArray *tabStops = self.document.editor.defaultParagraphStyle.tabStops; + XCTAssertGreaterThan(tabStops.count, 0U, @"There should be at least one tab stop"); + + NSTextTab *firstTab = tabStops[0]; + XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, + @"At default zoom, first tab stop should match " + @"the base-font tab interval"); +} + + +#pragma mark - Preview Scale Calculation Tests + +/** + * scaleWebview routes through previewScale. These tests pin the scale + * computation across both branches of the previewZoomRelativeToBaseFontSize + * preference, including the regression risk introduced by removing the + * old early-return when the preference is OFF. + * + * Each test saves and restores the preference values it touches so the + * shared MPPreferences instance is not mutated across tests. + */ + +- (void)testPreviewScaleAtDefaultZoomWhenPreferenceOffIsOne +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = NO; + self.document.zoomMultiplier = 1.0; + + XCTAssertEqualWithAccuracy([self.document previewScale], 1.0, 0.001, + @"At default zoom with preference OFF, " + @"previewScale must be 1.0 (no-op)."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleTracksZoomMultiplierWhenPreferenceOff +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = NO; + self.document.zoomMultiplier = 1.5; + + XCTAssertEqualWithAccuracy([self.document previewScale], 1.5, 0.001, + @"With preference OFF, previewScale should " + @"equal zoomMultiplier (1.5)."); + + self.document.zoomMultiplier = 0.5; + XCTAssertEqualWithAccuracy([self.document previewScale], 0.5, 0.001, + @"With preference OFF, previewScale should " + @"track zoomMultiplier across changes."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleCombinesFontRatioAndZoomWhenPreferenceOn +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + NSFont *savedFont = prefs.editorBaseFont; + @try { + prefs.previewZoomRelativeToBaseFontSize = YES; + + NSFont *font21 = [NSFont fontWithName:savedFont.fontName size:21.0]; + if (!font21) { + NSLog(@"Skipping testPreviewScaleCombinesFontRatioAndZoomWhenPreferenceOn" + @" - cannot construct 21pt variant of base font."); + return; + } + prefs.editorBaseFont = font21; + + self.document.zoomMultiplier = 2.0; + + // 21pt / 14pt default = 1.5; combined with 2.0 zoom = 3.0. + XCTAssertEqualWithAccuracy([self.document previewScale], 3.0, 0.01, + @"With preference ON, previewScale should " + @"be (fontSize/14) * zoomMultiplier."); + } @finally { + prefs.editorBaseFont = savedFont; + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +- (void)testPreviewScaleAtDefaultZoomWhenPreferenceOnMatchesFontRatio +{ + MPPreferences *prefs = [MPPreferences sharedInstance]; + BOOL savedPref = prefs.previewZoomRelativeToBaseFontSize; + @try { + prefs.previewZoomRelativeToBaseFontSize = YES; + self.document.zoomMultiplier = 1.0; + + // At zoom 1.0, scale should match the legacy fontSize/14 behaviour. + CGFloat expected = prefs.editorBaseFontSize / 14.0; + XCTAssertEqualWithAccuracy([self.document previewScale], expected, 0.001, + @"At zoom 1.0 with preference ON, previewScale" + @" must equal the pre-PR fontSize/14 ratio."); + } @finally { + prefs.previewZoomRelativeToBaseFontSize = savedPref; + } +} + +@end