From 5d895d44415a416ff8a4688ec325c2356f2b1378 Mon Sep 17 00:00:00 2001 From: Yusuf Mohsinally <463376+yusufm@users.noreply.github.com> Date: Sun, 3 May 2026 14:07:14 -0700 Subject: [PATCH] Add editor column guide controls --- MacDown/Code/Document/MPDocument.m | 110 ++++++++++++++++++- MacDown/Code/Preferences/MPPreferences.h | 3 + MacDown/Code/Preferences/MPPreferences.m | 5 + MacDown/Code/View/MPEditorView.h | 4 + MacDown/Code/View/MPEditorView.m | 41 +++++++ MacDown/Localization/Base.lproj/MainMenu.xib | 38 +++++++ MacDownTests/MPEditorViewSubstitutionTests.m | 27 +++++ MacDownTests/MPPreferencesTests.m | 24 ++++ 8 files changed, 250 insertions(+), 2 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 75cc0fea..593132be 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -75,6 +75,8 @@ @"editorBaseFontInfo", @"extensionFootnotes", @"editorHorizontalInset", @"editorVerticalInset", @"editorWidthLimited", @"editorMaximumWidth", @"editorLineSpacing", + @"editorColumnGuideEnabled", @"editorColumnGuideWidth", + @"editorWrapsAtColumnGuide", @"editorOnRight", @"editorStyleName", @"editorShowWordCount", @"editorScrollsPastEnd", @"htmlMathJax", @"htmlMathJaxInlineDollar", nil @@ -267,6 +269,7 @@ - (void)refreshHeaderCacheAfterResize; - (void)windowDidEndLiveResize:(NSNotification *)notification; - (void)windowDidChangeFullScreen:(NSNotification *)notification; - (void)applyEditorStartInPreviewModePreference; +- (void)applyColumnGuidePreferences; // Commit 8 (gap 9): MathJax generation counter accessor (used by tests via category) - (NSUInteger)mathJaxRenderGeneration; @@ -899,6 +902,39 @@ - (BOOL)validateUserInterfaceItem:(id)item return NO; } } + else if (action == @selector(toggleColumnGuide:)) + { + ((NSMenuItem *)item).state = self.preferences.editorColumnGuideEnabled + ? NSControlStateValueOn : NSControlStateValueOff; + return self.editor != nil; + } + else if (action == @selector(toggleWrapAtColumnGuide:)) + { + ((NSMenuItem *)item).state = self.preferences.editorWrapsAtColumnGuide + ? NSControlStateValueOn : NSControlStateValueOff; + return self.editor != nil; + } + else if (action == @selector(setColumnGuideWidth80:)) + { + ((NSMenuItem *)item).state = + self.preferences.editorColumnGuideWidth == 80 + ? NSControlStateValueOn : NSControlStateValueOff; + return self.editor != nil; + } + else if (action == @selector(setColumnGuideWidth100:)) + { + ((NSMenuItem *)item).state = + self.preferences.editorColumnGuideWidth == 100 + ? NSControlStateValueOn : NSControlStateValueOff; + return self.editor != nil; + } + else if (action == @selector(setColumnGuideWidth120:)) + { + ((NSMenuItem *)item).state = + self.preferences.editorColumnGuideWidth == 120 + ? NSControlStateValueOn : NSControlStateValueOff; + return self.editor != nil; + } return result; } @@ -1995,6 +2031,33 @@ - (IBAction)toggleEditorPane:(id)sender [self toggleSplitterCollapsingEditorPane:YES]; } +- (IBAction)toggleColumnGuide:(id)sender +{ + self.preferences.editorColumnGuideEnabled = + !self.preferences.editorColumnGuideEnabled; +} + +- (IBAction)toggleWrapAtColumnGuide:(id)sender +{ + self.preferences.editorWrapsAtColumnGuide = + !self.preferences.editorWrapsAtColumnGuide; +} + +- (IBAction)setColumnGuideWidth80:(id)sender +{ + self.preferences.editorColumnGuideWidth = 80; +} + +- (IBAction)setColumnGuideWidth100:(id)sender +{ + self.preferences.editorColumnGuideWidth = 100; +} + +- (IBAction)setColumnGuideWidth120:(id)sender +{ + self.preferences.editorColumnGuideWidth = 120; +} + - (IBAction)render:(id)sender { [self.renderer parseAndRenderLater]; @@ -2152,14 +2215,19 @@ - (void)setupEditor:(NSString *)changedKey if (!changedKey || [changedKey isEqualToString:@"editorHorizontalInset"] || [changedKey isEqualToString:@"editorVerticalInset"] || [changedKey isEqualToString:@"editorWidthLimited"] - || [changedKey isEqualToString:@"editorMaximumWidth"]) + || [changedKey isEqualToString:@"editorMaximumWidth"] + || [changedKey isEqualToString:@"editorColumnGuideEnabled"] + || [changedKey isEqualToString:@"editorColumnGuideWidth"] + || [changedKey isEqualToString:@"editorWrapsAtColumnGuide"]) { [self adjustEditorInsets]; + [self applyColumnGuidePreferences]; } if (!changedKey || [changedKey isEqualToString:@"editorBaseFontInfo"] || [changedKey isEqualToString:@"editorStyleName"] - || [changedKey isEqualToString:@"editorLineSpacing"]) + || [changedKey isEqualToString:@"editorLineSpacing"] + || [changedKey isEqualToString:@"editorColumnGuideWidth"]) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; style.lineSpacing = self.preferences.editorLineSpacing; @@ -2209,6 +2277,7 @@ - (void)setupEditor:(NSString *)changedKey if (backgroundCGColor) layer.backgroundColor = backgroundCGColor; self.editorContainer.layer = layer; + [self applyColumnGuidePreferences]; } if ([changedKey isEqualToString:@"editorBaseFontInfo"]) @@ -2298,6 +2367,43 @@ - (void)adjustEditorInsets self.editor.textContainerInset = NSMakeSize(x, y); } +- (void)applyColumnGuidePreferences +{ + NSInteger column = self.preferences.editorColumnGuideWidth; + if (column <= 0) + column = 80; + + self.editor.columnGuideVisible = + self.preferences.editorColumnGuideEnabled; + self.editor.columnGuideColumn = column; + self.editor.wrapsAtColumnGuide = + self.preferences.editorWrapsAtColumnGuide; + + NSTextContainer *container = self.editor.textContainer; + if (!container) + return; + + if (self.editor.wrapsAtColumnGuide) + { + NSFont *font = self.editor.font ?: + self.preferences.editorBaseFont ?: + [NSFont userFixedPitchFontOfSize:0.0]; + NSDictionary *attributes = @{NSFontAttributeName: font}; + CGFloat characterWidth = [@"0" sizeWithAttributes:attributes].width; + CGFloat width = characterWidth * column + + 2.0 * container.lineFragmentPadding; + container.widthTracksTextView = NO; + container.containerSize = NSMakeSize(width, CGFLOAT_MAX); + } + else + { + container.widthTracksTextView = YES; + container.containerSize = NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX); + } + + self.editor.needsDisplay = YES; +} + - (void)redrawDivider { if (!self.editorVisible) diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 3a249e3a..03b67afd 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -48,6 +48,9 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) CGFloat editorLineSpacing; @property (assign) BOOL editorWidthLimited; @property (assign) CGFloat editorMaximumWidth; +@property (assign) BOOL editorColumnGuideEnabled; +@property (assign) NSInteger editorColumnGuideWidth; +@property (assign) BOOL editorWrapsAtColumnGuide; @property (assign) BOOL editorOnRight; @property (assign) BOOL editorStartInPreviewMode; @property (assign) BOOL editorShowWordCount; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index c3d9cd5a..ec5f317e 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -247,6 +247,9 @@ - (void)migratePreferencesFromLegacyBundleIdentifierIfNeeded @dynamic editorLineSpacing; @dynamic editorWidthLimited; @dynamic editorMaximumWidth; +@dynamic editorColumnGuideEnabled; +@dynamic editorColumnGuideWidth; +@dynamic editorWrapsAtColumnGuide; @dynamic editorOnRight; @dynamic editorStartInPreviewMode; @dynamic editorShowWordCount; @@ -427,6 +430,8 @@ - (void)loadDefaultUserDefaults NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; if (![defaults objectForKey:@"editorMaximumWidth"]) self.editorMaximumWidth = 1000.0; + if (![defaults objectForKey:@"editorColumnGuideWidth"]) + self.editorColumnGuideWidth = 80; if (![defaults objectForKey:@"editorAutoIncrementNumberedLists"]) self.editorAutoIncrementNumberedLists = YES; if (![defaults objectForKey:@"editorInsertPrefixInBlock"]) diff --git a/MacDown/Code/View/MPEditorView.h b/MacDown/Code/View/MPEditorView.h index c9168ebf..0ede3051 100644 --- a/MacDown/Code/View/MPEditorView.h +++ b/MacDown/Code/View/MPEditorView.h @@ -11,7 +11,11 @@ @interface MPEditorView : NSTextView @property BOOL scrollsPastEnd; +@property BOOL columnGuideVisible; +@property NSInteger columnGuideColumn; +@property BOOL wrapsAtColumnGuide; - (NSRect)contentRect; +- (CGFloat)columnGuideXPosition; - (void)paste:(id)sender; @end diff --git a/MacDown/Code/View/MPEditorView.m b/MacDown/Code/View/MPEditorView.m index eb47cce9..cb0e02bb 100644 --- a/MacDown/Code/View/MPEditorView.m +++ b/MacDown/Code/View/MPEditorView.m @@ -38,6 +38,18 @@ @implementation MPEditorView @synthesize contentRect = _contentRect; @synthesize scrollsPastEnd = _scrollsPastEnd; +- (void)setColumnGuideVisible:(BOOL)columnGuideVisible +{ + _columnGuideVisible = columnGuideVisible; + self.needsDisplay = YES; +} + +- (void)setColumnGuideColumn:(NSInteger)columnGuideColumn +{ + _columnGuideColumn = MAX(1, columnGuideColumn); + self.needsDisplay = YES; +} + - (BOOL)scrollsPastEnd { @synchronized(self) { @@ -45,6 +57,16 @@ - (BOOL)scrollsPastEnd } } +- (CGFloat)columnGuideXPosition +{ + NSFont *font = self.font ?: [NSFont userFixedPitchFontOfSize:0.0]; + NSDictionary *attributes = @{NSFontAttributeName: font}; + CGFloat characterWidth = [@"0" sizeWithAttributes:attributes].width; + CGFloat padding = self.textContainer.lineFragmentPadding; + return self.textContainerOrigin.x + padding + + characterWidth * MAX(1, self.columnGuideColumn); +} + - (void)awakeFromNib { [self registerForDraggedTypes:@[NSPasteboardTypeFileURL]]; [super awakeFromNib]; @@ -168,6 +190,25 @@ - (void)didChangeText [self updateContentGeometry]; } +- (void)drawViewBackgroundInRect:(NSRect)rect +{ + [super drawViewBackgroundInRect:rect]; + + if (!self.columnGuideVisible || self.columnGuideColumn <= 0) + return; + + CGFloat x = floor([self columnGuideXPosition]) + 0.5; + if (x < NSMinX(rect) || x > NSMaxX(rect)) + return; + + NSBezierPath *path = [NSBezierPath bezierPath]; + [path moveToPoint:NSMakePoint(x, NSMinY(rect))]; + [path lineToPoint:NSMakePoint(x, NSMaxY(rect))]; + [[NSColor separatorColor] setStroke]; + path.lineWidth = 1.0; + [path stroke]; +} + /** Overridden to advertise markdown UTType support for pasteboard operations. */ - (NSArray *)writablePasteboardTypes diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index b5ee90f1..5815c704 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -367,6 +367,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MacDownTests/MPEditorViewSubstitutionTests.m b/MacDownTests/MPEditorViewSubstitutionTests.m index 5cd15faa..f23f6e73 100644 --- a/MacDownTests/MPEditorViewSubstitutionTests.m +++ b/MacDownTests/MPEditorViewSubstitutionTests.m @@ -146,6 +146,33 @@ - (void)removePreferenceForKey:(NSString *)key [defaults synchronize]; } +#pragma mark - Column Guide Tests + +- (void)testColumnGuideXPositionUsesConfiguredColumn +{ + NSFont *font = [NSFont fontWithName:@"Menlo-Regular" size:12.0]; + self.editorView.font = font; + self.editorView.textContainer.lineFragmentPadding = 0.0; + self.editorView.textContainerInset = NSZeroSize; + self.editorView.columnGuideColumn = 10; + + CGFloat characterWidth = + [@"0" sizeWithAttributes:@{NSFontAttributeName: font}].width; + + XCTAssertEqualWithAccuracy([self.editorView columnGuideXPosition], + characterWidth * 10.0, + 0.5, + @"Guide should be placed after the configured column"); +} + +- (void)testColumnGuideColumnClampsToPositiveValue +{ + self.editorView.columnGuideColumn = 0; + + XCTAssertEqual(self.editorView.columnGuideColumn, 1, + @"Column guide should clamp non-positive widths"); +} + #pragma mark - Automatic Dash Substitution Tests /** diff --git a/MacDownTests/MPPreferencesTests.m b/MacDownTests/MPPreferencesTests.m index 10d1461e..808930ca 100644 --- a/MacDownTests/MPPreferencesTests.m +++ b/MacDownTests/MPPreferencesTests.m @@ -152,6 +152,30 @@ - (void)testStartInPreviewModeToggle [self.preferences synchronize]; } +- (void)testColumnGuidePreferences +{ + BOOL originalEnabled = self.preferences.editorColumnGuideEnabled; + BOOL originalWrap = self.preferences.editorWrapsAtColumnGuide; + NSInteger originalWidth = self.preferences.editorColumnGuideWidth; + + self.preferences.editorColumnGuideEnabled = YES; + self.preferences.editorWrapsAtColumnGuide = YES; + self.preferences.editorColumnGuideWidth = 100; + [self.preferences synchronize]; + + XCTAssertTrue(self.preferences.editorColumnGuideEnabled, + @"Column guide should be ON"); + XCTAssertTrue(self.preferences.editorWrapsAtColumnGuide, + @"Column wrapping should be ON"); + XCTAssertEqual(self.preferences.editorColumnGuideWidth, 100, + @"Column guide width should persist"); + + self.preferences.editorColumnGuideEnabled = originalEnabled; + self.preferences.editorWrapsAtColumnGuide = originalWrap; + self.preferences.editorColumnGuideWidth = originalWidth; + [self.preferences synchronize]; +} + - (void)testExtensionFlags { // Save originals