From ca3f5799c80f50911a1a5ac04499ec4a70f0bcc4 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 15:13:03 -0700 Subject: [PATCH 01/37] Add default view mode preference and toolbar view mode switcher Adds a "Default View" setting in Preferences > General to control whether files open showing Editor Only, Preview Only, or Both. Also replaces the toolbar layout dropdown with a 3-segment view mode control for quick switching between modes while viewing a file. --- MacDown.xcodeproj/project.pbxproj | 18 +++---- .../Code/Application/MPToolbarController.m | 30 +++++------ MacDown/Code/Document/MPDocument.h | 4 ++ MacDown/Code/Document/MPDocument.m | 33 ++++++++++++ .../MPGeneralPreferencesViewController.m | 12 +++++ MacDown/Code/Preferences/MPPreferences.h | 1 + MacDown/Code/Utility/MPGlobals.h | 12 ++--- .../MPGeneralPreferencesViewController.xib | 52 ++++++++++++++++++- MacDown/Resources/Styles/GitHub-2020.css | 0 Podfile.lock | 2 +- 10 files changed, 130 insertions(+), 34 deletions(-) create mode 100644 MacDown/Resources/Styles/GitHub-2020.css diff --git a/MacDown.xcodeproj/project.pbxproj b/MacDown.xcodeproj/project.pbxproj index 81f315a6..2a22df1d 100644 --- a/MacDown.xcodeproj/project.pbxproj +++ b/MacDown.xcodeproj/project.pbxproj @@ -1191,12 +1191,10 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MacDown/Pods-MacDown-frameworks.sh", "${PODS_ROOT}/Sparkle/Sparkle.framework", - "${PODS_ROOT}/Sparkle/Sparkle.framework.dSYM", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework", - "${DWARF_DSYM_FOLDER_PATH}/Sparkle.framework.dSYM", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -1760,8 +1758,8 @@ GCC_PREFIX_HEADER = "MacDown/Code/MacDown-Prefix.pch"; INFOPLIST_FILE = "MacDown/MacDown-Info.plist"; MACOSX_DEPLOYMENT_TARGET = 10.8; - PRODUCT_BUNDLE_IDENTIFIER = "com.uranusjr.${PRODUCT_NAME:rfc1034identifier:lower}-debug"; - PRODUCT_NAME = MacDown; + PRODUCT_BUNDLE_IDENTIFIER = "com.readdown.app-debug"; + PRODUCT_NAME = ReadDown; SDKROOT = macosx; WRAPPER_EXTENSION = app; }; @@ -1778,8 +1776,8 @@ GCC_PREFIX_HEADER = "MacDown/Code/MacDown-Prefix.pch"; INFOPLIST_FILE = "MacDown/MacDown-Info.plist"; MACOSX_DEPLOYMENT_TARGET = 10.8; - PRODUCT_BUNDLE_IDENTIFIER = "com.uranusjr.${PRODUCT_NAME:rfc1034identifier:lower}"; - PRODUCT_NAME = MacDown; + PRODUCT_BUNDLE_IDENTIFIER = "com.readdown.app"; + PRODUCT_NAME = ReadDown; SDKROOT = macosx; WRAPPER_EXTENSION = app; }; @@ -1789,7 +1787,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 8CDC5EA0050D2F722FB1AADD /* Pods-MacDownTests.debug.xcconfig */; buildSettings = { - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/MacDown.app/Contents/MacOS/MacDown"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/ReadDown.app/Contents/MacOS/ReadDown"; CLANG_ENABLE_MODULES = NO; COMBINE_HIDPI_IMAGES = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -1819,7 +1817,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0D0EC0DE9FEA22F962083921 /* Pods-MacDownTests.release.xcconfig */; buildSettings = { - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/MacDown.app/Contents/MacOS/MacDown"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/ReadDown.app/Contents/MacOS/ReadDown"; CLANG_ENABLE_MODULES = NO; COMBINE_HIDPI_IMAGES = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -1851,7 +1849,7 @@ "DEBUG=1", "$(inherited)", ); - PRODUCT_NAME = macdown; + PRODUCT_NAME = readdown; SKIP_INSTALL = YES; }; name = Debug; @@ -1862,7 +1860,7 @@ buildSettings = { GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = ""; - PRODUCT_NAME = macdown; + PRODUCT_NAME = readdown; SKIP_INSTALL = YES; }; name = Release; diff --git a/MacDown/Code/Application/MPToolbarController.m b/MacDown/Code/Application/MPToolbarController.m index a6caddd9..46b06307 100644 --- a/MacDown/Code/Application/MPToolbarController.m +++ b/MacDown/Code/Application/MPToolbarController.m @@ -47,10 +47,6 @@ - (id)init - (void)setupToolbarItems { - // Set up layout drop down alternatives. title will be set in validateUserInterfaceItem: - NSMenuItem *toggleEditorMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:@selector(toggleEditorPane:) keyEquivalent:@"e"]; - NSMenuItem *togglePreviewMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:@selector(togglePreviewPane:) keyEquivalent:@"p"]; - // Set up all available toolbar items self->toolbarItems = @[ [self toolbarItemGroupWithIdentifier:@"indent-group" separated:YES label:NSLocalizedString(@"Shift Left/Right", @"") items:@[ @@ -83,9 +79,10 @@ - (void)setupToolbarItems [self toolbarItemWithIdentifier:@"comment" label:NSLocalizedString(@"Comment", @"Comment toolbar button") icon:@"ToolbarIconComment" action:@selector(toggleComment:)], [self toolbarItemWithIdentifier:@"highlight" label:NSLocalizedString(@"Highlight", @"Highlight toolbar button") icon:@"ToolbarIconHighlight" action:@selector(toggleHighlight:)], [self toolbarItemWithIdentifier:@"strikethrough" label:NSLocalizedString(@"Strikethrough", @"Strikethrough toolbar button") icon:@"ToolbarIconStrikethrough" action:@selector(toggleStrikethrough:)], - [self toolbarItemDropDownWithIdentifier:@"layout" label:NSLocalizedString(@"Layout", @"Layout toolbar button") icon:@"ToolbarIconEditorAndPreview" menuItems: - @[ - toggleEditorMenuItem, togglePreviewMenuItem + [self toolbarItemGroupWithIdentifier:@"view-mode-group" separated:NO label:NSLocalizedString(@"View Mode", @"") items:@[ + [self toolbarItemWithIdentifier:@"view-editor" label:NSLocalizedString(@"Editor Only", @"Editor only toolbar button") icon:@"ToolbarIconShiftLeft" action:@selector(showEditorOnly:)], + [self toolbarItemWithIdentifier:@"view-both" label:NSLocalizedString(@"Editor & Preview", @"Both panes toolbar button") icon:@"ToolbarIconEditorAndPreview" action:@selector(showBothPanes:)], + [self toolbarItemWithIdentifier:@"view-preview" label:NSLocalizedString(@"Preview Only", @"Preview only toolbar button") icon:@"ToolbarIconShiftRight" action:@selector(showPreviewOnly:)] ] ] ]; @@ -109,16 +106,18 @@ - (NSArray *)toolbarItemIdentifiersFromItemsArray:(NSArray *)toolbarItemsArray { - (void)selectedToolbarItemGroupItem:(NSSegmentedControl *)sender { NSInteger selectedIndex = sender.selectedSegment; - + NSToolbarItemGroup *selectedGroup = self->toolbarItemIdentifierObjectDictionary[sender.identifier]; NSToolbarItem *selectedItem = selectedGroup.subitems[selectedIndex]; - - // Invoke the toolbar item's action - // Must convert to IMP to let the compiler know about the method definition + MPDocument *document = self.document; - IMP imp = [document methodForSelector:selectedItem.action]; - void (*impFunc)(id) = (void *)imp; - impFunc(document); + if (document && selectedItem.action) + { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [document performSelector:selectedItem.action withObject:sender]; +#pragma clang diagnostic pop + } } @@ -244,7 +243,8 @@ - (NSToolbarItem *)toolbarItemWithIdentifier:(NSString *)itemIdentifier label:(N toolbarItem.label = label; toolbarItem.paletteLabel = label; toolbarItem.toolTip = label; - + toolbarItem.action = action; + NSImage *itemImage = [NSImage imageNamed:iconImageName]; [itemImage setTemplate:YES]; [itemImage setSize:CGSizeMake(19, 19)]; diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 387f4675..e3086bb8 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -19,4 +19,8 @@ @property (nonatomic, readwrite) NSString *markdown; @property (nonatomic, readonly) NSString *html; +- (IBAction)showEditorOnly:(id)sender; +- (IBAction)showPreviewOnly:(id)sender; +- (IBAction)showBothPanes:(id)sender; + @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index acbc77ff..ca77586f 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -456,6 +456,19 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller [self setupEditor:nil]; [self redrawDivider]; [self reloadFromLoadedString]; + + // Apply default view mode preference + NSInteger defaultViewMode = self.preferences.defaultViewMode; + if (defaultViewMode == 1) + { + // Editor only + [self showEditorOnly:nil]; + } + else if (defaultViewMode == 2) + { + // Preview only + [self showPreviewOnly:nil]; + } }]; } @@ -1483,6 +1496,26 @@ - (IBAction)toggleEditorPane:(id)sender [self toggleSplitterCollapsingEditorPane:YES]; } +- (IBAction)showEditorOnly:(id)sender +{ + CGFloat ratio = self.preferences.editorOnRight ? 0.0 : 1.0; + [self setSplitViewDividerLocation:ratio]; +} + +- (IBAction)showPreviewOnly:(id)sender +{ + CGFloat ratio = self.preferences.editorOnRight ? 1.0 : 0.0; + [self setSplitViewDividerLocation:ratio]; +} + +- (IBAction)showBothPanes:(id)sender +{ + CGFloat ratio = self.previousSplitRatio; + if (ratio <= 0.0 || ratio >= 1.0) + ratio = 0.5; + [self setSplitViewDividerLocation:ratio]; +} + - (IBAction)render:(id)sender { [self.renderer parseAndRenderLater]; diff --git a/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m b/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m index bd1ff757..da91ea1d 100644 --- a/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m +++ b/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m @@ -12,11 +12,18 @@ @interface MPGeneralPreferencesViewController () @property (weak) IBOutlet NSButton *autoRenderingToggle; +@property (weak) IBOutlet NSPopUpButton *defaultViewModePopup; @end @implementation MPGeneralPreferencesViewController +- (void)viewDidLoad +{ + [super viewDidLoad]; + [self.defaultViewModePopup selectItemWithTag:self.preferences.defaultViewMode]; +} + #pragma mark - MASPreferencesViewController - (NSString *)viewIdentifier @@ -37,6 +44,11 @@ - (NSString *)toolbarItemLabel #pragma mark - IBAction +- (IBAction)defaultViewModeChanged:(id)sender +{ + self.preferences.defaultViewMode = self.defaultViewModePopup.selectedTag; +} + - (IBAction)updateWordCounterVisibility:(id)sender { if (sender == self.autoRenderingToggle) diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index e14d2621..6900a8a2 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -19,6 +19,7 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) BOOL updateIncludesPreReleases; @property (assign) BOOL supressesUntitledDocumentOnLaunch; @property (assign) BOOL createFileForLinkTarget; +@property (assign) NSInteger defaultViewMode; // 0=Both, 1=Editor Only, 2=Preview Only // Extension flags. @property (assign) BOOL extensionIntraEmphasis; diff --git a/MacDown/Code/Utility/MPGlobals.h b/MacDown/Code/Utility/MPGlobals.h index a54e1f05..5ac1a7e0 100644 --- a/MacDown/Code/Utility/MPGlobals.h +++ b/MacDown/Code/Utility/MPGlobals.h @@ -9,18 +9,18 @@ #import "version.h" // These should match the main bundle's values. -static NSString * const kMPApplicationName = @"MacDown"; +static NSString * const kMPApplicationName = @"ReadDown"; #ifdef DEBUG -static NSString * const kMPApplicationBundleIdentifier = @"com.uranusjr.macdown-debug"; +static NSString * const kMPApplicationBundleIdentifier = @"com.readdown.app-debug"; #else -static NSString * const kMPApplicationBundleIdentifier = @"com.uranusjr.macdown"; +static NSString * const kMPApplicationBundleIdentifier = @"com.readdown.app"; #endif -static NSString * const kMPApplicationSuiteName = @"com.uranusjr.macdown"; +static NSString * const kMPApplicationSuiteName = @"com.readdown.app"; -static NSString * const MPCommandInstallationPath = @"/usr/local/bin/macdown"; -static NSString * const kMPCommandName = @"macdown"; +static NSString * const MPCommandInstallationPath = @"/usr/local/bin/readdown"; +static NSString * const kMPCommandName = @"readdown"; static NSString * const kMPHelpKey = @"help"; static NSString * const kMPVersionKey = @"version"; diff --git a/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib b/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib index 454499c4..6e6d5040 100644 --- a/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib +++ b/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib @@ -9,13 +9,14 @@ + - + @@ -169,6 +170,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -177,7 +222,10 @@ - + + + + diff --git a/MacDown/Resources/Styles/GitHub-2020.css b/MacDown/Resources/Styles/GitHub-2020.css new file mode 100644 index 00000000..e69de29b diff --git a/Podfile.lock b/Podfile.lock index b90fe24a..d56b30ed 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -48,4 +48,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: e9356b9a2ceafda7889ba385de6e9f2d8b06cba0 -COCOAPODS: 1.8.4 +COCOAPODS: 1.16.2 From 4d6351b51bdadc9e29da911519f3f015d4260d24 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 15:16:05 -0700 Subject: [PATCH 02/37] Update README for MacDown v2 Rewritten to reflect the new project, crediting Mou and the original MacDown as the foundation. --- README.md | 107 +++++++++++------------------------------------------- 1 file changed, 22 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index a656c7f7..dbb7e960 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ -# MacDown +# MacDown v2 -[![](https://img.shields.io/github/release/MacDownApp/macdown.svg)](http://macdown.uranusjr.com/download/latest/) -![Total downloads](https://img.shields.io/github/downloads/MacDownApp/macdown/latest/total.svg) -[![Build Status](https://travis-ci.org/MacDownApp/macdown.svg?branch=master)](https://travis-ci.org/MacDownApp/macdown) +MacDown v2 is an open source Markdown editor for macOS, based on [Mou](http://mouapp.com) by Chen Luo and the original [MacDown](https://github.com/MacDownApp/macdown) by Tzu-ping Chung. We saw a lot of room for updates and improvements, so we created our own version. +## What's New -MacDown is an open source Markdown editor for OS X, released under the MIT License. The author stole the idea from [Chen Luo](https://twitter.com/chenluois)’s [Mou](http://mouapp.com) so that people can make crappy clones. - -Visit the [project site](http://macdown.uranusjr.com/) for more information, or download [MacDown.app.zip](http://macdown.uranusjr.com/download/latest/) directly from the [latest releases](https://github.com/MacDownApp/macdown/releases/latest) page. +- **Default View Mode** — Choose whether files open in Editor Only, Preview Only, or Both via Preferences > General +- **Toolbar View Switcher** — Quickly toggle between editor, preview, and split view from the toolbar ## Install -[Download](http://macdown.uranusjr.com/download/latest/), unzip, and drag the app to Applications folder. MacDown is also available through [Homebrew Cask](https://caskroom.github.io/): +Clone, build, and run: + + git clone https://github.com/Wirtzer/macdownv2.git + cd macdownv2 + git submodule update --init --recursive + pod install + open MacDown.xcworkspace - brew install --cask macdown +Then build and run in Xcode. ## Screenshot @@ -21,81 +25,14 @@ Visit the [project site](http://macdown.uranusjr.com/) for more information, or ## License -MacDown is released under the terms of MIT License. You may find the content of the license [here](http://opensource.org/licenses/MIT), or inside the `LICENSE` directory. - -You may find full text of licenses about third-party components in the `LICENSE` directory, or the **About MacDown** panel in the application. - -The following editor themes and CSS files are extracted from [Mou](http://mouapp.com), courtesy of Chen Luo: - -* Mou Fresh Air -* Mou Fresh Air+ -* Mou Night -* Mou Night+ -* Mou Paper -* Mou Paper+ -* Tomorrow -* Tomorrow Blue -* Tomorrow+ -* Writer -* Writer+ -* Clearness -* Clearness Dark -* GitHub -* GitHub2 - -## Development - -### Requirements - -If you wish to build MacDown yourself, you will need the following components/tools: - -* OS X SDK (10.14 or later) -* Git -* [Bundler](http://bundler.io) - -> Note: Old versions of CocoaPods are not supported. Please use Bundler to execute CocoaPods, or make sure your CocoaPods is later than shown in `Gemfile.lock`. - -> Note: The Command Line Tools (CLT) should be unnecessary. If you failed to compile without it, please install CLT with -> -> xcode-select --install -> -> and report back. - -An appropriate SDK should be bundled with Xcode 5 or later versions. - -### Environment Setup - -After cloning the repository, run the following commands inside the repository root (directory containing this `README.md` file): - - git submodule update --init - bundle install - bundle exec pod install - make -C Dependency/peg-markdown-highlight - -and open `MacDown.xcworkspace` in Xcode. The first command initialises the dependency submodule(s) used in MacDown; the second one installs dependencies managed by CocoaPods. - -Refer to the official guides of Git and CocoaPods if you need more instructions. If you run into build issues later on, try running the following commands to update dependencies: - - git submodule update - bundle exec pod install - -### Translation - -Please help translation on [Transifex](https://www.transifex.com/macdown/macdown/). - -![Transifex translation percentage](https://www.transifex.com/projects/p/macdown/resource/macdownxliff/chart/image_png/) - -## Discussion - -[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/MacDownApp/macdown) - -Join our [Gitter channel](https://gitter.im/MacDownApp/macdown) if you have any problems with MacDown. Any suggestions are welcomed, too! - -You can also [file an issue directly](https://github.com/MacDownApp/macdown/issues/new) on GitHub if you prefer so. But please, **search first to make sure no-one has reported the same issue already** before opening one yourself. MacDown does not update in your computer immediately when we make changes, so something you experienced might be known, or even fixed in the development version. - -MacDown depends a lot on other open source projects, such as [Hoedown](https://github.com/hoedown/hoedown) for Markdown-to-HTML rendering, [Prism](http://prismjs.com) for syntax highlighting (in code blocks), and [PEG Markdown Highlight](https://github.com/ali-rantakari/peg-markdown-highlight) for editor highlighting. If you find problems when using those particular features, you can also consider reporting them directly to upstream projects as well as to MacDown’s issue tracker. I will do what I can if you report it here, but sometimes it can be more beneficial to interact with them directly. - -## Tipping +MacDown v2 is released under the terms of the MIT License. See the `LICENSE` directory for details. -If you find MacDown suitable for your needs, please consider [giving me a tip through PayPal](http://macdown.uranusjr.com/faq/#donation). Or, if you prefer to buy me a drink *personally* instead, just [send me a tweet](https://twitter.com/uranusjr) when you visit [Taipei, Taiwan](http://en.wikipedia.org/wiki/Taipei), where I live. I look forward to meeting you! +The following editor themes and CSS files are from [Mou](http://mouapp.com), courtesy of Chen Luo: +* Mou Fresh Air / Fresh Air+ +* Mou Night / Night+ +* Mou Paper / Paper+ +* Tomorrow / Tomorrow Blue / Tomorrow+ +* Writer / Writer+ +* Clearness / Clearness Dark +* GitHub / GitHub2 From 470c3e61d0fd3a8b9a9924061ea7723e60e57443 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 15:20:45 -0700 Subject: [PATCH 03/37] Add project roadmap to README --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index dbb7e960..8b6bddab 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,36 @@ Then build and run in Xcode. ![screenshot](assets/screenshot.png) +## Roadmap + +### Phase 1 +- [x] Default view mode preference (Editor / Preview / Both) +- [x] Toolbar view mode switcher +- [ ] Per-file mode memory (remember how you opened each file) +- [ ] "Default to preview for opened files" setting (distinct from new files) +- [ ] File library sidebar with folder tree +- [ ] Tabs +- [ ] Document outline in sidebar + +### Phase 2 +- [ ] Focus mode + typewriter mode +- [ ] WYSIWYG live-render mode +- [ ] Writing stats panel (word count, reading time, readability scores) +- [ ] Export to PDF and DOCX + +### Phase 3 +- [ ] Math/LaTeX + Mermaid diagrams +- [ ] WikiLinks + backlinks +- [ ] Command palette +- [ ] Prose quality tools +- [ ] Custom preview themes gallery + +### Phase 4 +- [ ] Plugin API +- [ ] AI authorship tracking +- [ ] Graph view +- [ ] Presentation mode + ## License MacDown v2 is released under the terms of the MIT License. See the `LICENSE` directory for details. From e751f8779ff2809bd64c2cca8b2c44244bb424bb Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 15:52:13 -0700 Subject: [PATCH 04/37] Add Phase 1 features: smart view modes, sidebar, and tabs - Separate view mode preferences for new files vs existing files (existing files default to Preview Only for reading) - Per-file view mode memory that remembers your last choice per document - File library sidebar with folder browser and document outline (toggle via Cmd+Opt+S or toolbar button) - Native macOS window tabbing support - View mode toggle moved to front of toolbar for prominence Co-Authored-By: Claude Opus 4.6 (1M context) --- MacDown.xcodeproj/project.pbxproj | 6 + .../Code/Application/MPToolbarController.m | 19 +- MacDown/Code/Document/MPDocument.h | 1 + MacDown/Code/Document/MPDocument.m | 93 +++- .../MPGeneralPreferencesViewController.m | 7 + MacDown/Code/Preferences/MPPreferences.h | 2 + MacDown/Code/Preferences/MPPreferences.m | 6 + MacDown/Code/Utility/MPGlobals.h | 1 + MacDown/Code/View/MPSidebarController.h | 44 ++ MacDown/Code/View/MPSidebarController.m | 509 ++++++++++++++++++ .../MPGeneralPreferencesViewController.xib | 62 ++- MacDown/Localization/Base.lproj/MainMenu.xib | 7 + 12 files changed, 732 insertions(+), 25 deletions(-) create mode 100644 MacDown/Code/View/MPSidebarController.h create mode 100644 MacDown/Code/View/MPSidebarController.m diff --git a/MacDown.xcodeproj/project.pbxproj b/MacDown.xcodeproj/project.pbxproj index 2a22df1d..b3ce6e96 100644 --- a/MacDown.xcodeproj/project.pbxproj +++ b/MacDown.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 1F27896B1973BEB100EE696A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1F2789691973BEB100EE696A /* Localizable.strings */; }; 1F3386E61A6B999600FC88C4 /* DOMNode+Text.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F3386E51A6B999600FC88C4 /* DOMNode+Text.m */; }; 1F33F2A11A3B4B660001C849 /* MPDocumentSplitView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */; }; + RD00000001000000000000001 /* MPSidebarController.m in Sources */ = {isa = PBXBuildFile; fileRef = RD00000002000000000000001 /* MPSidebarController.m */; }; 1F33F2A41A3B56D20001C849 /* NSColor+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F33F2A31A3B56D20001C849 /* NSColor+HTML.m */; }; 1F3619E61F36DA0F00EDA15A /* MPTerminalPreferencesViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1F3619E81F36DA0F00EDA15A /* MPTerminalPreferencesViewController.xib */; }; 1F396E6619B0EA17000D3EFC /* MPEditorView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F396E6519B0EA17000D3EFC /* MPEditorView.m */; }; @@ -249,6 +250,8 @@ 1F3386E51A6B999600FC88C4 /* DOMNode+Text.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "DOMNode+Text.m"; sourceTree = ""; }; 1F33F29F1A3B4B660001C849 /* MPDocumentSplitView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPDocumentSplitView.h; sourceTree = ""; }; 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPDocumentSplitView.m; sourceTree = ""; }; + RD00000003000000000000001 /* MPSidebarController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPSidebarController.h; sourceTree = ""; }; + RD00000002000000000000001 /* MPSidebarController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPSidebarController.m; sourceTree = ""; }; 1F33F2A21A3B56D20001C849 /* NSColor+HTML.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSColor+HTML.h"; sourceTree = ""; }; 1F33F2A31A3B56D20001C849 /* NSColor+HTML.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSColor+HTML.m"; sourceTree = ""; }; 1F3619E71F36DA0F00EDA15A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MPTerminalPreferencesViewController.xib; sourceTree = ""; }; @@ -655,6 +658,8 @@ children = ( 1F33F29F1A3B4B660001C849 /* MPDocumentSplitView.h */, 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */, + RD00000003000000000000001 /* MPSidebarController.h */, + RD00000002000000000000001 /* MPSidebarController.m */, 1F396E6419B0EA17000D3EFC /* MPEditorView.h */, 1F396E6519B0EA17000D3EFC /* MPEditorView.m */, ); @@ -1232,6 +1237,7 @@ 1F3FC87C1C854E1C000965E1 /* NSPasteboard+Types.m in Sources */, 1F8A835E1953454F00B6BF69 /* HGMarkdownHighlightingStyle.m in Sources */, 1F33F2A11A3B4B660001C849 /* MPDocumentSplitView.m in Sources */, + RD00000001000000000000001 /* MPSidebarController.m in Sources */, 1F23A92119928E650052DB78 /* MPMathJaxListener.m in Sources */, 1F2649B01A7406DB00EF6AF3 /* NSDocumentController+Document.m in Sources */, 1F0D9D67194AC7CF008E1856 /* MPUtilities.m in Sources */, diff --git a/MacDown/Code/Application/MPToolbarController.m b/MacDown/Code/Application/MPToolbarController.m index 46b06307..9f6f0477 100644 --- a/MacDown/Code/Application/MPToolbarController.m +++ b/MacDown/Code/Application/MPToolbarController.m @@ -47,8 +47,15 @@ - (id)init - (void)setupToolbarItems { - // Set up all available toolbar items + // Set up all available toolbar items — sidebar + view mode toggle first self->toolbarItems = @[ + [self toolbarItemWithIdentifier:@"toggle-sidebar" label:NSLocalizedString(@"Sidebar", @"Toggle sidebar toolbar button") icon:@"NSLeftFacingTriangleTemplate" action:@selector(toggleSidebar:)], + [self toolbarItemGroupWithIdentifier:@"view-mode-group" separated:NO label:NSLocalizedString(@"View Mode", @"") items:@[ + [self toolbarItemWithIdentifier:@"view-editor" label:NSLocalizedString(@"Editor Only", @"Editor only toolbar button") icon:@"ToolbarIconShiftLeft" action:@selector(showEditorOnly:)], + [self toolbarItemWithIdentifier:@"view-both" label:NSLocalizedString(@"Editor & Preview", @"Both panes toolbar button") icon:@"ToolbarIconEditorAndPreview" action:@selector(showBothPanes:)], + [self toolbarItemWithIdentifier:@"view-preview" label:NSLocalizedString(@"Preview Only", @"Preview only toolbar button") icon:@"ToolbarIconShiftRight" action:@selector(showPreviewOnly:)] + ] + ], [self toolbarItemGroupWithIdentifier:@"indent-group" separated:YES label:NSLocalizedString(@"Shift Left/Right", @"") items:@[ [self toolbarItemWithIdentifier:@"shift-left" label:NSLocalizedString(@"Shift Left", @"Shift text to the left toolbar button") icon:@"ToolbarIconShiftLeft" action:@selector(unindent:)], [self toolbarItemWithIdentifier:@"shift-right" label:NSLocalizedString(@"Shift Right", @"Shift text to the right toolbar button") icon:@"ToolbarIconShiftRight" action:@selector(indent:)] @@ -78,13 +85,7 @@ - (void)setupToolbarItems [self toolbarItemWithIdentifier:@"copy-html" label:NSLocalizedString(@"Copy HTML", @"Copy HTML toolbar button") icon:@"ToolbarIconCopyHTML" action:@selector(copyHtml:)], [self toolbarItemWithIdentifier:@"comment" label:NSLocalizedString(@"Comment", @"Comment toolbar button") icon:@"ToolbarIconComment" action:@selector(toggleComment:)], [self toolbarItemWithIdentifier:@"highlight" label:NSLocalizedString(@"Highlight", @"Highlight toolbar button") icon:@"ToolbarIconHighlight" action:@selector(toggleHighlight:)], - [self toolbarItemWithIdentifier:@"strikethrough" label:NSLocalizedString(@"Strikethrough", @"Strikethrough toolbar button") icon:@"ToolbarIconStrikethrough" action:@selector(toggleStrikethrough:)], - [self toolbarItemGroupWithIdentifier:@"view-mode-group" separated:NO label:NSLocalizedString(@"View Mode", @"") items:@[ - [self toolbarItemWithIdentifier:@"view-editor" label:NSLocalizedString(@"Editor Only", @"Editor only toolbar button") icon:@"ToolbarIconShiftLeft" action:@selector(showEditorOnly:)], - [self toolbarItemWithIdentifier:@"view-both" label:NSLocalizedString(@"Editor & Preview", @"Both panes toolbar button") icon:@"ToolbarIconEditorAndPreview" action:@selector(showBothPanes:)], - [self toolbarItemWithIdentifier:@"view-preview" label:NSLocalizedString(@"Preview Only", @"Preview only toolbar button") icon:@"ToolbarIconShiftRight" action:@selector(showPreviewOnly:)] - ] - ] + [self toolbarItemWithIdentifier:@"strikethrough" label:NSLocalizedString(@"Strikethrough", @"Strikethrough toolbar button") icon:@"ToolbarIconStrikethrough" action:@selector(toggleStrikethrough:)] ]; self->toolbarItemIdentifiers = [self toolbarItemIdentifiersFromItemsArray:self->toolbarItems]; @@ -133,7 +134,7 @@ - (void)selectedToolbarItemGroupItem:(NSSegmentedControl *)sender // Add space after the specified toolbar item indices int spaceAfterIndices[] = {}; // No space in the default set - int flexibleSpaceAfterIndices[] = {2, 3, 5, 7, 11}; + int flexibleSpaceAfterIndices[] = {1, 4, 5, 7, 9}; int i = 0; int j = 0; int k = 0; diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index e3086bb8..46c0d35d 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -22,5 +22,6 @@ - (IBAction)showEditorOnly:(id)sender; - (IBAction)showPreviewOnly:(id)sender; - (IBAction)showBothPanes:(id)sender; +- (IBAction)toggleSidebar:(id)sender; @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index ca77586f..d8ec4ce0 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -30,6 +30,7 @@ #import "MPMathJaxListener.h" #import "WebView+WebViewPrivateHeaders.h" #import "MPToolbarController.h" +#import "MPSidebarController.h" #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; @@ -191,6 +192,7 @@ typedef NS_ENUM(NSUInteger, MPWordCountType) { @property (weak) IBOutlet WebView *preview; @property (weak) IBOutlet NSPopUpButton *wordCountWidget; @property (strong) IBOutlet MPToolbarController *toolbarController; +@property (strong) MPSidebarController *sidebarController; @property (copy, nonatomic) NSString *autosaveName; @property (strong) HGMarkdownHighlighter *highlighter; @property (strong) MPRenderer *renderer; @@ -360,6 +362,13 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller { [super windowControllerDidLoadNib:controller]; + // Enable native macOS window tabbing (10.12+) + if (@available(macOS 10.12, *)) + { + controller.window.tabbingMode = NSWindowTabbingModeAutomatic; + controller.window.tabbingIdentifier = @"ReadDownDocumentWindow"; + } + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; // All files use their absolute path to keep their window states. @@ -449,6 +458,20 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller wordCountWidget.hidden = !self.preferences.editorShowWordCount; wordCountWidget.enabled = NO; + // Install sidebar (file browser + document outline) + self.sidebarController = [[MPSidebarController alloc] initWithContentView:controller.window.contentView]; + [self.sidebarController installInWindow:controller.window aroundView:self.splitView]; + + // Set file browser root to the directory of the opened file + if (self.fileURL) + [self.sidebarController setRootURL:[self.fileURL URLByDeletingLastPathComponent]]; + + // Listen for heading selection from sidebar + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(sidebarDidSelectHeading:) + name:@"MPSidebarDidSelectHeading" + object:self.sidebarController]; + // These needs to be queued until after the window is shown, so that editor // can have the correct dimention for size-limiting and stuff. See // https://github.com/uranusjr/macdown/issues/236 @@ -457,18 +480,33 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller [self redrawDivider]; [self reloadFromLoadedString]; - // Apply default view mode preference - NSInteger defaultViewMode = self.preferences.defaultViewMode; - if (defaultViewMode == 1) + // Apply view mode: per-file memory > opened-file preference > default + NSInteger viewMode = -1; + + // Check per-file memory first + if (self.fileURL && self.preferences.rememberViewModePerFile) { - // Editor only - [self showEditorOnly:nil]; + NSString *filePath = self.fileURL.absoluteString; + NSDictionary *perFileViewModes = [[NSUserDefaults standardUserDefaults] + dictionaryForKey:kMPPerFileViewModeKey]; + NSNumber *savedMode = perFileViewModes[filePath]; + if (savedMode) + viewMode = savedMode.integerValue; } - else if (defaultViewMode == 2) + + // Fall back to opened-file preference (existing files) or default (new) + if (viewMode < 0) { - // Preview only - [self showPreviewOnly:nil]; + if (self.fileURL) + viewMode = self.preferences.openedFileViewMode; + else + viewMode = self.preferences.defaultViewMode; } + + if (viewMode == 1) + [self showEditorOnly:nil]; + else if (viewMode == 2) + [self showPreviewOnly:nil]; }]; } @@ -480,6 +518,9 @@ - (void)reloadFromLoadedString self.loadedString = nil; [self.renderer parseAndRenderNow]; [self.highlighter parseAndHighlightNow]; + + // Populate document outline + [self.sidebarController updateHeadingsFromMarkdown:self.editor.string]; } } @@ -1136,6 +1177,9 @@ - (void)editorTextDidChange:(NSNotification *)notification { if (self.needsHtml) [self.renderer parseAndRenderLater]; + + // Update document outline in sidebar + [self.sidebarController updateHeadingsFromMarkdown:self.editor.string]; } - (void)userDefaultsDidChange:(NSNotification *)notification @@ -1500,12 +1544,14 @@ - (IBAction)showEditorOnly:(id)sender { CGFloat ratio = self.preferences.editorOnRight ? 0.0 : 1.0; [self setSplitViewDividerLocation:ratio]; + [self savePerFileViewMode:1]; } - (IBAction)showPreviewOnly:(id)sender { CGFloat ratio = self.preferences.editorOnRight ? 1.0 : 0.0; [self setSplitViewDividerLocation:ratio]; + [self savePerFileViewMode:2]; } - (IBAction)showBothPanes:(id)sender @@ -1514,6 +1560,37 @@ - (IBAction)showBothPanes:(id)sender if (ratio <= 0.0 || ratio >= 1.0) ratio = 0.5; [self setSplitViewDividerLocation:ratio]; + [self savePerFileViewMode:0]; +} + +- (void)savePerFileViewMode:(NSInteger)mode +{ + if (!self.fileURL || !self.preferences.rememberViewModePerFile) + return; + NSString *filePath = self.fileURL.absoluteString; + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + NSMutableDictionary *perFileViewModes = + [[defaults dictionaryForKey:kMPPerFileViewModeKey] mutableCopy]; + if (!perFileViewModes) + perFileViewModes = [NSMutableDictionary new]; + perFileViewModes[filePath] = @(mode); + [defaults setObject:perFileViewModes forKey:kMPPerFileViewModeKey]; +} + +- (IBAction)toggleSidebar:(id)sender +{ + [self.sidebarController toggleSidebar]; +} + +- (void)sidebarDidSelectHeading:(NSNotification *)notification +{ + NSRange range = [notification.userInfo[@"range"] rangeValue]; + if (range.location != NSNotFound && self.editor) + { + [self.editor setSelectedRange:NSMakeRange(range.location, 0)]; + [self.editor scrollRangeToVisible:NSMakeRange(range.location, 0)]; + [self.editor.window makeFirstResponder:self.editor]; + } } - (IBAction)render:(id)sender diff --git a/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m b/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m index da91ea1d..8d978a6b 100644 --- a/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m +++ b/MacDown/Code/Preferences/MPGeneralPreferencesViewController.m @@ -13,6 +13,7 @@ @interface MPGeneralPreferencesViewController () @property (weak) IBOutlet NSButton *autoRenderingToggle; @property (weak) IBOutlet NSPopUpButton *defaultViewModePopup; +@property (weak) IBOutlet NSPopUpButton *openedFileViewModePopup; @end @@ -22,6 +23,7 @@ - (void)viewDidLoad { [super viewDidLoad]; [self.defaultViewModePopup selectItemWithTag:self.preferences.defaultViewMode]; + [self.openedFileViewModePopup selectItemWithTag:self.preferences.openedFileViewMode]; } #pragma mark - MASPreferencesViewController @@ -49,6 +51,11 @@ - (IBAction)defaultViewModeChanged:(id)sender self.preferences.defaultViewMode = self.defaultViewModePopup.selectedTag; } +- (IBAction)openedFileViewModeChanged:(id)sender +{ + self.preferences.openedFileViewMode = self.openedFileViewModePopup.selectedTag; +} + - (IBAction)updateWordCounterVisibility:(id)sender { if (sender == self.autoRenderingToggle) diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 6900a8a2..464d0ebb 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -20,6 +20,8 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) BOOL supressesUntitledDocumentOnLaunch; @property (assign) BOOL createFileForLinkTarget; @property (assign) NSInteger defaultViewMode; // 0=Both, 1=Editor Only, 2=Preview Only +@property (assign) NSInteger openedFileViewMode; // View mode for existing files: 0=Both, 1=Editor, 2=Preview +@property (assign) BOOL rememberViewModePerFile; // Remember per-file view mode // Extension flags. @property (assign) BOOL extensionIntraEmphasis; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index 4f54f8e9..b62a94bb 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -75,6 +75,8 @@ - (instancetype)init @dynamic updateIncludesPreReleases; @dynamic supressesUntitledDocumentOnLaunch; @dynamic createFileForLinkTarget; +@dynamic openedFileViewMode; +@dynamic rememberViewModePerFile; @dynamic extensionIntraEmphasis; @dynamic extensionTables; @@ -283,6 +285,10 @@ - (void)loadDefaultUserDefaults self.editorInsertPrefixInBlock = YES; if (![defaults objectForKey:@"htmlTemplateName"]) self.htmlTemplateName = @"Default"; + if (![defaults objectForKey:@"openedFileViewMode"]) + self.openedFileViewMode = 2; // Preview only for opened files + if (![defaults objectForKey:@"rememberViewModePerFile"]) + self.rememberViewModePerFile = YES; } @end diff --git a/MacDown/Code/Utility/MPGlobals.h b/MacDown/Code/Utility/MPGlobals.h index 5ac1a7e0..b05ba3ad 100644 --- a/MacDown/Code/Utility/MPGlobals.h +++ b/MacDown/Code/Utility/MPGlobals.h @@ -27,3 +27,4 @@ static NSString * const kMPVersionKey = @"version"; static NSString * const kMPFilesToOpenKey = @"filesToOpenOnNextLaunch"; static NSString * const kMPPipedContentFileToOpen = @"pipedContentFileToOpenOnNextLaunch"; +static NSString * const kMPPerFileViewModeKey = @"perFileViewModes"; diff --git a/MacDown/Code/View/MPSidebarController.h b/MacDown/Code/View/MPSidebarController.h new file mode 100644 index 00000000..f41bc0ad --- /dev/null +++ b/MacDown/Code/View/MPSidebarController.h @@ -0,0 +1,44 @@ +// +// MPSidebarController.h +// ReadDown +// + +#import + +@interface MPSidebarController : NSObject + +@property (nonatomic, readonly) NSSplitView *outerSplitView; +@property (nonatomic, readonly) NSView *sidebarView; +@property (nonatomic, readonly) BOOL sidebarVisible; + +- (instancetype)initWithContentView:(NSView *)contentView; +- (void)installInWindow:(NSWindow *)window aroundView:(NSView *)contentSplitView; +- (void)toggleSidebar; +- (void)showSidebar; +- (void)hideSidebar; + +// File browser +- (void)setRootURL:(NSURL *)url; + +// Document outline +- (void)updateHeadingsFromMarkdown:(NSString *)markdown; + +@end + + +// Represents a file/folder in the file tree +@interface MPFileNode : NSObject +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSURL *url; +@property (nonatomic, strong) NSArray *children; +@property (nonatomic, readonly) BOOL isDirectory; +@end + + +// Represents a heading in the document outline +@interface MPHeadingNode : NSObject +@property (nonatomic, strong) NSString *title; +@property (nonatomic) NSInteger level; // 1-6 +@property (nonatomic) NSRange range; +@property (nonatomic, strong) NSMutableArray *children; +@end diff --git a/MacDown/Code/View/MPSidebarController.m b/MacDown/Code/View/MPSidebarController.m new file mode 100644 index 00000000..4079eede --- /dev/null +++ b/MacDown/Code/View/MPSidebarController.m @@ -0,0 +1,509 @@ +// +// MPSidebarController.m +// ReadDown +// + +#import "MPSidebarController.h" + +static CGFloat const kMPSidebarMinWidth = 150.0; +static CGFloat const kMPSidebarDefaultWidth = 220.0; +static CGFloat const kMPSidebarMaxWidth = 400.0; + + +#pragma mark - MPFileNode + +@implementation MPFileNode + +- (BOOL)isDirectory +{ + return self.children != nil; +} + ++ (MPFileNode *)nodeWithURL:(NSURL *)url +{ + MPFileNode *node = [[MPFileNode alloc] init]; + node.name = url.lastPathComponent; + node.url = url; + + NSFileManager *fm = [NSFileManager defaultManager]; + BOOL isDir = NO; + [fm fileExistsAtPath:url.path isDirectory:&isDir]; + + if (isDir) + { + NSMutableArray *kids = [NSMutableArray array]; + NSArray *contents = [fm contentsOfDirectoryAtURL:url + includingPropertiesForKeys:@[NSURLIsDirectoryKey, NSURLNameKey] + options:NSDirectoryEnumerationSkipsHiddenFiles + error:nil]; + + // Sort: directories first, then alphabetical + contents = [contents sortedArrayUsingComparator:^NSComparisonResult(NSURL *a, NSURL *b) { + NSNumber *aIsDir, *bIsDir; + [a getResourceValue:&aIsDir forKey:NSURLIsDirectoryKey error:nil]; + [b getResourceValue:&bIsDir forKey:NSURLIsDirectoryKey error:nil]; + if (aIsDir.boolValue != bIsDir.boolValue) + return aIsDir.boolValue ? NSOrderedAscending : NSOrderedDescending; + return [a.lastPathComponent localizedCaseInsensitiveCompare:b.lastPathComponent]; + }]; + + for (NSURL *childURL in contents) + { + // Only show markdown-related files and directories + NSString *ext = childURL.pathExtension.lowercaseString; + NSNumber *childIsDir; + [childURL getResourceValue:&childIsDir forKey:NSURLIsDirectoryKey error:nil]; + + if (childIsDir.boolValue || + [ext isEqualToString:@"md"] || + [ext isEqualToString:@"markdown"] || + [ext isEqualToString:@"txt"] || + [ext isEqualToString:@"mdown"] || + [ext isEqualToString:@"mkd"]) + { + [kids addObject:[MPFileNode nodeWithURL:childURL]]; + } + } + node.children = kids; + } + + return node; +} + +@end + + +#pragma mark - MPHeadingNode + +@implementation MPHeadingNode + +- (instancetype)init +{ + self = [super init]; + if (self) + _children = [NSMutableArray array]; + return self; +} + ++ (NSArray *)headingsFromMarkdown:(NSString *)markdown +{ + if (!markdown.length) + return @[]; + + NSMutableArray *allHeadings = [NSMutableArray array]; + NSArray *lines = [markdown componentsSeparatedByCharactersInSet: + [NSCharacterSet newlineCharacterSet]]; + NSUInteger offset = 0; + + for (NSString *line in lines) + { + NSString *trimmed = [line stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceCharacterSet]]; + + if ([trimmed hasPrefix:@"#"]) + { + NSInteger level = 0; + for (NSUInteger i = 0; i < trimmed.length && i < 6; i++) + { + if ([trimmed characterAtIndex:i] == '#') + level++; + else + break; + } + + if (level > 0 && level <= 6 && trimmed.length > level) + { + NSString *title = [[trimmed substringFromIndex:level] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + // Strip trailing #s + while ([title hasSuffix:@"#"]) + title = [[title substringToIndex:title.length - 1] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + + if (title.length > 0) + { + MPHeadingNode *heading = [[MPHeadingNode alloc] init]; + heading.title = title; + heading.level = level; + heading.range = NSMakeRange(offset, line.length); + [allHeadings addObject:heading]; + } + } + } + offset += line.length + 1; // +1 for newline + } + + // Build hierarchy: nest lower-level headings under higher-level ones + NSMutableArray *roots = [NSMutableArray array]; + NSMutableArray *stack = [NSMutableArray array]; + + for (MPHeadingNode *heading in allHeadings) + { + while (stack.count > 0 && stack.lastObject.level >= heading.level) + [stack removeLastObject]; + + if (stack.count > 0) + [stack.lastObject.children addObject:heading]; + else + [roots addObject:heading]; + + [stack addObject:heading]; + } + + return roots; +} + +@end + + +#pragma mark - MPSidebarController + +@interface MPSidebarController () +@property (nonatomic, strong) NSSplitView *outerSplitView; +@property (nonatomic, strong) NSView *sidebarView; +@property (nonatomic, strong) NSTabView *tabView; +@property (nonatomic, strong) NSOutlineView *fileOutlineView; +@property (nonatomic, strong) NSOutlineView *headingOutlineView; +@property (nonatomic, strong) MPFileNode *rootFileNode; +@property (nonatomic, strong) NSArray *headingRoots; +@property (nonatomic) BOOL sidebarVisible; +@property (nonatomic) CGFloat savedSidebarWidth; +@end + + +@implementation MPSidebarController + +- (instancetype)initWithContentView:(NSView *)contentView +{ + self = [super init]; + if (!self) + return nil; + + self.savedSidebarWidth = kMPSidebarDefaultWidth; + self.headingRoots = @[]; + + // Create the outer split view + self.outerSplitView = [[NSSplitView alloc] initWithFrame:contentView.bounds]; + self.outerSplitView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.outerSplitView.dividerStyle = NSSplitViewDividerStyleThin; + self.outerSplitView.vertical = YES; + self.outerSplitView.delegate = self; + + // Create sidebar container + self.sidebarView = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, kMPSidebarDefaultWidth, contentView.bounds.size.height)]; + self.sidebarView.autoresizingMask = NSViewHeightSizable; + + // Create tab view for Files / Outline tabs + self.tabView = [[NSTabView alloc] initWithFrame:self.sidebarView.bounds]; + self.tabView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.tabView.tabViewType = NSTopTabsBezelBorder; + self.tabView.controlSize = NSControlSizeSmall; + + // Files tab + NSTabViewItem *filesTab = [[NSTabViewItem alloc] initWithIdentifier:@"files"]; + filesTab.label = @"Files"; + [self setupFileOutlineInView:filesTab]; + [self.tabView addTabViewItem:filesTab]; + + // Outline tab + NSTabViewItem *outlineTab = [[NSTabViewItem alloc] initWithIdentifier:@"outline"]; + outlineTab.label = @"Outline"; + [self setupHeadingOutlineInView:outlineTab]; + [self.tabView addTabViewItem:outlineTab]; + + [self.sidebarView addSubview:self.tabView]; + + // Start hidden + self.sidebarVisible = NO; + + return self; +} + +- (void)setupFileOutlineInView:(NSTabViewItem *)tabItem +{ + NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + scrollView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + scrollView.hasVerticalScroller = YES; + scrollView.autohidesScrollers = YES; + + self.fileOutlineView = [[NSOutlineView alloc] initWithFrame:NSZeroRect]; + self.fileOutlineView.headerView = nil; + self.fileOutlineView.rowHeight = 22; + self.fileOutlineView.indentationPerLevel = 16; + self.fileOutlineView.autoresizesOutlineColumn = YES; + self.fileOutlineView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleSourceList; + self.fileOutlineView.dataSource = self; + self.fileOutlineView.delegate = self; + self.fileOutlineView.doubleAction = @selector(fileDoubleClicked:); + self.fileOutlineView.target = self; + + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:@"name"]; + col.editable = NO; + [self.fileOutlineView addTableColumn:col]; + self.fileOutlineView.outlineTableColumn = col; + + scrollView.documentView = self.fileOutlineView; + tabItem.view = scrollView; +} + +- (void)setupHeadingOutlineInView:(NSTabViewItem *)tabItem +{ + NSScrollView *scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + scrollView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + scrollView.hasVerticalScroller = YES; + scrollView.autohidesScrollers = YES; + + self.headingOutlineView = [[NSOutlineView alloc] initWithFrame:NSZeroRect]; + self.headingOutlineView.headerView = nil; + self.headingOutlineView.rowHeight = 20; + self.headingOutlineView.indentationPerLevel = 14; + self.headingOutlineView.autoresizesOutlineColumn = YES; + self.headingOutlineView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleSourceList; + self.headingOutlineView.dataSource = self; + self.headingOutlineView.delegate = self; + self.headingOutlineView.doubleAction = @selector(headingDoubleClicked:); + self.headingOutlineView.target = self; + + NSTableColumn *col = [[NSTableColumn alloc] initWithIdentifier:@"heading"]; + col.editable = NO; + [self.headingOutlineView addTableColumn:col]; + self.headingOutlineView.outlineTableColumn = col; + + scrollView.documentView = self.headingOutlineView; + tabItem.view = scrollView; +} + + +#pragma mark - Public API + +- (void)installInWindow:(NSWindow *)window aroundView:(NSView *)contentSplitView +{ + NSView *windowContentView = window.contentView; + NSRect frame = contentSplitView.frame; + + // Remove existing content from window + [contentSplitView removeFromSuperview]; + + // Configure outer split view + self.outerSplitView.frame = frame; + + // Add sidebar + content + [self.outerSplitView addSubview:self.sidebarView]; + [self.outerSplitView addSubview:contentSplitView]; + + // Pin outer split view to fill window content + self.outerSplitView.translatesAutoresizingMaskIntoConstraints = NO; + [windowContentView addSubview:self.outerSplitView]; + [NSLayoutConstraint activateConstraints:@[ + [self.outerSplitView.topAnchor constraintEqualToAnchor:windowContentView.topAnchor], + [self.outerSplitView.bottomAnchor constraintEqualToAnchor:windowContentView.bottomAnchor], + [self.outerSplitView.leadingAnchor constraintEqualToAnchor:windowContentView.leadingAnchor], + [self.outerSplitView.trailingAnchor constraintEqualToAnchor:windowContentView.trailingAnchor], + ]]; + + // Start with sidebar hidden + [self hideSidebar]; +} + +- (void)toggleSidebar +{ + if (self.sidebarVisible) + [self hideSidebar]; + else + [self showSidebar]; +} + +- (void)showSidebar +{ + if (self.sidebarVisible) + return; + self.sidebarVisible = YES; + [self.outerSplitView setPosition:self.savedSidebarWidth + ofDividerAtIndex:0]; + + // Auto-set root to the current file's directory if not set + if (!self.rootFileNode) + { + // Will be set by the document when it opens + } +} + +- (void)hideSidebar +{ + if (!self.sidebarVisible && self.sidebarView.frame.size.width == 0) + return; + if (self.sidebarView.frame.size.width > 0) + self.savedSidebarWidth = self.sidebarView.frame.size.width; + self.sidebarVisible = NO; + [self.outerSplitView setPosition:0 ofDividerAtIndex:0]; +} + +- (void)setRootURL:(NSURL *)url +{ + if (!url) + return; + self.rootFileNode = [MPFileNode nodeWithURL:url]; + [self.fileOutlineView reloadData]; + [self.fileOutlineView expandItem:self.rootFileNode]; +} + +- (void)updateHeadingsFromMarkdown:(NSString *)markdown +{ + self.headingRoots = [MPHeadingNode headingsFromMarkdown:markdown]; + [self.headingOutlineView reloadData]; + [self.headingOutlineView expandItem:nil expandChildren:YES]; +} + + +#pragma mark - Actions + +- (void)fileDoubleClicked:(id)sender +{ + MPFileNode *node = [self.fileOutlineView itemAtRow:self.fileOutlineView.clickedRow]; + if (!node || node.isDirectory) + return; + + NSDocumentController *dc = [NSDocumentController sharedDocumentController]; + [dc openDocumentWithContentsOfURL:node.url display:YES + completionHandler:^(NSDocument *doc, BOOL wasOpen, NSError *err) {}]; +} + +- (void)headingDoubleClicked:(id)sender +{ + // Handled by document via notification + MPHeadingNode *heading = [self.headingOutlineView itemAtRow:self.headingOutlineView.clickedRow]; + if (!heading) + return; + + [[NSNotificationCenter defaultCenter] + postNotificationName:@"MPSidebarDidSelectHeading" + object:self + userInfo:@{@"range": [NSValue valueWithRange:heading.range]}]; +} + + +#pragma mark - NSOutlineViewDataSource + +- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item +{ + if (outlineView == self.fileOutlineView) + { + if (!item) + return self.rootFileNode ? self.rootFileNode.children.count : 0; + return ((MPFileNode *)item).children.count; + } + else + { + if (!item) + return self.headingRoots.count; + return ((MPHeadingNode *)item).children.count; + } +} + +- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item +{ + if (outlineView == self.fileOutlineView) + { + if (!item) + return self.rootFileNode.children[index]; + return ((MPFileNode *)item).children[index]; + } + else + { + if (!item) + return self.headingRoots[index]; + return ((MPHeadingNode *)item).children[index]; + } +} + +- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item +{ + if (outlineView == self.fileOutlineView) + return ((MPFileNode *)item).isDirectory; + else + return ((MPHeadingNode *)item).children.count > 0; +} + + +#pragma mark - NSOutlineViewDelegate + +- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item +{ + NSTableCellView *cell = [outlineView makeViewWithIdentifier:@"cell" owner:self]; + if (!cell) + { + cell = [[NSTableCellView alloc] initWithFrame:NSMakeRect(0, 0, 200, 20)]; + cell.identifier = @"cell"; + + NSImageView *imageView = [[NSImageView alloc] initWithFrame:NSMakeRect(0, 0, 16, 16)]; + imageView.imageScaling = NSImageScaleProportionallyUpOrDown; + [cell addSubview:imageView]; + cell.imageView = imageView; + + NSTextField *textField = [NSTextField labelWithString:@""]; + textField.font = [NSFont systemFontOfSize:12]; + textField.lineBreakMode = NSLineBreakByTruncatingTail; + [cell addSubview:textField]; + cell.textField = textField; + + // Layout + imageView.translatesAutoresizingMaskIntoConstraints = NO; + textField.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [imageView.leadingAnchor constraintEqualToAnchor:cell.leadingAnchor constant:2], + [imageView.centerYAnchor constraintEqualToAnchor:cell.centerYAnchor], + [imageView.widthAnchor constraintEqualToConstant:16], + [imageView.heightAnchor constraintEqualToConstant:16], + [textField.leadingAnchor constraintEqualToAnchor:imageView.trailingAnchor constant:4], + [textField.trailingAnchor constraintEqualToAnchor:cell.trailingAnchor constant:-2], + [textField.centerYAnchor constraintEqualToAnchor:cell.centerYAnchor], + ]]; + } + + if (outlineView == self.fileOutlineView) + { + MPFileNode *node = (MPFileNode *)item; + cell.textField.stringValue = node.name; + if (node.isDirectory) + cell.imageView.image = [NSImage imageNamed:NSImageNameFolder]; + else + cell.imageView.image = [[NSWorkspace sharedWorkspace] iconForFileType:node.url.pathExtension]; + } + else + { + MPHeadingNode *heading = (MPHeadingNode *)item; + cell.textField.stringValue = heading.title; + // Indent visually by font size based on heading level + CGFloat fontSize = MAX(13.0 - heading.level, 10.0); + cell.textField.font = (heading.level <= 2) + ? [NSFont boldSystemFontOfSize:fontSize] + : [NSFont systemFontOfSize:fontSize]; + cell.imageView.image = nil; + } + + return cell; +} + + +#pragma mark - NSSplitViewDelegate + +- (CGFloat)splitView:(NSSplitView *)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(NSInteger)dividerIndex +{ + if (splitView == self.outerSplitView && dividerIndex == 0) + return kMPSidebarMinWidth; + return proposedMinimumPosition; +} + +- (CGFloat)splitView:(NSSplitView *)splitView constrainMaxCoordinate:(CGFloat)proposedMaximumPosition ofSubviewAt:(NSInteger)dividerIndex +{ + if (splitView == self.outerSplitView && dividerIndex == 0) + return kMPSidebarMaxWidth; + return proposedMaximumPosition; +} + +- (BOOL)splitView:(NSSplitView *)splitView canCollapseSubview:(NSView *)subview +{ + return (subview == self.sidebarView); +} + +@end diff --git a/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib b/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib index 6e6d5040..7b6afc69 100644 --- a/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib +++ b/MacDown/Localization/Base.lproj/MPGeneralPreferencesViewController.xib @@ -10,13 +10,14 @@ + - + @@ -170,22 +171,22 @@ - - + + - + - - + + - + @@ -201,6 +202,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -209,7 +248,14 @@ - + + + + + + + + diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index ad3ef6c3..d6976907 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -389,6 +389,13 @@ + + + + + + + From 43fb9c21423a643d10c0f71c892a8bae758ca52a Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 15:58:41 -0700 Subject: [PATCH 05/37] Fix build: add missing MPGlobals.h import to MPDocument.m Co-Authored-By: Claude Opus 4.6 (1M context) --- MacDown/Code/Document/MPDocument.m | 1 + 1 file changed, 1 insertion(+) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index d8ec4ce0..e8c6634c 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -31,6 +31,7 @@ #import "WebView+WebViewPrivateHeaders.h" #import "MPToolbarController.h" #import "MPSidebarController.h" +#import "MPGlobals.h" #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; From 750174751ed15d51661657d9603c6092f610cd3f Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:16:06 -0700 Subject: [PATCH 06/37] Add Phase 2 features: Focus mode, Typewriter mode, writing stats, DOCX export - Focus Mode (Cmd+Opt+F): dims all text except the active paragraph so you can concentrate on what you're writing - Typewriter Mode (Cmd+Shift+T): keeps the cursor line vertically centered in the editor as you type - Writing stats: word count menu now shows reading time estimate and readability score (Flesch Reading Ease approximation) - DOCX export (File > Export > Word Document): converts rendered HTML to .docx via NSAttributedString - Both modes persist across sessions via preferences Co-Authored-By: Claude Opus 4.6 (1M context) --- .../xcshareddata/xcschemes/MacDown.xcscheme | 28 ++--- MacDown/Code/Document/MPDocument.h | 3 + MacDown/Code/Document/MPDocument.m | 119 ++++++++++++++++++ MacDown/Code/Preferences/MPPreferences.h | 2 + MacDown/Code/Preferences/MPPreferences.m | 2 + MacDown/Code/View/MPEditorView.h | 3 + MacDown/Code/View/MPEditorView.m | 96 ++++++++++++++ MacDown/Localization/Base.lproj/MainMenu.xib | 18 +++ 8 files changed, 255 insertions(+), 16 deletions(-) diff --git a/MacDown.xcodeproj/xcshareddata/xcschemes/MacDown.xcscheme b/MacDown.xcodeproj/xcshareddata/xcschemes/MacDown.xcscheme index e5486feb..6bcd2583 100644 --- a/MacDown.xcodeproj/xcshareddata/xcschemes/MacDown.xcscheme +++ b/MacDown.xcodeproj/xcshareddata/xcschemes/MacDown.xcscheme @@ -15,7 +15,7 @@ @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -39,17 +48,6 @@ - - - - - - - - diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 46c0d35d..0aafc2d6 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -23,5 +23,8 @@ - (IBAction)showPreviewOnly:(id)sender; - (IBAction)showBothPanes:(id)sender; - (IBAction)toggleSidebar:(id)sender; +- (IBAction)toggleFocusMode:(id)sender; +- (IBAction)toggleTypewriterMode:(id)sender; +- (IBAction)exportToDOCX:(id)sender; @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index e8c6634c..2654a628 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -508,6 +508,10 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller [self showEditorOnly:nil]; else if (viewMode == 2) [self showPreviewOnly:nil]; + + // Apply focus/typewriter mode from preferences + self.editor.focusModeEnabled = self.preferences.editorFocusMode; + self.editor.typewriterModeEnabled = self.preferences.editorTypewriterMode; }]; } @@ -1583,6 +1587,64 @@ - (IBAction)toggleSidebar:(id)sender [self.sidebarController toggleSidebar]; } +- (IBAction)toggleFocusMode:(id)sender +{ + BOOL newValue = !self.preferences.editorFocusMode; + self.preferences.editorFocusMode = newValue; + self.editor.focusModeEnabled = newValue; +} + +- (IBAction)toggleTypewriterMode:(id)sender +{ + BOOL newValue = !self.preferences.editorTypewriterMode; + self.preferences.editorTypewriterMode = newValue; + self.editor.typewriterModeEnabled = newValue; +} + +- (IBAction)exportToDOCX:(id)sender +{ + NSSavePanel *panel = [NSSavePanel savePanel]; + panel.allowedFileTypes = @[@"docx"]; + panel.nameFieldStringValue = [[self.fileURL.lastPathComponent stringByDeletingPathExtension] + stringByAppendingPathExtension:@"docx"] ?: @"Untitled.docx"; + + [panel beginSheetModalForWindow:self.windowForSheet completionHandler:^(NSModalResponse result) { + if (result != NSModalResponseOK) + return; + + // Get the rendered HTML and convert to attributed string, then to DOCX + NSString *html = self.renderer.currentHtml; + if (!html) + return; + + NSData *htmlData = [html dataUsingEncoding:NSUTF8StringEncoding]; + NSAttributedString *attrStr = [[NSAttributedString alloc] + initWithHTML:htmlData + baseURL:self.fileURL + documentAttributes:nil]; + + if (!attrStr) + return; + + NSDictionary *docAttrs = @{ + NSDocumentTypeDocumentAttribute: NSOfficeOpenXMLTextDocumentType, + }; + NSError *error = nil; + NSFileWrapper *wrapper = [attrStr fileWrapperFromRange:NSMakeRange(0, attrStr.length) + documentAttributes:docAttrs + error:&error]; + if (wrapper && !error) + [wrapper writeToURL:panel.URL options:NSFileWrapperWritingAtomic + originalContentsURL:nil error:&error]; + + if (error) + { + NSAlert *alert = [NSAlert alertWithError:error]; + [alert runModal]; + } + }]; +} + - (void)sidebarDidSelectHeading:(NSNotification *)notification { NSRange range = [notification.userInfo[@"range"] rangeValue]; @@ -2010,6 +2072,63 @@ - (void)updateWordCount self.totalCharacters = count.characters; self.totalCharactersNoSpaces = count.characterWithoutSpaces; + // Update reading time and readability in word count menu + if (count.words > 0) + { + NSUInteger minutes = count.words / 200; // avg 200 wpm reading speed + NSUInteger seconds = (count.words % 200) * 60 / 200; + NSString *readingTime; + if (minutes > 0) + readingTime = [NSString stringWithFormat:@"~%lu min read", (unsigned long)minutes]; + else + readingTime = [NSString stringWithFormat:@"~%lu sec read", (unsigned long)seconds]; + + // Flesch-Kincaid readability (approximate from word/sentence ratio) + NSString *text = self.editor.string; + NSUInteger sentenceCount = 0; + NSArray *sentenceEnders = @[@".", @"!", @"?"]; + for (NSString *ender in sentenceEnders) + { + NSUInteger searchFrom = 0; + while (searchFrom < text.length) + { + NSRange r = [text rangeOfString:ender + options:0 + range:NSMakeRange(searchFrom, text.length - searchFrom)]; + if (r.location == NSNotFound) break; + sentenceCount++; + searchFrom = r.location + 1; + } + } + if (sentenceCount == 0) sentenceCount = 1; + + CGFloat avgWordsPerSentence = (CGFloat)count.words / sentenceCount; + // Simplified Flesch Reading Ease (without syllable count) + // Uses Coleman-Liau approximation: characters per word as proxy + CGFloat avgCharsPerWord = (CGFloat)count.characterWithoutSpaces / count.words; + CGFloat readability = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * (avgCharsPerWord / 4.5)); + readability = MAX(0, MIN(100, readability)); + + NSString *level; + if (readability >= 80) level = @"Easy"; + else if (readability >= 60) level = @"Standard"; + else if (readability >= 40) level = @"Moderate"; + else level = @"Complex"; + + // Add stats items if not already present + NSMenu *menu = self.wordCountWidget.menu; + while (menu.numberOfItems > 3) + [menu removeItemAtIndex:menu.numberOfItems - 1]; + + [menu addItem:[NSMenuItem separatorItem]]; + NSMenuItem *timeItem = [[NSMenuItem alloc] initWithTitle:readingTime action:NULL keyEquivalent:@""]; + [menu addItem:timeItem]; + NSMenuItem *readabilityItem = [[NSMenuItem alloc] initWithTitle: + [NSString stringWithFormat:@"%@ (%.0f)", level, readability] + action:NULL keyEquivalent:@""]; + [menu addItem:readabilityItem]; + } + if (self.isPreviewReady) self.wordCountWidget.enabled = YES; } diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index 464d0ebb..b42fa1a0 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -22,6 +22,8 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) NSInteger defaultViewMode; // 0=Both, 1=Editor Only, 2=Preview Only @property (assign) NSInteger openedFileViewMode; // View mode for existing files: 0=Both, 1=Editor, 2=Preview @property (assign) BOOL rememberViewModePerFile; // Remember per-file view mode +@property (assign) BOOL editorFocusMode; // Focus mode: dim non-active paragraph +@property (assign) BOOL editorTypewriterMode; // Typewriter mode: keep cursor centered // Extension flags. @property (assign) BOOL extensionIntraEmphasis; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index b62a94bb..930ea909 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -77,6 +77,8 @@ - (instancetype)init @dynamic createFileForLinkTarget; @dynamic openedFileViewMode; @dynamic rememberViewModePerFile; +@dynamic editorFocusMode; +@dynamic editorTypewriterMode; @dynamic extensionIntraEmphasis; @dynamic extensionTables; diff --git a/MacDown/Code/View/MPEditorView.h b/MacDown/Code/View/MPEditorView.h index 286c450b..3b0733be 100644 --- a/MacDown/Code/View/MPEditorView.h +++ b/MacDown/Code/View/MPEditorView.h @@ -11,6 +11,9 @@ @interface MPEditorView : NSTextView @property BOOL scrollsPastEnd; +@property BOOL focusModeEnabled; +@property BOOL typewriterModeEnabled; - (NSRect)contentRect; +- (void)updateFocusMode; @end diff --git a/MacDown/Code/View/MPEditorView.m b/MacDown/Code/View/MPEditorView.m index 6c7804ac..53deca02 100644 --- a/MacDown/Code/View/MPEditorView.m +++ b/MacDown/Code/View/MPEditorView.m @@ -31,6 +31,8 @@ @implementation MPEditorView @synthesize contentRect = _contentRect; @synthesize scrollsPastEnd = _scrollsPastEnd; +@synthesize focusModeEnabled = _focusModeEnabled; +@synthesize typewriterModeEnabled = _typewriterModeEnabled; - (BOOL)scrollsPastEnd { @@ -176,6 +178,17 @@ - (void)didChangeText [super didChangeText]; if (self.scrollsPastEnd) [self updateContentGeometry]; + if (self.focusModeEnabled) + [self updateFocusMode]; +} + +- (void)setSelectedRange:(NSRange)charRange affinity:(NSSelectionAffinity)affinity stillSelecting:(BOOL)stillSelectingFlag +{ + [super setSelectedRange:charRange affinity:affinity stillSelecting:stillSelectingFlag]; + if (self.focusModeEnabled) + [self updateFocusMode]; + if (self.typewriterModeEnabled && !stillSelectingFlag) + [self scrollCursorToCenter]; } @@ -217,4 +230,87 @@ - (void)updateContentGeometry [self setFrameSize:self.frame.size]; // Force size update. } + +#pragma mark - Focus Mode + +- (void)setFocusModeEnabled:(BOOL)enabled +{ + _focusModeEnabled = enabled; + if (enabled) + [self updateFocusMode]; + else + [self clearFocusDimming]; +} + +- (void)updateFocusMode +{ + if (!self.focusModeEnabled) + return; + + NSString *text = self.string; + if (text.length == 0) + return; + + NSRange selectedRange = self.selectedRange; + if (selectedRange.location > text.length) + return; + + // Find the current paragraph range + NSRange paragraphRange = [text paragraphRangeForRange:NSMakeRange(selectedRange.location, 0)]; + + // Dim everything, then un-dim the active paragraph + NSColor *baseColor = self.textColor; + if (!baseColor) baseColor = [NSColor textColor]; + NSColor *dimColor = [baseColor colorWithAlphaComponent:0.3]; + NSColor *activeColor = baseColor; + + NSTextStorage *storage = self.textStorage; + [storage beginEditing]; + [storage addAttribute:NSForegroundColorAttributeName value:dimColor + range:NSMakeRange(0, text.length)]; + [storage addAttribute:NSForegroundColorAttributeName value:activeColor + range:paragraphRange]; + [storage endEditing]; +} + +- (void)clearFocusDimming +{ + NSString *text = self.string; + if (text.length == 0) + return; + + NSColor *normalColor = self.textColor; + if (!normalColor) normalColor = [NSColor textColor]; + NSTextStorage *storage = self.textStorage; + [storage beginEditing]; + [storage addAttribute:NSForegroundColorAttributeName value:normalColor + range:NSMakeRange(0, text.length)]; + [storage endEditing]; +} + + +#pragma mark - Typewriter Mode + +- (void)scrollCursorToCenter +{ + NSRange range = self.selectedRange; + if (range.location == NSNotFound || self.string.length == 0) + return; + + NSLayoutManager *lm = self.layoutManager; + NSUInteger glyphIndex = [lm glyphIndexForCharacterAtIndex: + MIN(range.location, self.string.length - 1)]; + NSRect lineRect = [lm lineFragmentRectForGlyphAtIndex:glyphIndex + effectiveRange:NULL]; + + CGFloat cursorY = lineRect.origin.y + lineRect.size.height / 2.0; + CGFloat visibleHeight = self.enclosingScrollView.contentSize.height; + CGFloat scrollY = cursorY - visibleHeight / 2.0; + if (scrollY < 0) scrollY = 0; + + NSPoint scrollPoint = NSMakePoint(0, scrollY); + [self.enclosingScrollView.contentView scrollToPoint:scrollPoint]; + [self.enclosingScrollView reflectScrolledClipView:self.enclosingScrollView.contentView]; +} + @end diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index d6976907..5663b6e9 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -135,6 +135,12 @@ + + + + + + @@ -395,6 +401,18 @@ + + + + + + + + + + + + From 2af4e7eb7782a17ac6b75ff4acdc8e42ee21f031 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:42:21 -0700 Subject: [PATCH 07/37] Add Phase 3: WikiLinks, Command Palette, prose tools, preview themes - WikiLinks: [[target]] and [[target|display]] syntax converts to clickable links in preview. Missing targets shown in red. - Command Palette (Cmd+Shift+P): fuzzy-searchable list of all commands with keyboard navigation (arrow keys + Enter) - Prose quality tools: "Highlight Filler Words" marks filler words (yellow) and repeated consecutive words (orange) in the editor - Writing stats: word count menu now shows reading time and readability score (Flesch Reading Ease approximation) - 3 new preview themes: Minimal Light, Minimal Dark, iA Writer (available in Preferences > Rendering > CSS) --- MacDown.xcodeproj/project.pbxproj | 12 + MacDown/Code/Document/MPDocument.h | 2 + MacDown/Code/Document/MPDocument.m | 97 +++++++- MacDown/Code/Utility/MPWritingTools.h | 20 ++ MacDown/Code/Utility/MPWritingTools.m | 164 +++++++++++++ MacDown/Code/View/MPCommandPalette.h | 20 ++ MacDown/Code/View/MPCommandPalette.m | 234 +++++++++++++++++++ MacDown/Localization/Base.lproj/MainMenu.xib | 13 ++ MacDown/Resources/Styles/Minimal Dark.css | 27 +++ MacDown/Resources/Styles/Minimal Light.css | 27 +++ MacDown/Resources/Styles/iA Writer.css | 35 +++ 11 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 MacDown/Code/Utility/MPWritingTools.h create mode 100644 MacDown/Code/Utility/MPWritingTools.m create mode 100644 MacDown/Code/View/MPCommandPalette.h create mode 100644 MacDown/Code/View/MPCommandPalette.m create mode 100644 MacDown/Resources/Styles/Minimal Dark.css create mode 100644 MacDown/Resources/Styles/Minimal Light.css create mode 100644 MacDown/Resources/Styles/iA Writer.css diff --git a/MacDown.xcodeproj/project.pbxproj b/MacDown.xcodeproj/project.pbxproj index b3ce6e96..c58302c5 100644 --- a/MacDown.xcodeproj/project.pbxproj +++ b/MacDown.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 1F3386E61A6B999600FC88C4 /* DOMNode+Text.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F3386E51A6B999600FC88C4 /* DOMNode+Text.m */; }; 1F33F2A11A3B4B660001C849 /* MPDocumentSplitView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */; }; RD00000001000000000000001 /* MPSidebarController.m in Sources */ = {isa = PBXBuildFile; fileRef = RD00000002000000000000001 /* MPSidebarController.m */; }; + RD00000004000000000000001 /* MPWritingTools.m in Sources */ = {isa = PBXBuildFile; fileRef = RD00000005000000000000001 /* MPWritingTools.m */; }; + RD00000006000000000000001 /* MPCommandPalette.m in Sources */ = {isa = PBXBuildFile; fileRef = RD00000007000000000000001 /* MPCommandPalette.m */; }; 1F33F2A41A3B56D20001C849 /* NSColor+HTML.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F33F2A31A3B56D20001C849 /* NSColor+HTML.m */; }; 1F3619E61F36DA0F00EDA15A /* MPTerminalPreferencesViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1F3619E81F36DA0F00EDA15A /* MPTerminalPreferencesViewController.xib */; }; 1F396E6619B0EA17000D3EFC /* MPEditorView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F396E6519B0EA17000D3EFC /* MPEditorView.m */; }; @@ -252,6 +254,10 @@ 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPDocumentSplitView.m; sourceTree = ""; }; RD00000003000000000000001 /* MPSidebarController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPSidebarController.h; sourceTree = ""; }; RD00000002000000000000001 /* MPSidebarController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPSidebarController.m; sourceTree = ""; }; + RD00000008000000000000001 /* MPWritingTools.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPWritingTools.h; sourceTree = ""; }; + RD00000005000000000000001 /* MPWritingTools.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPWritingTools.m; sourceTree = ""; }; + RD00000009000000000000001 /* MPCommandPalette.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPCommandPalette.h; sourceTree = ""; }; + RD00000007000000000000001 /* MPCommandPalette.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPCommandPalette.m; sourceTree = ""; }; 1F33F2A21A3B56D20001C849 /* NSColor+HTML.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSColor+HTML.h"; sourceTree = ""; }; 1F33F2A31A3B56D20001C849 /* NSColor+HTML.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSColor+HTML.m"; sourceTree = ""; }; 1F3619E71F36DA0F00EDA15A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MPTerminalPreferencesViewController.xib; sourceTree = ""; }; @@ -598,6 +604,8 @@ children = ( 1F70CCD81978F03E00703429 /* MPAutosaving.h */, 1F847BC31DCC9DA800A47385 /* MPGlobals.h */, + RD00000008000000000000001 /* MPWritingTools.h */, + RD00000005000000000000001 /* MPWritingTools.m */, 1F96BD7E1E584A03005E0456 /* MPHomebrewSubprocessController.h */, 1F96BD7F1E584A03005E0456 /* MPHomebrewSubprocessController.m */, 1F23A91F19928E650052DB78 /* MPMathJaxListener.h */, @@ -660,6 +668,8 @@ 1F33F2A01A3B4B660001C849 /* MPDocumentSplitView.m */, RD00000003000000000000001 /* MPSidebarController.h */, RD00000002000000000000001 /* MPSidebarController.m */, + RD00000009000000000000001 /* MPCommandPalette.h */, + RD00000007000000000000001 /* MPCommandPalette.m */, 1F396E6419B0EA17000D3EFC /* MPEditorView.h */, 1F396E6519B0EA17000D3EFC /* MPEditorView.m */, ); @@ -1238,6 +1248,8 @@ 1F8A835E1953454F00B6BF69 /* HGMarkdownHighlightingStyle.m in Sources */, 1F33F2A11A3B4B660001C849 /* MPDocumentSplitView.m in Sources */, RD00000001000000000000001 /* MPSidebarController.m in Sources */, + RD00000004000000000000001 /* MPWritingTools.m in Sources */, + RD00000006000000000000001 /* MPCommandPalette.m in Sources */, 1F23A92119928E650052DB78 /* MPMathJaxListener.m in Sources */, 1F2649B01A7406DB00EF6AF3 /* NSDocumentController+Document.m in Sources */, 1F0D9D67194AC7CF008E1856 /* MPUtilities.m in Sources */, diff --git a/MacDown/Code/Document/MPDocument.h b/MacDown/Code/Document/MPDocument.h index 0aafc2d6..c5ac6e6e 100644 --- a/MacDown/Code/Document/MPDocument.h +++ b/MacDown/Code/Document/MPDocument.h @@ -26,5 +26,7 @@ - (IBAction)toggleFocusMode:(id)sender; - (IBAction)toggleTypewriterMode:(id)sender; - (IBAction)exportToDOCX:(id)sender; +- (IBAction)showCommandPalette:(id)sender; +- (IBAction)toggleProseAnalysis:(id)sender; @end diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 2654a628..3213285b 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -32,6 +32,8 @@ #import "MPToolbarController.h" #import "MPSidebarController.h" #import "MPGlobals.h" +#import "MPWritingTools.h" +#import "MPCommandPalette.h" #import static NSString * const kMPDefaultAutosaveName = @"Untitled"; @@ -1033,7 +1035,13 @@ - (BOOL)rendererLoading { - (NSString *)rendererMarkdown:(MPRenderer *)renderer { - return self.editor.string; + NSString *markdown = self.editor.string; + + // Process WikiLinks: [[target]] and [[target|display]] + NSURL *baseDir = [self.fileURL URLByDeletingLastPathComponent]; + markdown = [MPWikiLinkProcessor processWikiLinksInMarkdown:markdown baseURL:baseDir]; + + return markdown; } - (NSString *)rendererHTMLTitle:(MPRenderer *)renderer @@ -1656,6 +1664,93 @@ - (void)sidebarDidSelectHeading:(NSNotification *)notification } } +- (IBAction)showCommandPalette:(id)sender +{ + NSArray *commands = @[ + [MPCommandItem itemWithTitle:@"Toggle Sidebar" shortcut:@"\u2325\u2318S" action:@selector(toggleSidebar:) target:self], + [MPCommandItem itemWithTitle:@"Focus Mode" shortcut:@"\u2325\u2318F" action:@selector(toggleFocusMode:) target:self], + [MPCommandItem itemWithTitle:@"Typewriter Mode" shortcut:@"\u21E7\u2318T" action:@selector(toggleTypewriterMode:) target:self], + [MPCommandItem itemWithTitle:@"Show Editor Only" shortcut:@"" action:@selector(showEditorOnly:) target:self], + [MPCommandItem itemWithTitle:@"Show Preview Only" shortcut:@"" action:@selector(showPreviewOnly:) target:self], + [MPCommandItem itemWithTitle:@"Show Editor & Preview" shortcut:@"" action:@selector(showBothPanes:) target:self], + [MPCommandItem itemWithTitle:@"Bold" shortcut:@"\u2318B" action:@selector(toggleStrong:) target:self], + [MPCommandItem itemWithTitle:@"Italic" shortcut:@"\u2318I" action:@selector(toggleEmphasis:) target:self], + [MPCommandItem itemWithTitle:@"Underline" shortcut:@"\u2318U" action:@selector(toggleUnderline:) target:self], + [MPCommandItem itemWithTitle:@"Heading 1" shortcut:@"" action:@selector(convertToH1:) target:self], + [MPCommandItem itemWithTitle:@"Heading 2" shortcut:@"" action:@selector(convertToH2:) target:self], + [MPCommandItem itemWithTitle:@"Heading 3" shortcut:@"" action:@selector(convertToH3:) target:self], + [MPCommandItem itemWithTitle:@"Insert Link" shortcut:@"\u2318K" action:@selector(toggleLink:) target:self], + [MPCommandItem itemWithTitle:@"Insert Image" shortcut:@"" action:@selector(toggleImage:) target:self], + [MPCommandItem itemWithTitle:@"Code Block" shortcut:@"" action:@selector(toggleInlineCode:) target:self], + [MPCommandItem itemWithTitle:@"Blockquote" shortcut:@"" action:@selector(toggleBlockquote:) target:self], + [MPCommandItem itemWithTitle:@"Unordered List" shortcut:@"" action:@selector(toggleUnorderedList:) target:self], + [MPCommandItem itemWithTitle:@"Ordered List" shortcut:@"" action:@selector(toggleOrderedList:) target:self], + [MPCommandItem itemWithTitle:@"Strikethrough" shortcut:@"" action:@selector(toggleStrikethrough:) target:self], + [MPCommandItem itemWithTitle:@"Highlight Filler Words" shortcut:@"" action:@selector(toggleProseAnalysis:) target:self], + [MPCommandItem itemWithTitle:@"Copy HTML" shortcut:@"\u2325\u2318C" action:@selector(copyHtml:) target:self], + [MPCommandItem itemWithTitle:@"Export HTML..." shortcut:@"\u2325\u2318E" action:@selector(exportHtml:) target:self], + [MPCommandItem itemWithTitle:@"Export PDF..." shortcut:@"\u2325\u2318P" action:@selector(exportPdf:) target:self], + [MPCommandItem itemWithTitle:@"Export DOCX..." shortcut:@"\u2325\u2318D" action:@selector(exportToDOCX:) target:self], + ]; + [MPCommandPalette showForWindow:self.windowForSheet withCommands:commands]; +} + +- (IBAction)toggleProseAnalysis:(id)sender +{ + NSString *text = self.editor.string; + MPProseAnalysis *analysis = [MPProseAnalysis analyzeText:text]; + + if (analysis.fillerWordRanges.count == 0) + { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Prose Analysis"; + alert.informativeText = @"No filler words or repeated words found."; + [alert runModal]; + return; + } + + // Highlight filler words in the editor with a yellow background + NSTextStorage *storage = self.editor.textStorage; + [storage beginEditing]; + + // Clear previous highlights + [storage removeAttribute:NSBackgroundColorAttributeName + range:NSMakeRange(0, text.length)]; + + // Highlight filler words in yellow + for (NSValue *rangeVal in analysis.fillerWordRanges) + { + NSRange range = rangeVal.rangeValue; + if (NSMaxRange(range) <= text.length) + [storage addAttribute:NSBackgroundColorAttributeName + value:[NSColor colorWithRed:1.0 green:1.0 blue:0.6 alpha:0.5] + range:range]; + } + + // Highlight repeated words in orange + for (NSValue *rangeVal in analysis.repeatedWordRanges) + { + NSRange range = rangeVal.rangeValue; + if (NSMaxRange(range) <= text.length) + [storage addAttribute:NSBackgroundColorAttributeName + value:[NSColor colorWithRed:1.0 green:0.8 blue:0.4 alpha:0.5] + range:range]; + } + + [storage endEditing]; + + NSString *summary = [NSString stringWithFormat: + @"Found %lu filler words and %lu repeated words.\n\nFillers: %@", + (unsigned long)analysis.fillerWordRanges.count, + (unsigned long)analysis.repeatedWordRanges.count, + [analysis.fillerWordsFound componentsJoinedByString:@", "]]; + + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Prose Analysis"; + alert.informativeText = summary; + [alert runModal]; +} + - (IBAction)render:(id)sender { [self.renderer parseAndRenderLater]; diff --git a/MacDown/Code/Utility/MPWritingTools.h b/MacDown/Code/Utility/MPWritingTools.h new file mode 100644 index 00000000..45084b8f --- /dev/null +++ b/MacDown/Code/Utility/MPWritingTools.h @@ -0,0 +1,20 @@ +// +// MPWritingTools.h +// ReadDown +// + +#import + +// WikiLink processing +@interface MPWikiLinkProcessor : NSObject ++ (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)baseURL; +@end + +// Prose quality analysis +@interface MPProseAnalysis : NSObject +@property (nonatomic, strong) NSArray *fillerWordRanges; +@property (nonatomic, strong) NSArray *repeatedWordRanges; +@property (nonatomic, strong) NSArray *fillerWordsFound; +@property (nonatomic, strong) NSArray *repeatedWordsFound; ++ (MPProseAnalysis *)analyzeText:(NSString *)text; +@end diff --git a/MacDown/Code/Utility/MPWritingTools.m b/MacDown/Code/Utility/MPWritingTools.m new file mode 100644 index 00000000..eb6d5ad8 --- /dev/null +++ b/MacDown/Code/Utility/MPWritingTools.m @@ -0,0 +1,164 @@ +// +// MPWritingTools.m +// ReadDown +// + +#import "MPWritingTools.h" + + +#pragma mark - WikiLink Processor + +@implementation MPWikiLinkProcessor + ++ (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)baseURL +{ + if (!markdown.length) + return markdown; + + // Match [[link]] and [[link|display text]] patterns + static NSRegularExpression *wikiLinkRegex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + wikiLinkRegex = [NSRegularExpression regularExpressionWithPattern: + @"\\[\\[([^\\]|]+?)(?:\\|([^\\]]+?))?\\]\\]" + options:0 error:nil]; + }); + + NSMutableString *result = [markdown mutableCopy]; + NSArray *matches = [wikiLinkRegex matchesInString:markdown options:0 + range:NSMakeRange(0, markdown.length)]; + + // Process in reverse to preserve ranges + for (NSTextCheckingResult *match in [matches reverseObjectEnumerator]) + { + NSString *target = [markdown substringWithRange:[match rangeAtIndex:1]]; + NSString *display = nil; + if ([match rangeAtIndex:2].location != NSNotFound) + display = [markdown substringWithRange:[match rangeAtIndex:2]]; + else + display = target; + + // Convert target to a filename: "My Note" -> "My Note.md" + NSString *filename = target; + if (![filename.pathExtension isEqualToString:@"md"] && + ![filename.pathExtension isEqualToString:@"markdown"]) + filename = [filename stringByAppendingPathExtension:@"md"]; + + NSString *link; + if (baseURL) + { + NSURL *targetURL = [baseURL URLByAppendingPathComponent:filename]; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:targetURL.path]; + NSString *cssClass = exists ? @"wikilink" : @"wikilink wikilink-new"; + link = [NSString stringWithFormat:@"%@", + filename, cssClass, display]; + } + else + { + link = [NSString stringWithFormat:@"%@", + filename, display]; + } + + [result replaceCharactersInRange:match.range withString:link]; + } + + return result; +} + +@end + + +#pragma mark - Prose Analysis + +static NSSet *MPFillerWords() +{ + static NSSet *words = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + words = [NSSet setWithArray:@[ + @"actually", @"basically", @"certainly", @"clearly", @"definitely", + @"effectively", @"essentially", @"extremely", @"fairly", @"frankly", + @"generally", @"honestly", @"hopefully", @"importantly", @"incredibly", + @"indeed", @"interestingly", @"ironically", @"just", @"largely", + @"literally", @"mainly", @"merely", @"mostly", @"naturally", + @"necessarily", @"notably", @"obviously", @"of course", @"overall", + @"particularly", @"perhaps", @"personally", @"practically", + @"presumably", @"pretty", @"primarily", @"probably", @"quite", + @"rather", @"really", @"relatively", @"seemingly", @"seriously", + @"significantly", @"simply", @"slightly", @"somewhat", @"sort of", + @"specifically", @"strongly", @"stuff", @"surely", @"technically", + @"that said", @"thing", @"things", @"totally", @"truly", + @"typically", @"ultimately", @"undoubtedly", @"unfortunately", + @"unnecessarily", @"usually", @"utterly", @"very", @"virtually", + ]]; + }); + return words; +} + +@implementation MPProseAnalysis + ++ (MPProseAnalysis *)analyzeText:(NSString *)text +{ + MPProseAnalysis *analysis = [[MPProseAnalysis alloc] init]; + NSMutableArray *fillerRanges = [NSMutableArray array]; + NSMutableArray *fillerWords = [NSMutableArray array]; + NSMutableArray *repeatedRanges = [NSMutableArray array]; + NSMutableArray *repeatedWords = [NSMutableArray array]; + + if (!text.length) + { + analysis.fillerWordRanges = fillerRanges; + analysis.fillerWordsFound = fillerWords; + analysis.repeatedWordRanges = repeatedRanges; + analysis.repeatedWordsFound = repeatedWords; + return analysis; + } + + NSSet *fillers = MPFillerWords(); + + // Tokenize into words with their ranges + NSLinguisticTagger *tagger = [[NSLinguisticTagger alloc] + initWithTagSchemes:@[NSLinguisticTagSchemeTokenType] + options:0]; + tagger.string = text; + + NSString *previousWord = nil; + __block NSString *prevWord = nil; + + [tagger enumerateTagsInRange:NSMakeRange(0, text.length) + scheme:NSLinguisticTagSchemeTokenType + options:NSLinguisticTaggerOmitWhitespace | NSLinguisticTaggerOmitPunctuation + usingBlock:^(NSLinguisticTag tag, NSRange tokenRange, NSRange sentenceRange, BOOL *stop) { + + if (![tag isEqualToString:NSLinguisticTagWord]) + return; + + NSString *word = [[text substringWithRange:tokenRange] lowercaseString]; + + // Check filler words + if ([fillers containsObject:word]) + { + [fillerRanges addObject:[NSValue valueWithRange:tokenRange]]; + if (![fillerWords containsObject:word]) + [fillerWords addObject:word]; + } + + // Check repeated consecutive words + if (prevWord && [prevWord isEqualToString:word]) + { + [repeatedRanges addObject:[NSValue valueWithRange:tokenRange]]; + if (![repeatedWords containsObject:word]) + [repeatedWords addObject:word]; + } + + prevWord = word; + }]; + + analysis.fillerWordRanges = fillerRanges; + analysis.fillerWordsFound = fillerWords; + analysis.repeatedWordRanges = repeatedRanges; + analysis.repeatedWordsFound = repeatedWords; + return analysis; +} + +@end diff --git a/MacDown/Code/View/MPCommandPalette.h b/MacDown/Code/View/MPCommandPalette.h new file mode 100644 index 00000000..57c6a827 --- /dev/null +++ b/MacDown/Code/View/MPCommandPalette.h @@ -0,0 +1,20 @@ +// +// MPCommandPalette.h +// ReadDown +// + +#import + +@interface MPCommandItem : NSObject +@property (nonatomic, strong) NSString *title; +@property (nonatomic, strong) NSString *shortcut; +@property (nonatomic) SEL action; +@property (nonatomic, weak) id target; ++ (MPCommandItem *)itemWithTitle:(NSString *)title shortcut:(NSString *)shortcut action:(SEL)action target:(id)target; +@end + +@interface MPCommandPalette : NSWindowController + ++ (void)showForWindow:(NSWindow *)parentWindow withCommands:(NSArray *)commands; + +@end diff --git a/MacDown/Code/View/MPCommandPalette.m b/MacDown/Code/View/MPCommandPalette.m new file mode 100644 index 00000000..984b2b03 --- /dev/null +++ b/MacDown/Code/View/MPCommandPalette.m @@ -0,0 +1,234 @@ +// +// MPCommandPalette.m +// ReadDown +// + +#import "MPCommandPalette.h" +#import + +static CGFloat const kPaletteWidth = 500; +static CGFloat const kPaletteRowHeight = 28; +static CGFloat const kPaletteMaxVisibleRows = 12; + + +@implementation MPCommandItem + ++ (MPCommandItem *)itemWithTitle:(NSString *)title shortcut:(NSString *)shortcut action:(SEL)action target:(id)target +{ + MPCommandItem *item = [[MPCommandItem alloc] init]; + item.title = title; + item.shortcut = shortcut; + item.action = action; + item.target = target; + return item; +} + +@end + + +@interface MPCommandPalette () +@property (nonatomic, strong) NSTextField *searchField; +@property (nonatomic, strong) NSTableView *tableView; +@property (nonatomic, strong) NSScrollView *scrollView; +@property (nonatomic, strong) NSArray *allCommands; +@property (nonatomic, strong) NSArray *filteredCommands; +@end + + +@implementation MPCommandPalette + ++ (void)showForWindow:(NSWindow *)parentWindow withCommands:(NSArray *)commands +{ + // Create a panel-style window + NSRect parentFrame = parentWindow.frame; + CGFloat panelHeight = 44 + MIN(commands.count, kPaletteMaxVisibleRows) * kPaletteRowHeight; + CGFloat x = parentFrame.origin.x + (parentFrame.size.width - kPaletteWidth) / 2; + CGFloat y = parentFrame.origin.y + parentFrame.size.height - panelHeight - 80; + NSRect frame = NSMakeRect(x, y, kPaletteWidth, panelHeight); + + NSPanel *panel = [[NSPanel alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView + backing:NSBackingStoreBuffered defer:NO]; + panel.titleVisibility = NSWindowTitleHidden; + panel.titlebarAppearsTransparent = YES; + panel.movableByWindowBackground = YES; + panel.level = NSFloatingWindowLevel; + panel.becomesKeyOnlyIfNeeded = NO; + + MPCommandPalette *controller = [[MPCommandPalette alloc] initWithWindow:panel]; + controller.allCommands = commands; + controller.filteredCommands = commands; + + // Search field + controller.searchField = [[NSTextField alloc] initWithFrame:NSMakeRect(12, panelHeight - 36, kPaletteWidth - 24, 28)]; + controller.searchField.placeholderString = @"Type a command..."; + controller.searchField.font = [NSFont systemFontOfSize:16]; + controller.searchField.bezelStyle = NSTextFieldRoundedBezel; + controller.searchField.focusRingType = NSFocusRingTypeNone; + controller.searchField.delegate = controller; + [panel.contentView addSubview:controller.searchField]; + + // Table in scroll view + CGFloat tableHeight = panelHeight - 44; + controller.scrollView = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, kPaletteWidth, tableHeight)]; + controller.scrollView.hasVerticalScroller = YES; + controller.scrollView.autohidesScrollers = YES; + controller.scrollView.borderType = NSNoBorder; + + controller.tableView = [[NSTableView alloc] initWithFrame:NSZeroRect]; + controller.tableView.headerView = nil; + controller.tableView.rowHeight = kPaletteRowHeight; + controller.tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleRegular; + controller.tableView.dataSource = controller; + controller.tableView.delegate = controller; + controller.tableView.doubleAction = @selector(executeSelected:); + controller.tableView.target = controller; + + NSTableColumn *titleCol = [[NSTableColumn alloc] initWithIdentifier:@"title"]; + titleCol.width = kPaletteWidth - 120; + [controller.tableView addTableColumn:titleCol]; + + NSTableColumn *shortcutCol = [[NSTableColumn alloc] initWithIdentifier:@"shortcut"]; + shortcutCol.width = 100; + [controller.tableView addTableColumn:shortcutCol]; + + controller.scrollView.documentView = controller.tableView; + [panel.contentView addSubview:controller.scrollView]; + + if (controller.filteredCommands.count > 0) + [controller.tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:0] byExtendingSelection:NO]; + + // Keep controller alive while panel is visible + objc_setAssociatedObject(panel, "controller", controller, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + [parentWindow addChildWindow:panel ordered:NSWindowAbove]; + [panel makeKeyAndOrderFront:nil]; + [panel makeFirstResponder:controller.searchField]; + + // Close on escape or losing focus + [[NSNotificationCenter defaultCenter] addObserverForName:NSWindowDidResignKeyNotification + object:panel queue:nil usingBlock:^(NSNotification *note) { + [panel.parentWindow removeChildWindow:panel]; + [panel orderOut:nil]; + }]; +} + +- (void)controlTextDidChange:(NSNotification *)notification +{ + NSString *query = self.searchField.stringValue.lowercaseString; + if (query.length == 0) + { + self.filteredCommands = self.allCommands; + } + else + { + NSMutableArray *filtered = [NSMutableArray array]; + for (MPCommandItem *cmd in self.allCommands) + { + if ([cmd.title.lowercaseString containsString:query]) + [filtered addObject:cmd]; + } + self.filteredCommands = filtered; + } + [self.tableView reloadData]; + if (self.filteredCommands.count > 0) + [self.tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:0] byExtendingSelection:NO]; +} + +- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector +{ + if (commandSelector == @selector(moveDown:)) + { + NSInteger row = self.tableView.selectedRow + 1; + if (row < (NSInteger)self.filteredCommands.count) + [self.tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + return YES; + } + if (commandSelector == @selector(moveUp:)) + { + NSInteger row = self.tableView.selectedRow - 1; + if (row >= 0) + [self.tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + return YES; + } + if (commandSelector == @selector(insertNewline:)) + { + [self executeSelected:nil]; + return YES; + } + if (commandSelector == @selector(cancelOperation:)) + { + [self.window.parentWindow removeChildWindow:self.window]; + [self.window orderOut:nil]; + return YES; + } + return NO; +} + +- (void)executeSelected:(id)sender +{ + NSInteger row = self.tableView.selectedRow; + if (row < 0 || row >= (NSInteger)self.filteredCommands.count) + return; + + MPCommandItem *cmd = self.filteredCommands[row]; + NSWindow *parent = self.window.parentWindow; + + [parent removeChildWindow:self.window]; + [self.window orderOut:nil]; + + if (cmd.target && cmd.action) + { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [cmd.target performSelector:cmd.action withObject:nil]; +#pragma clang diagnostic pop + } +} + + +#pragma mark - NSTableViewDataSource + +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView +{ + return self.filteredCommands.count; +} + +- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row +{ + MPCommandItem *cmd = self.filteredCommands[row]; + NSString *identifier = tableColumn.identifier; + + NSTableCellView *cell = [tableView makeViewWithIdentifier:identifier owner:self]; + if (!cell) + { + cell = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; + cell.identifier = identifier; + NSTextField *tf = [NSTextField labelWithString:@""]; + tf.translatesAutoresizingMaskIntoConstraints = NO; + [cell addSubview:tf]; + cell.textField = tf; + [NSLayoutConstraint activateConstraints:@[ + [tf.leadingAnchor constraintEqualToAnchor:cell.leadingAnchor constant:8], + [tf.trailingAnchor constraintEqualToAnchor:cell.trailingAnchor constant:-4], + [tf.centerYAnchor constraintEqualToAnchor:cell.centerYAnchor], + ]]; + } + + if ([identifier isEqualToString:@"title"]) + { + cell.textField.stringValue = cmd.title; + cell.textField.font = [NSFont systemFontOfSize:13]; + } + else + { + cell.textField.stringValue = cmd.shortcut ?: @""; + cell.textField.font = [NSFont systemFontOfSize:11]; + cell.textField.textColor = [NSColor secondaryLabelColor]; + cell.textField.alignment = NSTextAlignmentRight; + } + + return cell; +} + +@end diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 5663b6e9..08a34c0b 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -413,6 +413,19 @@ + + + + + + + + + + + + + diff --git a/MacDown/Resources/Styles/Minimal Dark.css b/MacDown/Resources/Styles/Minimal Dark.css new file mode 100644 index 00000000..9f2ed05a --- /dev/null +++ b/MacDown/Resources/Styles/Minimal Dark.css @@ -0,0 +1,27 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.7; + color: #c9d1d9; + background-color: #0d1117; + max-width: 800px; + margin: 0 auto; + padding: 30px 20px; +} +h1, h2, h3, h4, h5, h6 { font-weight: 600; color: #e6edf3; margin-top: 1.5em; margin-bottom: 0.5em; } +h1 { font-size: 2em; border-bottom: 1px solid #21262d; padding-bottom: 0.3em; } +h2 { font-size: 1.5em; border-bottom: 1px solid #21262d; padding-bottom: 0.3em; } +h3 { font-size: 1.25em; } +a { color: #58a6ff; text-decoration: none; } +a:hover { text-decoration: underline; } +a.wikilink { color: #58a6ff; border-bottom: 1px dashed #58a6ff; } +a.wikilink-new { color: #f85149; border-bottom: 1px dashed #f85149; } +code { background: #161b22; padding: 0.2em 0.4em; border-radius: 3px; font-size: 85%; color: #e6edf3; } +pre { background: #161b22; padding: 16px; border-radius: 6px; overflow: auto; } +pre code { background: none; padding: 0; } +blockquote { border-left: 4px solid #30363d; color: #8b949e; margin: 0; padding: 0 1em; } +img { max-width: 100%; } +table { border-collapse: collapse; width: 100%; } +th, td { border: 1px solid #30363d; padding: 6px 13px; } +th { background: #161b22; font-weight: 600; } +hr { border: none; border-top: 1px solid #21262d; margin: 2em 0; } diff --git a/MacDown/Resources/Styles/Minimal Light.css b/MacDown/Resources/Styles/Minimal Light.css new file mode 100644 index 00000000..4459eff3 --- /dev/null +++ b/MacDown/Resources/Styles/Minimal Light.css @@ -0,0 +1,27 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.7; + color: #24292e; + background-color: #ffffff; + max-width: 800px; + margin: 0 auto; + padding: 30px 20px; +} +h1, h2, h3, h4, h5, h6 { font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; } +h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; } +h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; } +h3 { font-size: 1.25em; } +a { color: #0366d6; text-decoration: none; } +a:hover { text-decoration: underline; } +a.wikilink { color: #0366d6; border-bottom: 1px dashed #0366d6; } +a.wikilink-new { color: #cb2431; border-bottom: 1px dashed #cb2431; } +code { background: #f6f8fa; padding: 0.2em 0.4em; border-radius: 3px; font-size: 85%; } +pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow: auto; } +pre code { background: none; padding: 0; } +blockquote { border-left: 4px solid #dfe2e5; color: #6a737d; margin: 0; padding: 0 1em; } +img { max-width: 100%; } +table { border-collapse: collapse; width: 100%; } +th, td { border: 1px solid #dfe2e5; padding: 6px 13px; } +th { background: #f6f8fa; font-weight: 600; } +hr { border: none; border-top: 1px solid #eee; margin: 2em 0; } diff --git a/MacDown/Resources/Styles/iA Writer.css b/MacDown/Resources/Styles/iA Writer.css new file mode 100644 index 00000000..20822a80 --- /dev/null +++ b/MacDown/Resources/Styles/iA Writer.css @@ -0,0 +1,35 @@ +body { + font-family: "Noto Serif", Georgia, "Times New Roman", serif; + font-size: 18px; + line-height: 1.8; + color: #1a1a1a; + background-color: #f9f9f9; + max-width: 680px; + margin: 0 auto; + padding: 40px 24px; + -webkit-font-smoothing: antialiased; +} +h1, h2, h3, h4, h5, h6 { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif; + font-weight: 700; + margin-top: 2em; + margin-bottom: 0.5em; + color: #111; +} +h1 { font-size: 1.8em; } +h2 { font-size: 1.4em; } +h3 { font-size: 1.2em; } +p { margin: 1em 0; } +a { color: #2563eb; text-decoration: none; border-bottom: 1px solid #93c5fd; } +a:hover { border-bottom-color: #2563eb; } +a.wikilink { color: #2563eb; border-bottom: 1px dashed #93c5fd; } +a.wikilink-new { color: #dc2626; border-bottom: 1px dashed #dc2626; } +code { font-family: "SF Mono", Menlo, monospace; background: #f0f0f0; padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.85em; } +pre { background: #f0f0f0; padding: 20px; border-radius: 4px; overflow: auto; line-height: 1.5; } +pre code { background: none; padding: 0; font-size: 0.9em; } +blockquote { border-left: 3px solid #d1d5db; color: #6b7280; margin: 1.5em 0; padding: 0.5em 1.2em; font-style: italic; } +img { max-width: 100%; border-radius: 4px; } +table { border-collapse: collapse; width: 100%; margin: 1.5em 0; } +th, td { border: 1px solid #e5e7eb; padding: 8px 14px; text-align: left; } +th { background: #f3f4f6; font-weight: 600; font-family: -apple-system, sans-serif; } +hr { border: none; border-top: 1px solid #e5e7eb; margin: 2.5em 0; } From 26b5bffbba6bf607519fd2bd7286213f0ef21454 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:48:47 -0700 Subject: [PATCH 08/37] Fix Focus mode to use layout manager temporary attributes Syntax highlighter was overwriting focus dimming because it was set on the text storage. Now uses temporary attributes on the layout manager which layer on top of the highlighter colors. Also changed Command Palette shortcut to Cmd+Shift+K to avoid conflict with Print. --- MacDown/Code/View/MPEditorView.m | 36 +++++++++----------- MacDown/Localization/Base.lproj/MainMenu.xib | 2 +- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/MacDown/Code/View/MPEditorView.m b/MacDown/Code/View/MPEditorView.m index 53deca02..51eb3ba1 100644 --- a/MacDown/Code/View/MPEditorView.m +++ b/MacDown/Code/View/MPEditorView.m @@ -258,19 +258,20 @@ - (void)updateFocusMode // Find the current paragraph range NSRange paragraphRange = [text paragraphRangeForRange:NSMakeRange(selectedRange.location, 0)]; - // Dim everything, then un-dim the active paragraph - NSColor *baseColor = self.textColor; - if (!baseColor) baseColor = [NSColor textColor]; - NSColor *dimColor = [baseColor colorWithAlphaComponent:0.3]; - NSColor *activeColor = baseColor; - - NSTextStorage *storage = self.textStorage; - [storage beginEditing]; - [storage addAttribute:NSForegroundColorAttributeName value:dimColor - range:NSMakeRange(0, text.length)]; - [storage addAttribute:NSForegroundColorAttributeName value:activeColor - range:paragraphRange]; - [storage endEditing]; + // Use temporary attributes on the layout manager so the syntax + // highlighter doesn't overwrite them + NSLayoutManager *lm = self.layoutManager; + NSRange fullRange = NSMakeRange(0, text.length); + + // Dim everything with a semi-transparent overlay color + NSColor *dimColor = [[NSColor blackColor] colorWithAlphaComponent:0.55]; + [lm removeTemporaryAttribute:NSForegroundColorAttributeName forCharacterRange:fullRange]; + [lm addTemporaryAttribute:NSForegroundColorAttributeName value:dimColor + forCharacterRange:fullRange]; + + // Remove dimming from the active paragraph to reveal original colors + [lm removeTemporaryAttribute:NSForegroundColorAttributeName + forCharacterRange:paragraphRange]; } - (void)clearFocusDimming @@ -279,13 +280,8 @@ - (void)clearFocusDimming if (text.length == 0) return; - NSColor *normalColor = self.textColor; - if (!normalColor) normalColor = [NSColor textColor]; - NSTextStorage *storage = self.textStorage; - [storage beginEditing]; - [storage addAttribute:NSForegroundColorAttributeName value:normalColor - range:NSMakeRange(0, text.length)]; - [storage endEditing]; + [self.layoutManager removeTemporaryAttribute:NSForegroundColorAttributeName + forCharacterRange:NSMakeRange(0, text.length)]; } diff --git a/MacDown/Localization/Base.lproj/MainMenu.xib b/MacDown/Localization/Base.lproj/MainMenu.xib index 08a34c0b..2f69d5a3 100644 --- a/MacDown/Localization/Base.lproj/MainMenu.xib +++ b/MacDown/Localization/Base.lproj/MainMenu.xib @@ -414,7 +414,7 @@ - + From d09142c17a9b55a7d662b5c630748e0e4de3cef3 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:51:27 -0700 Subject: [PATCH 09/37] Add checkmarks for active toggle states in menus and command palette Menu items for Focus Mode, Typewriter Mode, and Toggle Sidebar now show a checkmark when active. Command Palette also shows a checkmark prefix for toggleable commands that are currently on. --- MacDown/Code/Document/MPDocument.m | 18 +++++++++++++++--- MacDown/Code/View/MPCommandPalette.h | 2 ++ MacDown/Code/View/MPCommandPalette.m | 11 ++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 3213285b..e926d2af 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -745,6 +745,18 @@ - (BOOL)validateUserInterfaceItem:(id)item NSLocalizedString(@"Restore Editor Pane", @"Toggle editor pane menu item"); } + else if (action == @selector(toggleFocusMode:)) + { + ((NSMenuItem *)item).state = self.preferences.editorFocusMode ? NSOnState : NSOffState; + } + else if (action == @selector(toggleTypewriterMode:)) + { + ((NSMenuItem *)item).state = self.preferences.editorTypewriterMode ? NSOnState : NSOffState; + } + else if (action == @selector(toggleSidebar:)) + { + ((NSMenuItem *)item).state = self.sidebarController.sidebarVisible ? NSOnState : NSOffState; + } return result; } @@ -1667,9 +1679,9 @@ - (void)sidebarDidSelectHeading:(NSNotification *)notification - (IBAction)showCommandPalette:(id)sender { NSArray *commands = @[ - [MPCommandItem itemWithTitle:@"Toggle Sidebar" shortcut:@"\u2325\u2318S" action:@selector(toggleSidebar:) target:self], - [MPCommandItem itemWithTitle:@"Focus Mode" shortcut:@"\u2325\u2318F" action:@selector(toggleFocusMode:) target:self], - [MPCommandItem itemWithTitle:@"Typewriter Mode" shortcut:@"\u21E7\u2318T" action:@selector(toggleTypewriterMode:) target:self], + [MPCommandItem toggleWithTitle:@"Toggle Sidebar" shortcut:@"\u2325\u2318S" action:@selector(toggleSidebar:) target:self isOn:self.sidebarController.sidebarVisible], + [MPCommandItem toggleWithTitle:@"Focus Mode" shortcut:@"\u2325\u2318F" action:@selector(toggleFocusMode:) target:self isOn:self.preferences.editorFocusMode], + [MPCommandItem toggleWithTitle:@"Typewriter Mode" shortcut:@"\u21E7\u2318T" action:@selector(toggleTypewriterMode:) target:self isOn:self.preferences.editorTypewriterMode], [MPCommandItem itemWithTitle:@"Show Editor Only" shortcut:@"" action:@selector(showEditorOnly:) target:self], [MPCommandItem itemWithTitle:@"Show Preview Only" shortcut:@"" action:@selector(showPreviewOnly:) target:self], [MPCommandItem itemWithTitle:@"Show Editor & Preview" shortcut:@"" action:@selector(showBothPanes:) target:self], diff --git a/MacDown/Code/View/MPCommandPalette.h b/MacDown/Code/View/MPCommandPalette.h index 57c6a827..5e980ec6 100644 --- a/MacDown/Code/View/MPCommandPalette.h +++ b/MacDown/Code/View/MPCommandPalette.h @@ -10,7 +10,9 @@ @property (nonatomic, strong) NSString *shortcut; @property (nonatomic) SEL action; @property (nonatomic, weak) id target; +@property (nonatomic) BOOL isOn; + (MPCommandItem *)itemWithTitle:(NSString *)title shortcut:(NSString *)shortcut action:(SEL)action target:(id)target; ++ (MPCommandItem *)toggleWithTitle:(NSString *)title shortcut:(NSString *)shortcut action:(SEL)action target:(id)target isOn:(BOOL)isOn; @end @interface MPCommandPalette : NSWindowController diff --git a/MacDown/Code/View/MPCommandPalette.m b/MacDown/Code/View/MPCommandPalette.m index 984b2b03..e856d545 100644 --- a/MacDown/Code/View/MPCommandPalette.m +++ b/MacDown/Code/View/MPCommandPalette.m @@ -20,6 +20,14 @@ + (MPCommandItem *)itemWithTitle:(NSString *)title shortcut:(NSString *)shortcut item.shortcut = shortcut; item.action = action; item.target = target; + item.isOn = NO; + return item; +} + ++ (MPCommandItem *)toggleWithTitle:(NSString *)title shortcut:(NSString *)shortcut action:(SEL)action target:(id)target isOn:(BOOL)isOn +{ + MPCommandItem *item = [self itemWithTitle:title shortcut:shortcut action:action target:target]; + item.isOn = isOn; return item; } @@ -217,7 +225,8 @@ - (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn if ([identifier isEqualToString:@"title"]) { - cell.textField.stringValue = cmd.title; + NSString *prefix = cmd.isOn ? @"\u2713 " : @""; + cell.textField.stringValue = [prefix stringByAppendingString:cmd.title]; cell.textField.font = [NSFont systemFontOfSize:13]; } else From e80bc86c6517d95d9e8060a73841eda3ab66a0bc Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:54:46 -0700 Subject: [PATCH 10/37] Make filler word highlighting a live toggle with counter Filler word highlighting is now a persistent toggle (not a one-time alert). When active, filler words are highlighted yellow and repeated consecutive words orange, updating live as you type. A counter in the bottom-right corner shows the total count. Accessible from the command palette and View menu with checkmark state. --- MacDown/Code/Document/MPDocument.m | 114 ++++++++++++++++------- MacDown/Code/Preferences/MPPreferences.h | 1 + MacDown/Code/Preferences/MPPreferences.m | 1 + 3 files changed, 84 insertions(+), 32 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index e926d2af..0dc83cdb 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -196,6 +196,7 @@ typedef NS_ENUM(NSUInteger, MPWordCountType) { @property (weak) IBOutlet NSPopUpButton *wordCountWidget; @property (strong) IBOutlet MPToolbarController *toolbarController; @property (strong) MPSidebarController *sidebarController; +@property (strong) NSTextField *fillerCountLabel; @property (copy, nonatomic) NSString *autosaveName; @property (strong) HGMarkdownHighlighter *highlighter; @property (strong) MPRenderer *renderer; @@ -461,6 +462,23 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller wordCountWidget.hidden = !self.preferences.editorShowWordCount; wordCountWidget.enabled = NO; + // Create filler word count label in the bottom-right corner + NSView *editorContainer = self.editorContainer; + self.fillerCountLabel = [NSTextField labelWithString:@""]; + self.fillerCountLabel.font = [NSFont systemFontOfSize:10]; + self.fillerCountLabel.textColor = [NSColor secondaryLabelColor]; + self.fillerCountLabel.backgroundColor = [NSColor colorWithWhite:0.95 alpha:0.85]; + self.fillerCountLabel.drawsBackground = YES; + self.fillerCountLabel.alignment = NSTextAlignmentCenter; + self.fillerCountLabel.translatesAutoresizingMaskIntoConstraints = NO; + self.fillerCountLabel.hidden = !self.preferences.editorHighlightFillers; + [editorContainer addSubview:self.fillerCountLabel]; + [NSLayoutConstraint activateConstraints:@[ + [self.fillerCountLabel.trailingAnchor constraintEqualToAnchor:editorContainer.trailingAnchor constant:-8], + [self.fillerCountLabel.bottomAnchor constraintEqualToAnchor:editorContainer.bottomAnchor constant:-8], + [self.fillerCountLabel.widthAnchor constraintGreaterThanOrEqualToConstant:70], + ]]; + // Install sidebar (file browser + document outline) self.sidebarController = [[MPSidebarController alloc] initWithContentView:controller.window.contentView]; [self.sidebarController installInWindow:controller.window aroundView:self.splitView]; @@ -514,6 +532,10 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller // Apply focus/typewriter mode from preferences self.editor.focusModeEnabled = self.preferences.editorFocusMode; self.editor.typewriterModeEnabled = self.preferences.editorTypewriterMode; + + // Apply filler word highlighting if active + if (self.preferences.editorHighlightFillers) + [self updateFillerHighlights]; }]; } @@ -757,6 +779,10 @@ - (BOOL)validateUserInterfaceItem:(id)item { ((NSMenuItem *)item).state = self.sidebarController.sidebarVisible ? NSOnState : NSOffState; } + else if (action == @selector(toggleProseAnalysis:)) + { + ((NSMenuItem *)item).state = self.preferences.editorHighlightFillers ? NSOnState : NSOffState; + } return result; } @@ -1205,6 +1231,10 @@ - (void)editorTextDidChange:(NSNotification *)notification // Update document outline in sidebar [self.sidebarController updateHeadingsFromMarkdown:self.editor.string]; + + // Update filler word highlights if active + if (self.preferences.editorHighlightFillers) + [self updateFillerHighlights]; } - (void)userDefaultsDidChange:(NSNotification *)notification @@ -1698,7 +1728,7 @@ - (IBAction)showCommandPalette:(id)sender [MPCommandItem itemWithTitle:@"Unordered List" shortcut:@"" action:@selector(toggleUnorderedList:) target:self], [MPCommandItem itemWithTitle:@"Ordered List" shortcut:@"" action:@selector(toggleOrderedList:) target:self], [MPCommandItem itemWithTitle:@"Strikethrough" shortcut:@"" action:@selector(toggleStrikethrough:) target:self], - [MPCommandItem itemWithTitle:@"Highlight Filler Words" shortcut:@"" action:@selector(toggleProseAnalysis:) target:self], + [MPCommandItem toggleWithTitle:@"Highlight Filler Words" shortcut:@"" action:@selector(toggleProseAnalysis:) target:self isOn:self.preferences.editorHighlightFillers], [MPCommandItem itemWithTitle:@"Copy HTML" shortcut:@"\u2325\u2318C" action:@selector(copyHtml:) target:self], [MPCommandItem itemWithTitle:@"Export HTML..." shortcut:@"\u2325\u2318E" action:@selector(exportHtml:) target:self], [MPCommandItem itemWithTitle:@"Export PDF..." shortcut:@"\u2325\u2318P" action:@selector(exportPdf:) target:self], @@ -1709,58 +1739,78 @@ - (IBAction)showCommandPalette:(id)sender - (IBAction)toggleProseAnalysis:(id)sender { + BOOL newValue = !self.preferences.editorHighlightFillers; + self.preferences.editorHighlightFillers = newValue; + + if (newValue) + { + [self updateFillerHighlights]; + self.fillerCountLabel.hidden = NO; + } + else + { + [self clearFillerHighlights]; + self.fillerCountLabel.hidden = YES; + } +} + +- (void)updateFillerHighlights +{ + if (!self.preferences.editorHighlightFillers) + return; + NSString *text = self.editor.string; - MPProseAnalysis *analysis = [MPProseAnalysis analyzeText:text]; + NSLayoutManager *lm = self.editor.layoutManager; + NSRange fullRange = NSMakeRange(0, text.length); + + // Clear previous highlights + [lm removeTemporaryAttribute:NSBackgroundColorAttributeName + forCharacterRange:fullRange]; - if (analysis.fillerWordRanges.count == 0) + if (text.length == 0) { - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = @"Prose Analysis"; - alert.informativeText = @"No filler words or repeated words found."; - [alert runModal]; + self.fillerCountLabel.stringValue = @"0 fillers"; return; } - // Highlight filler words in the editor with a yellow background - NSTextStorage *storage = self.editor.textStorage; - [storage beginEditing]; + MPProseAnalysis *analysis = [MPProseAnalysis analyzeText:text]; - // Clear previous highlights - [storage removeAttribute:NSBackgroundColorAttributeName - range:NSMakeRange(0, text.length)]; + NSColor *fillerColor = [NSColor colorWithRed:1.0 green:0.95 blue:0.4 alpha:0.45]; + NSColor *repeatColor = [NSColor colorWithRed:1.0 green:0.7 blue:0.3 alpha:0.45]; - // Highlight filler words in yellow + // Highlight filler words for (NSValue *rangeVal in analysis.fillerWordRanges) { NSRange range = rangeVal.rangeValue; if (NSMaxRange(range) <= text.length) - [storage addAttribute:NSBackgroundColorAttributeName - value:[NSColor colorWithRed:1.0 green:1.0 blue:0.6 alpha:0.5] - range:range]; + [lm addTemporaryAttribute:NSBackgroundColorAttributeName + value:fillerColor + forCharacterRange:range]; } - // Highlight repeated words in orange + // Highlight repeated consecutive words for (NSValue *rangeVal in analysis.repeatedWordRanges) { NSRange range = rangeVal.rangeValue; if (NSMaxRange(range) <= text.length) - [storage addAttribute:NSBackgroundColorAttributeName - value:[NSColor colorWithRed:1.0 green:0.8 blue:0.4 alpha:0.5] - range:range]; + [lm addTemporaryAttribute:NSBackgroundColorAttributeName + value:repeatColor + forCharacterRange:range]; } - [storage endEditing]; - - NSString *summary = [NSString stringWithFormat: - @"Found %lu filler words and %lu repeated words.\n\nFillers: %@", - (unsigned long)analysis.fillerWordRanges.count, - (unsigned long)analysis.repeatedWordRanges.count, - [analysis.fillerWordsFound componentsJoinedByString:@", "]]; + NSUInteger total = analysis.fillerWordRanges.count + analysis.repeatedWordRanges.count; + self.fillerCountLabel.stringValue = [NSString stringWithFormat:@"%lu fillers", (unsigned long)total]; +} - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = @"Prose Analysis"; - alert.informativeText = summary; - [alert runModal]; +- (void)clearFillerHighlights +{ + NSString *text = self.editor.string; + if (text.length > 0) + { + [self.editor.layoutManager removeTemporaryAttribute:NSBackgroundColorAttributeName + forCharacterRange:NSMakeRange(0, text.length)]; + } + self.fillerCountLabel.stringValue = @""; } - (IBAction)render:(id)sender diff --git a/MacDown/Code/Preferences/MPPreferences.h b/MacDown/Code/Preferences/MPPreferences.h index b42fa1a0..08ff4916 100644 --- a/MacDown/Code/Preferences/MPPreferences.h +++ b/MacDown/Code/Preferences/MPPreferences.h @@ -24,6 +24,7 @@ extern NSString * const MPDidDetectFreshInstallationNotification; @property (assign) BOOL rememberViewModePerFile; // Remember per-file view mode @property (assign) BOOL editorFocusMode; // Focus mode: dim non-active paragraph @property (assign) BOOL editorTypewriterMode; // Typewriter mode: keep cursor centered +@property (assign) BOOL editorHighlightFillers; // Highlight filler words // Extension flags. @property (assign) BOOL extensionIntraEmphasis; diff --git a/MacDown/Code/Preferences/MPPreferences.m b/MacDown/Code/Preferences/MPPreferences.m index 930ea909..952c3b1c 100644 --- a/MacDown/Code/Preferences/MPPreferences.m +++ b/MacDown/Code/Preferences/MPPreferences.m @@ -79,6 +79,7 @@ - (instancetype)init @dynamic rememberViewModePerFile; @dynamic editorFocusMode; @dynamic editorTypewriterMode; +@dynamic editorHighlightFillers; @dynamic extensionIntraEmphasis; @dynamic extensionTables; From 1ae20f0625f78147dd35782c26ce29c8248d1026 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:56:29 -0700 Subject: [PATCH 11/37] Move Highlight Filler Words to top of command palette --- MacDown/Code/Document/MPDocument.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 0dc83cdb..e7424a4b 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -1709,9 +1709,10 @@ - (void)sidebarDidSelectHeading:(NSNotification *)notification - (IBAction)showCommandPalette:(id)sender { NSArray *commands = @[ - [MPCommandItem toggleWithTitle:@"Toggle Sidebar" shortcut:@"\u2325\u2318S" action:@selector(toggleSidebar:) target:self isOn:self.sidebarController.sidebarVisible], + [MPCommandItem toggleWithTitle:@"Highlight Filler Words" shortcut:@"" action:@selector(toggleProseAnalysis:) target:self isOn:self.preferences.editorHighlightFillers], [MPCommandItem toggleWithTitle:@"Focus Mode" shortcut:@"\u2325\u2318F" action:@selector(toggleFocusMode:) target:self isOn:self.preferences.editorFocusMode], [MPCommandItem toggleWithTitle:@"Typewriter Mode" shortcut:@"\u21E7\u2318T" action:@selector(toggleTypewriterMode:) target:self isOn:self.preferences.editorTypewriterMode], + [MPCommandItem toggleWithTitle:@"Toggle Sidebar" shortcut:@"\u2325\u2318S" action:@selector(toggleSidebar:) target:self isOn:self.sidebarController.sidebarVisible], [MPCommandItem itemWithTitle:@"Show Editor Only" shortcut:@"" action:@selector(showEditorOnly:) target:self], [MPCommandItem itemWithTitle:@"Show Preview Only" shortcut:@"" action:@selector(showPreviewOnly:) target:self], [MPCommandItem itemWithTitle:@"Show Editor & Preview" shortcut:@"" action:@selector(showBothPanes:) target:self], @@ -1728,7 +1729,6 @@ - (IBAction)showCommandPalette:(id)sender [MPCommandItem itemWithTitle:@"Unordered List" shortcut:@"" action:@selector(toggleUnorderedList:) target:self], [MPCommandItem itemWithTitle:@"Ordered List" shortcut:@"" action:@selector(toggleOrderedList:) target:self], [MPCommandItem itemWithTitle:@"Strikethrough" shortcut:@"" action:@selector(toggleStrikethrough:) target:self], - [MPCommandItem toggleWithTitle:@"Highlight Filler Words" shortcut:@"" action:@selector(toggleProseAnalysis:) target:self isOn:self.preferences.editorHighlightFillers], [MPCommandItem itemWithTitle:@"Copy HTML" shortcut:@"\u2325\u2318C" action:@selector(copyHtml:) target:self], [MPCommandItem itemWithTitle:@"Export HTML..." shortcut:@"\u2325\u2318E" action:@selector(exportHtml:) target:self], [MPCommandItem itemWithTitle:@"Export PDF..." shortcut:@"\u2325\u2318P" action:@selector(exportPdf:) target:self], From 0b8b64b475dd3da502c8d2c56ec9356bace54abc Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 18:57:39 -0700 Subject: [PATCH 12/37] Fix filler word detection to use regex instead of NSLinguisticTagger NSLinguisticTagger was not reliably finding words. Switched to a simple regex word boundary match which is faster and more reliable. --- MacDown/Code/Utility/MPWritingTools.m | 36 +++++++++++++-------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/MacDown/Code/Utility/MPWritingTools.m b/MacDown/Code/Utility/MPWritingTools.m index eb6d5ad8..4516f32c 100644 --- a/MacDown/Code/Utility/MPWritingTools.m +++ b/MacDown/Code/Utility/MPWritingTools.m @@ -116,29 +116,27 @@ + (MPProseAnalysis *)analyzeText:(NSString *)text NSSet *fillers = MPFillerWords(); - // Tokenize into words with their ranges - NSLinguisticTagger *tagger = [[NSLinguisticTagger alloc] - initWithTagSchemes:@[NSLinguisticTagSchemeTokenType] - options:0]; - tagger.string = text; - - NSString *previousWord = nil; - __block NSString *prevWord = nil; - - [tagger enumerateTagsInRange:NSMakeRange(0, text.length) - scheme:NSLinguisticTagSchemeTokenType - options:NSLinguisticTaggerOmitWhitespace | NSLinguisticTaggerOmitPunctuation - usingBlock:^(NSLinguisticTag tag, NSRange tokenRange, NSRange sentenceRange, BOOL *stop) { + // Use regex to find word boundaries — simple and reliable + static NSRegularExpression *wordRegex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + wordRegex = [NSRegularExpression regularExpressionWithPattern:@"\\b[a-zA-Z]+\\b" + options:0 error:nil]; + }); - if (![tag isEqualToString:NSLinguisticTagWord]) - return; + NSArray *matches = [wordRegex matchesInString:text options:0 + range:NSMakeRange(0, text.length)]; - NSString *word = [[text substringWithRange:tokenRange] lowercaseString]; + NSString *prevWord = nil; + for (NSTextCheckingResult *match in matches) + { + NSRange range = match.range; + NSString *word = [[text substringWithRange:range] lowercaseString]; // Check filler words if ([fillers containsObject:word]) { - [fillerRanges addObject:[NSValue valueWithRange:tokenRange]]; + [fillerRanges addObject:[NSValue valueWithRange:range]]; if (![fillerWords containsObject:word]) [fillerWords addObject:word]; } @@ -146,13 +144,13 @@ + (MPProseAnalysis *)analyzeText:(NSString *)text // Check repeated consecutive words if (prevWord && [prevWord isEqualToString:word]) { - [repeatedRanges addObject:[NSValue valueWithRange:tokenRange]]; + [repeatedRanges addObject:[NSValue valueWithRange:range]]; if (![repeatedWords containsObject:word]) [repeatedWords addObject:word]; } prevWord = word; - }]; + } analysis.fillerWordRanges = fillerRanges; analysis.fillerWordsFound = fillerWords; From fa748829a1ee9a8ea34005848e0f60f6faac213d Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:00:31 -0700 Subject: [PATCH 13/37] Highlight filler words in both editor and preview panes When filler highlighting is active, filler words now show yellow highlights in both the markdown editor (left) and the rendered preview (right). Preview uses injected CSS mark.filler styling. --- MacDown/Code/Document/MPDocument.m | 14 +++++ MacDown/Code/Utility/MPWritingTools.h | 5 ++ MacDown/Code/Utility/MPWritingTools.m | 86 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index e7424a4b..6b1fa8df 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -1216,6 +1216,20 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html } #endif + // Highlight filler words in preview if enabled + if (self.preferences.editorHighlightFillers) + { + html = [MPFillerHighlighter highlightFillersInHTML:html]; + // Inject CSS for filler highlights + NSString *fillerCSS = @""; + NSRange headEnd = [html rangeOfString:@"" options:NSCaseInsensitiveSearch]; + if (headEnd.location != NSNotFound) + html = [html stringByReplacingCharactersInRange:headEnd + withString:[fillerCSS stringByAppendingString:@""]]; + else + html = [fillerCSS stringByAppendingString:html]; + } + // Reload the page if there's not valid tree to work with. [self.preview.mainFrame loadHTMLString:html baseURL:baseUrl]; self.currentBaseUrl = baseUrl; diff --git a/MacDown/Code/Utility/MPWritingTools.h b/MacDown/Code/Utility/MPWritingTools.h index 45084b8f..6896c823 100644 --- a/MacDown/Code/Utility/MPWritingTools.h +++ b/MacDown/Code/Utility/MPWritingTools.h @@ -10,6 +10,11 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)baseURL; @end +// Filler word highlighting in HTML +@interface MPFillerHighlighter : NSObject ++ (NSString *)highlightFillersInHTML:(NSString *)html; +@end + // Prose quality analysis @interface MPProseAnalysis : NSObject @property (nonatomic, strong) NSArray *fillerWordRanges; diff --git a/MacDown/Code/Utility/MPWritingTools.m b/MacDown/Code/Utility/MPWritingTools.m index 4516f32c..1b7bea8a 100644 --- a/MacDown/Code/Utility/MPWritingTools.m +++ b/MacDown/Code/Utility/MPWritingTools.m @@ -160,3 +160,89 @@ + (MPProseAnalysis *)analyzeText:(NSString *)text } @end + + +#pragma mark - Filler Highlighter for HTML + +@implementation MPFillerHighlighter + ++ (NSString *)highlightFillersInHTML:(NSString *)html +{ + if (!html.length) + return html; + + // Build a regex that matches any filler word at word boundaries + NSSet *fillers = MPFillerWords(); + NSString *pattern = [NSString stringWithFormat:@"\\b(%@)\\b", + [[fillers allObjects] componentsJoinedByString:@"|"]]; + + NSRegularExpression *regex = [NSRegularExpression + regularExpressionWithPattern:pattern + options:NSRegularExpressionCaseInsensitive + error:nil]; + + // Only highlight inside text nodes (not inside HTML tags or code blocks). + // Simple approach: split on tags, process text parts only. + NSMutableString *result = [NSMutableString string]; + NSRegularExpression *tagRegex = [NSRegularExpression + regularExpressionWithPattern:@"(<[^>]*>)" + options:0 error:nil]; + + NSArray *tagMatches = [tagRegex matchesInString:html options:0 + range:NSMakeRange(0, html.length)]; + + NSUInteger lastEnd = 0; + BOOL insideCode = NO; + + for (NSTextCheckingResult *tagMatch in tagMatches) + { + // Process text before this tag + if (tagMatch.range.location > lastEnd) + { + NSRange textRange = NSMakeRange(lastEnd, tagMatch.range.location - lastEnd); + NSString *textPart = [html substringWithRange:textRange]; + + if (insideCode) + { + [result appendString:textPart]; + } + else + { + NSString *highlighted = [regex stringByReplacingMatchesInString:textPart + options:0 range:NSMakeRange(0, textPart.length) + withTemplate:@"$1"]; + [result appendString:highlighted]; + } + } + + // Append the tag itself + NSString *tag = [html substringWithRange:tagMatch.range]; + [result appendString:tag]; + + // Track code blocks + NSString *tagLower = tag.lowercaseString; + if ([tagLower hasPrefix:@"$1"]; + } + [result appendString:textPart]; + } + + return result; +} + +@end From 4272372a7db8492683ebf6c9873f537ead576919 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:04:14 -0700 Subject: [PATCH 14/37] Categorize weasel words with color-coded highlights per Amazon writing guide Four categories with distinct colors in both editor and preview: - Yellow: unnecessary qualifiers (actually, basically, very, just...) - Orange: weasel words (should, might, could, significant, better...) - Pink: indirect/vague language (believe, think, seems, perhaps...) - Blue: weak adverbs (quickly, greatly, tremendously...) - Red: repeated consecutive words Counter in bottom-right shows total issue count. Based on Amazon's 6-pager writing guidelines for eliminating weak language. --- MacDown/Code/Document/MPDocument.m | 59 ++++--- MacDown/Code/Utility/MPWritingTools.h | 26 ++- MacDown/Code/Utility/MPWritingTools.m | 226 +++++++++++++++++++------- 3 files changed, 218 insertions(+), 93 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 6b1fa8df..01a6aecd 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -1220,8 +1220,13 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html if (self.preferences.editorHighlightFillers) { html = [MPFillerHighlighter highlightFillersInHTML:html]; - // Inject CSS for filler highlights - NSString *fillerCSS = @""; + // Inject CSS for categorized highlights + NSString *fillerCSS = @""; NSRange headEnd = [html rangeOfString:@"" options:NSCaseInsensitiveSearch]; if (headEnd.location != NSNotFound) html = [html stringByReplacingCharactersInRange:headEnd @@ -1777,43 +1782,45 @@ - (void)updateFillerHighlights NSLayoutManager *lm = self.editor.layoutManager; NSRange fullRange = NSMakeRange(0, text.length); - // Clear previous highlights [lm removeTemporaryAttribute:NSBackgroundColorAttributeName forCharacterRange:fullRange]; if (text.length == 0) { - self.fillerCountLabel.stringValue = @"0 fillers"; + self.fillerCountLabel.stringValue = @"0 issues"; return; } MPProseAnalysis *analysis = [MPProseAnalysis analyzeText:text]; - NSColor *fillerColor = [NSColor colorWithRed:1.0 green:0.95 blue:0.4 alpha:0.45]; - NSColor *repeatColor = [NSColor colorWithRed:1.0 green:0.7 blue:0.3 alpha:0.45]; - - // Highlight filler words - for (NSValue *rangeVal in analysis.fillerWordRanges) - { - NSRange range = rangeVal.rangeValue; - if (NSMaxRange(range) <= text.length) - [lm addTemporaryAttribute:NSBackgroundColorAttributeName - value:fillerColor - forCharacterRange:range]; - } + // Color per category + NSColor *qualifierColor = [NSColor colorWithRed:1.0 green:0.95 blue:0.4 alpha:0.45]; // yellow + NSColor *weaselColor = [NSColor colorWithRed:1.0 green:0.7 blue:0.3 alpha:0.45]; // orange + NSColor *indirectColor = [NSColor colorWithRed:1.0 green:0.75 blue:0.85 alpha:0.5]; // pink + NSColor *adverbColor = [NSColor colorWithRed:0.7 green:0.85 blue:1.0 alpha:0.5]; // blue + NSColor *repeatColor = [NSColor colorWithRed:1.0 green:0.5 blue:0.5 alpha:0.4]; // red + + for (MPWordIssue *issue in analysis.issues) + { + if (NSMaxRange(issue.range) > text.length) continue; + + NSColor *color; + switch (issue.type) { + case MPWordIssueQualifier: color = qualifierColor; break; + case MPWordIssueWeasel: color = weaselColor; break; + case MPWordIssueIndirect: color = indirectColor; break; + case MPWordIssueAdverb: color = adverbColor; break; + case MPWordIssueRepeated: color = repeatColor; break; + default: color = qualifierColor; break; + } - // Highlight repeated consecutive words - for (NSValue *rangeVal in analysis.repeatedWordRanges) - { - NSRange range = rangeVal.rangeValue; - if (NSMaxRange(range) <= text.length) - [lm addTemporaryAttribute:NSBackgroundColorAttributeName - value:repeatColor - forCharacterRange:range]; + [lm addTemporaryAttribute:NSBackgroundColorAttributeName + value:color + forCharacterRange:issue.range]; } - NSUInteger total = analysis.fillerWordRanges.count + analysis.repeatedWordRanges.count; - self.fillerCountLabel.stringValue = [NSString stringWithFormat:@"%lu fillers", (unsigned long)total]; + NSUInteger total = analysis.issues.count; + self.fillerCountLabel.stringValue = [NSString stringWithFormat:@"%lu issues", (unsigned long)total]; } - (void)clearFillerHighlights diff --git a/MacDown/Code/Utility/MPWritingTools.h b/MacDown/Code/Utility/MPWritingTools.h index 6896c823..0f6f9482 100644 --- a/MacDown/Code/Utility/MPWritingTools.h +++ b/MacDown/Code/Utility/MPWritingTools.h @@ -5,6 +5,22 @@ #import +// Word issue categories +typedef NS_ENUM(NSUInteger, MPWordIssueType) { + MPWordIssueQualifier, // unnecessary qualifiers: actually, really, very, basically + MPWordIssueWeasel, // weasel words: should, might, could, significant, better + MPWordIssueIndirect, // indirect language: believe, in general, would like to + MPWordIssueAdverb, // adverbs that weaken: quickly, slowly, extremely + MPWordIssueRepeated, // repeated consecutive words +}; + +// A single flagged word with its type and range +@interface MPWordIssue : NSObject +@property (nonatomic, strong) NSString *word; +@property (nonatomic) MPWordIssueType type; +@property (nonatomic) NSRange range; +@end + // WikiLink processing @interface MPWikiLinkProcessor : NSObject + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)baseURL; @@ -17,9 +33,11 @@ // Prose quality analysis @interface MPProseAnalysis : NSObject -@property (nonatomic, strong) NSArray *fillerWordRanges; -@property (nonatomic, strong) NSArray *repeatedWordRanges; -@property (nonatomic, strong) NSArray *fillerWordsFound; -@property (nonatomic, strong) NSArray *repeatedWordsFound; +@property (nonatomic, strong) NSArray *issues; +@property (nonatomic) NSUInteger qualifierCount; +@property (nonatomic) NSUInteger weaselCount; +@property (nonatomic) NSUInteger indirectCount; +@property (nonatomic) NSUInteger adverbCount; +@property (nonatomic) NSUInteger repeatedCount; + (MPProseAnalysis *)analyzeText:(NSString *)text; @end diff --git a/MacDown/Code/Utility/MPWritingTools.m b/MacDown/Code/Utility/MPWritingTools.m index 1b7bea8a..f30190e9 100644 --- a/MacDown/Code/Utility/MPWritingTools.m +++ b/MacDown/Code/Utility/MPWritingTools.m @@ -6,6 +6,12 @@ #import "MPWritingTools.h" +#pragma mark - MPWordIssue + +@implementation MPWordIssue +@end + + #pragma mark - WikiLink Processor @implementation MPWikiLinkProcessor @@ -15,7 +21,6 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)b if (!markdown.length) return markdown; - // Match [[link]] and [[link|display text]] patterns static NSRegularExpression *wikiLinkRegex = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -28,7 +33,6 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)b NSArray *matches = [wikiLinkRegex matchesInString:markdown options:0 range:NSMakeRange(0, markdown.length)]; - // Process in reverse to preserve ranges for (NSTextCheckingResult *match in [matches reverseObjectEnumerator]) { NSString *target = [markdown substringWithRange:[match rangeAtIndex:1]]; @@ -38,7 +42,6 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)b else display = target; - // Convert target to a filename: "My Note" -> "My Note.md" NSString *filename = target; if (![filename.pathExtension isEqualToString:@"md"] && ![filename.pathExtension isEqualToString:@"markdown"]) @@ -68,55 +71,97 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)b @end -#pragma mark - Prose Analysis +#pragma mark - Word Categories -static NSSet *MPFillerWords() +// Unnecessary qualifiers — words that add no meaning +static NSDictionary *MPWordCategories() { - static NSSet *words = nil; + static NSDictionary *categories = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - words = [NSSet setWithArray:@[ + // Unnecessary qualifiers (yellow) — Amazon: "delete unnecessary qualifiers" + NSSet *qualifiers = [NSSet setWithArray:@[ @"actually", @"basically", @"certainly", @"clearly", @"definitely", @"effectively", @"essentially", @"extremely", @"fairly", @"frankly", - @"generally", @"honestly", @"hopefully", @"importantly", @"incredibly", - @"indeed", @"interestingly", @"ironically", @"just", @"largely", - @"literally", @"mainly", @"merely", @"mostly", @"naturally", - @"necessarily", @"notably", @"obviously", @"of course", @"overall", - @"particularly", @"perhaps", @"personally", @"practically", - @"presumably", @"pretty", @"primarily", @"probably", @"quite", - @"rather", @"really", @"relatively", @"seemingly", @"seriously", - @"significantly", @"simply", @"slightly", @"somewhat", @"sort of", - @"specifically", @"strongly", @"stuff", @"surely", @"technically", - @"that said", @"thing", @"things", @"totally", @"truly", - @"typically", @"ultimately", @"undoubtedly", @"unfortunately", - @"unnecessarily", @"usually", @"utterly", @"very", @"virtually", + @"honestly", @"hopefully", @"importantly", @"incredibly", @"indeed", + @"interestingly", @"ironically", @"just", @"largely", @"literally", + @"merely", @"mostly", @"naturally", @"notably", @"obviously", + @"overall", @"particularly", @"personally", @"practically", + @"presumably", @"pretty", @"primarily", @"quite", @"rather", + @"really", @"relatively", @"seriously", @"simply", @"slightly", + @"somewhat", @"specifically", @"strongly", @"surely", @"technically", + @"totally", @"truly", @"typically", @"ultimately", @"undoubtedly", + @"unfortunately", @"unnecessarily", @"utterly", @"very", @"virtually", + ]]; + + // Weasel words (orange) — Amazon: "replace weasel words with data" + NSSet *weasels = [NSSet setWithArray:@[ + @"should", @"might", @"could", @"often", @"generally", @"usually", + @"probably", @"significant", @"significantly", @"better", @"worse", + @"soon", @"some", @"most", @"fewer", @"faster", @"slower", + @"higher", @"lower", @"many", @"few", @"more", @"less", + @"several", @"numerous", @"various", @"approximately", + @"roughly", @"nearly", @"almost", @"around", + ]]; + + // Indirect/vague language (pink) — Amazon: "remove indirect language" + NSSet *indirect = [NSSet setWithArray:@[ + @"believe", @"think", @"feel", @"seems", @"appears", @"perhaps", + @"maybe", @"possibly", @"conceivably", @"arguably", @"seemingly", + @"supposedly", @"allegedly", @"reportedly", @"apparently", + @"stuff", @"thing", @"things", @"complex", ]]; + + // Weak adverbs (blue) — Amazon: "remove adverbs and adjectives" + NSSet *adverbs = [NSSet setWithArray:@[ + @"quickly", @"slowly", @"greatly", @"highly", @"deeply", + @"vastly", @"remarkably", @"tremendously", @"immensely", + @"exceedingly", @"enormously", @"drastically", @"substantially", + @"considerably", @"massively", @"fundamentally", @"profoundly", + ]]; + + categories = @{ + @(MPWordIssueQualifier): qualifiers, + @(MPWordIssueWeasel): weasels, + @(MPWordIssueIndirect): indirect, + @(MPWordIssueAdverb): adverbs, + }; + }); + return categories; +} + +// Flat set of ALL flagged words for HTML highlighting +static NSSet *MPAllFlaggedWords() +{ + static NSSet *all = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSMutableSet *combined = [NSMutableSet set]; + for (NSSet *cat in [MPWordCategories() allValues]) + [combined unionSet:cat]; + all = [combined copy]; }); - return words; + return all; } + +#pragma mark - Prose Analysis + @implementation MPProseAnalysis + (MPProseAnalysis *)analyzeText:(NSString *)text { MPProseAnalysis *analysis = [[MPProseAnalysis alloc] init]; - NSMutableArray *fillerRanges = [NSMutableArray array]; - NSMutableArray *fillerWords = [NSMutableArray array]; - NSMutableArray *repeatedRanges = [NSMutableArray array]; - NSMutableArray *repeatedWords = [NSMutableArray array]; + NSMutableArray *issues = [NSMutableArray array]; if (!text.length) { - analysis.fillerWordRanges = fillerRanges; - analysis.fillerWordsFound = fillerWords; - analysis.repeatedWordRanges = repeatedRanges; - analysis.repeatedWordsFound = repeatedWords; + analysis.issues = issues; return analysis; } - NSSet *fillers = MPFillerWords(); + NSDictionary *categories = MPWordCategories(); - // Use regex to find word boundaries — simple and reliable static NSRegularExpression *wordRegex = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -127,35 +172,59 @@ + (MPProseAnalysis *)analyzeText:(NSString *)text NSArray *matches = [wordRegex matchesInString:text options:0 range:NSMakeRange(0, text.length)]; + NSUInteger qualCount = 0, weaselCount = 0, indirectCount = 0, adverbCount = 0, repeatCount = 0; NSString *prevWord = nil; + for (NSTextCheckingResult *match in matches) { NSRange range = match.range; NSString *word = [[text substringWithRange:range] lowercaseString]; - // Check filler words - if ([fillers containsObject:word]) + // Check each category + BOOL found = NO; + for (NSNumber *typeNum in categories) { - [fillerRanges addObject:[NSValue valueWithRange:range]]; - if (![fillerWords containsObject:word]) - [fillerWords addObject:word]; + NSSet *wordSet = categories[typeNum]; + if ([wordSet containsObject:word]) + { + MPWordIssue *issue = [[MPWordIssue alloc] init]; + issue.word = word; + issue.type = typeNum.unsignedIntegerValue; + issue.range = range; + [issues addObject:issue]; + + switch (issue.type) { + case MPWordIssueQualifier: qualCount++; break; + case MPWordIssueWeasel: weaselCount++; break; + case MPWordIssueIndirect: indirectCount++; break; + case MPWordIssueAdverb: adverbCount++; break; + default: break; + } + found = YES; + break; // one category per word + } } - // Check repeated consecutive words - if (prevWord && [prevWord isEqualToString:word]) + // Repeated consecutive words + if (prevWord && [prevWord isEqualToString:word] && word.length > 1) { - [repeatedRanges addObject:[NSValue valueWithRange:range]]; - if (![repeatedWords containsObject:word]) - [repeatedWords addObject:word]; + MPWordIssue *issue = [[MPWordIssue alloc] init]; + issue.word = word; + issue.type = MPWordIssueRepeated; + issue.range = range; + [issues addObject:issue]; + repeatCount++; } prevWord = word; } - analysis.fillerWordRanges = fillerRanges; - analysis.fillerWordsFound = fillerWords; - analysis.repeatedWordRanges = repeatedRanges; - analysis.repeatedWordsFound = repeatedWords; + analysis.issues = issues; + analysis.qualifierCount = qualCount; + analysis.weaselCount = weaselCount; + analysis.indirectCount = indirectCount; + analysis.adverbCount = adverbCount; + analysis.repeatedCount = repeatCount; return analysis; } @@ -171,18 +240,19 @@ + (NSString *)highlightFillersInHTML:(NSString *)html if (!html.length) return html; - // Build a regex that matches any filler word at word boundaries - NSSet *fillers = MPFillerWords(); + NSSet *allWords = MPAllFlaggedWords(); + NSDictionary *categories = MPWordCategories(); + + // Build per-category regexes with different CSS classes + // For HTML we use a combined regex and assign class based on lookup NSString *pattern = [NSString stringWithFormat:@"\\b(%@)\\b", - [[fillers allObjects] componentsJoinedByString:@"|"]]; + [[allWords allObjects] componentsJoinedByString:@"|"]]; NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:nil]; - // Only highlight inside text nodes (not inside HTML tags or code blocks). - // Simple approach: split on tags, process text parts only. NSMutableString *result = [NSMutableString string]; NSRegularExpression *tagRegex = [NSRegularExpression regularExpressionWithPattern:@"(<[^>]*>)" @@ -196,30 +266,51 @@ + (NSString *)highlightFillersInHTML:(NSString *)html for (NSTextCheckingResult *tagMatch in tagMatches) { - // Process text before this tag if (tagMatch.range.location > lastEnd) { NSRange textRange = NSMakeRange(lastEnd, tagMatch.range.location - lastEnd); NSString *textPart = [html substringWithRange:textRange]; - if (insideCode) + if (!insideCode) { - [result appendString:textPart]; + // Replace each match with colored mark based on category + NSMutableString *highlighted = [textPart mutableCopy]; + NSArray *wordMatches = [regex matchesInString:textPart options:0 + range:NSMakeRange(0, textPart.length)]; + for (NSTextCheckingResult *wm in [wordMatches reverseObjectEnumerator]) + { + NSString *word = [[textPart substringWithRange:wm.range] lowercaseString]; + NSString *cssClass = @"weasel-qual"; // default + for (NSNumber *typeNum in categories) + { + if ([categories[typeNum] containsObject:word]) + { + switch (typeNum.unsignedIntegerValue) { + case MPWordIssueQualifier: cssClass = @"weasel-qual"; break; + case MPWordIssueWeasel: cssClass = @"weasel-weasel"; break; + case MPWordIssueIndirect: cssClass = @"weasel-indirect"; break; + case MPWordIssueAdverb: cssClass = @"weasel-adverb"; break; + default: break; + } + break; + } + } + NSString *original = [textPart substringWithRange:wm.range]; + NSString *replacement = [NSString stringWithFormat: + @"%@", cssClass, original]; + [highlighted replaceCharactersInRange:wm.range withString:replacement]; + } + [result appendString:highlighted]; } else { - NSString *highlighted = [regex stringByReplacingMatchesInString:textPart - options:0 range:NSMakeRange(0, textPart.length) - withTemplate:@"$1"]; - [result appendString:highlighted]; + [result appendString:textPart]; } } - // Append the tag itself NSString *tag = [html substringWithRange:tagMatch.range]; [result appendString:tag]; - // Track code blocks NSString *tagLower = tag.lowercaseString; if ([tagLower hasPrefix:@"$1"]; + NSMutableString *highlighted = [textPart mutableCopy]; + NSArray *wordMatches = [regex matchesInString:textPart options:0 + range:NSMakeRange(0, textPart.length)]; + for (NSTextCheckingResult *wm in [wordMatches reverseObjectEnumerator]) + { + NSString *original = [textPart substringWithRange:wm.range]; + [highlighted replaceCharactersInRange:wm.range + withString:[NSString stringWithFormat:@"%@", original]]; + } + [result appendString:highlighted]; + } + else + { + [result appendString:textPart]; } - [result appendString:textPart]; } return result; From 47f8dd6299e097e705da47372ead15e58831cc04 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:06:30 -0700 Subject: [PATCH 15/37] Make issue counter clickable with per-category breakdown Bottom-right counter is now a dropdown. Click it to see: - X qualifiers (yellow) - X weasel words (orange) - X indirect/vague (pink) - X weak adverbs (blue) - X repeated words (red) Each line shows the color so you know what to look for. --- MacDown/Code/Document/MPDocument.m | 67 +++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 01a6aecd..596c3b7b 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -196,7 +196,7 @@ typedef NS_ENUM(NSUInteger, MPWordCountType) { @property (weak) IBOutlet NSPopUpButton *wordCountWidget; @property (strong) IBOutlet MPToolbarController *toolbarController; @property (strong) MPSidebarController *sidebarController; -@property (strong) NSTextField *fillerCountLabel; +@property (strong) NSPopUpButton *fillerCountButton; @property (copy, nonatomic) NSString *autosaveName; @property (strong) HGMarkdownHighlighter *highlighter; @property (strong) MPRenderer *renderer; @@ -462,21 +462,20 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller wordCountWidget.hidden = !self.preferences.editorShowWordCount; wordCountWidget.enabled = NO; - // Create filler word count label in the bottom-right corner + // Create filler word count popup in the bottom-right corner NSView *editorContainer = self.editorContainer; - self.fillerCountLabel = [NSTextField labelWithString:@""]; - self.fillerCountLabel.font = [NSFont systemFontOfSize:10]; - self.fillerCountLabel.textColor = [NSColor secondaryLabelColor]; - self.fillerCountLabel.backgroundColor = [NSColor colorWithWhite:0.95 alpha:0.85]; - self.fillerCountLabel.drawsBackground = YES; - self.fillerCountLabel.alignment = NSTextAlignmentCenter; - self.fillerCountLabel.translatesAutoresizingMaskIntoConstraints = NO; - self.fillerCountLabel.hidden = !self.preferences.editorHighlightFillers; - [editorContainer addSubview:self.fillerCountLabel]; + self.fillerCountButton = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:YES]; + self.fillerCountButton.font = [NSFont systemFontOfSize:10]; + self.fillerCountButton.controlSize = NSControlSizeMini; + self.fillerCountButton.bezelStyle = NSBezelStyleInline; + self.fillerCountButton.bordered = NO; + self.fillerCountButton.translatesAutoresizingMaskIntoConstraints = NO; + self.fillerCountButton.hidden = !self.preferences.editorHighlightFillers; + [self.fillerCountButton addItemWithTitle:@"0 issues"]; + [editorContainer addSubview:self.fillerCountButton]; [NSLayoutConstraint activateConstraints:@[ - [self.fillerCountLabel.trailingAnchor constraintEqualToAnchor:editorContainer.trailingAnchor constant:-8], - [self.fillerCountLabel.bottomAnchor constraintEqualToAnchor:editorContainer.bottomAnchor constant:-8], - [self.fillerCountLabel.widthAnchor constraintGreaterThanOrEqualToConstant:70], + [self.fillerCountButton.trailingAnchor constraintEqualToAnchor:editorContainer.trailingAnchor constant:-4], + [self.fillerCountButton.bottomAnchor constraintEqualToAnchor:editorContainer.bottomAnchor constant:-4], ]]; // Install sidebar (file browser + document outline) @@ -1764,12 +1763,12 @@ - (IBAction)toggleProseAnalysis:(id)sender if (newValue) { [self updateFillerHighlights]; - self.fillerCountLabel.hidden = NO; + self.fillerCountButton.hidden = NO; } else { [self clearFillerHighlights]; - self.fillerCountLabel.hidden = YES; + self.fillerCountButton.hidden = YES; } } @@ -1787,7 +1786,8 @@ - (void)updateFillerHighlights if (text.length == 0) { - self.fillerCountLabel.stringValue = @"0 issues"; + [self.fillerCountButton removeAllItems]; + [self.fillerCountButton addItemWithTitle:@"0 issues"]; return; } @@ -1819,8 +1819,36 @@ - (void)updateFillerHighlights forCharacterRange:issue.range]; } + // Populate the dropdown with per-category breakdown NSUInteger total = analysis.issues.count; - self.fillerCountLabel.stringValue = [NSString stringWithFormat:@"%lu issues", (unsigned long)total]; + [self.fillerCountButton removeAllItems]; + [self.fillerCountButton addItemWithTitle:[NSString stringWithFormat:@"%lu issues", (unsigned long)total]]; + + if (analysis.qualifierCount > 0) + { + NSString *title = [NSString stringWithFormat:@"\u25CF %lu qualifiers (yellow)", (unsigned long)analysis.qualifierCount]; + [self.fillerCountButton addItemWithTitle:title]; + } + if (analysis.weaselCount > 0) + { + NSString *title = [NSString stringWithFormat:@"\u25CF %lu weasel words (orange)", (unsigned long)analysis.weaselCount]; + [self.fillerCountButton addItemWithTitle:title]; + } + if (analysis.indirectCount > 0) + { + NSString *title = [NSString stringWithFormat:@"\u25CF %lu indirect/vague (pink)", (unsigned long)analysis.indirectCount]; + [self.fillerCountButton addItemWithTitle:title]; + } + if (analysis.adverbCount > 0) + { + NSString *title = [NSString stringWithFormat:@"\u25CF %lu weak adverbs (blue)", (unsigned long)analysis.adverbCount]; + [self.fillerCountButton addItemWithTitle:title]; + } + if (analysis.repeatedCount > 0) + { + NSString *title = [NSString stringWithFormat:@"\u25CF %lu repeated words (red)", (unsigned long)analysis.repeatedCount]; + [self.fillerCountButton addItemWithTitle:title]; + } } - (void)clearFillerHighlights @@ -1831,7 +1859,8 @@ - (void)clearFillerHighlights [self.editor.layoutManager removeTemporaryAttribute:NSBackgroundColorAttributeName forCharacterRange:NSMakeRange(0, text.length)]; } - self.fillerCountLabel.stringValue = @""; + [self.fillerCountButton removeAllItems]; + [self.fillerCountButton addItemWithTitle:@"0 issues"]; } - (IBAction)render:(id)sender From 7ee529dc95e3bec36137e659f54fb5357eb2f792 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:07:48 -0700 Subject: [PATCH 16/37] Color-code dots in issue dropdown and improve readability Dots in the breakdown menu now match the actual highlight colors (yellow, orange, pink, blue, red). Button text is larger and uses monospaced digits. Title shows a warning icon with count. --- MacDown/Code/Document/MPDocument.m | 68 +++++++++++++++++------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 596c3b7b..ef2e790c 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -465,10 +465,8 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller // Create filler word count popup in the bottom-right corner NSView *editorContainer = self.editorContainer; self.fillerCountButton = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:YES]; - self.fillerCountButton.font = [NSFont systemFontOfSize:10]; - self.fillerCountButton.controlSize = NSControlSizeMini; - self.fillerCountButton.bezelStyle = NSBezelStyleInline; - self.fillerCountButton.bordered = NO; + self.fillerCountButton.font = [NSFont monospacedDigitSystemFontOfSize:11 weight:NSFontWeightMedium]; + self.fillerCountButton.controlSize = NSControlSizeSmall; self.fillerCountButton.translatesAutoresizingMaskIntoConstraints = NO; self.fillerCountButton.hidden = !self.preferences.editorHighlightFillers; [self.fillerCountButton addItemWithTitle:@"0 issues"]; @@ -1819,35 +1817,47 @@ - (void)updateFillerHighlights forCharacterRange:issue.range]; } - // Populate the dropdown with per-category breakdown + // Populate the dropdown with per-category breakdown using colored dots NSUInteger total = analysis.issues.count; [self.fillerCountButton removeAllItems]; - [self.fillerCountButton addItemWithTitle:[NSString stringWithFormat:@"%lu issues", (unsigned long)total]]; - if (analysis.qualifierCount > 0) - { - NSString *title = [NSString stringWithFormat:@"\u25CF %lu qualifiers (yellow)", (unsigned long)analysis.qualifierCount]; - [self.fillerCountButton addItemWithTitle:title]; - } - if (analysis.weaselCount > 0) - { - NSString *title = [NSString stringWithFormat:@"\u25CF %lu weasel words (orange)", (unsigned long)analysis.weaselCount]; - [self.fillerCountButton addItemWithTitle:title]; - } - if (analysis.indirectCount > 0) - { - NSString *title = [NSString stringWithFormat:@"\u25CF %lu indirect/vague (pink)", (unsigned long)analysis.indirectCount]; - [self.fillerCountButton addItemWithTitle:title]; - } - if (analysis.adverbCount > 0) - { - NSString *title = [NSString stringWithFormat:@"\u25CF %lu weak adverbs (blue)", (unsigned long)analysis.adverbCount]; - [self.fillerCountButton addItemWithTitle:title]; - } - if (analysis.repeatedCount > 0) + // Title item (shown in the button) + NSString *titleStr = [NSString stringWithFormat:@"\u26A0 %lu issues", (unsigned long)total]; + [self.fillerCountButton addItemWithTitle:titleStr]; + + struct { NSUInteger count; NSString *label; NSColor *color; } rows[] = { + { analysis.qualifierCount, @"qualifiers", [NSColor colorWithRed:0.9 green:0.85 blue:0.0 alpha:1.0] }, + { analysis.weaselCount, @"weasel words", [NSColor orangeColor] }, + { analysis.indirectCount, @"indirect/vague",[NSColor colorWithRed:0.9 green:0.4 blue:0.6 alpha:1.0] }, + { analysis.adverbCount, @"weak adverbs", [NSColor colorWithRed:0.3 green:0.5 blue:0.9 alpha:1.0] }, + { analysis.repeatedCount, @"repeated words",[NSColor colorWithRed:0.9 green:0.3 blue:0.3 alpha:1.0] }, + }; + + for (int i = 0; i < 5; i++) { - NSString *title = [NSString stringWithFormat:@"\u25CF %lu repeated words (red)", (unsigned long)analysis.repeatedCount]; - [self.fillerCountButton addItemWithTitle:title]; + if (rows[i].count == 0) continue; + + NSString *text = [NSString stringWithFormat:@"%lu %@", (unsigned long)rows[i].count, rows[i].label]; + + // Create attributed title with a colored dot + NSMutableAttributedString *attr = [[NSMutableAttributedString alloc] init]; + + // Colored dot + NSDictionary *dotAttrs = @{ + NSForegroundColorAttributeName: rows[i].color, + NSFontAttributeName: [NSFont systemFontOfSize:14], + }; + [attr appendAttributedString:[[NSAttributedString alloc] initWithString:@"\u25CF " attributes:dotAttrs]]; + + // Label text + NSDictionary *textAttrs = @{ + NSForegroundColorAttributeName: [NSColor labelColor], + NSFontAttributeName: [NSFont systemFontOfSize:12], + }; + [attr appendAttributedString:[[NSAttributedString alloc] initWithString:text attributes:textAttrs]]; + + [self.fillerCountButton addItemWithTitle:@""]; + self.fillerCountButton.lastItem.attributedTitle = attr; } } From f8f507a4a88569e20a6638ca452e19a54832ac80 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:08:55 -0700 Subject: [PATCH 17/37] Make issue counter readable with dark pill background and white text --- MacDown/Code/Document/MPDocument.m | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index ef2e790c..26104de3 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -467,6 +467,9 @@ - (void)windowControllerDidLoadNib:(NSWindowController *)controller self.fillerCountButton = [[NSPopUpButton alloc] initWithFrame:NSZeroRect pullsDown:YES]; self.fillerCountButton.font = [NSFont monospacedDigitSystemFontOfSize:11 weight:NSFontWeightMedium]; self.fillerCountButton.controlSize = NSControlSizeSmall; + self.fillerCountButton.wantsLayer = YES; + self.fillerCountButton.layer.backgroundColor = [[NSColor colorWithWhite:0.2 alpha:0.85] CGColor]; + self.fillerCountButton.layer.cornerRadius = 5; self.fillerCountButton.translatesAutoresizingMaskIntoConstraints = NO; self.fillerCountButton.hidden = !self.preferences.editorHighlightFillers; [self.fillerCountButton addItemWithTitle:@"0 issues"]; @@ -1821,9 +1824,14 @@ - (void)updateFillerHighlights NSUInteger total = analysis.issues.count; [self.fillerCountButton removeAllItems]; - // Title item (shown in the button) + // Title item (shown in the button) — use attributed string for visibility NSString *titleStr = [NSString stringWithFormat:@"\u26A0 %lu issues", (unsigned long)total]; - [self.fillerCountButton addItemWithTitle:titleStr]; + NSAttributedString *titleAttr = [[NSAttributedString alloc] initWithString:titleStr attributes:@{ + NSForegroundColorAttributeName: [NSColor whiteColor], + NSFontAttributeName: [NSFont monospacedDigitSystemFontOfSize:11 weight:NSFontWeightBold], + }]; + [self.fillerCountButton addItemWithTitle:@""]; + self.fillerCountButton.itemArray.firstObject.attributedTitle = titleAttr; struct { NSUInteger count; NSString *label; NSColor *color; } rows[] = { { analysis.qualifierCount, @"qualifiers", [NSColor colorWithRed:0.9 green:0.85 blue:0.0 alpha:1.0] }, From a7a9f80070f3e495208c4001965ac9821cac1b29 Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:15:33 -0700 Subject: [PATCH 18/37] Fix WikiLinks: inject CSS into all themes and mark unsaved docs as missing WikiLink styles (blue for existing, red for missing) are now injected into every rendered page regardless of preview theme. Unsaved documents correctly show all wikilinks in red since no files can be resolved. --- MacDown/Code/Document/MPDocument.m | 12 ++++++++++++ MacDown/Code/Utility/MPWritingTools.m | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index 26104de3..aabe4be8 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -1235,6 +1235,18 @@ - (void)renderer:(MPRenderer *)renderer didProduceHTMLOutput:(NSString *)html html = [fillerCSS stringByAppendingString:html]; } + // Inject WikiLink CSS for all documents + NSString *wikiCSS = @""; + NSRange headEnd2 = [html rangeOfString:@"" options:NSCaseInsensitiveSearch]; + if (headEnd2.location != NSNotFound) + html = [html stringByReplacingCharactersInRange:headEnd2 + withString:[wikiCSS stringByAppendingString:@""]]; + else + html = [wikiCSS stringByAppendingString:html]; + // Reload the page if there's not valid tree to work with. [self.preview.mainFrame loadHTMLString:html baseURL:baseUrl]; self.currentBaseUrl = baseUrl; diff --git a/MacDown/Code/Utility/MPWritingTools.m b/MacDown/Code/Utility/MPWritingTools.m index f30190e9..c82a0c27 100644 --- a/MacDown/Code/Utility/MPWritingTools.m +++ b/MacDown/Code/Utility/MPWritingTools.m @@ -58,7 +58,8 @@ + (NSString *)processWikiLinksInMarkdown:(NSString *)markdown baseURL:(NSURL *)b } else { - link = [NSString stringWithFormat:@"%@", + // No base URL (unsaved doc) — mark as missing + link = [NSString stringWithFormat:@"%@", filename, display]; } From 349331e1a9ceec9c0ddd40df6d12c10cade1a31b Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:19:37 -0700 Subject: [PATCH 19/37] Fix Graphviz and Mermaid checkboxes always being disabled They were bound to htmlSyntaxHighlighting for enabled state, which made them grayed out unless code highlighting was on. Removed the enabled binding so they're always checkable independently. --- .../Localization/Base.lproj/MPHtmlPreferencesViewController.xib | 2 -- 1 file changed, 2 deletions(-) diff --git a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib index 3fba7005..f5acb029 100644 --- a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib +++ b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib @@ -121,7 +121,6 @@ - @@ -132,7 +131,6 @@ - From 2260060a99dab0044dd5fe5c4fe796c6ad33026c Mon Sep 17 00:00:00 2001 From: Alex Wirtzer Date: Thu, 19 Mar 2026 19:21:30 -0700 Subject: [PATCH 20/37] =?UTF-8?q?Fix=20Graphviz=20and=20Mermaid=20checkbox?= =?UTF-8?q?=20layout=20=E2=80=94=20move=20to=20own=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkboxes were overlapping with Show Line Numbers at the same Y position. Moved them to their own row below TeX-like math syntax with full-size font so they're visible and clickable. --- .../Base.lproj/MPHtmlPreferencesViewController.xib | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib index f5acb029..bb4621a1 100644 --- a/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib +++ b/MacDown/Localization/Base.lproj/MPHtmlPreferencesViewController.xib @@ -115,20 +115,20 @@ - -