35 Commits
Author SHA1 Message Date
james.maes 466a1e655c Merge pull request 'ci: shared ci-workflows macOS workflow; fix the Xcode 27 Document ambiguity (blocked: runner has no GUI session)' (#3) from ci/shared-macos into main
macos / macos (push) Successful in 14s
build / macos (push) Successful in 14s
2026-11-08 23:28:56 +00:00
James MaesandClaude Opus 5.5 be68c9f1bb ci: build only on CI; run the app-hosted tests locally
macos / macos (push) Successful in 16s
build / macos (push) Successful in 16s
The grogu runner has no GUI session, so xcodebuild test cannot reach
testmanagerd. CI compiles the app and the test bundle unsigned
(build-for-testing); the README documents running just test locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:40 -06:00
James MaesandClaude Opus 5.5 77b9274434 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:46:19 -06:00
James MaesandClaude Opus 5.5 ab3731e0df fix: qualify Markdown.Document for the Xcode 27 SDK
macos / macos (push) Failing after 14s
build / macos (push) Failing after 14s
SwiftUI now declares a Document protocol, so the unqualified name in the
plain-text visitor is ambiguous and the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:37:16 -06:00
James MaesandClaude Opus 5.5 4536db3f09 ci: build on the shared ci-workflows macOS workflow
macos / macos (push) Failing after 16s
build / macos (push) Failing after 16s
Runs the repository's build (and tests where it has them) on the KOF macOS
host runner through ci-workflows macos.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:34:06 -06:00
james.maes 2bfdf91f77 Merge pull request #1 from KofTwentyTwo/docs/fork-notice
docs: mark this repository as a contribution fork of the upstream app
2026-10-13 06:26:49 -05:00
james.maesandClaude Opus 5.5 6e25ec7963 docs: mark this repository as a contribution fork of the upstream app
The README was the upstream README word for word, so it read as if
KofTwentyTwo shipped the macOS app. Every download, cask and update link
points at darinkelkhoff/markdownViewr, and this fork publishes no
releases. Say so, and point to the upstream printing PRs (#2 merged,
#3 open) and to the Windows port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:23:23 -05:00
james.maes 1ab16bf4ed feat(printing): add print font scaling options and document-proportional clean print theme sizing 2026-07-24 08:28:58 -05:00
james.maes 0d1133aafd test(printing): add unit test validating every theme option generates non-empty CSS and script injection 2026-07-24 08:26:49 -05:00
james.maes fbb313ccb9 test(printing): expand test assertions for all heading sizes, colors, blockquotes, and highlights 2026-07-24 08:26:15 -05:00
james.maes 53a6ae7896 fix(printing): enforce theme fonts, sizes, and colors in print media queries 2026-07-24 08:23:49 -05:00
Darin Kelkhoff 76ed62618c release: add 1.5.0 release notes link to appcast 2026-07-23 08:35:09 -05:00
Darin Kelkhoff cb29d64a1d release: update appcast for v1.5.0 2026-07-23 08:33:56 -05:00
Darin Kelkhoff cde9351f11 Prepare release 1.5.0 2026-07-23 08:31:48 -05:00
Darin Kelkhoff ad2cc7e55f Merge pull request #2 from KofTwentyTwo/feature/mac-printing
feat: Add native macOS printing and custom printing configuration preferences
2026-07-23 08:22:17 -05:00
Darin Kelkhoff 05d65cf0c7 Clean up printing PR whitespace 2026-07-23 08:13:50 -05:00
james.maes ff7b942d46 fix(printing): update test assertions for clean print theme and space item 2026-07-22 12:05:47 -05:00
james.maes 01ff200f8d merge: incorporate native toolbar space item from fix/toolbar-spacer 2026-07-22 12:05:02 -05:00
james.maes c9e8867ec3 fix(printing): use built-in Theme.cleanPrinting for clean print option 2026-07-22 12:04:37 -05:00
Darin Kelkhoff 2c2f153299 Polish printing toolbar integration 2026-07-22 10:00:18 -05:00
Darin Kelkhoff f6f4ac723c Use native toolbar space item 2026-07-22 08:46:00 -05:00
Darin Kelkhoff 484d9af0ea Disable frame autosave for tabbed document windows 2026-07-22 08:26:57 -05:00
james.maes 87f8318f4d feat: Add native macOS printing and custom printing configuration preferences 2026-07-21 16:21:56 -05:00
james.maes 4199c8b375 fix: Remove label and border container from fixed space toolbar item 2026-07-21 16:21:47 -05:00
Darin Kelkhoff 6d9b5a32f5 Improve TOC hover and document navigation 2026-07-17 09:59:20 -05:00
Darin Kelkhoff baac7e71a8 fix toolbar persistence, menu pruning, and tabbed window frame handling 2026-07-13 14:45:56 -05:00
Darin Kelkhoff 1abc51eb25 release: add appcast release notes for v1.4.0 2026-06-26 09:00:55 -05:00
Darin Kelkhoff 91985c3fbc release: update appcast for v1.4.0 2026-06-26 09:00:06 -05:00
Darin Kelkhoff 39a08ae42e Prepare release 1.4.0 2026-06-26 08:57:44 -05:00
Darin Kelkhoff 8373722174 Make theme selection per-window 2026-06-26 08:43:04 -05:00
Darin Kelkhoff e06facc915 Refine document window and toolbar behavior 2026-06-25 13:10:33 -05:00
Darin Kelkhoff 71d71df7dd Add customizable document toolbar 2026-06-22 15:51:59 -05:00
Darin Kelkhoff 842d6bf025 Add native toolbar customization 2026-06-22 12:40:59 -05:00
Darin Kelkhoff 28458b0892 Polish menus and toolbar controls 2026-06-22 11:38:34 -05:00
Darin Kelkhoff ad76a0c8e1 Prune editing commands from File menu 2026-06-19 16:40:24 -05:00
22 changed files with 3383 additions and 206 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
macos:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/macos.yaml@v1
secrets: inherit
with:
# Build only: the app and the test bundle are compiled unsigned, the tests are not run. They are
# app-hosted XCTests and the grogu runner has no GUI session for testmanagerd; run `just test`
# locally before merging (README, Building from Source). Package.swift has no test target.
commands: |
xcodegen generate
xcodebuild -quiet -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build-for-testing
+11
View File
@@ -1,5 +1,7 @@
# markdownViewr
> **This is KofTwentyTwo's contribution fork of [darinkelkhoff/markdownViewr](https://github.com/darinkelkhoff/markdownViewr).** It publishes no builds. The download, Homebrew cask and Sparkle updates below all come from the upstream project. The fork carries printing work that is offered upstream: native printing merged as [darinkelkhoff/markdownViewr#2](https://github.com/darinkelkhoff/markdownViewr/pull/2), and the print-styling fixes are open as [#3](https://github.com/darinkelkhoff/markdownViewr/pull/3). Report app issues upstream. The Windows port is [KofTwentyTwo/MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
[![Download](https://img.shields.io/badge/Download-macOS-blue?style=flat-square&logo=apple)](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
@@ -59,3 +61,12 @@ just run
```
`just --list` shows all available recipes.
### Tests
The tests are app-hosted XCTests (`markdownViewrTests`). CI builds the app and the test bundle unsigned
but does not run them (the CI macOS runner has no GUI session), so run them locally before merging:
```bash
just test # xcodegen generate first if the project is stale
```
+18 -16
View File
@@ -5,6 +5,24 @@
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.5.0</title>
<pubDate>Thu, 23 Jul 2026 08:33:55 -0500</pubDate>
<sparkle:version>10500</sparkle:version>
<sparkle:shortVersionString>1.5.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.5.0.html</sparkle:releaseNotesLink>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.5.0/markdownViewr-1.5.0.zip" length="4426474" type="application/octet-stream" sparkle:edSignature="sIjjKAMzb8Fv9zjr0vkfbpxeriD+lmkdB/L+wcs/Ay0mcvYu+WIr4UkIpeYBLsLevOl2aYfM3+KOhHdTjAnXDA=="/>
</item>
<item>
<title>1.4.0</title>
<pubDate>Fri, 26 Jun 2026 09:00:05 -0500</pubDate>
<sparkle:version>10400</sparkle:version>
<sparkle:shortVersionString>1.4.0</sparkle:shortVersionString>
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.4.0.html</sparkle:releaseNotesLink>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.4.0/markdownViewr-1.4.0.zip" length="4385892" type="application/octet-stream" sparkle:edSignature="7OtkBh/Qd6/BB5MYr38LVGJA3C24+hzFfd4OOe88gim2LEKShRoMkz8EXuuCCIhcVbDIDrtcU6XzckshCKKJBg=="/>
</item>
<item>
<title>1.3.0</title>
<pubDate>Fri, 19 Jun 2026 14:47:14 -0500</pubDate>
@@ -14,21 +32,5 @@
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.3.0/markdownViewr-1.3.0.zip" length="4256339" type="application/octet-stream" sparkle:edSignature="TNbtuUw4oI/YHBwiHqtfy4CFTu/pay+R/fGjF/usHw9DWVfKh+tRvgu8UyHVe0SbR7KFX6PGg1aVzeONMicqDw=="/>
</item>
<item>
<title>1.2.0</title>
<pubDate>Tue, 09 Jun 2026 13:58:18 -0500</pubDate>
<sparkle:version>10200</sparkle:version>
<sparkle:shortVersionString>1.2.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.2.0/markdownViewr-1.2.0.zip" length="4220100" type="application/octet-stream" sparkle:edSignature="To0Yh+F2nAaYfLCwd/nxOR+bMkEwTIS01T1YA0tJNto6VYXc5haNeOU+uX2e4nx3JAHvknGFKWQRunkS1aCSBw=="/>
</item>
<item>
<title>1.1.6</title>
<pubDate>Mon, 08 Jun 2026 12:01:59 -0500</pubDate>
<sparkle:version>10106</sparkle:version>
<sparkle:shortVersionString>1.1.6</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.6/markdownViewr-1.1.6.zip" length="4202331" type="application/octet-stream" sparkle:edSignature="PJHorFzsVMKacxkjrVMNfRYoUfoHrBvy0okZ7NO7+0fFSOqFEi50D0dMlog8DlCvOxeXEfkMXQoPjmugDArcCg=="/>
</item>
</channel>
</rss>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr 1.4.0 Release Notes</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 28px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1d1d1f;
background: #fff;
line-height: 1.5;
}
.release-notes {
max-width: 680px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 1.8rem;
letter-spacing: -0.02em;
}
.date {
margin: 0 0 24px;
color: #6e6e73;
font-size: 0.92rem;
}
h2 {
margin: 24px 0 8px;
font-size: 1.1rem;
}
ul {
margin: 8px 0 0;
padding-left: 1.35rem;
}
li {
margin: 8px 0;
}
code {
padding: 1px 5px;
border-radius: 5px;
background: #f5f5f7;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
.date { color: #a1a1a6; }
code { background: #2c2c2e; }
}
</style>
</head>
<body>
<div class="release-notes">
<h1>markdownViewr 1.4.0</h1>
<p class="date">Released June 26, 2026</p>
<h2>Native macOS toolbar customization</h2>
<ul>
<li>Added native macOS toolbar customization for document windows.</li>
</ul>
<h2>View settings now apply per window</h2>
<ul>
<li>TOC, TOC depth, Markdown Source, zoom, and active theme are now per-window settings.</li>
<li>Added Settings options to define default settings for new windows.</li>
</ul>
<h2>Menu and shortcut improvements</h2>
<ul>
<li>Cleaned up the File menu by removing New, Save, and Revert, and adding Open in External Editor.</li>
<li>Added View menu commands for TOC, Markdown Source, and TOC depth.</li>
<li>Added <code>Cmd-T</code> to toggle TOC, <code>Cmd-U</code> to toggle Markdown Source, and <code>Cmd-E</code> to open in the external editor.</li>
</ul>
<h2>Window and settings polish</h2>
<ul>
<li>Improved default window sizing and side pane widths.</li>
<li>Added per-file window size restoration.</li>
<li>Added the ability to reorder external editors.</li>
</ul>
</div>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>markdownViewr 1.5.0 Release Notes</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 28px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1d1d1f;
background: #fff;
line-height: 1.5;
}
.release-notes {
max-width: 680px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 1.8rem;
letter-spacing: -0.02em;
}
.date {
margin: 0 0 24px;
color: #6e6e73;
font-size: 0.92rem;
}
h2 {
margin: 24px 0 8px;
font-size: 1.1rem;
}
ul {
margin: 8px 0 0;
padding-left: 1.35rem;
}
li {
margin: 8px 0;
}
code {
padding: 1px 5px;
border-radius: 5px;
background: #f5f5f7;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
.date { color: #a1a1a6; }
code { background: #2c2c2e; }
}
</style>
</head>
<body>
<div class="release-notes">
<h1>markdownViewr 1.5.0</h1>
<p class="date">Released July 23, 2026</p>
<h2>Native printing</h2>
<ul>
<li>Added native macOS printing for rendered Markdown documents.</li>
<li>Added a Printing tab in Settings for print theme, images, backgrounds, borders, page padding, zoom, and content width.</li>
<li>Added a built-in Clean Printing theme for predictable print output.</li>
</ul>
<h2>Toolbar improvements</h2>
<ul>
<li>Added an optional Print toolbar item that can be added from Customize Toolbar.</li>
<li>Replaced the custom small-space toolbar item with AppKit's native repeatable space item.</li>
</ul>
<h2>Window and navigation polish</h2>
<ul>
<li>Updated table-of-contents highlighting to follow the mouse cursor instead of just the top-of-window scroll position.</li>
<li>Improved tabbed document window behavior by avoiding frame autosave on tab groups.</li>
</ul>
</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
# Think different
## Here's to the crazy ones.
1. The misfits
2. The rebels
3. The troublemakers
The round pegs in the square holes. The ones who see things differently. They're not fond of rules.
And they have no respect for the status quo. You can quote them, disagree with them, glorify or vilify them. About the only thing you can't do is ignore them. Because they change things.
***They push the human race forward***.
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
[Think different](http://www.apple.com)
+18 -7
View File
@@ -11,11 +11,11 @@ build:
# Kill, build, and launch the app
run: kill build
@open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
# Open a specific markdown file
open file: build
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
# Run the test suite
test:
@@ -67,6 +67,14 @@ release: kill
echo "Error: v$VERSION is already released. Bump MARKETING_VERSION in project.yml first."
exit 1
fi
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
echo "Error: local tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
echo "Error: remote tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
exit 1
fi
ARCHIVE="/tmp/markdownViewr.xcarchive"
EXPORT="/tmp/markdownViewr-export"
ZIP="/tmp/markdownViewr-$VERSION.zip"
@@ -112,11 +120,6 @@ release: kill
--app-drop-link 480 185 \
"$DMG" \
"$DMG_STAGING/"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$DMG" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
echo "==> Updating appcast..."
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
if [[ -z "$SPARKLE_BIN" ]]; then
@@ -133,7 +136,15 @@ release: kill
echo "==> Committing and pushing appcast..."
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
echo "==> Tagging release v$VERSION..."
git tag -a "v$VERSION" -m "markdownViewr v$VERSION"
git push origin main
git push origin "v$VERSION"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$DMG" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
just update-cask
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
File diff suppressed because it is too large. Load diff
+35
View File
@@ -23,6 +23,24 @@ struct EditorConfig: Codable, Identifiable, Hashable {
}
}
enum ExternalEditorMenuState: Equatable {
case disabled
case single(EditorConfig, title: String)
case submenu([EditorConfig])
init(editors: [EditorConfig]) {
switch editors.count {
case 0:
self = .disabled
case 1:
let editor = editors[0]
self = .single(editor, title: "Open in \(editor.name)")
default:
self = .submenu(editors)
}
}
}
class EditorManager: ObservableObject {
@Published var editors: [EditorConfig] {
didSet {
@@ -51,6 +69,21 @@ class EditorManager: ObservableObject {
editors.removeAll { $0.id == id }
}
func moveEditor(id: UUID, by offset: Int) {
guard let source = editors.firstIndex(where: { $0.id == id }) else { return }
let destination = source + offset
guard editors.indices.contains(destination) else { return }
var reorderedEditors = editors
reorderedEditors.swapAt(source, destination)
editors = reorderedEditors
}
func setOpensFolder(_ opensFolder: Bool, for id: UUID) {
guard let index = editors.firstIndex(where: { $0.id == id }) else { return }
editors[index].opensFolder = opensFolder
}
private func save() {
if let data = try? JSONEncoder().encode(editors) {
UserDefaults.standard.set(data, forKey: "configuredEditors")
@@ -70,6 +103,8 @@ class EditorManager: ObservableObject {
: fileURL
let config = NSWorkspace.OpenConfiguration()
config.activates = true
config.allowsRunningApplicationSubstitution = false
workspace.open([urlToOpen], withApplicationAt: appURL, configuration: config)
}
}
+215
View File
@@ -0,0 +1,215 @@
import AppKit
import Combine
enum FileMenuPruner {
private static let removedTitles: Set<String> = ["New", "Save", "Save...", "Revert To"]
static func removeEditingItems(from menu: NSMenu) {
for item in menu.items.reversed() where shouldRemove(item) {
menu.removeItem(item)
}
}
static func removeEditingItems(from mainMenu: NSMenu?) {
guard let fileMenu = fileMenu(in: mainMenu) else { return }
removeEditingItems(from: fileMenu)
}
static func fileMenu(in mainMenu: NSMenu?) -> NSMenu? {
mainMenu?.item(withTitle: "File")?.submenu
}
private static func shouldRemove(_ item: NSMenuItem) -> Bool {
let title = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
return removedTitles.contains(title) || title.hasPrefix("Revert To")
}
}
final class FileMenuPrunerApplicationDelegate: NSObject, NSApplicationDelegate {
private var menuObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
installMenuObserver()
DispatchQueue.main.async {
self.configureFileMenu()
}
}
deinit {
if let menuObserver {
NotificationCenter.default.removeObserver(menuObserver)
}
}
private func installMenuObserver() {
guard menuObserver == nil else { return }
menuObserver = NotificationCenter.default.addObserver(
forName: NSMenu.didBeginTrackingNotification,
object: nil,
queue: .main
) { notification in
guard let menu = notification.object as? NSMenu,
menu === FileMenuPruner.fileMenu(in: NSApp.mainMenu)
else { return }
self.updateFileMenu(menu)
}
}
private func configureFileMenu() {
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApp.mainMenu) else { return }
updateFileMenu(fileMenu)
}
private func updateFileMenu(_ fileMenu: NSMenu) {
FileMenuPruner.removeEditingItems(from: fileMenu)
ExternalEditorFileMenuController.shared.update(in: fileMenu)
}
}
final class ExternalEditorFileMenuController: NSObject, NSMenuItemValidation {
static let shared = ExternalEditorFileMenuController()
var currentFileURLProvider: () -> URL? = {
ExternalEditorFileMenuController.currentOpenDocumentFileURL()
}
weak var editorManager: EditorManager? {
didSet {
editorSubscription = editorManager?.$editors.sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateMainMenu()
}
}
updateMainMenu()
}
}
private let dividerIdentifier = "markdownViewr.externalEditor.divider"
private let itemIdentifier = "markdownViewr.externalEditor.item"
private var editorSubscription: AnyCancellable?
private var dividerMenuIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier(dividerIdentifier)
}
private var itemMenuIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier(itemIdentifier)
}
static func currentOpenDocumentFileURL() -> URL? {
let documentController = NSDocumentController.shared
return currentOpenDocumentFileURL(in: [documentController.currentDocument] + documentController.documents)
}
static func currentOpenDocumentFileURL(in documents: [NSDocument?]) -> URL? {
for document in documents {
guard let document else { continue }
guard document.windowControllers.contains(where: { $0.window?.isVisible == true }) else { continue }
if let fileURL = document.fileURL {
return fileURL
}
}
return nil
}
func updateMainMenu() {
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApplication.shared.mainMenu) else { return }
update(in: fileMenu)
}
func update(in menu: NSMenu) {
removeExistingItems(from: menu)
let separator = NSMenuItem.separator()
separator.identifier = dividerMenuIdentifier
let externalEditorItem = makeExternalEditorItem()
externalEditorItem.identifier = itemMenuIdentifier
let insertionIndex = externalEditorInsertionIndex(in: menu)
menu.insertItem(separator, at: insertionIndex)
menu.insertItem(externalEditorItem, at: insertionIndex + 1)
}
private func makeExternalEditorItem() -> NSMenuItem {
let editors = editorManager?.editors ?? []
let hasOpenFile = currentFileURLProvider() != nil
switch ExternalEditorMenuState(editors: editors) {
case .disabled:
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
item.isEnabled = false
return item
case let .single(editor, title):
return makeEditorMenuItem(title: title, editor: editor, keyEquivalent: "e", isEnabled: hasOpenFile)
case let .submenu(editors):
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
let submenu = NSMenu(title: "Open in External Editor")
for (index, editor) in editors.enumerated() {
submenu.addItem(makeEditorMenuItem(
title: editor.name,
editor: editor,
keyEquivalent: index == 0 ? "e" : "",
isEnabled: hasOpenFile
))
}
item.submenu = submenu
return item
}
}
private func makeEditorMenuItem(
title: String,
editor: EditorConfig,
keyEquivalent: String = "",
isEnabled: Bool = true
) -> NSMenuItem {
let item = NSMenuItem(
title: title,
action: isEnabled ? #selector(openInExternalEditor(_:)) : nil,
keyEquivalent: keyEquivalent
)
item.target = isEnabled ? self : nil
item.representedObject = editor.id.uuidString
item.isEnabled = isEnabled
item.keyEquivalentModifierMask = [.command]
return item
}
@objc private func openInExternalEditor(_ sender: NSMenuItem) {
guard let editorIDString = sender.representedObject as? String,
let editorID = UUID(uuidString: editorIDString),
let editor = editorManager?.editors.first(where: { $0.id == editorID }),
let fileURL = currentFileURLProvider()
else { return }
editorManager?.openFile(fileURL, with: editor)
}
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
guard menuItem.representedObject as? String != nil else { return true }
return currentFileURLProvider() != nil
}
private func removeExistingItems(from menu: NSMenu) {
for item in menu.items.reversed() {
if item.identifier == dividerMenuIdentifier || item.identifier == itemMenuIdentifier {
menu.removeItem(item)
}
}
}
private func externalEditorInsertionIndex(in menu: NSMenu) -> Int {
if let openRecentIndex = menu.items.firstIndex(where: { item in
let normalizedTitle = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
return normalizedTitle == "Open Recent"
}) {
return openRecentIndex + 1
}
return min(menu.items.count, 1)
}
}
+1 -1
View File
@@ -435,7 +435,7 @@ struct HelpView: View {
Enable **Opens folder instead of file** if the editor works better when opened \
to the containing directory rather than the file itself — useful for \
project-based editors like VS Code or Nova, terminal emulators like Terminal \
project-based editors like VS Code, terminal emulators like Terminal \
(`/System/Applications/Utilities/Terminal.app`), or Finder \
(`/System/Library/CoreServices/Finder.app`).
+1 -1
View File
@@ -275,7 +275,7 @@ struct MarkdownDocument: FileDocument {
private struct HTMLConverter: MarkupWalker {
private var result = ""
mutating func visit(_ document: Document) -> String {
mutating func visit(_ document: Markdown.Document) -> String {
result = ""
for child in document.children {
visitChild(child)
+219 -27
View File
@@ -5,6 +5,8 @@ import Sparkle
@main
struct MarkdownViewrApp: App {
@NSApplicationDelegateAdaptor(FileMenuPrunerApplicationDelegate.self) private var appDelegate
#if !MAS_BUILD
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
@@ -13,7 +15,14 @@ struct MarkdownViewrApp: App {
)
#endif
@StateObject private var themeManager = ThemeManager()
@StateObject private var editorManager = EditorManager()
@StateObject private var editorManager: EditorManager
@FocusedValue(\.documentViewCommands) private var documentViewCommands
init() {
let editorManager = EditorManager()
_editorManager = StateObject(wrappedValue: editorManager)
ExternalEditorFileMenuController.shared.editorManager = editorManager
}
var body: some Scene {
DocumentGroup(viewing: MarkdownDocument.self) { file in
@@ -25,7 +34,7 @@ struct MarkdownViewrApp: App {
.environmentObject(editorManager)
.background(DocumentWindowConfigurator(fileURL: file.fileURL))
}
.defaultSize(width: 700, height: 900)
.defaultSize(width: 1100, height: 800)
.commands {
#if !MAS_BUILD
CommandGroup(after: .appInfo) {
@@ -46,6 +55,14 @@ struct MarkdownViewrApp: App {
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .printItem) {
Button("Print...") {
documentViewCommands?.printDocument()
}
.keyboardShortcut("p", modifiers: .command)
.disabled(documentViewCommands == nil)
}
CommandGroup(replacing: .textEditing) {
Button("Find...") {
NotificationCenter.default.post(name: .findToggle, object: nil)
@@ -70,54 +87,229 @@ struct MarkdownViewrApp: App {
.keyboardShortcut("?", modifiers: .command)
}
CommandGroup(replacing: .toolbar) {
Button("Zoom In") {
themeManager.zoomIn()
}
.keyboardShortcut("+", modifiers: .command)
Button("Zoom Out") {
themeManager.zoomOut()
}
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") {
themeManager.zoomReset()
}
.keyboardShortcut("0", modifiers: .command)
CommandGroup(before: .toolbar) {
DocumentViewCommandItems()
}
CommandMenu("Theme") {
Button("Next Theme") {
themeManager.cycleTheme(direction: 1)
documentViewCommands?.nextTheme()
}
.keyboardShortcut(.downArrow, modifiers: [.command, .shift])
.disabled(documentViewCommands == nil)
Button("Previous Theme") {
themeManager.cycleTheme(direction: -1)
documentViewCommands?.previousTheme()
}
.keyboardShortcut(.upArrow, modifiers: [.command, .shift])
.disabled(documentViewCommands == nil)
}
}
}
}
struct DocumentViewCommandItems: View {
@FocusedValue(\.documentViewCommands) private var documentViewCommands
var body: some View {
Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))
.keyboardShortcut("t", modifiers: .command)
.disabled(documentViewCommands == nil)
Menu("Table of Contents Depth") {
ForEach(1...6, id: \.self) { depth in
Button {
documentViewCommands?.setTocDepth(depth)
} label: {
if documentViewCommands?.tocDepth == depth {
Label("H\(depth)", systemImage: "checkmark")
} else {
Text("H\(depth)")
}
}
}
}
.disabled(documentViewCommands == nil)
Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))
.keyboardShortcut("u", modifiers: .command)
.disabled(documentViewCommands == nil)
Divider()
Button("Zoom In") {
documentViewCommands?.zoomIn()
}
.keyboardShortcut("+", modifiers: .command)
.disabled(documentViewCommands == nil)
Button("Zoom Out") {
documentViewCommands?.zoomOut()
}
.keyboardShortcut("-", modifiers: .command)
.disabled(documentViewCommands == nil)
Button("Actual Size") {
documentViewCommands?.zoomReset()
}
.keyboardShortcut("0", modifiers: .command)
.disabled(documentViewCommands == nil)
}
}
struct DocumentWindowConfigurator: NSViewRepresentable {
let fileURL: URL?
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize {
let width: CGFloat
switch (tocVisible, rawVisible) {
case (true, true):
width = 1500
case (false, true):
width = 1350
case (true, false):
width = 1250
case (false, false):
width = 1100
}
return NSSize(width: width, height: 800)
}
private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()
static func shouldApplyDocumentFrame(isTabbedWindow: Bool, hasTabbedHistory: Bool = false) -> Bool {
!isTabbedWindow && !hasTabbedHistory
}
static func autosaveName(for fileURL: URL) -> String {
var allowedCharacters = CharacterSet.alphanumerics
allowedCharacters.insert(charactersIn: "-_.")
let stablePath = fileURL.standardizedFileURL.path
let encodedPath = stablePath.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? stablePath
return "doc-\(encodedPath)"
}
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window, let fileURL else { return }
let autosaveName = "doc-\(fileURL.path.hashValue)"
if !window.setFrameAutosaveName(autosaveName) {
window.setFrameUsingName(autosaveName)
}
configureWindow(for: view, context: context)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
configureWindow(for: nsView, context: context)
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
private func configureWindow(for view: NSView, context: Context) {
guard let window = view.window, let fileURL else { return }
window.isRestorable = false
let autosaveName = Self.autosaveName(for: fileURL)
guard context.coordinator.needsConfiguration(for: window, autosaveName: autosaveName) else { return }
let isTabbedWindow = Self.isTabbedWindow(window)
if isTabbedWindow {
Self.markTabGroupHasTabbedHistory(window)
context.coordinator.stopSavingFrame(for: window)
return
}
let hasTabbedHistory = Self.windowHasTabbedHistory(window)
if Self.shouldApplyDocumentFrame(isTabbedWindow: isTabbedWindow, hasTabbedHistory: hasTabbedHistory) {
let restoredFrame = window.setFrameUsingName(autosaveName)
if !restoredFrame {
window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))
window.center()
}
}
if !hasTabbedHistory {
window.setFrameAutosaveName(autosaveName)
}
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
}
private static func isTabbedWindow(_ window: NSWindow) -> Bool {
window.tabbedWindows?.contains { $0 !== window } == true
}
private static func markTabGroupHasTabbedHistory(_ window: NSWindow) {
let tabGroupWindows = window.tabbedWindows ?? [window]
tabGroupWindows.forEach {
windowsWithTabbedHistory.add($0)
$0.isRestorable = false
$0.setFrameAutosaveName("")
}
}
private static func windowHasTabbedHistory(_ window: NSWindow) -> Bool {
windowsWithTabbedHistory.contains(window)
}
class Coordinator {
private weak var configuredWindow: NSWindow?
private var configuredAutosaveName: String?
private var observerTokens: [NSObjectProtocol] = []
func needsConfiguration(for window: NSWindow, autosaveName: String) -> Bool {
configuredWindow !== window || configuredAutosaveName != autosaveName
}
func configureSavingFrame(for window: NSWindow, autosaveName: String) {
removeObservers()
configuredWindow = window
configuredAutosaveName = autosaveName
let center = NotificationCenter.default
let saveFrame: (Notification) -> Void = { [weak window] _ in
window?.saveFrame(usingName: autosaveName)
}
observerTokens = [
center.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main,
using: saveFrame
),
center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main,
using: saveFrame
),
center.addObserver(
forName: NSWindow.didMoveNotification,
object: window,
queue: .main,
using: saveFrame
)
]
window.saveFrame(usingName: autosaveName)
}
func stopSavingFrame(for window: NSWindow) {
removeObservers()
configuredWindow = nil
configuredAutosaveName = nil
}
deinit {
removeObservers()
}
private func removeObservers() {
let center = NotificationCenter.default
observerTokens.forEach { center.removeObserver($0) }
observerTokens = []
}
}
}
class HelpWindowController: NSObject, NSWindowDelegate {
@@ -170,10 +362,10 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
.environmentObject(editorManager)
let hostingView = NSHostingView(rootView: settingsView)
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
contentRect: NSRect(x: 0, y: 0, width: 600, height: 720),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
+13 -1
View File
@@ -62,6 +62,11 @@ struct MarkdownWebView: NSViewRepresentable {
injectCSS(in: webView)
}
if !context.coordinator.isPageLoaded {
queueInitialState(context: context)
return
}
if context.coordinator.lastTocVisible != tocVisible {
context.coordinator.lastTocVisible = tocVisible
webView.evaluateJavaScript("setTOCVisible(\(tocVisible))") { _, _ in }
@@ -116,6 +121,7 @@ struct MarkdownWebView: NSViewRepresentable {
context.coordinator.lastHTML = html
context.coordinator.lastCSS = themeCSS
context.coordinator.isPageLoaded = false
#if MAS_BUILD
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
// both unreadable and unnecessary.
@@ -147,7 +153,11 @@ struct MarkdownWebView: NSViewRepresentable {
webView.loadHTMLString(template, baseURL: nil)
}
// Queue TOC state to apply after page loads
// Queue document state to apply after page loads.
queueInitialState(context: context)
}
private func queueInitialState(context: Context) {
context.coordinator.pendingTocVisible = tocVisible
context.coordinator.pendingTocDepth = tocDepth
context.coordinator.pendingTocWidth = tocWidth
@@ -195,6 +205,7 @@ struct MarkdownWebView: NSViewRepresentable {
var lastRawSource: String = ""
var lastRawVisible: Bool = false
var lastRawWidth: Double = 400
var isPageLoaded = false
var pendingTocVisible: Bool?
var pendingTocDepth: Int?
var pendingTocWidth: Double?
@@ -331,6 +342,7 @@ struct MarkdownWebView: NSViewRepresentable {
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
isPageLoaded = true
if let width = pendingTocWidth {
lastTocWidth = width
webView.evaluateJavaScript("setTOCWidth(\(width))") { _, _ in }
+322 -4
View File
@@ -211,7 +211,7 @@ body {
#toc a.active {
opacity: 1;
color: var(--link);
font-weight: 600;
text-shadow: 0 0 0.35px currentColor;
}
#toc .toc-h2 { padding-left: 18px; }
@@ -408,6 +408,174 @@ mark {
font-weight: 600;
white-space: nowrap;
}
@media print {
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
html, body {
height: auto !important;
overflow: visible !important;
display: block !important;
background-color: var(--bg) !important;
color: var(--text) !important;
font-family: var(--body-font) !important;
font-size: var(--base-font-size) !important;
line-height: var(--line-height) !important;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--heading-font) !important;
}
h1 { color: var(--h1) !important; font-size: var(--h1-size) !important; }
h2 { color: var(--h2) !important; font-size: var(--h2-size) !important; }
h3 { color: var(--h3) !important; font-size: var(--h3-size) !important; }
h4 { color: var(--h4) !important; font-size: var(--h4-size) !important; }
h5 { color: var(--h5) !important; font-size: var(--h5-size) !important; }
h6 { color: var(--h6) !important; font-size: var(--h6-size) !important; }
a { color: var(--link) !important; }
pre { background-color: var(--code-bg) !important; }
pre code, code {
font-family: var(--code-font) !important;
font-size: var(--code-font-size) !important;
color: var(--code-text) !important;
}
code { background-color: var(--code-bg) !important; }
blockquote {
border-left-color: var(--blockquote-border) !important;
background-color: var(--blockquote-bg) !important;
}
mark {
background-color: var(--highlight-bg) !important;
color: var(--highlight-text) !important;
}
#content {
flex: none !important;
height: auto !important;
overflow: visible !important;
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
display: block !important;
}
#content-inner {
padding: var(--print-padding, 0px) !important;
margin: 0 !important;
}
/* Force collapsed editor sections to print expanded */
#content-inner > * {
display: revert !important;
}
html.print-full-width #content-inner {
max-width: none !important;
}
#toc, #toc-resizer, #raw-resizer, #raw-source, .collapse-arrow, .resizer {
display: none !important;
}
html.print-no-backgrounds, html.print-no-backgrounds body {
background-color: #ffffff !important;
}
html.print-no-backgrounds * {
-webkit-print-color-adjust: economy !important;
print-color-adjust: economy !important;
}
html.print-no-backgrounds pre,
html.print-no-backgrounds code,
html.print-no-backgrounds blockquote,
html.print-no-backgrounds mark,
html.print-no-backgrounds .frontmatter {
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
}
/* Maintain link readability in ink-saving mode */
html.print-no-backgrounds a {
text-decoration: underline !important;
}
/* Ensure rules and borders are visible in ink-saving mode */
html.print-no-backgrounds hr {
border-top: 1px solid #dddddd !important;
}
html.print-no-backgrounds th,
html.print-no-backgrounds td {
border: 1px solid #dddddd !important;
}
html.print-no-backgrounds blockquote {
border-left: 3px solid #cccccc !important;
}
html.print-no-backgrounds h1 {
border-bottom: 1px solid #dddddd !important;
}
html.print-no-backgrounds h2 {
border-bottom: 1px solid #eeeeee !important;
}
/* Add borders to content blocks that have custom background colors when print-borders is active */
html.print-borders pre,
html.print-borders .frontmatter {
border: 1px solid color-mix(in srgb, var(--text) 25%, transparent) !important;
}
html.print-borders blockquote {
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent) !important;
border-left: 4px solid var(--blockquote-border) !important;
}
html.print-borders code {
border: 1px solid color-mix(in srgb, var(--text) 30%, transparent) !important;
}
/* Specific override to strip images when printImages is false */
html.print-no-images #content-inner img,
html.print-no-images #content-inner > img {
display: none !important;
}
/* Print Layout/Rendering Optimizations */
h1, h2, h3, h4, h5, h6 {
break-after: avoid !important;
page-break-after: avoid !important;
}
/* Keep lists and table rows unbroken on page boundaries */
pre, blockquote, tr, li {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
img {
max-width: 100% !important;
break-inside: avoid !important;
page-break-inside: avoid !important;
}
thead {
display: table-header-group !important;
}
th, td {
word-wrap: break-word !important;
white-space: normal !important;
}
}
</style>
<style id="theme-css">
{{THEME_CSS}}
@@ -440,6 +608,7 @@ function updateContent(html) {
var content = _contentScroller();
var scrollRatio = content.scrollTop /
Math.max(1, content.scrollHeight - content.clientHeight);
_tocHoverHeadingId = null;
_contentContainer().innerHTML = html;
var newMax = content.scrollHeight - content.clientHeight;
content.scrollTop = scrollRatio * newMax;
@@ -650,6 +819,8 @@ var _tocVisible = false;
var _tocWidth = 220;
var _tocWrap = false;
var _tocBullets = false;
var _tocHoverHeadingId = null;
var _tocHoverHighlightSetup = false;
var _rawVisible = false;
var _rawWidth = 400;
var _syncContentHeadings = [];
@@ -1008,6 +1179,7 @@ function setupTOCScrollHighlight() {
var timeout;
var content = _contentScroller();
content.addEventListener('scroll', function() {
if (_tocHoverHeadingId) return;
clearTimeout(timeout);
timeout = setTimeout(highlightActiveTOC, 50);
});
@@ -1036,7 +1208,36 @@ function _scrollTOCLinkIntoView(link) {
toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
function _tocLinkForHeadingId(headingId) {
var toc = document.getElementById('toc');
var links = toc.querySelectorAll('a');
for (var i = 0; i < links.length; i++) {
if (links[i].dataset.headingId === headingId) return links[i];
}
return null;
}
function highlightTOCHeading(headingId, scrollIntoView) {
var toc = document.getElementById('toc');
if (!toc.classList.contains('visible')) return;
var links = toc.querySelectorAll('a');
var activeLink = _tocLinkForHeadingId(headingId);
for (var i = 0; i < links.length; i++) {
links[i].classList.remove('active');
}
if (activeLink) {
activeLink.classList.add('active');
if (scrollIntoView) _scrollTOCLinkIntoView(activeLink);
}
}
function highlightActiveTOC() {
if (_tocHoverHeadingId) return;
var toc = document.getElementById('toc');
if (!toc.classList.contains('visible')) return;
@@ -1054,16 +1255,116 @@ function highlightActiveTOC() {
if (targetTop <= scrollTop + 60) {
activeLink = links[i];
}
links[i].classList.remove('active');
}
if (activeLink) {
activeLink.classList.add('active');
_scrollTOCLinkIntoView(activeLink);
highlightTOCHeading(activeLink.dataset.headingId, true);
}
}
function _lastHeadingInElement(element) {
if (element.matches && element.matches('h1, h2, h3, h4, h5, h6')) return element;
var headings = element.querySelectorAll ? element.querySelectorAll('h1, h2, h3, h4, h5, h6') : [];
return headings.length ? headings[headings.length - 1] : null;
}
function _headingForHoveredContent(element) {
var content = _contentContainer();
if (!element || !content.contains(element)) return null;
var heading = element.closest ? element.closest('h1, h2, h3, h4, h5, h6') : null;
if (heading && content.contains(heading)) return heading;
var node = element;
while (node && node !== content) {
var sibling = node.previousElementSibling;
while (sibling) {
var found = _lastHeadingInElement(sibling);
if (found) return found;
sibling = sibling.previousElementSibling;
}
node = node.parentElement;
}
return null;
}
function _headingForHoverPosition(clientY) {
var content = _contentContainer();
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
var activeHeading = null;
for (var i = 0; i < headings.length; i++) {
var heading = headings[i];
if (heading.getBoundingClientRect().top <= clientY) {
activeHeading = heading;
} else {
break;
}
}
return activeHeading;
}
function _headingIdForRawHoverPosition(clientY) {
var rawHeading = null;
for (var i = 0; i < _syncRawHeadings.length; i++) {
var heading = _syncRawHeadings[i];
if (heading.getBoundingClientRect().top <= clientY) {
rawHeading = i;
} else {
break;
}
}
if (rawHeading === null) return null;
return _tocHeadingIdForHover(_syncContentHeadings[rawHeading]);
}
function _tocHeadingIdForHover(heading) {
while (heading) {
if (heading.id && _tocLinkForHeadingId(heading.id)) return heading.id;
heading = _headingForHoveredContent(heading.previousElementSibling || heading.parentElement);
}
return null;
}
function _applyTOCHoverHeadingId(headingId) {
_tocHoverHeadingId = headingId;
if (_tocHoverHeadingId) {
highlightTOCHeading(_tocHoverHeadingId, false);
} else {
highlightActiveTOC();
}
}
function setupTOCHoverHighlight() {
if (_tocHoverHighlightSetup) return;
_tocHoverHighlightSetup = true;
var content = _contentScroller();
var raw = document.getElementById('raw-source');
content.addEventListener('mousemove', function(event) {
if (!_tocVisible) return;
var heading = _headingForHoverPosition(event.clientY);
_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading));
});
content.addEventListener('mouseleave', function() {
_tocHoverHeadingId = null;
highlightActiveTOC();
});
if (raw) {
raw.addEventListener('mousemove', function(event) {
if (!_tocVisible || !_rawVisible) return;
_applyTOCHoverHeadingId(_headingIdForRawHoverPosition(event.clientY));
});
raw.addEventListener('mouseleave', function() {
_tocHoverHeadingId = null;
highlightActiveTOC();
});
}
}
document.addEventListener('DOMContentLoaded', function() {
setupCollapsibleHeadings();
setupTOCHoverHighlight();
if (_tocVisible) {
buildTOC();
setupTOCScrollHighlight();
@@ -1119,9 +1420,26 @@ function _vimSendMessage(action) {
}
}
function _handleCommandArrowScroll(e) {
if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false;
if (e.key === 'ArrowUp') {
_contentScroller().scrollTo({ top: 0, behavior: 'smooth' });
e.preventDefault();
return true;
}
if (e.key === 'ArrowDown') {
var content = _contentScroller();
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' });
e.preventDefault();
return true;
}
return false;
}
window.addEventListener('keydown', function(e) {
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (_handleCommandArrowScroll(e)) return;
if (e.metaKey || e.altKey) return;
var key = e.key;
+251 -19
View File
@@ -23,13 +23,33 @@ struct SettingsView: View {
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
PrintingSettingsView()
.tabItem {
Label("Printing", systemImage: "printer")
}
}
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
}
private struct SettingsScrollView<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView(.vertical) {
content
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(20)
}
}
}
struct GeneralSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
@AppStorage("defaultTocDepth") private var defaultTocDepth = 3
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
@AppStorage("tocWrap") private var tocWrap = false
@AppStorage("tocBullets") private var tocBullets = false
@State private var isDefault = false
@@ -37,10 +57,11 @@ struct GeneralSettingsView: View {
@State private var showCSSHelp = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("General")
.font(.headline)
.padding(.bottom, 16)
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("General")
.font(.headline)
.padding(.bottom, 16)
GroupBox {
HStack {
@@ -109,6 +130,31 @@ struct GeneralSettingsView: View {
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 10) {
Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)
.toggleStyle(.checkbox)
HStack {
Text("Default Table of Contents depth")
Spacer()
Picker("Default Table of Contents depth", selection: $defaultTocDepth) {
ForEach(1...6, id: \.self) { depth in
Text("H\(depth)").tag(depth)
}
}
.labelsHidden()
.frame(width: 90)
}
Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)
.toggleStyle(.checkbox)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
}
.padding(.top, 12)
GroupBox {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 6) {
@@ -161,8 +207,8 @@ struct GeneralSettingsView: View {
.padding(.top, 12)
Spacer()
}
}
.padding(20)
.onAppear {
checkIfDefault()
}
@@ -207,6 +253,7 @@ struct GeneralSettingsView: View {
struct EditorsSettingsView: View {
@EnvironmentObject var editorManager: EditorManager
@State private var selectedEditorID: UUID?
private let editorActionButtonSize: CGFloat = 28
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -267,7 +314,10 @@ struct EditorsSettingsView: View {
addEditor()
} label: {
Image(systemName: "plus")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
Button {
if let id = selectedEditorID {
@@ -276,16 +326,45 @@ struct EditorsSettingsView: View {
}
} label: {
Image(systemName: "minus")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(selectedEditorID == nil)
Divider()
.frame(height: editorActionButtonSize)
.padding(.horizontal, 2)
Button {
moveSelectedEditor(by: -1)
} label: {
Image(systemName: "chevron.up")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Move selected editor up")
.disabled(!canMoveSelectedEditorUp)
Button {
moveSelectedEditor(by: 1)
} label: {
Image(systemName: "chevron.down")
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Move selected editor down")
.disabled(!canMoveSelectedEditorDown)
Spacer()
if let id = selectedEditorID,
let index = editorManager.editors.firstIndex(where: { $0.id == id }) {
Toggle("Opens folder instead of file", isOn: Binding(
get: { editorManager.editors[index].opensFolder },
set: { editorManager.editors[index].opensFolder = $0 }
set: { editorManager.setOpensFolder($0, for: id) }
))
.font(.caption)
}
@@ -295,6 +374,24 @@ struct EditorsSettingsView: View {
.padding(20)
}
private var selectedEditorIndex: Int? {
guard let selectedEditorID else { return nil }
return editorManager.editors.firstIndex { $0.id == selectedEditorID }
}
private var canMoveSelectedEditorUp: Bool {
selectedEditorIndex.map { $0 > 0 } ?? false
}
private var canMoveSelectedEditorDown: Bool {
selectedEditorIndex.map { $0 < editorManager.editors.count - 1 } ?? false
}
private func moveSelectedEditor(by offset: Int) {
guard let selectedEditorID else { return }
editorManager.moveEditor(id: selectedEditorID, by: offset)
}
private func addEditor() {
let panel = NSOpenPanel()
panel.title = "Select Application"
@@ -316,15 +413,16 @@ struct MarkdownSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
GroupBox {
VStack(spacing: 0) {
@@ -368,8 +466,8 @@ struct MarkdownSettingsView: View {
}
Spacer()
}
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
@@ -388,6 +486,144 @@ struct MarkdownSettingsView: View {
}
}
struct PrintingSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@AppStorage("printTheme") private var printTheme = "Clean Printing"
@AppStorage("printBackgrounds") private var printBackgrounds = false
@AppStorage("printImages") private var printImages = true
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
@AppStorage("printBorders") private var printBorders = false
@AppStorage("printPagePadding") private var printPagePadding = "0px"
var body: some View {
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Printing")
.font(.headline)
.padding(.bottom, 16)
GroupBox(label: Text("Print Theme")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Theme")
Spacer()
Picker("Print Theme", selection: $printTheme) {
Text("Active Screen Theme").tag("Active Theme")
Text("Plain HTML (Unstyled)").tag("Plain HTML")
Text("Clean Printing").tag("Clean Printing")
Divider()
ForEach(themeManager.themes, id: \.name) { theme in
Text(theme.name).tag(theme.name)
}
}
.frame(width: 200)
.labelsHidden()
}
Text("Select the theme to use when printing or exporting to PDF.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
GroupBox(label: Text("Page Layout")) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Include Background Colors", isOn: $printBackgrounds)
.toggleStyle(.checkbox)
Text("When enabled, code block backgrounds, blockquotes, and highlights will print with their theme colors. Disable to save ink.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Images", isOn: $printImages)
.toggleStyle(.checkbox)
Text("When enabled, inline and document images will print. Disable to save color ink and space.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Content Borders", isOn: $printBorders)
.toggleStyle(.checkbox)
Text("When enabled, adds borders to styled blocks like code blocks, blockquotes, and tables to guarantee contrast with page background.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Page Padding")
Spacer()
Picker("Page Padding", selection: $printPagePadding) {
Text("None (0px)").tag("0px")
Text("Small (24px)").tag("24px")
Text("Medium (48px)").tag("48px")
Text("Large (72px)").tag("72px")
}
.frame(width: 200)
.labelsHidden()
}
Text("Add margins inside the background color container to keep text away from the page edge.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
GroupBox(label: Text("Zoom & Scaling")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Font Scaling")
Spacer()
Picker("Font Scaling", selection: $printZoom) {
Text("Compact (75%)").tag("Compact (75%)")
Text("Small (85%)").tag("Small (85%)")
Text("Standard (100%)").tag("Standard (100%)")
Text("Large (115%)").tag("Large (115%)")
Divider()
Text("Match Screen").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select the scale for printed document fonts, or match current screen zoom.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Content Width")
Spacer()
Picker("Content Width", selection: $printContentWidth) {
Text("Full Page Width").tag("Full Page")
Text("Match Screen Width").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select whether page layout uses full printable bounds or conforms to screen width guidelines.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
}
}
}
}
struct ThemeEditorItem: Identifiable {
let id = UUID()
let theme: Theme?
@@ -414,10 +650,6 @@ struct ThemeSettingsView: View {
.padding(.bottom, 8)
HStack {
Text("Drag to reorder. Cmd+Shift+↑/↓ cycles through enabled themes.")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
HStack(spacing: 0) {
+38
View File
@@ -172,3 +172,41 @@ struct ThemeSizes: Codable, Hashable {
self.lineHeight = lineHeight
}
}
extension Theme {
static let cleanPrinting = Theme(
name: "Clean Printing",
colors: ThemeColors(
background: "#ffffff",
text: "#24292e",
heading1: "#1b1f23",
heading2: "#1b1f23",
heading3: "#1b1f23",
heading4: "#1b1f23",
heading5: "#1b1f23",
heading6: "#1b1f23",
link: "#0366d6",
codeBackground: "#f6f8fa",
codeText: "#24292e",
blockquoteBorder: "#dfe2e5",
blockquoteBackground: "#fafbfc",
highlightBackground: "#fff5b1",
highlightText: "#24292e"
),
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
sizes: ThemeSizes(
baseFontSize: 11,
h1Size: 22,
h2Size: 18,
h3Size: 15,
h4Size: 13.5,
h5Size: 12,
h6Size: 11,
codeFontSize: 10.5,
lineHeight: 1.5
),
customCSS: "",
isBuiltIn: true
)
}
+4
View File
@@ -352,6 +352,10 @@ class ThemeManager: ObservableObject {
}
func generateCSS(for theme: Theme) -> String {
generateCSS(for: theme, zoomScale: zoomScale)
}
func generateCSS(for theme: Theme, zoomScale: Double) -> String {
let bodyFont = theme.fonts.body == "System"
? "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"
: "'\(theme.fonts.body)', -apple-system, sans-serif"
@@ -0,0 +1,185 @@
import AppKit
import XCTest
@testable import markdownViewr
final class FileMenuPrunerTests: XCTestCase {
func testRemovesOnlyRequestedFileMenuItems() {
let fileMenu = NSMenu(title: "File")
["New", "Open...", "Open Recent", "Close", "Save", "Duplicate", "Rename", "Save As...", "Revert To", "Page Setup...", "Print..."]
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(
fileMenu.items.map(\.title),
["Open...", "Open Recent", "Close", "Duplicate", "Rename", "Save As...", "Page Setup...", "Print..."]
)
}
func testRemovesSaveWithEllipsisWithoutRemovingSaveAs() {
let fileMenu = NSMenu(title: "File")
["Save...", "Save As...", "Duplicate", "Rename"]
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(fileMenu.items.map(\.title), ["Save As...", "Duplicate", "Rename"])
}
func testRemovesNestedRevertToSubmenu() {
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open...", action: nil, keyEquivalent: "")
let revertItem = NSMenuItem(title: "Revert To", action: nil, keyEquivalent: "")
revertItem.submenu = NSMenu(title: "Revert To")
fileMenu.addItem(revertItem)
fileMenu.addItem(withTitle: "Print...", action: nil, keyEquivalent: "")
FileMenuPruner.removeEditingItems(from: fileMenu)
XCTAssertEqual(fileMenu.items.map(\.title), ["Open...", "Print..."])
}
}
final class ExternalEditorMenuStateTests: XCTestCase {
func testDisabledWhenNoEditorsAreAvailable() {
let state = ExternalEditorMenuState(editors: [])
XCTAssertEqual(state, .disabled)
}
func testSingleEditorUsesAppNameInTitle() {
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
let state = ExternalEditorMenuState(editors: [editor])
XCTAssertEqual(state, .single(editor, title: "Open in TextEdit"))
}
func testMultipleEditorsUseSubmenu() {
let textEdit = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
let code = EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
let state = ExternalEditorMenuState(editors: [textEdit, code])
XCTAssertEqual(state, .submenu([textEdit, code]))
}
func testSingleEditorFileMenuItemKeepsEditorIDForAction() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
manager.editors = [editor]
controller.editorManager = manager
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertEqual(item.representedObject as? String, editor.id.uuidString)
XCTAssertTrue(item.target === controller)
}
func testSingleEditorFileMenuItemIsDisabledWithoutOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
controller.editorManager = manager
controller.currentFileURLProvider = { nil }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertFalse(item.isEnabled)
XCTAssertNil(item.action)
XCTAssertNil(item.target)
XCTAssertFalse(controller.validateMenuItem(item))
}
func testExternalEditorSubmenuItemsAreDisabledWithoutOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [
EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app"),
EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
]
controller.editorManager = manager
controller.currentFileURLProvider = { nil }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in External Editor"))
let submenu = try XCTUnwrap(item.submenu)
XCTAssertTrue(item.isEnabled)
XCTAssertTrue(submenu.items.allSatisfy { !$0.isEnabled })
XCTAssertTrue(submenu.items.allSatisfy { $0.action == nil })
XCTAssertTrue(submenu.items.allSatisfy { $0.target == nil })
XCTAssertTrue(submenu.items.allSatisfy { !controller.validateMenuItem($0) })
}
func testExternalEditorMenuItemValidationAllowsOpenFile() throws {
let controller = ExternalEditorFileMenuController()
let manager = EditorManager()
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
controller.editorManager = manager
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
let fileMenu = NSMenu(title: "File")
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
controller.update(in: fileMenu)
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
XCTAssertEqual(item.action, NSSelectorFromString("openInExternalEditor:"))
XCTAssertTrue(item.target === controller)
XCTAssertTrue(controller.validateMenuItem(item))
}
func testCurrentOpenDocumentFileURLIgnoresDocumentsWithoutVisibleWindows() {
let document = NSDocument()
document.fileURL = URL(fileURLWithPath: "/tmp/closed.md")
document.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
XCTAssertNil(ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [document]))
}
func testCurrentOpenDocumentFileURLUsesVisibleDocumentWindow() {
let hiddenDocument = NSDocument()
hiddenDocument.fileURL = URL(fileURLWithPath: "/tmp/hidden.md")
hiddenDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
let visibleDocument = NSDocument()
let visibleURL = URL(fileURLWithPath: "/tmp/visible.md")
visibleDocument.fileURL = visibleURL
visibleDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: true)))
XCTAssertEqual(
ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [hiddenDocument, visibleDocument]),
visibleURL
)
}
}
private final class TestWindow: NSWindow {
private let isVisibleForTest: Bool
init(isVisibleForTest: Bool) {
self.isVisibleForTest = isVisibleForTest
super.init(
contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
styleMask: [],
backing: .buffered,
defer: false
)
}
override var isVisible: Bool {
isVisibleForTest
}
}
+485
View File
@@ -0,0 +1,485 @@
import XCTest
import WebKit
@testable import markdownViewr
final class PrintingTests: XCTestCase {
// 1. Unit Test for firstSubview(ofType:)
func testNSViewFirstSubviewOfTypeRecurse() {
let parent = NSView()
let mid = NSView()
let target = WKWebView()
mid.addSubview(target)
parent.addSubview(mid)
// Assert we can find the WKWebView recursively
let foundWebView = parent.firstSubview(ofType: WKWebView.self)
XCTAssertNotNil(foundWebView)
XCTAssertEqual(foundWebView, target)
// Assert we find nil for a type that doesn't exist
class CustomTestView: NSView {}
XCTAssertNil(parent.firstSubview(ofType: CustomTestView.self))
}
// 2. Static Assertions on source files
func testAppConfiguresReplacementPrintCommand() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
// Verify we replaced the default print command group
XCTAssertTrue(appSource.contains("CommandGroup(replacing: .printItem)"))
XCTAssertTrue(appSource.contains("documentViewCommands?.printDocument()"))
}
func testContentViewBindsPrintDocumentActionAndCoordinator() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
// Verify printDocument is in DocumentViewCommands struct
XCTAssertTrue(contentSource.contains("let printDocument: () -> Void"))
// Verify printDocument is instantiated with printActiveDocument
XCTAssertTrue(contentSource.contains("printDocument: { printActiveDocument() }"))
// Verify printActiveDocument exists
XCTAssertTrue(contentSource.contains("func printActiveDocument()"))
// Verify recursive traversal is used to find WKWebView
XCTAssertTrue(contentSource.contains("window.contentView?.firstSubview(ofType: WKWebView.self)"))
// Verify print settings storage variables exist
XCTAssertTrue(contentSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify print coordinator instantiation passing all parameters
XCTAssertTrue(contentSource.contains("let coordinator = PrintCoordinator("))
XCTAssertTrue(contentSource.contains("stripBackgrounds: !printBackgroundsVal"))
XCTAssertTrue(contentSource.contains("stripImages: !printImagesVal"))
XCTAssertTrue(contentSource.contains("stripWidth: printContentWidthVal == \"Full Page\""))
// Verify we run the print operation modally with coordinator pointer
XCTAssertTrue(contentSource.contains("printOp.runModal("))
XCTAssertTrue(contentSource.contains("didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:))"))
XCTAssertTrue(contentSource.contains("contextInfo: coordinatorPointer"))
// Verify PrintCoordinator class exists, conforms to @MainActor and supports stripWidth
XCTAssertTrue(contentSource.contains("@MainActor\nclass PrintCoordinator"))
XCTAssertTrue(contentSource.contains("let stripImages: Bool"))
XCTAssertTrue(contentSource.contains("let stripWidth: Bool"))
XCTAssertTrue(contentSource.contains("func restore()"))
}
func testPrintOperationUsesCopiedPrintInfo() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("NSPrintInfo.shared.copy() as? NSPrintInfo"))
XCTAssertFalse(contentSource.contains("let printInfo = NSPrintInfo.shared\n"))
}
func testPrintToolbarItemIsAvailableForCustomization() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("printDocument: @escaping () -> Void"))
XCTAssertTrue(contentSource.contains("self.printDocument = printDocument"))
XCTAssertTrue(contentSource.contains(".printDocument, .space"))
XCTAssertFalse(contentSource.contains("return [.toc, .tocDepth, .printDocument"))
XCTAssertTrue(contentSource.contains("case .printDocument:\n return makePrintItem()"))
XCTAssertTrue(contentSource.contains(#"item.label = "Print""#))
XCTAssertTrue(contentSource.contains(#"item.paletteLabel = "Print""#))
XCTAssertTrue(contentSource.contains(#"NSImage(systemSymbolName: "printer""#))
XCTAssertTrue(contentSource.contains("item.action = #selector(printDocumentFromToolbar)"))
XCTAssertTrue(contentSource.contains("@objc private func printDocumentFromToolbar()"))
XCTAssertTrue(contentSource.contains("printDocument?()"))
XCTAssertTrue(contentSource.contains(#"static let printDocument = NSToolbarItem.Identifier("print-document")"#))
}
func testXcodeGenConfigurationIncludesTestSources() throws {
let projectSource = try loadSourceFile("project.yml")
XCTAssertTrue(projectSource.contains("markdownViewrTests:"))
XCTAssertTrue(projectSource.contains("- markdownViewrTests"))
}
func testSettingsViewIncludesPrintingTab() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
// Verify PrintingSettingsView is declared in TabView
XCTAssertTrue(settingsSource.contains("PrintingSettingsView()"))
XCTAssertTrue(settingsSource.contains(#"Label("Printing", systemImage: "printer")"#))
// Verify PrintingSettingsView struct layout
XCTAssertTrue(settingsSource.contains("struct PrintingSettingsView: View"))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify spacing and alignment matching HIG guidelines
XCTAssertTrue(settingsSource.contains("VStack(alignment: .leading, spacing: 0)"))
XCTAssertTrue(settingsSource.contains(".padding(.bottom, 16)"))
XCTAssertTrue(settingsSource.contains(".padding(.top, 12)"))
// Verify Settings Layout Components (splits and columns)
XCTAssertTrue(settingsSource.contains("Picker("))
XCTAssertTrue(settingsSource.contains(#"Text("Plain HTML (Unstyled)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Clean Printing")"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Print Theme"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Page Layout"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Zoom & Scaling"))"#))
XCTAssertTrue(settingsSource.contains("Toggle("))
}
// 3. Template @media print assertion
func testTemplateIncludesPrintMediaQueries() throws {
let template = try loadTemplate()
// Verify print media query exists
XCTAssertTrue(template.contains("@media print"))
let printBlock = try printMediaQueryBlock(in: template)
// Verify print color adjust properties are present to preserve theme colors
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
// Verify print background toggling override style exists without wiping theme text colors
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds"))
XCTAssertTrue(printBlock.contains("print-color-adjust: economy !important"))
XCTAssertFalse(printBlock.contains("color: #000000 !important"))
// Verify ink-saving layout overrides for links, rules, headers, and blockquotes
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
// Verify print image toggling override style targets specific content images
XCTAssertTrue(printBlock.contains("html.print-no-images #content-inner img"))
// Verify collapsed editor expansion rules
XCTAssertTrue(printBlock.contains("#content-inner > *"))
XCTAssertTrue(printBlock.contains("display: revert !important"))
// Verify print full width toggling override style exists
XCTAssertTrue(printBlock.contains("html.print-full-width #content-inner"))
XCTAssertTrue(printBlock.contains("max-width: none !important"))
// Verify layout overrides
XCTAssertTrue(printBlock.contains("html, body"))
XCTAssertTrue(printBlock.contains("height: auto !important"))
XCTAssertTrue(printBlock.contains("overflow: visible !important"))
XCTAssertTrue(printBlock.contains("#content"))
// Verify elements hidden
XCTAssertTrue(printBlock.contains("#toc"))
XCTAssertTrue(printBlock.contains("#raw-source"))
XCTAssertTrue(printBlock.contains(".collapse-arrow"))
XCTAssertTrue(printBlock.contains("display: none !important"))
}
func testTemplatePrintStylesForEdgeCases() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// 1. Heading Orphaning: Ensure headings (h1-h6) have CSS break-after avoid rules
XCTAssertTrue(printBlock.contains("h1, h2, h3, h4, h5, h6 {"))
XCTAssertTrue(printBlock.contains("break-after: avoid"))
XCTAssertTrue(printBlock.contains("page-break-after: avoid"))
// 2. Element Page Splits: Ensure small code blocks (pre/code), blockquotes, table rows, and list items have break-inside/page-break-inside avoid rules
XCTAssertTrue(printBlock.contains("pre, blockquote, tr, li {"))
XCTAssertTrue(printBlock.contains("break-inside: avoid"))
XCTAssertTrue(printBlock.contains("page-break-inside: avoid"))
// 3. Image Overflow: Ensure images do not exceed page boundaries and break-inside: avoid
XCTAssertTrue(printBlock.contains("img {"))
XCTAssertTrue(printBlock.contains("max-width: 100%"))
// 4. Table Formatting: Ensure table headers repeat on page boundaries and headers/cells wrap text nicely
XCTAssertTrue(printBlock.contains("thead {"))
XCTAssertTrue(printBlock.contains("display: table-header-group"))
XCTAssertTrue(printBlock.contains("th, td {"))
XCTAssertTrue(printBlock.contains("word-wrap: break-word"))
XCTAssertTrue(printBlock.contains("white-space: normal"))
}
func testPrintMediaQueriesPreserveThemeColorsInInkSavingMode() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// Ensure print-no-backgrounds class does not apply universal color: #000000 !important
// so that selected theme CSS variables (--h1, --h2, --link, --code-text, --text) apply during print.
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds * {\n -webkit-print-color-adjust: economy !important;\n print-color-adjust: economy !important;\n background: transparent !important;\n background-color: transparent !important;\n background-image: none !important;\n /* Avoid light text rendering on white backgrounds */\n color: #000000 !important;\n }"))
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds a {\n color: #0969da !important;"))
}
func testPrintMediaQueriesEnforceThemeFontsSizesAndColors() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// Verify fonts are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--body-font) !important"))
XCTAssertTrue(printBlock.contains("var(--base-font-size) !important"))
XCTAssertTrue(printBlock.contains("var(--heading-font) !important"))
XCTAssertTrue(printBlock.contains("var(--code-font) !important"))
XCTAssertTrue(printBlock.contains("var(--code-font-size) !important"))
// Verify heading sizes are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--h1-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h2-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h3-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h4-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h5-size) !important"))
XCTAssertTrue(printBlock.contains("var(--h6-size) !important"))
// Verify colors are explicitly bound in @media print
XCTAssertTrue(printBlock.contains("var(--h1) !important"))
XCTAssertTrue(printBlock.contains("var(--h2) !important"))
XCTAssertTrue(printBlock.contains("var(--h3) !important"))
XCTAssertTrue(printBlock.contains("var(--h4) !important"))
XCTAssertTrue(printBlock.contains("var(--h5) !important"))
XCTAssertTrue(printBlock.contains("var(--h6) !important"))
XCTAssertTrue(printBlock.contains("var(--link) !important"))
XCTAssertTrue(printBlock.contains("var(--code-text) !important"))
XCTAssertTrue(printBlock.contains("var(--code-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--blockquote-border) !important"))
XCTAssertTrue(printBlock.contains("var(--blockquote-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--highlight-bg) !important"))
XCTAssertTrue(printBlock.contains("var(--highlight-text) !important"))
}
func testPrintPrepareScriptForcesLayoutPass() {
let script = PrintScriptGenerator.prepareScript(
escapedCSS: "body { color: red; }",
stripBackgrounds: false,
stripImages: false,
stripWidth: false,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(script.contains("document.body.offsetHeight;"))
}
@MainActor
func testEveryThemeOptionIsRespectedAndGeneratesValidPrintCSS() {
let manager = ThemeManager()
let themesToTest = manager.allThemes
XCTAssertFalse(themesToTest.isEmpty, "ThemeManager should load built-in themes")
for theme in themesToTest {
let css = manager.generateCSS(for: theme, zoomScale: 1.0)
XCTAssertFalse(css.isEmpty, "Theme '\(theme.name)' should generate non-empty CSS")
XCTAssertTrue(css.contains("--bg: \(theme.colors.background)"), "Theme '\(theme.name)' should include background color")
XCTAssertTrue(css.contains("--text: \(theme.colors.text)"), "Theme '\(theme.name)' should include text color")
XCTAssertTrue(css.contains("--h1: \(theme.colors.heading1)"), "Theme '\(theme.name)' should include h1 color")
XCTAssertTrue(css.contains("--link: \(theme.colors.link)"), "Theme '\(theme.name)' should include link color")
XCTAssertTrue(css.contains("--code-bg: \(theme.colors.codeBackground)"), "Theme '\(theme.name)' should include code bg color")
XCTAssertTrue(css.contains("--code-text: \(theme.colors.codeText)"), "Theme '\(theme.name)' should include code text color")
let prepareScript = PrintScriptGenerator.prepareScript(
escapedCSS: css.replacingOccurrences(of: "\n", with: "\\n"),
stripBackgrounds: false,
stripImages: false,
stripWidth: true,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent ="), "Script for '\(theme.name)' should target theme-css element")
XCTAssertTrue(prepareScript.contains(theme.colors.background), "Prepared script for '\(theme.name)' should inject background color")
}
// Verify Clean Printing option
let cleanCSS = manager.generateCSS(for: Theme.cleanPrinting, zoomScale: 1.0)
XCTAssertTrue(cleanCSS.contains("--bg: #ffffff"), "Clean printing should use white background")
XCTAssertTrue(cleanCSS.contains("--text: #24292e"), "Clean printing should use dark text")
// Verify Active Theme option
let activeCSS = manager.generateCSS(for: manager.activeTheme, zoomScale: 1.0)
XCTAssertFalse(activeCSS.isEmpty, "Active theme CSS should generate non-empty CSS")
// Verify Plain HTML option
let plainScript = PrintScriptGenerator.prepareScript(
escapedCSS: "",
stripBackgrounds: true,
stripImages: false,
stripWidth: true,
printBorders: false,
pagePadding: "0px"
)
XCTAssertTrue(plainScript.contains("document.getElementById('theme-css').textContent = ''"))
}
func testPrintZoomResolver() {
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Compact (75%)", screenZoom: 1.5), 0.75)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Small (85%)", screenZoom: 1.5), 0.85)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Standard (100%)", screenZoom: 1.5), 1.0)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Large (115%)", screenZoom: 1.5), 1.15)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Match Screen", screenZoom: 1.5), 1.5)
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Unknown", screenZoom: 1.5), 1.0)
}
func testCleanPrintingThemeUsesPrintProportionalFontSizes() {
let sizes = Theme.cleanPrinting.sizes
XCTAssertLessThanOrEqual(sizes.baseFontSize, 12.0)
XCTAssertLessThanOrEqual(sizes.h1Size, 22.0)
XCTAssertLessThanOrEqual(sizes.h2Size, 18.0)
XCTAssertLessThanOrEqual(sizes.codeFontSize, 11.0)
}
func testSettingsViewIncludesExpandedFontScalingOptions() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
XCTAssertTrue(settingsSource.contains(#"Text("Compact (75%)").tag("Compact (75%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Small (85%)").tag("Small (85%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Standard (100%)").tag("Standard (100%)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Large (115%)").tag("Large (115%)")"#))
}
// 4. Functional test for PrintCoordinator script building and memory cycles
@MainActor
func testPrintCoordinatorMemoryManagementAndRestore() {
class MockWebView: WKWebView {
var lastEvaluatedJavaScript: String?
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
self.lastEvaluatedJavaScript = javaScriptString
completionHandler?(nil, nil)
}
}
let webView = MockWebView()
var coordinator: PrintCoordinator? = PrintCoordinator(
webView: webView,
restoreCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
weak var weakCoordinator = coordinator
XCTAssertNotNil(weakCoordinator)
// Pass retained pointer simulating print runModal contextInfo
let pointer = Unmanaged.passRetained(coordinator!).toOpaque()
// Release our strong local reference (ref count is now held only by the unmanaged pointer)
coordinator = nil
XCTAssertNotNil(weakCoordinator)
// Simulate AppKit delegate execution finishing
let dummyOperation = NSPrintOperation()
weakCoordinator?.printOperationDidRun(dummyOperation, success: true, contextInfo: pointer)
// Assert the unmanaged pointer is released and coordinator is deallocated
XCTAssertNil(weakCoordinator)
// Assert Javascript script execution occurred and escaped CSS correctly
XCTAssertNotNil(webView.lastEvaluatedJavaScript)
let js = webView.lastEvaluatedJavaScript!
XCTAssertTrue(js.contains("document.getElementById('theme-css').textContent = 'body { background: \\'white\\'; }';"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(js.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
func testCleanPrintingThemeIsBuiltInAndIndependent() {
let theme = Theme.cleanPrinting
XCTAssertEqual(theme.name, "Clean Printing")
XCTAssertTrue(theme.isBuiltIn)
XCTAssertEqual(theme.colors.background, "#ffffff")
XCTAssertEqual(theme.colors.text, "#24292e")
}
// 5. Test that template print query stylesheet contains the print-borders rules
func testTemplatePrintStylesForBorders() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
XCTAssertTrue(printBlock.contains("html.print-borders pre"))
XCTAssertTrue(printBlock.contains("html.print-borders .frontmatter"))
XCTAssertTrue(printBlock.contains("html.print-borders blockquote"))
XCTAssertTrue(printBlock.contains("html.print-borders code"))
}
// 6. Test that PrintScriptGenerator outputs correct prepare and restore scripts
func testPrintScriptGeneratorScriptOutputs() {
let prepareScript = PrintScriptGenerator.prepareScript(
escapedCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'white\'; }';"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-backgrounds');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-images');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-full-width');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-borders');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.style.setProperty('--print-padding', '48px');"))
let restoreScript = PrintScriptGenerator.restoreScript(
escapedCSS: "body { background: 'dark'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(restoreScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'dark\'; }';"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
// Helper function to read source files
private func loadSourceFile(_ relativePath: String) throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceURL = repoRoot.appendingPathComponent(relativePath)
return try String(contentsOf: sourceURL, encoding: .utf8)
}
// Helper function to read HTML template
private func loadTemplate() throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let templateURL = repoRoot.appendingPathComponent("markdownViewr/Resources/template.html")
return try String(contentsOf: templateURL, encoding: .utf8)
}
// Helper function to extract @media print block
private func printMediaQueryBlock(in template: String) throws -> String {
let start = try XCTUnwrap(template.range(of: "@media print {"))
let remainder = template[start.upperBound...]
var bracketCount = 1
var index = remainder.startIndex
while bracketCount > 0 && index < remainder.endIndex {
let char = remainder[index]
if char == "{" {
bracketCount += 1
} else if char == "}" {
bracketCount -= 1
}
if bracketCount > 0 {
index = remainder.index(after: index)
}
}
return String(remainder[..<index])
}
}
@@ -1,4 +1,5 @@
import XCTest
@testable import markdownViewr
final class TemplateScrollbarTests: XCTestCase {
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
@@ -50,6 +51,48 @@ final class TemplateScrollbarTests: XCTestCase {
XCTAssertFalse(template.contains("activeLink.scrollIntoView"))
}
func testCommandArrowKeysScrollDocumentToTopAndBottom() throws {
let template = try loadTemplate()
XCTAssertTrue(template.contains("function _handleCommandArrowScroll(e)"))
XCTAssertTrue(template.contains("if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false"))
XCTAssertTrue(template.contains("if (e.key === 'ArrowUp')"))
XCTAssertTrue(template.contains("_contentScroller().scrollTo({ top: 0, behavior: 'smooth' })"))
XCTAssertTrue(template.contains("if (e.key === 'ArrowDown')"))
XCTAssertTrue(template.contains("content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })"))
XCTAssertTrue(template.contains("if (_handleCommandArrowScroll(e)) return"))
}
func testTOCHighlightFollowsHoveredRenderedSection() throws {
let template = try loadTemplate()
let updateStart = try XCTUnwrap(template.range(of: "function updateContent(html)"))
let updateRemainder = template[updateStart.lowerBound...]
let updateEnd = try XCTUnwrap(updateRemainder.range(of: "var _findPairs"))
let updateContentFunction = String(updateRemainder[..<updateEnd.lowerBound])
let activeTOCRule = try cssRule("#toc a.active", in: template)
XCTAssertTrue(template.contains("var _tocHoverHeadingId = null"))
XCTAssertTrue(template.contains("function setupTOCHoverHighlight()"))
XCTAssertTrue(template.contains("content.addEventListener('mousemove'"))
XCTAssertTrue(template.contains("content.addEventListener('mouseleave'"))
XCTAssertTrue(template.contains("raw.addEventListener('mousemove'"))
XCTAssertTrue(template.contains("raw.addEventListener('mouseleave'"))
XCTAssertTrue(template.contains("function _headingForHoverPosition(clientY)"))
XCTAssertTrue(template.contains("_headingForHoverPosition(event.clientY)"))
XCTAssertTrue(template.contains("function _headingIdForRawHoverPosition(clientY)"))
XCTAssertTrue(template.contains("_headingIdForRawHoverPosition(event.clientY)"))
XCTAssertTrue(template.contains("heading.getBoundingClientRect().top <= clientY"))
XCTAssertTrue(template.contains("function _tocHeadingIdForHover(heading)"))
XCTAssertTrue(template.contains("_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading))"))
XCTAssertTrue(template.contains("function _tocLinkForHeadingId(headingId)"))
XCTAssertTrue(template.contains("links[i].dataset.headingId === headingId"))
XCTAssertTrue(template.contains("if (_tocHoverHeadingId) return"))
XCTAssertTrue(template.contains("highlightTOCHeading(_tocHoverHeadingId, false)"))
XCTAssertTrue(template.contains("highlightActiveTOC()"))
XCTAssertTrue(updateContentFunction.contains("_tocHoverHeadingId = null"))
XCTAssertFalse(activeTOCRule.contains("font-weight"))
}
func testCSSHelpDocumentsScrollbarSelectors() throws {
let cssHelp = try loadSourceFile("markdownViewr/CSSHelpView.swift")
let appHelp = try loadSourceFile("markdownViewr/HelpView.swift")
@@ -70,6 +113,370 @@ final class TemplateScrollbarTests: XCTestCase {
XCTAssertTrue(appHelp.contains("scrollbar thumbs"))
}
func testFileMenuKeepsDefaultCommandGroupsForNonEditingItems() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .newItem)"))
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .saveItem)"))
XCTAssertTrue(appSource.contains("FileMenuPrunerApplicationDelegate"))
let prunerSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(prunerSource.contains("NSMenu.didBeginTrackingNotification"))
XCTAssertFalse(prunerSource.contains("NSMenuDelegate"))
XCTAssertFalse(prunerSource.contains(".delegate = self"))
XCTAssertFalse(prunerSource.contains("func menuNeedsUpdate"))
XCTAssertFalse(prunerSource.contains("func applicationWillUpdate"))
XCTAssertFalse(prunerSource.contains("func menuWillOpen"))
}
func testExternalEditorCommandIsSeparatedBelowOpenRecent() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(appSource.contains("ExternalEditorFileMenuController.shared.editorManager = editorManager"))
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.shared.update(in: fileMenu)"))
XCTAssertTrue(menuSource.contains(#"normalizedTitle == "Open Recent""#))
XCTAssertTrue(menuSource.contains("NSMenuItem.separator()"))
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.currentOpenDocumentFileURL()"))
XCTAssertTrue(menuSource.contains("window?.isVisible == true"))
XCTAssertFalse(appSource.contains(".disabled(fileURL == nil)"))
}
func testExternalEditorAddRemoveButtonsUseMatchingSize() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
XCTAssertTrue(settingsSource.contains("private let editorActionButtonSize"))
XCTAssertTrue(settingsSource.contains(".frame(width: editorActionButtonSize, height: editorActionButtonSize)"))
}
func testExternalEditorSettingsCanReorderConfiguredEditors() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let editorSource = try loadSourceFile("markdownViewr/EditorConfig.swift")
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.up")"#))
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.down")"#))
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: -1)"))
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: 1)"))
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorUp"))
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorDown"))
XCTAssertTrue(settingsSource.contains("editorManager.moveEditor(id: selectedEditorID, by: offset)"))
XCTAssertTrue(editorSource.contains("func moveEditor(id: UUID, by offset: Int)"))
XCTAssertTrue(editorSource.contains("reorderedEditors.swapAt(source, destination)"))
XCTAssertTrue(editorSource.contains("editors = reorderedEditors"))
}
func testViewMenuIncludesTogglesForTableOfContentsAndMarkdownSource() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains(#"@AppStorage("tocVisible")"#))
XCTAssertFalse(appSource.contains(#"@AppStorage("rawVisible")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocVisible")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawVisible")"#))
XCTAssertTrue(contentSource.contains("@State private var tocVisible = false"))
XCTAssertTrue(contentSource.contains("@State private var rawVisible = false"))
XCTAssertTrue(contentSource.contains(".focusedSceneValue(\\.documentViewCommands, documentViewCommands)"))
XCTAssertFalse(contentSource.contains(".focusedValue(\\.documentViewCommands, documentViewCommands)"))
XCTAssertTrue(appSource.contains("@FocusedValue(\\.documentViewCommands) private var documentViewCommands"))
XCTAssertTrue(appSource.contains(#"Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))"#))
XCTAssertTrue(appSource.contains(#"Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))"#))
}
func testViewMenuIncludesTableOfContentsDepthSubmenu() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains(#"@AppStorage("tocDepth")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocDepth")"#))
XCTAssertTrue(contentSource.contains("@State private var tocDepth = 3"))
XCTAssertTrue(appSource.contains(#"Menu("Table of Contents Depth")"#))
XCTAssertTrue(appSource.contains(#"ForEach(1...6, id: \.self)"#))
XCTAssertTrue(appSource.contains("documentViewCommands?.setTocDepth(depth)"))
XCTAssertTrue(appSource.contains(#"Label("H\(depth)", systemImage: "checkmark")"#))
XCTAssertFalse(appSource.contains(#"Picker("Table of Contents Depth", selection: $tocDepth)"#))
}
func testDocumentViewStateAndZoomAreWindowLocal() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
let themeSource = try loadSourceFile("markdownViewr/ThemeManager.swift")
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocWidth")"#))
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawWidth")"#))
XCTAssertTrue(contentSource.contains("@State private var tocWidth: Double = 220"))
XCTAssertTrue(contentSource.contains("@State private var rawWidth: Double = 400"))
XCTAssertTrue(contentSource.contains("@State private var zoomScale = 1.0"))
XCTAssertTrue(contentSource.contains("@State private var activeThemeName: String?"))
XCTAssertTrue(contentSource.contains("private var activeTheme: Theme"))
XCTAssertTrue(contentSource.contains("themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)"))
XCTAssertTrue(contentSource.contains("activeThemeName = themeManager.activeThemeName"))
XCTAssertTrue(contentSource.contains("setZoomScale: { zoomScale = $0 }"))
XCTAssertTrue(contentSource.contains("setActiveThemeName: { activeThemeName = $0 }"))
XCTAssertTrue(contentSource.contains("zoomScale: Binding("))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomIn()"))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomOut()"))
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomReset()"))
XCTAssertFalse(appSource.contains("themeManager.zoomIn()"))
XCTAssertFalse(appSource.contains("themeManager.zoomOut()"))
XCTAssertFalse(appSource.contains("themeManager.zoomReset()"))
XCTAssertFalse(appSource.contains("themeManager.cycleTheme(direction:"))
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = title"))
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = sender.title"))
XCTAssertTrue(themeSource.contains("func generateCSS(for theme: Theme, zoomScale: Double) -> String"))
}
func testSettingsIncludeNewWindowViewDefaults() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
let webViewSource = try loadSourceFile("markdownViewr/MarkdownWebView.swift")
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)"#))
XCTAssertTrue(settingsSource.contains(#"Picker("Default Table of Contents depth", selection: $defaultTocDepth)"#))
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
XCTAssertTrue(contentSource.contains("initializeWindowViewStateIfNeeded(windowWidth: Double(window.contentView?.bounds.width ?? 0))"))
XCTAssertFalse(contentSource.contains(".onAppear {\n initializeWindowViewStateIfNeeded()"))
XCTAssertFalse(contentSource.contains("guard windowWidth > 0 else { return }"))
XCTAssertTrue(contentSource.contains("tocVisible = defaultTocVisible"))
XCTAssertTrue(contentSource.contains("tocDepth = defaultTocDepth"))
XCTAssertTrue(contentSource.contains("rawVisible = defaultRawVisible"))
XCTAssertTrue(contentSource.contains("sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)"))
XCTAssertTrue(contentSource.contains("guard rawVisible && !hasActivatedRawSource && windowWidth > 0 else { return }"))
XCTAssertTrue(contentSource.contains("windowWidth: windowWidth"))
XCTAssertTrue(webViewSource.contains("var isPageLoaded = false"))
XCTAssertTrue(webViewSource.contains("context.coordinator.isPageLoaded = false"))
XCTAssertTrue(webViewSource.contains("if !context.coordinator.isPageLoaded {"))
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingTocVisible = tocVisible"))
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingRawVisible = rawVisible"))
XCTAssertTrue(webViewSource.contains("isPageLoaded = true"))
}
func testSettingsTabsScrollWhenWindowIsSmall() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(settingsSource.contains("private struct SettingsScrollView<Content: View>: View"))
XCTAssertTrue(settingsSource.contains("ScrollView(.vertical)"))
XCTAssertTrue(settingsSource.contains("content\n .frame(maxWidth: .infinity, alignment: .topLeading)\n .padding(20)"))
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"General\")"))
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"Markdown Extensions\")"))
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n .onAppear {\n checkIfDefault()"))
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n }\n\n private func extensionRow"))
XCTAssertTrue(settingsSource.contains("}\n .padding(20)\n }\n\n private var selectedEditorIndex"))
XCTAssertFalse(settingsSource.contains("struct EditorsSettingsView: View {\n var body: some View {\n SettingsScrollView"))
XCTAssertFalse(settingsSource.contains("struct ThemeSettingsView: View {\n var body: some View {\n SettingsScrollView"))
XCTAssertTrue(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)"))
XCTAssertTrue(appSource.contains("contentRect: NSRect(x: 0, y: 0, width: 600, height: 720)"))
XCTAssertFalse(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)"))
}
func testDocumentWindowsUseLandscapeDefaultSize() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(appSource.contains(".defaultSize(width: 1100, height: 800)"))
XCTAssertTrue(appSource.contains("static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize"))
XCTAssertTrue(appSource.contains("let autosaveName = Self.autosaveName(for: fileURL)"))
XCTAssertTrue(appSource.contains("let restoredFrame = window.setFrameUsingName(autosaveName)"))
XCTAssertTrue(appSource.contains("window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))"))
XCTAssertTrue(appSource.contains("window.center()"))
XCTAssertTrue(appSource.contains("window.setFrameAutosaveName(autosaveName)"))
XCTAssertTrue(appSource.contains("context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
XCTAssertTrue(appSource.contains("func updateNSView(_ nsView: NSView, context: Context)"))
XCTAssertTrue(appSource.contains("window.saveFrame(usingName: autosaveName)"))
XCTAssertTrue(appSource.contains("NSWindow.willCloseNotification"))
XCTAssertTrue(appSource.contains("NSWindow.didResizeNotification"))
XCTAssertTrue(appSource.contains("NSWindow.didMoveNotification"))
XCTAssertFalse(appSource.contains(".defaultSize(width: 700, height: 900)"))
XCTAssertFalse(appSource.contains("if !window.setFrameAutosaveName(autosaveName)"))
XCTAssertFalse(appSource.contains("fileURL.path.hashValue"))
}
func testDocumentWindowAutosaveNameIsStableAcrossLaunches() {
let fileURL = URL(fileURLWithPath: "/tmp/My File.md")
XCTAssertEqual(
DocumentWindowConfigurator.autosaveName(for: fileURL),
"doc-%2Ftmp%2FMy%20File.md"
)
}
func testDocumentWindowDefaultWidthAccountsForDefaultSidePanes() {
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: false), NSSize(width: 1100, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: false), NSSize(width: 1250, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: true), NSSize(width: 1350, height: 800))
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: true), NSSize(width: 1500, height: 800))
}
func testDocumentWindowFrameConfigurationSkipsTabbedWindows() {
XCTAssertTrue(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false))
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false, hasTabbedHistory: true))
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: true))
}
func testDocumentWindowFrameAutosaveSkipsTabbedWindows() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
XCTAssertTrue(appSource.contains("let isTabbedWindow = Self.isTabbedWindow(window)"))
XCTAssertTrue(appSource.contains("window.isRestorable = false"))
XCTAssertTrue(appSource.contains("if isTabbedWindow {\n Self.markTabGroupHasTabbedHistory(window)\n context.coordinator.stopSavingFrame(for: window)\n return\n }"))
XCTAssertTrue(appSource.contains("Self.markTabGroupHasTabbedHistory(window)"))
XCTAssertTrue(appSource.contains("tabGroupWindows.forEach {\n windowsWithTabbedHistory.add($0)\n $0.isRestorable = false\n $0.setFrameAutosaveName(\"\")\n }"))
XCTAssertTrue(appSource.contains("let hasTabbedHistory = Self.windowHasTabbedHistory(window)"))
XCTAssertTrue(appSource.contains("if !hasTabbedHistory {\n window.setFrameAutosaveName(autosaveName)\n }\n context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
XCTAssertTrue(appSource.contains("private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()"))
XCTAssertTrue(appSource.contains("func stopSavingFrame(for window: NSWindow)"))
XCTAssertFalse(appSource.contains("func consumeRecentlyTabbedWindow(_ window: NSWindow) -> Bool"))
}
func testInitialSourceWidthUsesHalfAvailableWindowWidth() {
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: false, tocWidth: 300),
600
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: true, tocWidth: 300),
450
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 380, tocVisible: true, tocWidth: 260),
220
)
XCTAssertEqual(
DocumentViewLayout.initialRawWidth(windowWidth: 1500, tocVisible: true, tocWidth: 220),
640
)
}
func testSourceWidthOnlyAutoSizesOnFirstActivation() {
XCTAssertEqual(
DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: 360,
hasActivatedRawSource: false,
windowWidth: 1200,
tocVisible: true,
tocWidth: 300
),
450
)
XCTAssertEqual(
DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: 360,
hasActivatedRawSource: true,
windowWidth: 1200,
tocVisible: true,
tocWidth: 300
),
360
)
}
func testViewAndExternalEditorCommandsHaveKeyboardShortcuts() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
XCTAssertTrue(appSource.contains(#".keyboardShortcut("t", modifiers: .command)"#))
XCTAssertTrue(appSource.contains(#".keyboardShortcut("u", modifiers: .command)"#))
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: .command)"#))
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: [.command, .shift])"#))
XCTAssertTrue(menuSource.contains(#"keyEquivalent: "e""#))
XCTAssertTrue(menuSource.contains("makeEditorMenuItem(title: title, editor: editor, keyEquivalent: \"e\", isEnabled: hasOpenFile)"))
XCTAssertTrue(menuSource.contains("keyEquivalent: index == 0 ? \"e\" : \"\""))
XCTAssertTrue(menuSource.contains("let hasOpenFile = currentFileURLProvider() != nil"))
XCTAssertTrue(menuSource.contains("item.keyEquivalentModifierMask = [.command]"))
}
func testToolbarControlsExposeStableTextLabels() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("DocumentToolbarController"))
XCTAssertTrue(contentSource.contains("static let toc = NSToolbarItem.Identifier(\"toc\")"))
XCTAssertFalse(contentSource.contains("ToolbarItemGroup(placement: .automatic)"))
XCTAssertFalse(contentSource.contains(#".toolbar(id: "document-toolbar")"#))
XCTAssertFalse(contentSource.contains("ToolbarSpacer"))
XCTAssertFalse(contentSource.contains("tocControls"))
XCTAssertFalse(contentSource.contains("zoomControls"))
XCTAssertTrue(contentSource.contains(#"item.label = "TOC""#))
XCTAssertTrue(contentSource.contains(#"item.label = "TOC Depth""#))
XCTAssertFalse(contentSource.contains(#"item.label = "Table of Contents Depth""#))
XCTAssertTrue(contentSource.contains(#"item.label = "Markdown Source""#))
XCTAssertFalse(contentSource.contains("ToolbarDisplayModeReader"))
XCTAssertFalse(contentSource.contains("toolbarDisplayMode"))
XCTAssertFalse(contentSource.contains("zoomTextMenu"))
XCTAssertTrue(contentSource.contains(#"item.label = "Zoom""#))
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = zoomMenuItem()"))
XCTAssertTrue(contentSource.contains(#"NSMenuItem(title: "Zoom", action: nil, keyEquivalent: "")"#))
XCTAssertTrue(contentSource.contains("item.view = zoomControlView"))
XCTAssertTrue(contentSource.contains("item.target = self"))
XCTAssertTrue(contentSource.contains("item.action = #selector(showZoomMenuFromToolbar(_:))"))
XCTAssertTrue(contentSource.contains("NSSegmentedControl(labels:"))
XCTAssertTrue(contentSource.contains("final class ZoomToolbarControlView"))
XCTAssertTrue(contentSource.contains("control.setWidth(56, forSegment: 1)"))
XCTAssertTrue(contentSource.contains("private func zoomMenu() -> NSMenu"))
XCTAssertTrue(contentSource.contains("menu.popUp(positioning: nil"))
XCTAssertTrue(contentSource.contains("showZoomMenuFromToolbar"))
XCTAssertTrue(contentSource.contains("zoomSegmentSelected"))
XCTAssertFalse(contentSource.contains("dumpToolbar"))
XCTAssertFalse(contentSource.contains("toolbar-debug"))
XCTAssertFalse(contentSource.contains("ControlGroup"))
XCTAssertTrue(contentSource.contains(#"item.label = "Theme""#))
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = themeMenuItem()"))
XCTAssertTrue(contentSource.contains(#"item.label = externalEditorLabel"#))
XCTAssertTrue(contentSource.contains("item.action = #selector(openExternalEditorFromToolbar(_:))"))
XCTAssertTrue(contentSource.contains("item.autovalidates = false"))
XCTAssertTrue(contentSource.contains("item.view = externalEditorButton(isEnabled: !validEditors.isEmpty)"))
XCTAssertTrue(contentSource.contains("private func externalEditorButton(isEnabled: Bool) -> NSButton"))
XCTAssertTrue(contentSource.contains("button.showsBorderOnlyWhileMouseInside = true"))
XCTAssertFalse(contentSource.contains("button.isBordered = false"))
XCTAssertTrue(contentSource.contains("func validateToolbarItem(_ item: NSToolbarItem) -> Bool"))
XCTAssertTrue(contentSource.contains("return hasValidExternalEditor"))
XCTAssertTrue(contentSource.contains("showExternalEditorMenu(from: sender)"))
XCTAssertFalse(contentSource.contains("externalEditorPopup"))
}
func testToolbarIsNativelyCustomizable() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .toolbar)"))
XCTAssertTrue(appSource.contains("CommandGroup(before: .toolbar)"))
XCTAssertTrue(contentSource.contains("final class DocumentToolbarController: NSObject"))
XCTAssertTrue(contentSource.contains("ObservableObject"))
XCTAssertTrue(contentSource.contains("NSToolbarDelegate"))
XCTAssertTrue(contentSource.contains("window.toolbar = toolbar"))
XCTAssertTrue(contentSource.contains("toolbar.delegate = self"))
XCTAssertTrue(contentSource.contains("toolbar.allowsUserCustomization = true"))
XCTAssertTrue(contentSource.contains("toolbar.autosavesConfiguration = true"))
XCTAssertTrue(contentSource.contains("toolbar.displayMode = NSToolbar.DisplayMode.default"))
XCTAssertTrue(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v6")"#))
XCTAssertTrue(contentSource.contains("removeLegacySavedToolbarConfigurations()"))
XCTAssertTrue(contentSource.contains(#""NSToolbar Configuration \(identifier)""#))
XCTAssertTrue(contentSource.contains(#""document-toolbar-v5""#))
XCTAssertTrue(contentSource.contains(#""document-toolbar-v4""#))
XCTAssertFalse(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v5")"#))
XCTAssertTrue(contentSource.contains("} else if window.toolbar?.delegate !== self {\n window.toolbar?.delegate = self\n }"))
XCTAssertTrue(contentSource.contains("item.target = self"))
XCTAssertTrue(contentSource.contains("toolbarAllowedItemIdentifiers"))
XCTAssertTrue(contentSource.contains("toolbarDefaultItemIdentifiers"))
}
func testDocumentToolbarUsesNativeRepeatableSpaceItem() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains(".space"))
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]"))
XCTAssertFalse(contentSource.contains("case .fixedSpace:"))
XCTAssertFalse(contentSource.contains("makeFixedSpaceItem"))
XCTAssertFalse(contentSource.contains(#"NSToolbarItem.Identifier("document-fixed-space")"#))
XCTAssertFalse(contentSource.contains("Small Space"))
}
private func loadTemplate() throws -> String {
let templateURL = try XCTUnwrap(
Bundle.main.url(forResource: "template", withExtension: "html")
+2 -2
View File
@@ -33,8 +33,8 @@ targetTemplates:
INFOPLIST_FILE: markdownViewr/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
PRODUCT_NAME: markdownViewr
MARKETING_VERSION: "1.3.0"
CURRENT_PROJECT_VERSION: "3"
MARKETING_VERSION: "1.5.0"
CURRENT_PROJECT_VERSION: "5"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon