Skip to content

Finish and ship unified document zoom — #529 - #552

Merged
schuyler merged 13 commits into
mainfrom
claude/issue-529-readiness-j7r6g2
Aug 3, 2026
Merged

Finish and ship unified document zoom — #529#552
schuyler merged 13 commits into
mainfrom
claude/issue-529-readiness-j7r6g2

Conversation

@schuyler

@schuyler schuyler commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Takes over draft PR #518 and finishes it per the execution plan in #529. Unified document zoom becomes a single document-level control: ⌘+ / ⌘− / ⌘0 and a toolbar preset dropdown drive one shared zoom level that applies to every open document window and persists across launches.

This branch is #518's 8 zoom commits rebased onto current main, plus five fix commits addressing the wiring bug, keybinding collisions, test hygiene, and testability called out in #529.

Changes

MPDocument.m / MPDocument.h — Zoom constants and preset list (0.53.0). zoomMultiplier property with clamped setter. Preset stepping via stepDocumentZoomDirection:. zoomIn:/zoomOut:/resetZoom:/selectDocumentZoom: actions. Refactored applyEditorFontAndParagraphStyle for zoom-scaled editor font and tab stops. Preview re-zoom on WebView reload. KVO registration extracted into registerSharedPreferenceObservers/unregisterSharedPreferenceObservers for cross-window sync and testability. Menu validation for zoom items.

MPToolbarController.m — Zoom preset dropdown. Items target self with deferred document resolution at click time (the outlet is nil at construction), forwarding to selectDocumentZoom:. KVO on documentZoomLevel keeps the dropdown synced.

MPPreferences.m / MPPreferences.hdocumentZoomLevel property, default 1.0, migration v6 (additive).

MainMenu.xib — Added Zoom In (⌘+), Zoom Out (⌘−), Actual Size (⌘0) to View menu. Relocated colliding Format shortcuts:

Action Before After
Strikethrough ⌘− ⌘⇧X
Paragraph ⌘0 ⌘⌥0
Highlight ⌘= (dropped)

MPZoomTests.m (new) — 26 tests: multiplier basics, clamping, preset snap-step from off-grid values, menu validation, preference observers, tab-stop scaling, preview scale, cross-window propagation.

MPPreviewZoomTests.m (new) — 8 tests: preset snap-step, default value, boundary clamp, actual-size reset.

MPPreferencesTests.m — Migration version assertions updated (5 → 6).

MPToolbarControllerTests.m — Toolbar item count assertions updated for the new zoom dropdown.

Testing

Build and full test suite verified by macOS CI (this branch was prepared on Linux).

Follow-ups

Related to #529, #518, #470, #335, #504, #519.

dpankros and others added 13 commits August 3, 2026 09:34
Provide Actual Size (Cmd+0), Zoom In (Cmd++), and Zoom Out (Cmd+-) menu
items plus a toolbar dropdown so users can rescale the rendered preview
for readability, presentations, or larger displays. Zoom level persists
across launches and snaps to preset increments: 50, 75, 90, 100, 110,
125, 150, and 200 percent.
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
- Move kMinZoom/kMaxZoom to file-scope constants
- Fix preview zoom when previewZoomRelativeToBaseFontSize is off
- Fix floating-point comparison in resetZoom: validation
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
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
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
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
Unified document zoom claims Cmd+/Cmd-/Cmd0. Rather than dropping the
three colliding Format shortcuts as the draft did, relocate them:

- Strikethrough: Cmd- -> Cmd-Shift-X (Google Docs/Slack convention)
- Paragraph: Cmd0 -> Cmd-Opt-0 (sits with the numeric heading family)
- Highlight: drop the shortcut (optional extension feature; no free
  idiomatic home). Rebinding tracked as a follow-up.

Zoom In/Out/Actual Size remain on Cmd+/Cmd-/Cmd0. Verified no new
collisions: Cmd-Shift-X and Cmd-Opt-0 are otherwise unused.

Related to #529
The zoom preset popup set each item's target to self.document at
construction time, when the document outlet is still nil, so clicking a
preset silently did nothing. Route the items through the controller
(target=self) and resolve self.document lazily at click time in a new
-documentZoomPopUpClicked: forwarder, matching the file's existing
deferred-dispatch idiom. The forwarder sends selectDocumentZoom:
directly (it is a declared IBAction visible via the import chain), which
reads the level from the clicked item's representedObject.

Related to #529
MPZoomTests.m carried three tests whose docstrings asserted a flat
+/-0.1 stepping model. The shipped model is a non-uniform preset list
(0.5, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 2.0, 3.0) snapped by
stepDocumentZoomDirection:, so that model is false even though the old
tests' specific data points happened to line up.

Replace them with tests that exercise the real snap-to-nearest-preset
behavior from off-grid values via the public zoomIn:/zoomOut: actions
(1.05 -> 1.1 up, 1.05 -> 1.0 down) and a corrected preference-cycle
test (1.5 -> 2.0). All model-independent tests (menu validation,
rendering, tab stops, previewScale) are kept unchanged. Net count
preserved at 25. MPPreviewZoomTests.m remains authoritative.

Related to #529
Two related zoom changes in MPDocument:

Fix #4 (production side): the shared-preference KVO registration that
drives cross-window zoom sharing lived inside windowControllerDidLoadNib:,
so it never ran in headless tests. Extract it into
-registerSharedPreferenceObservers / -unregisterSharedPreferenceObservers
(called from windowControllerDidLoadNib: and, under the existing
needsToUnregister guard, from -close). Pure extract-method: same key set,
options, context, and ordering relative to the editor-KVO loop. This lets
a headless test register the observer directly and assert propagation.

Fix #5: resetZoom: and selectDocumentZoom: wrote documentZoomLevel
directly, bypassing the bounds clamp in setZoomMultiplier:. Route both
through self.zoomMultiplier so clamping lives in one place. setZoomMultiplier:
persists the clamped value to the shared documentZoomLevel pref, so
cross-window sharing is unaffected; behavior is unchanged for in-range
presets.

Related to #529
Prove the headline behavior: a zoom change in one document window
propagates to others via the shared documentZoomLevel preference. The
observer registration was extracted out of the nib path (prior commit)
precisely so a headless test can drive it.

The test spies on -applyCurrentZoom via an MPDocument subclass rather
than asserting on -zoomMultiplier: the latter is a passthrough over the
shared pref and would read the new value even if the observer never
fired, proving nothing. Doc A writes the shared zoom; the test asserts
Doc B's handler ran exactly once and observed the propagated value.

Doc B registers observers outside the nib path, so -close (gated on
needsToUnregister) will not tear them down; the test unregisters
explicitly before releasing Doc B to avoid a KVO-dealloc crash.

Related to #529
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report

Current Coverage: 64.81%

