diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index 33194fe0..ccd4b4ff 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -107,6 +107,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 */; }; E087CFBB2125A4D2B37C132F /* MPHTMLResourceURLsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8552820CEE8D00E83AEE3897 /* MPHTMLResourceURLsTests.m */; }; @@ -547,6 +548,7 @@ B3BD139B59E787FBB9F38228 /* MPEditorViewSubstitutionTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = MPEditorViewSubstitutionTests.m; sourceTree = ""; }; C2B84BF8A8BC4F4B871646F8 /* MPScrollSyncTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPScrollSyncTests.m; sourceTree = ""; }; CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPCheckboxToggleTests.m; sourceTree = ""; }; + 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 = ""; }; E6D070A6E080254A17B3B197 /* MPResourceWatcherSet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPResourceWatcherSet.m; sourceTree = ""; }; E70ECDD4241C933C00537A46 /* ru-RU */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ru-RU"; path = "Localization/ru-RU.lproj/Localizable.strings"; sourceTree = ""; }; @@ -961,6 +963,7 @@ 197TESTS0F00000000197RSTF /* MPRendererStateTests.m */, C2B84BF8A8BC4F4B871646F8 /* MPScrollSyncTests.m */, CHKBOXTGL0001FILEREFID /* MPCheckboxToggleTests.m */, + PRVZMTST0001FILEREFID /* MPPreviewZoomTests.m */, ISSUE285SMARTQTFILEREF /* MPSmartQuoteTests.m */, 1FFEB3261972DAB400B2254F /* MPHTMLTabularizeTests.m */, 1FFEB32F19ABCD1500B2254F /* MPMarkdownRenderingTests.m */, @@ -1430,6 +1433,7 @@ 03224E1B02E36783D5307F4A /* MPDocumentIOTests.m in Sources */, B9A8DE030E3748EB899BD45E /* MPScrollSyncTests.m in Sources */, CHKBOXTGL0001BUILDFILER /* MPCheckboxToggleTests.m in Sources */, + PRVZMTST0001BUILDFILER /* MPPreviewZoomTests.m in Sources */, ISSUE285SMARTQTBUILDFILE /* MPSmartQuoteTests.m in Sources */, 1F51C9A5194565050015A96F /* MPPreferencesTests.m in Sources */, 1FF1420419A8A24800CF8A6A /* MPUtilityTests.m in Sources */, diff --git a/MacDown/Code/Application/MPToolbarController.m b/MacDown/Code/Application/MPToolbarController.m index de229773..adcbeea5 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,34 +15,80 @@ static CGFloat itemWidth = 37; +// Preview-zoom presets must match MPPreviewZoomLevels() in MPDocument.m. +static NSArray *MPToolbarPreviewZoomLevels(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]; + }); + return levels; +} @implementation MPToolbarController { NSArray *toolbarItems; NSArray *toolbarItemIdentifiers; - + /** * Map toolbar item identifier to it's NSToolbarItem or NSToolbarItemGroup object */ NSMutableDictionary *toolbarItemIdentifierObjectDictionary; + + /** + * 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 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:@"previewZoomLevel" + options:NSKeyValueObservingOptionNew + context:NULL]; + return self; } +- (void)dealloc +{ + @try { + [[NSUserDefaults standardUserDefaults] + removeObserver:self forKeyPath:@"previewZoomLevel"]; + } @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:@"previewZoomLevel"]) + { + [self syncPreviewZoomDisplay]; + } +} + #pragma mark - Private @@ -87,10 +134,14 @@ - (void)setupToolbarItems @[ toggleEditorMenuItem, togglePreviewMenuItem ] - ] + ], + [self toolbarItemZoomPopUpWithIdentifier:@"preview-zoom" label:NSLocalizedString(@"Preview Zoom", @"Preview pane zoom toolbar item")] ]; - + self->toolbarItemIdentifiers = [self toolbarItemIdentifiersFromItemsArray:self->toolbarItems]; + + // Reflect the persisted preference once everything is wired up. + [self syncPreviewZoomDisplay]; } /** @@ -323,13 +374,80 @@ - (NSToolbarItem *)toolbarItemDropDownWithIdentifier:(NSString *)itemIdentifier [[popupButton lastItem] setTarget:self.document]; [[popupButton lastItem] setAction:menuItem.action]; } - + toolbarItem.view = popupButton; - + [self->toolbarItemIdentifierObjectDictionary setObject:toolbarItem forKey:itemIdentifier]; - + return toolbarItem; } +/** + * Factory method for the preview-zoom popup. Unlike the layout dropdown + * this is a regular (non-pull-down) NSPopUpButton: the currently selected + * item is shown as the button label so the user sees the active zoom + * percentage at a glance. Each menu item is wired to + * -selectPreviewZoom: on the document, with the target zoom level + * (NSNumber) attached as the item's representedObject. + */ +- (NSToolbarItem *)toolbarItemZoomPopUpWithIdentifier:(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 = MPToolbarPreviewZoomLevels(); + for (NSNumber *level in levels) + { + NSString *title = [NSString stringWithFormat:@"%.0f%%", level.doubleValue * 100.0]; + [popupButton addItemWithTitle:title]; + NSMenuItem *added = [popupButton lastItem]; + added.representedObject = level; + added.target = self.document; + added.action = @selector(selectPreviewZoom:); + } + + toolbarItem.view = popupButton; + + [self->toolbarItemIdentifierObjectDictionary setObject:toolbarItem forKey:itemIdentifier]; + _zoomPopUp = popupButton; + + return toolbarItem; +} + +/** + * Update the popup's selection to match the current preview-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)syncPreviewZoomDisplay +{ + NSPopUpButton *popup = _zoomPopUp; + if (!popup) + return; + + CGFloat current = [MPPreferences sharedInstance].previewZoomLevel; + NSArray *levels = MPToolbarPreviewZoomLevels(); + + 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..64ce0b65 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -27,4 +27,31 @@ */ + (NSString *)toggleCheckboxAtIndex:(NSUInteger)index inMarkdown:(NSString *)markdown; +/** + * Step the preview pane zoom level up to the next preset. + * Snaps to the next preset > current; beeps if already at the maximum. + * Bound to View > Zoom In (Command-+). + */ +- (IBAction)zoomPreviewIn:(id)sender; + +/** + * Step the preview pane zoom level down to the previous preset. + * Snaps to the next preset < current; beeps if already at the minimum. + * Bound to View > Zoom Out (Command--). + */ +- (IBAction)zoomPreviewOut:(id)sender; + +/** + * Reset the preview pane zoom level to 100%. + * Bound to View > Actual Size (Command-0). + */ +- (IBAction)actualPreviewSize:(id)sender; + +/** + * Set the preview pane 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)selectPreviewZoom:(id)sender; + @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 75cc0fea..266ac644 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -77,12 +77,28 @@ @"editorWidthLimited", @"editorMaximumWidth", @"editorLineSpacing", @"editorOnRight", @"editorStyleName", @"editorShowWordCount", @"editorScrollsPastEnd", - @"htmlMathJax", @"htmlMathJaxInlineDollar", nil + @"htmlMathJax", @"htmlMathJaxInlineDollar", + @"previewZoomLevel", nil ]; }); return keys; } +/** + * Ordered list of preset preview 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 *MPPreviewZoomLevels() +{ + 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]; + }); + return levels; +} + NS_INLINE NSString *MPRectStringForAutosaveName(NSString *name) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; @@ -267,6 +283,9 @@ - (void)refreshHeaderCacheAfterResize; - (void)windowDidEndLiveResize:(NSNotification *)notification; - (void)windowDidChangeFullScreen:(NSNotification *)notification; - (void)applyEditorStartInPreviewModePreference; +// Preview zoom helpers +- (void)applyPreviewZoom; +- (void)stepPreviewZoomDirection:(NSInteger)direction; // Commit 8 (gap 9): MathJax generation counter accessor (used by tests via category) - (NSUInteger)mathJaxRenderGeneration; @@ -899,6 +918,25 @@ - (BOOL)validateUserInterfaceItem:(id)item return NO; } } + else if (action == @selector(zoomPreviewIn:)) + { + // Grey out ⌘+ once we are already at the maximum preset. + NSArray *levels = MPPreviewZoomLevels(); + CGFloat max = levels.lastObject.doubleValue; + return (self.preferences.previewZoomLevel < max - 1e-6); + } + else if (action == @selector(zoomPreviewOut:)) + { + // Grey out ⌘- once we are already at the minimum preset. + NSArray *levels = MPPreviewZoomLevels(); + CGFloat min = levels.firstObject.doubleValue; + return (self.preferences.previewZoomLevel > min + 1e-6); + } + else if (action == @selector(actualPreviewSize:) || + action == @selector(selectPreviewZoom:)) + { + return YES; + } return result; } @@ -1141,6 +1179,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 @@ -1519,6 +1564,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; @@ -1721,6 +1772,14 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object } else if (object == [NSUserDefaults standardUserDefaults]) { + // Preview zoom changes only need to push a new page-size multiplier + // to the WebView; the rendered HTML is unaffected, so skip the + // editor setup / divider redraw path that other preferences need. + if ([keyPath isEqualToString:@"previewZoomLevel"]) + { + [self applyPreviewZoom]; + return; + } if (self.highlighter.isActive) [self setupEditor:keyPath]; [self redrawDivider]; @@ -3362,4 +3421,121 @@ - (void)reloadFromDisk [self startFileWatching]; } + +#pragma mark - Preview zoom + +/** + * Push the user's preview-zoom preference into the underlying WebView. + * Clamps the value to the supported [0.5, 2.0] range so a corrupt or + * out-of-range preference cannot blank the preview pane. Silently does + * nothing when the preview outlet is not yet wired (early launch / unit + * tests instantiating MPDocument without the nib). + */ +- (void)applyPreviewZoom +{ + if (!self.preview) + return; + + CGFloat level = self.preferences.previewZoomLevel; + NSArray *levels = MPPreviewZoomLevels(); + CGFloat min = levels.firstObject.doubleValue; + CGFloat max = levels.lastObject.doubleValue; + if (level < min) level = min; + if (level > max) level = max; + + [self.preview setPageSizeMultiplier:(float)level]; +} + +/** + * Step the preview 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)stepPreviewZoomDirection:(NSInteger)direction +{ + NSArray *levels = MPPreviewZoomLevels(); + CGFloat current = self.preferences.previewZoomLevel; + 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.previewZoomLevel = levels[(NSUInteger)targetIdx].doubleValue; +} + +- (IBAction)zoomPreviewIn:(id)sender +{ + [self stepPreviewZoomDirection:+1]; +} + +- (IBAction)zoomPreviewOut:(id)sender +{ + [self stepPreviewZoomDirection:-1]; +} + +- (IBAction)actualPreviewSize:(id)sender +{ + self.preferences.previewZoomLevel = 1.0; +} + +- (IBAction)selectPreviewZoom:(id)sender +{ + // Sender is an NSPopUpButton (toolbar) or NSMenuItem (future menu). + // Both carry the target level as an NSNumber in representedObject. + NSNumber *level = nil; + if ([sender isKindOfClass:[NSMenuItem class]]) + { + level = [(NSMenuItem *)sender representedObject]; + } + else if ([sender isKindOfClass:[NSPopUpButton class]]) + { + level = [[(NSPopUpButton *)sender selectedItem] representedObject]; + } + if ([level isKindOfClass:[NSNumber class]]) + { + self.preferences.previewZoomLevel = level.doubleValue; + } +} + @end diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 3a249e3a..e46c4ba2 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -58,6 +58,7 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) NSInteger editorUnorderedListMarkerType; @property (assign) BOOL previewZoomRelativeToBaseFontSize; +@property (assign) CGFloat previewZoomLevel; @property (assign) NSString *htmlTemplateName; @property (assign) NSString *htmlStyleName; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index c3d9cd5a..fa9ba279 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -257,6 +257,7 @@ - (void)migratePreferencesFromLegacyBundleIdentifierIfNeeded @dynamic editorUnorderedListMarkerType; @dynamic previewZoomRelativeToBaseFontSize; +@dynamic previewZoomLevel; @dynamic htmlTemplateName; @dynamic htmlStyleName; @@ -409,6 +410,7 @@ - (void)loadDefaultPreferences self.htmlStyleName = kMPDefaultHtmlStyleName; self.htmlDefaultDirectoryUrl = [NSURL fileURLWithPath:NSHomeDirectory() isDirectory:YES]; + self.previewZoomLevel = 1.0; } /** Load default preferences when the app launches. @@ -438,6 +440,13 @@ - (void)loadDefaultUserDefaults if (![defaults objectForKey:@"editorAutoSave"]) self.editorAutoSave = YES; + // Defensive default for preview 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:@"previewZoomLevel"]) + self.previewZoomLevel = 1.0; + // Apply preference migrations using version-based system. [self applyPreferencesMigrations]; } @@ -455,6 +464,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: Preview zoom level default (100%) */ - (NSInteger)effectiveMigrationVersion { @@ -493,7 +503,7 @@ - (NSInteger)effectiveMigrationVersion */ - (void)applyPreferencesMigrations { - static NSInteger const kMPCurrentMigrationVersion = 5; + static NSInteger const kMPCurrentMigrationVersion = 6; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; NSInteger currentVersion = [self effectiveMigrationVersion]; @@ -550,6 +560,16 @@ - (void)applyPreferencesMigrations self.editorAutoSave = YES; } + // Migration Version 6: Preview zoom level default + // Establish a 100% page-size multiplier baseline for the preview pane. + // 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.previewZoomLevel = 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 b5ee90f1..c9f1f337 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -410,6 +410,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/MacDownTests/MPPreferencesTests.m b/MacDownTests/MPPreferencesTests.m index 10d1461e..e1febb32 100644 --- a/MacDownTests/MPPreferencesTests.m +++ b/MacDownTests/MPPreferencesTests.m @@ -564,10 +564,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, @@ -621,10 +621,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, @@ -685,10 +685,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, @@ -829,10 +829,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) @@ -979,10 +979,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..61c6ef31 --- /dev/null +++ b/MacDownTests/MPPreviewZoomTests.m @@ -0,0 +1,143 @@ +// +// MPPreviewZoomTests.m +// MacDown 3000 +// +// Tests for the preview pane zoom feature: preset snap-step semantics, +// default level, clamping at the bounds, and the actualPreviewSize: +// 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)stepPreviewZoomDirection:(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].previewZoomLevel; + // 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].previewZoomLevel = self.originalZoomLevel; + [[MPPreferences sharedInstance] synchronize]; + self.document = nil; + [super tearDown]; +} + +#pragma mark - Default + +/** + * After fresh-install initialization, previewZoomLevel 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.previewZoomLevel, 1.0, 1e-9, + @"Default preview zoom level should be 1.0"); +} + +#pragma mark - Stepping at preset boundaries + +- (void)testStepUpFromOneHundredGoesTo110 +{ + [MPPreferences sharedInstance].previewZoomLevel = 1.0; + [self.document stepPreviewZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 1.1, 1e-9, + @"Step up from 100%% should land on 110%%"); +} + +- (void)testStepDownFromOneHundredGoesTo90 +{ + [MPPreferences sharedInstance].previewZoomLevel = 1.0; + [self.document stepPreviewZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 0.9, 1e-9, + @"Step down from 100%% should land on 90%%"); +} + +#pragma mark - Clamping at bounds + +- (void)testStepUpAtMaxIsClamped +{ + [MPPreferences sharedInstance].previewZoomLevel = 2.0; + [self.document stepPreviewZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 2.0, 1e-9, + @"Stepping up at the max preset must not change the level"); +} + +- (void)testStepDownAtMinIsClamped +{ + [MPPreferences sharedInstance].previewZoomLevel = 0.5; + [self.document stepPreviewZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 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].previewZoomLevel = 1.05; + [self.document stepPreviewZoomDirection:+1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 1.1, 1e-9, + @"Step up from 105%% should snap to the next preset above (110%%)"); +} + +- (void)testSnapFromOffPresetDownRoundsDown +{ + [MPPreferences sharedInstance].previewZoomLevel = 1.05; + [self.document stepPreviewZoomDirection:-1]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 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].previewZoomLevel = 1.5; + [self.document actualPreviewSize:nil]; + XCTAssertEqualWithAccuracy([MPPreferences sharedInstance].previewZoomLevel, + 1.0, 1e-9, + @"actualPreviewSize: must reset zoom level to 1.0"); +} + +@end diff --git a/MacDownTests/MPToolbarControllerTests.m b/MacDownTests/MPToolbarControllerTests.m index d87b84af..4e7494bd 100644 --- a/MacDownTests/MPToolbarControllerTests.m +++ b/MacDownTests/MPToolbarControllerTests.m @@ -107,10 +107,10 @@ - (void)testAllowedIdentifiersContainAllCustomItems - (void)testAllowedIdentifiersTotalCount { NSArray *allowed = [self.controller toolbarAllowedItemIdentifiers:nil]; - // 13 custom + 3 system (flexible space, space, separator) - XCTAssertEqual(allowed.count, 16, - @"Allowed identifiers should have 16 items: " - @"13 custom + flexible space + space + separator"); + // 14 custom + 3 system (flexible space, space, separator) + XCTAssertEqual(allowed.count, 17, + @"Allowed identifiers should have 17 items: " + @"14 custom + flexible space + space + separator"); } - (void)testAllowedIdentifiersOrderCustomItemsFirst @@ -229,8 +229,8 @@ - (void)testSelectableIdentifiersContainAllCustomItems - (void)testSelectableIdentifiersCount { NSArray *selectable = [self.controller toolbarSelectableItemIdentifiers:nil]; - XCTAssertEqual(selectable.count, 13, - @"Selectable identifiers should have exactly 13 items (custom only, no system items)"); + XCTAssertEqual(selectable.count, 14, + @"Selectable identifiers should have exactly 14 items (custom only, no system items)"); } - (void)testSelectableIdentifiersAcceptsNilToolbar @@ -303,8 +303,8 @@ - (void)testDefaultIdentifiersContainFlexibleSpaces - (void)testDefaultIdentifiersTotalCount { NSArray *defaults = [self.controller toolbarDefaultItemIdentifiers:nil]; - XCTAssertEqual(defaults.count, 15, - @"Default toolbar should have 15 items: 10 custom + 5 flexible spaces"); + XCTAssertEqual(defaults.count, 16, + @"Default toolbar should have 16 items: 11 custom + 5 flexible spaces"); } - (void)testDefaultIdentifiersDoNotContainFixedSpaces @@ -333,7 +333,8 @@ - (void)testDefaultIdentifiersExactOrder NSToolbarFlexibleSpaceItemIdentifier, @"copy-html", NSToolbarFlexibleSpaceItemIdentifier, - @"layout" + @"layout", + @"preview-zoom" ]; XCTAssertEqual(defaults.count, expected.count,