diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index fa5856b9..a88e5186 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 */; }; @@ -596,6 +598,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 = ""; }; @@ -632,6 +635,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 = ""; }; @@ -1104,6 +1108,7 @@ 197TESTS0F00000000197RSTF /* MPRendererStateTests.m */, C2B84BF8A8BC4F4B871646F8 /* MPScrollSyncTests.m */, CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */, + PRVZMTST0001FILEREFID /* MPPreviewZoomTests.m */, ISSUE285SMARTQTFILEREF /* MPSmartQuoteTests.m */, PR1379INSRTRNGEFILEREFID /* MPInsertTextReplacementRangeTests.m */, PR1379HBREWFILEREFID00 /* MPHomebrewSubprocessControllerTests.m */, @@ -1141,6 +1146,7 @@ QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */, 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */, 504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */, + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */, ); path = MacDownTests; sourceTree = ""; @@ -1802,6 +1808,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 */, PR1379INSRTRNGEBUILDFILE /* MPInsertTextReplacementRangeTests.m in Sources */, PR1379HBREWBUILDFILEID /* MPHomebrewSubprocessControllerTests.m in Sources */, @@ -1846,6 +1853,7 @@ QLBLD00006PVCTESTSRC0 /* MPPreviewViewControllerTests.m in Sources */, 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */, 504AC0000000000000000005 /* MPPDFAnchorInjectorTests.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..bb7814f3 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]; } /** @@ -217,6 +268,15 @@ - (void)dropdownMenuItemClicked:(NSMenuItem *)sender } } +- (void)documentZoomPopUpClicked:(NSMenuItem *)sender +{ + MPDocument *document = self.document; + if (document) + { + [document selectDocumentZoom:sender]; + } +} + #pragma mark - NSToolbarDelegate - (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar @@ -400,13 +460,84 @@ - (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 carries the target zoom level + * (NSNumber) as its representedObject, but since self.document is nil at + * construction time (see the ivar block comment above), target/action are + * wired to -documentZoomPopUpClicked: on self, which resolves self.document + * lazily at click time and forwards to -selectDocumentZoom:, following the + * same deferred-dispatch idiom as -standaloneToolbarItemClicked: and + * -dropdownMenuItemClicked:. + */ +- (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; + added.action = @selector(documentZoomPopUpClicked:); + } + + 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 a5d5734c..657c7690 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -76,6 +76,9 @@ " return { links: links, headings: headings };" "})();"; +static const CGFloat kMPMinZoom = 0.5; +static const CGFloat kMPMaxZoom = 3.0; + NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) { if (!key.length) @@ -114,12 +117,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]; @@ -338,6 +357,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; @@ -363,6 +384,14 @@ - (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; +// Fix #4: Extracted from -windowControllerDidLoadNib:/-close so a headless +// test can register/unregister the shared-preference (zoom) KVO observer +// without needing a loaded nib. +- (void)registerSharedPreferenceObservers; +- (void)unregisterSharedPreferenceObservers; // Commit 8 (gap 9): MathJax generation counter accessor (used by tests via category) - (NSUInteger)mathJaxRenderGeneration; // Issue #504: PDF export post-processing (clickable internal anchor links). @@ -621,7 +650,6 @@ - (instancetype)init self.volumeLocalityChecker = ^BOOL(NSString *path) { return [MPFileWatcher pathIsOnLocalVolume:path]; }; - return self; } @@ -634,8 +662,6 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller { [super windowControllerDidLoadNib:controller]; - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - // All files use their absolute path to keep their window states. NSString *autosaveName = kMPDefaultAutosaveName; if (self.fileURL) @@ -660,11 +686,7 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller self.renderer.dataSource = self; self.renderer.delegate = self; - for (NSString *key in MPEditorPreferencesToObserve()) - { - [defaults addObserver:self forKeyPath:key - options:NSKeyValueObservingOptionNew context:NULL]; - } + [self registerSharedPreferenceObservers]; for (NSString *key in MPEditorKeysToObserve()) { [self.editor addObserver:self forKeyPath:key @@ -771,6 +793,28 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller }]; } +// Fix #4: Extracted from -windowControllerDidLoadNib: so a headless test can +// register the shared-preference (zoom) KVO observer directly, without a +// loaded nib. Touches only the standardUserDefaults singleton -- no nib +// outlets involved. +- (void)registerSharedPreferenceObservers +{ + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + for (NSString *key in MPEditorPreferencesToObserve()) + { + [defaults addObserver:self forKeyPath:key + options:NSKeyValueObservingOptionNew context:NULL]; + } +} + +// Fix #4: Extracted from -close (see -registerSharedPreferenceObservers). +- (void)unregisterSharedPreferenceObservers +{ + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + for (NSString *key in MPEditorPreferencesToObserve()) + [defaults removeObserver:self forKeyPath:key]; +} + - (void)reloadFromLoadedString { if (self.editor && self.renderer && self.highlighter) @@ -842,10 +886,7 @@ - (void)close [[NSNotificationCenter defaultCenter] removeObserver:self]; - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - - for (NSString *key in MPEditorPreferencesToObserve()) - [defaults removeObserver:self forKeyPath:key]; + [self unregisterSharedPreferenceObservers]; for (NSString *key in MPEditorKeysToObserve()) [self.editor removeObserver:self forKeyPath:key]; } @@ -1089,7 +1130,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 ? @@ -1145,6 +1200,10 @@ - (BOOL)validateUserInterfaceItem:(id)item ? NSControlStateValueOn : NSControlStateValueOff; return self.editor != nil; } + else if (action == @selector(selectDocumentZoom:)) + { + return YES; + } return result; } @@ -1387,6 +1446,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 @@ -1775,6 +1841,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; @@ -2118,6 +2190,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]; @@ -2851,32 +2929,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; @@ -3025,30 +3078,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.zoomMultiplier = 1.0; +} + +- (void)applyCurrentZoom +{ + [self applyEditorFontAndParagraphStyle]; + [self scaleWebview]; } /** @@ -4279,4 +4402,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.zoomMultiplier = 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..f9dc0b42 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -416,6 +416,25 @@ + + + + + + + + + + + + + + + + + + + @@ -456,7 +475,7 @@ - + @@ -466,7 +485,8 @@ - + + @@ -522,6 +542,7 @@ + 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..adffcee0 --- /dev/null +++ b/MacDownTests/MPZoomTests.m @@ -0,0 +1,677 @@ +// +// 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 - Cross-Window Zoom Testing Category + +// Fix #4 (MPDocument.m ~line 800-816) extracted registration of the shared +// documentZoomLevel KVO observer out of the nib-loading path into these two +// methods so a headless test can drive it directly. They are declared only +// in MPDocument.m's private class extension (~line 393-394), not in +// MPDocument.h, so re-declare them here. -applyCurrentZoom and +// -zoomMultiplier are already exposed by the ZoomTesting category above; +// do not redeclare them here to avoid duplicate-declaration conflicts. +@interface MPDocument (MPCrossWindowZoomTesting) +- (void)registerSharedPreferenceObservers; +- (void)unregisterSharedPreferenceObservers; +@end + +#pragma mark - Cross-Window Zoom Spy Document + +// Spies on -applyCurrentZoom (MPDocument.m ~line 3171, the KVO handler side +// effect invoked by -observeValueForKeyPath:... when the shared +// documentZoomLevel default changes) so the test can prove the *handler +// actually ran* on doc B, rather than merely reading doc B's zoomMultiplier +// (which is a passthrough over the shared pref and would read the new value +// regardless of whether doc B's observer ever fired). +@interface MPZoomSpyDocument : MPDocument +@property (nonatomic) NSInteger applyCurrentZoomCount; +@property (nonatomic) CGFloat lastAppliedMultiplier; +@end + +@implementation MPZoomSpyDocument + +- (void)applyCurrentZoom +{ + self.applyCurrentZoomCount++; + self.lastAppliedMultiplier = self.zoomMultiplier; + [super applyCurrentZoom]; +} + +@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"); +} + +/** + * zoomIn:/zoomOut: route through -stepDocumentZoomDirection: (MPDocument.m, + * defined ~line 4409), which steps across the fixed, non-uniform preset + * list returned by MPDocumentZoomLevels() (MPDocument.m ~line 133-141): + * 0.5, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 2.0, 3.0 + * From an exact preset it moves one entry in the requested direction; from + * an off-preset value it snaps to the nearest preset on the requested side + * (see the corrected "SnapsFromOffGrid" tests below, and the equivalent + * coverage of the private helper itself in MPPreviewZoomTests.m). + * There is no flat +/-0.1 step for arbitrary starting values: e.g. zoomIn: + * from 1.25 lands on 1.5, not 1.35, and zoomIn: from 0.75 lands on 0.9, not + * 0.85. The two tests that used to live here (testZoomInIncrementsMultiplier / + * testZoomOutDecrementsMultiplier) asserted 1.0 -> 1.1 and 1.0 -> 0.9, which + * happen to be numerically correct at exactly 1.0 (a preset immediately + * flanked by 0.9 and 1.1) but whose docstrings claimed a general flat-0.1 + * stepping rule that the shipped preset-snap model does not follow. They + * were removed in favor of the off-grid snap tests below, which exercise + * the actual preset-list/snap behavior rather than a coincidental data point. + */ + +/** + * 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)"); +} + +/** + * zoomIn: from an off-preset multiplier must snap UP to the nearest preset + * on the requested side, using the same model exercised against the + * private helper directly in MPPreviewZoomTests.m + * (-testSnapFromOffPresetUpRoundsUp). Per -stepDocumentZoomDirection: + * (MPDocument.m ~line 4409-4454): when the current value does not match a + * preset within epsilon, zooming in snaps to the smallest preset greater + * than the current value. 1.05 sits equidistant between the 1.0 and 1.1 + * presets (MPDocumentZoomLevels(), MPDocument.m ~line 138); the nearest-index + * scan (MPDocument.m ~line 4416-4426) keeps the first minimum found, i.e. + * 1.0, and since 1.0 < 1.05 the "snap up" branch (~line 4434-4440) + * advances one further to 1.1. + */ +- (void)testZoomInSnapsUpFromOffGridMultiplier +{ + self.document.zoomMultiplier = 1.05; + [self.document zoomIn:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.1, 0.001, + @"zoomIn: from an off-grid 1.05 should snap up " + @"to the next preset above (1.1)"); +} + +/** + * zoomOut: from an off-preset multiplier must snap DOWN to the nearest + * preset on the requested side (MPDocument.m ~line 4441-4447), mirroring + * -testSnapFromOffPresetDownRoundsDown in MPPreviewZoomTests.m. From 1.05, + * the nearest-index scan again lands on the 1.0 preset; since 1.0 is not + * greater than 1.05 the "snap down" branch does not decrement further, so + * the result is 1.0. + */ +- (void)testZoomOutSnapsDownFromOffGridMultiplier +{ + self.document.zoomMultiplier = 1.05; + [self.document zoomOut:nil]; + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.0, 0.001, + @"zoomOut: from an off-grid 1.05 should snap down " + @"to the next preset below (1.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. + * + * CORRECTED replacement for the removed testZoomThenPreferenceChangeThenZoomAgain: + * the original test's numeric expectation (1.5 -> 2.0) was actually already + * correct, but its docstring attributed that to a flat-model "advance by + * 0.1 per step" story, which is not what the code does. Per + * MPDocumentZoomLevels() (MPDocument.m ~line 133-141) the preset list is + * ..., 1.25, 1.5, 2.0, 3.0 - so 1.5 is itself an exact preset, and + * -stepDocumentZoomDirection: (MPDocument.m ~line 4409, exact-match branch + * ~line 4430-4433) simply advances to the very next entry in that list, + * which is 2.0, not "1.5 + 0.1". setupEditor: (MPDocument.m ~line 2887) + * does not read or write documentZoomLevel, so it cannot perturb the + * multiplier between the two zoom calls; this test still confirms that. + */ +- (void)testZoomThenPreferenceChangeThenZoomAgainSnapsToNextPreset +{ + 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 next preset " + @"in the list (2.0), per the preset-snap model"); +} + + +/** + * Cross-window zoom propagation: when doc A changes the shared + * documentZoomLevel default, doc B's KVO handler must actually run. + * + * Every MPDocument KVO-registers on [NSUserDefaults standardUserDefaults] + * for the keys in MPEditorPreferencesToObserve() (MPDocument.m ~line 109), + * which includes "documentZoomLevel". -observeValueForKeyPath:... (~line + * 2179-2203) special-cases that key: it calls -applyCurrentZoom and returns + * immediately (~line 2194-2198), with no other code path that could also + * invoke -applyCurrentZoom for the same KVO change. So a single write to + * the shared documentZoomLevel pref must produce exactly one call to + * -applyCurrentZoom on every registered observer -- hence the exact ( == 1 + * ) count assertion below, rather than a >= 1 relaxation. + * + * -setZoomMultiplier: (~line 3101-3105) is the writer: it clamps and stores + * into self.preferences.documentZoomLevel, which is @dynamic-backed by + * [NSUserDefaults standardUserDefaults] (MPPreferences.m line 261), so + * writing through doc A's zoomMultiplier is the same shared-pref write + * doc B's observer is watching. + * + * Doc B registers via -registerSharedPreferenceObservers directly (the + * headless entry point extracted from the nib-loading path in Fix #4) + * instead of via -makeWindowControllers/a loaded nib, keeping this test + * fully headless. Because doc B never goes through that nib path, + * -close's cleanup is gated on `needsToUnregister`, which is only set to + * YES from within that same path (~line 746) -- so -close would NOT + * unregister doc B here. Explicitly calling + * -unregisterSharedPreferenceObservers before releasing doc B is therefore + * required to avoid a KVO-observer-still-registered crash on dealloc. + * + * [super applyCurrentZoom] is headless-safe: it calls + * -applyEditorFontAndParagraphStyle, which only assigns to + * self.editor.defaultParagraphStyle/.font (~line 3151-3153) -- messaging + * the nil `editor` outlet in a headless doc B is a no-op, not a crash -- + * and -scaleWebview, which guards `if (!self.preview) return;` + * (~line 3107-3110) before touching the (nil, headless) preview. + */ +- (void)testZoomChangeInDocAPropagatesToDocB +{ + MPZoomSpyDocument *docB = [[MPZoomSpyDocument alloc] init]; + MPDocument *docA = [[MPDocument alloc] init]; + + // Known baseline on the shared pref, using the same accessor this + // file's setUp/tearDown already save and restore. + [MPPreferences sharedInstance].documentZoomLevel = 1.0; + + [docB registerSharedPreferenceObservers]; + docB.applyCurrentZoomCount = 0; // Ignore any registration-time noise. + + docA.zoomMultiplier = 1.5; // Writes the shared documentZoomLevel pref. + + XCTAssertEqual(docB.applyCurrentZoomCount, 1, + @"Doc B's zoom observer must fire exactly once when Doc A changes the shared zoom"); + XCTAssertEqualWithAccuracy(docB.lastAppliedMultiplier, 1.5, 0.001, + @"Doc B must observe the propagated multiplier"); + + // REQUIRED: doc B registered outside the nib path, so -close's + // needsToUnregister-gated cleanup will not run for it. + [docB unregisterSharedPreferenceObservers]; + docB = nil; + docA = nil; +} + + +#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