Coverage Details (Summary)
Name                                                                                                                                   Coverage             
-------------------------------------------------------------------------------------------------------------------------------------- -------------------- 
MASPreferences.bundle                                                                                                                  0.00% (0/0)          
MacDown 3000.app                                                                                                                       65.27% (9984/15297)  
    /Users/runner/work/macdown3000/macdown3000/Dependency/peg-markdown-highlight/HGMarkdownHighlighter.m                               82.47% (461/559)     
        styleparsing_error_callback                                                                                                    90.00% (9/10)        
        -[HGMarkdownHighlighter init]                                                                                                  92.86% (13/14)       
        -[HGMarkdownHighlighter initWithTextView:]                                                                                     83.33% (5/6)         
        -[HGMarkdownHighlighter initWithTextView:waitInterval:]                                                                        83.33% (5/6)         
        -[HGMarkdownHighlighter initWithTextView:waitInterval:styles:]                                                                 83.33% (5/6)         
        -[HGMarkdownHighlighter parseText:]                                                                                            100.00% (9/9)        
        -[HGMarkdownHighlighter convertOffsets:text:]                                                                                  26.19% (11/42)       
        -[HGMarkdownHighlighter requestParsing]                                                                                        100.00% (15/15)      
        __39-[HGMarkdownHighlighter requestParsing]_block_invoke                                                                       100.00% (12/12)      
        __39-[HGMarkdownHighlighter requestParsing]_block_invoke.11                                                                    100.00% (3/3)        
        __39-[HGMarkdownHighlighter requestParsing]_block_invoke.13                                                                    100.00% (4/4)        
        -[HGMarkdownHighlighter getClearFontTraitMask:]                                                                                88.89% (16/18)       
        -[HGMarkdownHighlighter clearHighlightingForRange:]                                                                            100.00% (31/31)      
        __51-[HGMarkdownHighlighter clearHighlightingForRange:]_block_invoke                                                           100.00% (8/8)        
        -[HGMarkdownHighlighter readClearTextStylesFromTextView]                                                                       100.00% (17/17)      
        -[HGMarkdownHighlighter applyHighlighting:withRange:]                                                                          80.00% (60/75)       
        __53-[HGMarkdownHighlighter applyHighlighting:withRange:]_block_invoke                                                         92.31% (12/13)       
        -[HGMarkdownHighlighter applyVisibleRangeHighlighting]                                                                         100.00% (16/16)      
        -[HGMarkdownHighlighter clearHighlighting]                                                                                     100.00% (3/3)        
        -[HGMarkdownHighlighter cacheElementList:]                                                                                     100.00% (6/6)        
        -[HGMarkdownHighlighter clearElementsCache]                                                                                    100.00% (2/2)        
        -[HGMarkdownHighlighter textViewTextDidChange:]                                                                                0.00% (0/11)         
        __47-[HGMarkdownHighlighter textViewTextDidChange:]_block_invoke                                                               0.00% (0/3)          
        -[HGMarkdownHighlighter textViewDidScroll:]                                                                                    27.27% (3/11)        
        __43-[HGMarkdownHighlighter textViewDidScroll:]_block_invoke                                                                   0.00% (0/6)          
        __43-[HGMarkdownHighlighter textViewDidScroll:]_block_invoke_2                                                                 0.00% (0/3)          
        -[HGMarkdownHighlighter getDefaultStyles]                                                                                      100.00% (27/27)      
        -[HGMarkdownHighlighter applyStyleDependenciesToTargetTextView]                                                                92.86% (13/14)       
        -[HGMarkdownHighlighter setStyles:]                                                                                            100.00% (8/8)        
        -[HGMarkdownHighlighter getDefaultSelectedTextAttributes]                                                                      100.00% (7/7)        
        -[HGMarkdownHighlighter handleStyleParsingError:]                                                                              100.00% (12/12)      
        -[HGMarkdownHighlighter applyStylesFromStylesheet:withErrorHandler:]                                                           86.05% (74/86)       
        -[HGMarkdownHighlighter setTargetTextView:]                                                                                    100.00% (7/7)        
        -[HGMarkdownHighlighter parseAndHighlightNow]                                                                                  100.00% (3/3)        
        -[HGMarkdownHighlighter highlightNow]                                                                                          100.00% (3/3)        
        -[HGMarkdownHighlighter activate]                                                                                              100.00% (24/24)      
        -[HGMarkdownHighlighter deactivate]                                                                                            100.00% (18/18)      
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Document/MPAsset.m                                                         93.69% (104/111)     
        -[MPAsset typeName]                                                                                                            100.00% (3/3)        
        -[MPAsset defaultTypeName]                                                                                                     100.00% (3/3)        
        +[MPAsset assetWithURL:andType:]                                                                                               100.00% (3/3)        
        -[MPAsset initWithURL:andType:]                                                                                                87.50% (7/8)         
        -[MPAsset init]                                                                                                                100.00% (3/3)        
        -[MPAsset templateForOption:]                                                                                                  0.00% (0/6)          
        -[MPAsset htmlForOption:]                                                                                                      100.00% (28/28)      

... (3631 more lines truncated)

📊 **Full coverage report available in workflow artifacts**

@schuyler
schuyler merged commit 557d04d into main Aug 3, 2026
6 of 11 checks passed
@schuyler
schuyler deleted the claude/issue-529-readiness-j7r6g2 branch August 3, 2026 19:46
@schuyler schuyler changed the title Finish and ship unified document zoom (take over #518) — #529 Finish and ship unified document zoom — #529 Aug 3, 2026
@schuyler schuyler added the rc-pending Included in a release candidate awaiting validation label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rc-pending Included in a release candidate awaiting validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants