From 5b79801a550d3dd6c98a08b465fec95ffbf97322 Mon Sep 17 00:00:00 2001 From: Steve Stonebraker Date: Mon, 2 Mar 2026 22:47:11 -0600 Subject: [PATCH 1/6] Add Cmd+/Cmd-/Cmd+0 zoom shortcuts for editor and preview Implements transient per-document zoom as requested in #335: - Cmd+ zooms in (10% increments, max 300%) - Cmd- zooms out (10% decrements, min 50%) - Cmd+0 resets to actual size - Zoom applies to both editor and preview when preference is enabled - Zoom is transient (not saved to preferences) - Menu items validate at zoom limits - Does not mutate base font preference Fixes #335 --- MacDown/Code/Document/MPDocument.h | 4 ++ MacDown/Code/Document/MPDocument.m | 63 +++++++++++++++++++- MacDown/Localization/Base.lproj/MainMenu.xib | 16 +++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 312f5c09..1f795108 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -27,4 +27,8 @@ */ + (NSString *)toggleCheckboxAtIndex:(NSUInteger)index inMarkdown:(NSString *)markdown; +- (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 6b355b5d..abc25f6f 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -254,6 +254,9 @@ typedef NS_ENUM(NSUInteger, MPScrollOwner) { // Store file content in initializer until nib is loaded. @property (copy) NSString *loadedString; +// Transient per-document zoom level (not saved to preferences) +@property CGFloat zoomMultiplier; + - (void)scaleWebview; - (void)syncScrollers; - (void)syncScrollersReverse; @@ -440,6 +443,7 @@ - (instancetype)init _scrollOwner = MPScrollOwnerNeither; self.previousSplitRatio = -1.0; self.lastNonCollapsedRatio = -1.0; + self.zoomMultiplier = 1.0; return self; } @@ -853,7 +857,23 @@ - (BOOL)validateUserInterfaceItem:(id)item { BOOL result = [super validateUserInterfaceItem:item]; SEL action = item.action; - if (action == @selector(toggleToolbar:)) + + // Zoom menu validation + if (action == @selector(zoomIn:)) + { + static const CGFloat kMaxZoom = 3.0; + return self.zoomMultiplier < kMaxZoom; + } + else if (action == @selector(zoomOut:)) + { + static const CGFloat kMinZoom = 0.5; + return self.zoomMultiplier > kMinZoom; + } + else if (action == @selector(resetZoom:)) + { + return self.zoomMultiplier != 1.0; + } + else if (action == @selector(toggleToolbar:)) { NSMenuItem *it = ((NSMenuItem *)item); it.title = self.toolbarVisible ? @@ -2325,7 +2345,7 @@ - (void)scaleWebview return; static const CGFloat defaultSize = 14.0; - CGFloat scale = fontSize / defaultSize; + CGFloat scale = (fontSize / defaultSize) * self.zoomMultiplier; #if 0 // Sadly, this doesn’t work correctly. @@ -2341,6 +2361,45 @@ - (void)scaleWebview #endif } +- (IBAction)zoomIn:(id)sender +{ + static const CGFloat kMaxZoom = 3.0; + if (self.zoomMultiplier >= kMaxZoom) + return; + + self.zoomMultiplier = MIN(self.zoomMultiplier + 0.1, kMaxZoom); + [self applyCurrentZoom]; +} + +- (IBAction)zoomOut:(id)sender +{ + static const CGFloat kMinZoom = 0.5; + if (self.zoomMultiplier <= kMinZoom) + return; + + self.zoomMultiplier = MAX(self.zoomMultiplier - 0.1, kMinZoom); + [self applyCurrentZoom]; +} + +- (IBAction)resetZoom:(id)sender +{ + self.zoomMultiplier = 1.0; + [self applyCurrentZoom]; +} + +- (void)applyCurrentZoom +{ + NSFont *baseFont = self.preferences.editorBaseFont; + CGFloat zoomedSize = baseFont.pointSize * self.zoomMultiplier; + + // Apply zoom to editor (transient, not saved to preferences) + [self.editor setFont:[NSFont fontWithName:baseFont.fontName + size:zoomedSize]]; + + // Apply zoom to preview + [self scaleWebview]; +} + /** * Updates cached positions of reference points (headers, standalone images) in both * editor and preview for scroll synchronization. diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 57a5b077..4728f596 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -405,6 +405,22 @@ + + + + + + + + + + + + + + + + From 907530cf9693da729663af9c4ca2da8f84cb5573 Mon Sep 17 00:00:00 2001 From: Steve Stonebraker Date: Fri, 20 Mar 2026 17:15:32 -0500 Subject: [PATCH 2/6] Address review feedback for zoom shortcuts - Move kMinZoom/kMaxZoom to file-scope constants - Fix preview zoom when previewZoomRelativeToBaseFontSize is off - Fix floating-point comparison in resetZoom: validation --- MacDown/Code/Document/MPDocument.m | 40 ++++++++++++++++-------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index abc25f6f..069137c6 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) { @@ -861,17 +864,15 @@ - (BOOL)validateUserInterfaceItem:(id)item // Zoom menu validation if (action == @selector(zoomIn:)) { - static const CGFloat kMaxZoom = 3.0; - return self.zoomMultiplier < kMaxZoom; + return self.zoomMultiplier < kMPMaxZoom; } else if (action == @selector(zoomOut:)) { - static const CGFloat kMinZoom = 0.5; - return self.zoomMultiplier > kMinZoom; + return self.zoomMultiplier > kMPMinZoom; } else if (action == @selector(resetZoom:)) { - return self.zoomMultiplier != 1.0; + return fabs(self.zoomMultiplier - 1.0) > 0.001; } else if (action == @selector(toggleToolbar:)) { @@ -2337,16 +2338,19 @@ - (void)redrawDivider - (void)scaleWebview { - if (!self.preferences.previewZoomRelativeToBaseFontSize) - return; + CGFloat scale = self.zoomMultiplier; - CGFloat fontSize = self.preferences.editorBaseFontSize; - if (fontSize <= 0.0) - return; + if (self.preferences.previewZoomRelativeToBaseFontSize) + { + CGFloat fontSize = self.preferences.editorBaseFontSize; + if (fontSize > 0.0) + { + static const CGFloat defaultSize = 14.0; + scale = (fontSize / defaultSize) + * self.zoomMultiplier; + } + } - static const CGFloat defaultSize = 14.0; - CGFloat scale = (fontSize / defaultSize) * self.zoomMultiplier; - #if 0 // Sadly, this doesn’t work correctly. // It looks fine, but selections are offset relative to the mouse cursor. @@ -2363,21 +2367,19 @@ - (void)scaleWebview - (IBAction)zoomIn:(id)sender { - static const CGFloat kMaxZoom = 3.0; - if (self.zoomMultiplier >= kMaxZoom) + if (self.zoomMultiplier >= kMPMaxZoom) return; - self.zoomMultiplier = MIN(self.zoomMultiplier + 0.1, kMaxZoom); + self.zoomMultiplier = MIN(self.zoomMultiplier + 0.1, kMPMaxZoom); [self applyCurrentZoom]; } - (IBAction)zoomOut:(id)sender { - static const CGFloat kMinZoom = 0.5; - if (self.zoomMultiplier <= kMinZoom) + if (self.zoomMultiplier <= kMPMinZoom) return; - self.zoomMultiplier = MAX(self.zoomMultiplier - 0.1, kMinZoom); + self.zoomMultiplier = MAX(self.zoomMultiplier - 0.1, kMPMinZoom); [self applyCurrentZoom]; } From bb681dd380711198038365a7a244f6f8c8860c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 04:19:50 +0000 Subject: [PATCH 3/6] Add failing tests for zoom feature bugs Adds MPZoomTests.m with 22 tests covering zoom multiplier basics, menu validation, preference observer behavior, and tab stop calculation. Two tests are expected to FAIL against current code as TDD regression guards: - testSetupEditorPreservesZoomedFontSize: guards against setupEditor: resetting the editor font to its unzoomed base size, clobbering any active zoom. - testTabStopsReflectZoomedFontSize: guards against tab stops being computed from the base font instead of the zoomed font. Related to #335 --- MacDown 3000.xcodeproj/project.pbxproj | 4 + MacDownTests/MPZoomTests.m | 457 +++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 MacDownTests/MPZoomTests.m diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index 33194fe0..6b34e727 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0319BD8F4A64D9E406448C13 /* MPSyntaxHighlightingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 9C64395EB126274BB2E01E01 /* MPSyntaxHighlightingTests.m */; }; 03224E1B02E36783D5307F4A /* MPDocumentIOTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 35FB5AC5D9A67BFB58A9430F /* MPDocumentIOTests.m */; }; 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 */; }; @@ -522,6 +523,7 @@ 4DBAA63927A60EB2150642B3 /* Pods-macdown-cmd.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-macdown-cmd.debug.xcconfig"; path = "Pods/Target Support Files/Pods-macdown-cmd/Pods-macdown-cmd.debug.xcconfig"; sourceTree = ""; }; 5162FB5F85C6B4B09403BE65 /* MPHTMLResourceURLs.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPHTMLResourceURLs.h; sourceTree = ""; }; 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 = ""; }; @@ -991,6 +993,7 @@ QLTST00002PREF00000M /* MPQuickLookPreferencesTests.m */, QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */, 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */, + B2C3D4E5F67890ABCDEF1234 /* MPZoomTests.m */, ); path = MacDownTests; sourceTree = ""; @@ -1468,6 +1471,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/MacDownTests/MPZoomTests.m b/MacDownTests/MPZoomTests.m new file mode 100644 index 00000000..afad4c1c --- /dev/null +++ b/MacDownTests/MPZoomTests.m @@ -0,0 +1,457 @@ +// +// MPZoomTests.m +// MacDownTests +// +// TDD tests for the zoom feature in MPDocument. +// Some tests are RED regression guards and are expected to FAIL against the +// current code until the corresponding bug is fixed: +// +// Bug 2: setupEditor: resets the editor font to the base (unzoomed) size, +// clobbering any zoom that was applied via applyCurrentZoom. +// +// Bug 3: setupEditor: computes tab stops from the base font (not the zoomed +// font), so tab stops do not scale with zoom. +// + +#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; +@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; +@end + +@implementation MPZoomTests + +- (void)setUp +{ + [super setUp]; + self.document = [[MPDocument alloc] init]; +} + +- (void)tearDown +{ + 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 — Bug 2 Regression Tests + +/** + * setupEditor: must not reset zoomMultiplier to 1.0. + * Bug 2: If setupEditor: calls applyCurrentZoom internally with the base font it + * resets, zoom is effectively lost even though zoomMultiplier itself is unchanged. + * This test guards the multiplier value directly (no editor required). + */ +- (void)testSetupEditorDoesNotResetZoomMultiplier +{ + self.document.zoomMultiplier = 1.5; + XCTAssertNoThrow([self.document setupEditor:@"editorBaseFontInfo"], + @"setupEditor:editorBaseFontInfo should not throw"); + XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.5, 0.001, + @"setupEditor: must not reset zoomMultiplier"); +} + +/** + * Calling setupEditor: while zoomed should not crash. + * Bug 2 regression: 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. + * Bug 2 regression guard for the editorLineSpacing code path. + */ +- (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. + * Bug 2 regression guard for the editorStyleName code path. + */ +- (void)testSetupEditorDoesNotCrashForStyleChangeWhileZoomed +{ + self.document.zoomMultiplier = 1.3; + XCTAssertNoThrow([self.document setupEditor:@"editorStyleName"], + @"setupEditor:editorStyleName should not crash when zoomed"); +} + +/** + * CONDITIONAL / RED TEST — expected to FAIL until Bug 2 is fixed. + * + * After zooming to 1.5 and calling applyCurrentZoom, the editor font's point + * size should reflect the zoom. After a subsequent setupEditor:editorBaseFontInfo, + * the editor font should NOT revert to the base (unzoomed) size. + * + * This test skips gracefully in headless environments where the editor outlet + * is nil (e.g. CI without a display server). + */ +- (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; + + // BUG 2: setupEditor: resets the font to the base size, so afterSetupPointSize + // will equal the unzoomed base size, not zoomedPointSize. + // This assertion FAILS until Bug 2 is fixed. + XCTAssertEqualWithAccuracy(afterSetupPointSize, zoomedPointSize, 0.1, + @"Bug 2: 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 reflect both the original zoom and the new step. + * 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, 1.6, 0.001, + @"After zoom(1.5) -> setupEditor -> zoomIn, " + @"multiplier should be 1.6"); +} + + +#pragma mark - Tab Stop Calculation — Bug 3 Regression 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"); +} + +/** + * CONDITIONAL / RED TEST — expected to FAIL until Bug 3 is fixed. + * + * After zooming to 2.0, applyCurrentZoom, and setupEditor:editorBaseFontInfo, + * the first tab stop in the editor's paragraph style 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]; + + // BUG 3: setupEditor: uses the base font (not the zoomed font) to compute + // tab stops, so firstTab.location will equal the base-font tab interval. + // This assertion FAILS until Bug 3 is fixed. + XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, + @"Bug 3: 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"); +} + +@end From ed473c8f6813a06a6239fe2d5d0054e363038fee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 04:22:53 +0000 Subject: [PATCH 4/6] Fix three bugs in zoom feature Bug 1: Cmd+0 conflict. "Actual Size" was bound to Cmd+0, which collides with Format > "Paragraph". Remove the shortcut from "Actual Size" (menu only) and change "Zoom In" from "+" to "=" so Cmd+= works without Shift on US keyboards (standard macOS zoom behavior). Bug 2: Editor font reset on preference changes. setupEditor: applied the raw base font via self.editor.font = font, clobbering any active zoom when the font, style, or line-spacing preference changed. Bug 3: Tab stops computed from base font. setupEditor: computed tab stops from the unzoomed base font's space width, so tabs appeared at the wrong width after zoom. Add a zoomedEditorFont helper that returns base font x zoomMultiplier. setupEditor: now uses it for both the tab stop calculation and the editor font assignment, fixing bugs 2 and 3 in one change. Simplify applyCurrentZoom to delegate to setupEditor: (which already calls scaleWebview), so zoom actions also refresh tab stops and the syntax highlighter's font cache. Related to #335 --- MacDown/Code/Document/MPDocument.m | 23 ++++++++++---------- MacDown/Localization/Base.lproj/MainMenu.xib | 4 ++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 069137c6..8c74046c 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -2177,7 +2177,7 @@ - (void)setupEditor:(NSString *)changedKey style.lineSpacing = self.preferences.editorLineSpacing; // Configure tab stops to match 4-space tab width (fixes #195) - NSFont *font = [self.preferences.editorBaseFont copy]; + NSFont *font = [[self zoomedEditorFont] copy]; if (font) { NSDictionary *attrs = @{NSFontAttributeName: font}; @@ -2365,11 +2365,20 @@ - (void)scaleWebview #endif } +- (NSFont *)zoomedEditorFont +{ + NSFont *baseFont = self.preferences.editorBaseFont; + if (!baseFont) + return nil; + CGFloat zoomedSize = baseFont.pointSize * self.zoomMultiplier; + return [NSFont fontWithName:baseFont.fontName size:zoomedSize]; +} + - (IBAction)zoomIn:(id)sender { if (self.zoomMultiplier >= kMPMaxZoom) return; - + self.zoomMultiplier = MIN(self.zoomMultiplier + 0.1, kMPMaxZoom); [self applyCurrentZoom]; } @@ -2391,15 +2400,7 @@ - (IBAction)resetZoom:(id)sender - (void)applyCurrentZoom { - NSFont *baseFont = self.preferences.editorBaseFont; - CGFloat zoomedSize = baseFont.pointSize * self.zoomMultiplier; - - // Apply zoom to editor (transient, not saved to preferences) - [self.editor setFont:[NSFont fontWithName:baseFont.fontName - size:zoomedSize]]; - - // Apply zoom to preview - [self scaleWebview]; + [self setupEditor:@"editorBaseFontInfo"]; } /** diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 4728f596..d795c980 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -405,7 +405,7 @@ - + @@ -415,7 +415,7 @@ - + From d6f83182f5771092c9b4bc2af7d8d50decdcbf91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 04:35:10 +0000 Subject: [PATCH 5/6] Address review feedback on zoom fixes XIB: The previous fix moved Cmd+0 off "Actual Size" but introduced two new collisions - Cmd+= with Format > "Highlight" and Cmd+- with Format > "Strikethrough". Change "Zoom In" to Cmd+Plus (keyEquivalent "+") and add Shift+Cmd to "Zoom Out" so both use the Shift modifier and neither collides with the Format menu shortcuts. Performance: applyCurrentZoom previously routed through setupEditor:, which does far more than the zoom path needs - it deactivates and reactivates the PEG Markdown highlighter, re-reads and re-applies the stylesheet from disk, and replaces the editor's CALayer. Extract the font and paragraph-style logic into a dedicated applyEditorFontAndParagraphStyle helper that both setupEditor: and applyCurrentZoom call, so zoom keystrokes skip the highlighter re-parse and CALayer rebuild. Tests: remove outdated "expected to FAIL" comments now that the bugs are fixed, and drop testSetupEditorDoesNotResetZoomMultiplier, which only checked that a CGFloat property stayed unchanged across a method that never touched it - the real Bug 2 regression is covered by testSetupEditorPreservesZoomedFontSize. Related to #335 --- MacDown/Code/Document/MPDocument.m | 62 ++++++++++--------- MacDown/Localization/Base.lproj/MainMenu.xib | 3 +- MacDownTests/MPZoomTests.m | 63 +++++--------------- 3 files changed, 50 insertions(+), 78 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 8c74046c..6bee54c4 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -257,7 +257,6 @@ typedef NS_ENUM(NSUInteger, MPScrollOwner) { // Store file content in initializer until nib is loaded. @property (copy) NSString *loadedString; -// Transient per-document zoom level (not saved to preferences) @property CGFloat zoomMultiplier; - (void)scaleWebview; @@ -2173,32 +2172,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 zoomedEditorFont] 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; @@ -2374,6 +2348,37 @@ - (NSFont *)zoomedEditorFont return [NSFont fontWithName:baseFont.fontName 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 { if (self.zoomMultiplier >= kMPMaxZoom) @@ -2400,7 +2405,8 @@ - (IBAction)resetZoom:(id)sender - (void)applyCurrentZoom { - [self setupEditor:@"editorBaseFontInfo"]; + [self applyEditorFontAndParagraphStyle]; + [self scaleWebview]; } /** diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index d795c980..e23d0d52 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -405,12 +405,13 @@ - + + diff --git a/MacDownTests/MPZoomTests.m b/MacDownTests/MPZoomTests.m index afad4c1c..ac082eaf 100644 --- a/MacDownTests/MPZoomTests.m +++ b/MacDownTests/MPZoomTests.m @@ -2,15 +2,9 @@ // MPZoomTests.m // MacDownTests // -// TDD tests for the zoom feature in MPDocument. -// Some tests are RED regression guards and are expected to FAIL against the -// current code until the corresponding bug is fixed: -// -// Bug 2: setupEditor: resets the editor font to the base (unzoomed) size, -// clobbering any zoom that was applied via applyCurrentZoom. -// -// Bug 3: setupEditor: computes tab stops from the base font (not the zoomed -// font), so tab stops do not scale with zoom. +// 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 @@ -223,26 +217,11 @@ - (void)testZoomOutMenuValidationDisabledAtMinZoom } -#pragma mark - Preference Observer — Bug 2 Regression Tests - -/** - * setupEditor: must not reset zoomMultiplier to 1.0. - * Bug 2: If setupEditor: calls applyCurrentZoom internally with the base font it - * resets, zoom is effectively lost even though zoomMultiplier itself is unchanged. - * This test guards the multiplier value directly (no editor required). - */ -- (void)testSetupEditorDoesNotResetZoomMultiplier -{ - self.document.zoomMultiplier = 1.5; - XCTAssertNoThrow([self.document setupEditor:@"editorBaseFontInfo"], - @"setupEditor:editorBaseFontInfo should not throw"); - XCTAssertEqualWithAccuracy(self.document.zoomMultiplier, 1.5, 0.001, - @"setupEditor: must not reset zoomMultiplier"); -} +#pragma mark - Preference Observer Tests /** * Calling setupEditor: while zoomed should not crash. - * Bug 2 regression: nil changedKey exercises the full setup path. + * nil changedKey exercises the full setup path. */ - (void)testSetupEditorDoesNotCrashWhileZoomed { @@ -253,7 +232,6 @@ - (void)testSetupEditorDoesNotCrashWhileZoomed /** * Calling setupEditor: for a line-spacing change while zoomed should not crash. - * Bug 2 regression guard for the editorLineSpacing code path. */ - (void)testSetupEditorDoesNotCrashForLineSpacingChangeWhileZoomed { @@ -264,7 +242,6 @@ - (void)testSetupEditorDoesNotCrashForLineSpacingChangeWhileZoomed /** * Calling setupEditor: for a style change while zoomed should not crash. - * Bug 2 regression guard for the editorStyleName code path. */ - (void)testSetupEditorDoesNotCrashForStyleChangeWhileZoomed { @@ -274,14 +251,11 @@ - (void)testSetupEditorDoesNotCrashForStyleChangeWhileZoomed } /** - * CONDITIONAL / RED TEST — expected to FAIL until Bug 2 is fixed. + * After zooming to 1.5 and calling applyCurrentZoom, a subsequent + * setupEditor:editorBaseFontInfo must not revert the editor font to the + * unzoomed base size. * - * After zooming to 1.5 and calling applyCurrentZoom, the editor font's point - * size should reflect the zoom. After a subsequent setupEditor:editorBaseFontInfo, - * the editor font should NOT revert to the base (unzoomed) size. - * - * This test skips gracefully in headless environments where the editor outlet - * is nil (e.g. CI without a display server). + * Skips in headless environments where the editor outlet is nil. */ - (void)testSetupEditorPreservesZoomedFontSize { @@ -303,11 +277,8 @@ - (void)testSetupEditorPreservesZoomedFontSize CGFloat afterSetupPointSize = self.document.editor.font.pointSize; - // BUG 2: setupEditor: resets the font to the base size, so afterSetupPointSize - // will equal the unzoomed base size, not zoomedPointSize. - // This assertion FAILS until Bug 2 is fixed. XCTAssertEqualWithAccuracy(afterSetupPointSize, zoomedPointSize, 0.1, - @"Bug 2: setupEditor: must not revert the editor font to " + @"setupEditor: must not revert the editor font to " @"the unzoomed base size after applyCurrentZoom has run"); } @@ -330,7 +301,7 @@ - (void)testZoomThenPreferenceChangeThenZoomAgain } -#pragma mark - Tab Stop Calculation — Bug 3 Regression Tests +#pragma mark - Tab Stop Calculation Tests /** * Pure math test: a font at 2x size must produce a wider space character and @@ -365,11 +336,8 @@ - (void)testZoomedFontProducesDifferentTabWidth } /** - * CONDITIONAL / RED TEST — expected to FAIL until Bug 3 is fixed. - * - * After zooming to 2.0, applyCurrentZoom, and setupEditor:editorBaseFontInfo, - * the first tab stop in the editor's paragraph style should match the tab - * interval computed from the zoomed font (not the base font). + * 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. */ @@ -407,11 +375,8 @@ - (void)testTabStopsReflectZoomedFontSize NSTextTab *firstTab = tabStops[0]; - // BUG 3: setupEditor: uses the base font (not the zoomed font) to compute - // tab stops, so firstTab.location will equal the base-font tab interval. - // This assertion FAILS until Bug 3 is fixed. XCTAssertEqualWithAccuracy(firstTab.location, expectedTabInterval, 0.5, - @"Bug 3: First tab stop must reflect zoomed font size, " + @"First tab stop must reflect zoomed font size, " @"not the unzoomed base font size"); } From 0fc6ee4dda156879a8606a3a83ea72d2d0eaf447 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 19:20:48 +0000 Subject: [PATCH 6/6] Add previewScale tests to guard zoom regression in scaleWebview Extract previewScale from scaleWebview so the scale computation is unit-testable without mocking WebView. The PR's scaleWebview change removed the early-return when previewZoomRelativeToBaseFontSize is OFF, so add four tests that pin both branches of the calculation: - preference OFF + zoom 1.0 -> 1.0 (no-op equivalence) - preference OFF + non-default zoom -> tracks zoomMultiplier - preference ON + non-default zoom -> (fontSize/14) * zoomMultiplier - preference ON + zoom 1.0 -> matches legacy fontSize/14 ratio Each test saves and restores the preference values it touches. Related to #335 --- MacDown/Code/Document/MPDocument.m | 13 ++-- MacDownTests/MPZoomTests.m | 97 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 6bee54c4..0f325664 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -2310,20 +2310,23 @@ - (void)redrawDivider } } -- (void)scaleWebview +- (CGFloat)previewScale { - CGFloat scale = self.zoomMultiplier; - if (self.preferences.previewZoomRelativeToBaseFontSize) { CGFloat fontSize = self.preferences.editorBaseFontSize; if (fontSize > 0.0) { static const CGFloat defaultSize = 14.0; - scale = (fontSize / defaultSize) - * self.zoomMultiplier; + return (fontSize / defaultSize) * self.zoomMultiplier; } } + return self.zoomMultiplier; +} + +- (void)scaleWebview +{ + CGFloat scale = [self previewScale]; #if 0 // Sadly, this doesn’t work correctly. diff --git a/MacDownTests/MPZoomTests.m b/MacDownTests/MPZoomTests.m index ac082eaf..d8b592dc 100644 --- a/MacDownTests/MPZoomTests.m +++ b/MacDownTests/MPZoomTests.m @@ -22,6 +22,7 @@ - (IBAction)zoomOut:(id)sender; - (IBAction)resetZoom:(id)sender; - (void)applyCurrentZoom; - (void)setupEditor:(NSString *)changedKey; +- (CGFloat)previewScale; @end #pragma mark - Mock Menu Item @@ -419,4 +420,100 @@ - (void)testTabStopsAtDefaultZoomMatchBaseFont @"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