Compare commits

15 changed files with 1891 additions and 183 deletions

No files matched your search

+9 -8
View File
@@ -5,6 +5,15 @@
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
<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>
@@ -22,13 +31,5 @@
<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>
+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)
+2 -2
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:
+676 -123
View File
@@ -31,18 +31,31 @@ struct ContentView: View {
@StateObject private var folderAccess = FolderAccessManager()
@State private var renderedHTML = ""
@State private var docNeedsImageAccess = false
@AppStorage("tocVisible") private var tocVisible = false
@AppStorage("tocDepth") private var tocDepth = 3
@AppStorage("tocWidth") private var tocWidth: Double = 220
@StateObject private var toolbarController = DocumentToolbarController()
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
@AppStorage("defaultTocDepth") private var defaultTocDepth = 3
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
@State private var tocVisible = false
@State private var tocDepth = 3
@State private var tocWidth: Double = 220
@AppStorage("tocWrap") private var tocWrap = false
@AppStorage("tocBullets") private var tocBullets = false
@AppStorage("rawVisible") private var rawVisible = false
@AppStorage("rawWidth") private var rawWidth: Double = 400
@State private var rawVisible = false
@State private var rawWidth: Double = 400
@State private var hasActivatedRawSource = false
@State private var hasInitializedWindowViewState = false
@State private var zoomScale = 1.0
@State private var activeThemeName: String?
private var currentMarkdown: String {
liveContent.rawMarkdown.isEmpty ? document.rawMarkdown : liveContent.rawMarkdown
}
private var activeTheme: Theme {
let themeName = activeThemeName ?? themeManager.activeThemeName
return themeManager.themes.first { $0.name == themeName } ?? themeManager.activeTheme
}
private func rerender() {
let html = MarkdownDocument.convertToHTML(
currentMarkdown,
@@ -79,7 +92,7 @@ struct ContentView: View {
}
MarkdownWebView(
html: renderedHTML,
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
themeCSS: themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale),
fileURL: fileURL,
findBar: findBar,
tocVisible: tocVisible,
@@ -114,6 +127,29 @@ struct ContentView: View {
}
.background(WindowAccessor { window in
findBar.window = window
if let window {
initializeWindowViewStateIfNeeded(windowWidth: Double(window.contentView?.bounds.width ?? 0))
toolbarController.configure(
in: window,
themeManager: themeManager,
editorManager: editorManager,
fileURL: fileURL,
tocVisible: tocVisible,
tocDepth: tocDepth,
rawVisible: rawVisible,
zoomScale: zoomScale,
activeThemeName: activeTheme.name,
setTocVisible: { tocVisible = $0 },
setTocDepth: { tocDepth = $0 },
setRawVisible: setRawSourceVisible,
setZoomScale: { zoomScale = $0 },
setActiveThemeName: { activeThemeName = $0 },
showMissingEditor: { editorName in
missingEditorName = editorName
showMissingEditorAlert = true
}
)
}
})
.onAppear {
liveContent.rawMarkdown = document.rawMarkdown
@@ -128,15 +164,6 @@ struct ContentView: View {
.onDisappear {
liveContent.fileWatcher = nil
}
.toolbar {
ToolbarItemGroup(placement: .automatic) {
tocControls
rawButton
zoomControls
palettePicker
editorButton
}
}
.alert("Editor Not Found", isPresented: $showMissingEditorAlert) {
Button("Remove from List") {
editorManager.editors.removeAll { $0.name == missingEditorName }
@@ -145,139 +172,665 @@ struct ContentView: View {
} message: {
Text("\"\(missingEditorName)\" could not be found. It may have been moved or uninstalled.")
}
.focusedSceneValue(\.documentViewCommands, documentViewCommands)
}
private var tocControls: some View {
HStack(spacing: 2) {
Button {
tocVisible.toggle()
} label: {
Image(systemName: tocVisible ? "list.bullet.circle.fill" : "list.bullet.circle")
}
.help(tocVisible ? "Hide Table of Contents" : "Show Table of Contents")
private var documentViewCommands: DocumentViewCommands {
DocumentViewCommands(
tocVisible: Binding(
get: { tocVisible },
set: { tocVisible = $0 }
),
tocDepth: tocDepth,
setTocDepth: { tocDepth = $0 },
rawVisible: Binding(
get: { rawVisible },
set: setRawSourceVisible
),
zoomScale: Binding(
get: { zoomScale },
set: { zoomScale = $0 }
),
zoomIn: { zoomScale = min(zoomScale * 1.1, 5.0) },
zoomOut: { zoomScale = max(zoomScale / 1.1, 0.3) },
zoomReset: { zoomScale = 1.0 },
nextTheme: { cycleTheme(direction: 1) },
previousTheme: { cycleTheme(direction: -1) }
)
}
Picker("", selection: $tocDepth) {
Text("H1").tag(1)
Text("H2").tag(2)
Text("H3").tag(3)
Text("H4").tag(4)
Text("H5").tag(5)
Text("H6").tag(6)
}
.frame(width: 60)
.disabled(!tocVisible)
.help("Table of Contents depth")
private func cycleTheme(direction: Int) {
guard !themeManager.themes.isEmpty else { return }
let currentThemeName = activeThemeName ?? themeManager.activeThemeName
let currentIndex = themeManager.themes.firstIndex { $0.name == currentThemeName } ?? 0
let newIndex = (currentIndex + direction + themeManager.themes.count) % themeManager.themes.count
activeThemeName = themeManager.themes[newIndex].name
}
private func setRawSourceVisible(_ visible: Bool) {
if visible && !rawVisible {
rawWidth = DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: rawWidth,
hasActivatedRawSource: hasActivatedRawSource,
windowWidth: Double(findBar.window?.contentView?.bounds.width ?? 0),
tocVisible: tocVisible,
tocWidth: tocWidth
)
hasActivatedRawSource = true
}
rawVisible = visible
}
private func initializeWindowViewStateIfNeeded(windowWidth: Double) {
if hasInitializedWindowViewState {
sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)
return
}
hasInitializedWindowViewState = true
activeThemeName = themeManager.activeThemeName
tocVisible = defaultTocVisible
tocDepth = defaultTocDepth
rawVisible = defaultRawVisible
sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)
}
private func sizeInitialRawSourceIfNeeded(windowWidth: Double) {
guard rawVisible && !hasActivatedRawSource && windowWidth > 0 else { return }
rawWidth = DocumentViewLayout.rawWidthWhenActivating(
currentRawWidth: rawWidth,
hasActivatedRawSource: hasActivatedRawSource,
windowWidth: windowWidth,
tocVisible: tocVisible,
tocWidth: tocWidth
)
hasActivatedRawSource = true
}
}
enum DocumentViewLayout {
static func initialRawWidth(windowWidth: Double, tocVisible: Bool, tocWidth: Double) -> Double {
let availableWidth = max(0, windowWidth - (tocVisible ? tocWidth : 0))
return max(220, availableWidth / 2)
}
static func rawWidthWhenActivating(
currentRawWidth: Double,
hasActivatedRawSource: Bool,
windowWidth: Double,
tocVisible: Bool,
tocWidth: Double
) -> Double {
if hasActivatedRawSource {
return currentRawWidth
}
return initialRawWidth(windowWidth: windowWidth, tocVisible: tocVisible, tocWidth: tocWidth)
}
}
struct DocumentViewCommands {
let tocVisible: Binding<Bool>
let tocDepth: Int
let setTocDepth: (Int) -> Void
let rawVisible: Binding<Bool>
let zoomScale: Binding<Double>
let zoomIn: () -> Void
let zoomOut: () -> Void
let zoomReset: () -> Void
let nextTheme: () -> Void
let previousTheme: () -> Void
}
private struct DocumentViewCommandsKey: FocusedValueKey {
typealias Value = DocumentViewCommands
}
extension FocusedValues {
var documentViewCommands: DocumentViewCommands? {
get { self[DocumentViewCommandsKey.self] }
set { self[DocumentViewCommandsKey.self] = newValue }
}
}
final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDelegate {
private weak var themeManager: ThemeManager?
private weak var editorManager: EditorManager?
private weak var window: NSWindow?
private var fileURL: URL?
private var tocVisible = false
private var tocDepth = 3
private var rawVisible = false
private var zoomScale = 1.0
private var activeThemeName = ""
private var setTocVisible: ((Bool) -> Void)?
private var setTocDepth: ((Int) -> Void)?
private var setRawVisible: ((Bool) -> Void)?
private var setZoomScale: ((Double) -> Void)?
private var setActiveThemeName: ((String) -> Void)?
private var showMissingEditor: ((String) -> Void)?
func configure(
in window: NSWindow,
themeManager: ThemeManager,
editorManager: EditorManager,
fileURL: URL?,
tocVisible: Bool,
tocDepth: Int,
rawVisible: Bool,
zoomScale: Double,
activeThemeName: String,
setTocVisible: @escaping (Bool) -> Void,
setTocDepth: @escaping (Int) -> Void,
setRawVisible: @escaping (Bool) -> Void,
setZoomScale: @escaping (Double) -> Void,
setActiveThemeName: @escaping (String) -> Void,
showMissingEditor: @escaping (String) -> Void
) {
self.window = window
self.themeManager = themeManager
self.editorManager = editorManager
self.fileURL = fileURL
self.tocVisible = tocVisible
self.tocDepth = tocDepth
self.rawVisible = rawVisible
self.zoomScale = zoomScale
self.activeThemeName = activeThemeName
self.setTocVisible = setTocVisible
self.setTocDepth = setTocDepth
self.setRawVisible = setRawVisible
self.setZoomScale = setZoomScale
self.setActiveThemeName = setActiveThemeName
self.showMissingEditor = showMissingEditor
Self.removeLegacySavedToolbarConfigurations()
if window.toolbar?.identifier != .documentToolbar {
let toolbar = NSToolbar(identifier: .documentToolbar)
toolbar.delegate = self
toolbar.allowsUserCustomization = true
toolbar.autosavesConfiguration = true
toolbar.displayMode = NSToolbar.DisplayMode.default
window.toolbar = toolbar
} else if window.toolbar?.delegate !== self {
window.toolbar?.delegate = self
}
updateVisibleItems()
}
private static func removeLegacySavedToolbarConfigurations() {
let defaults = UserDefaults.standard
for identifier in legacyToolbarIdentifiers {
defaults.removeObject(forKey: "NSToolbar Configuration \(identifier)")
}
}
private var rawButton: some View {
Button {
rawVisible.toggle()
} label: {
Image(systemName: rawVisible ? "doc.plaintext.fill" : "doc.plaintext")
}
.help(rawVisible ? "Hide Markdown Source" : "Show Markdown Source")
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .space]
}
private var zoomControls: some View {
HStack(spacing: 2) {
Button {
themeManager.zoomOut()
} label: {
Image(systemName: "minus.magnifyingglass")
}
.help("Zoom Out")
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]
}
Text("\(Int(themeManager.zoomScale * 100))%")
.font(.system(size: 11).monospacedDigit())
.frame(width: 38)
.onTapGesture {
themeManager.zoomReset()
}
.help("Reset Zoom")
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[]
}
Button {
themeManager.zoomIn()
} label: {
Image(systemName: "plus.magnifyingglass")
}
.help("Zoom In")
func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
if item.itemIdentifier == .externalEditor {
return hasValidExternalEditor
}
return true
}
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
switch itemIdentifier {
case .toc:
return makeTOCItem()
case .tocDepth:
return makeTOCDepthItem()
case .markdownSource:
return makeMarkdownSourceItem()
case .zoom:
return makeZoomItem()
case .theme:
return makeThemeItem()
case .externalEditor:
return makeExternalEditorItem()
default:
return nil
}
}
private var palettePicker: some View {
Picker(selection: $themeManager.activeThemeName) {
ForEach(themeManager.themes) { theme in
HStack(spacing: 6) {
Circle()
.fill(Color(hex: theme.colors.heading1) ?? .purple)
.frame(width: 10, height: 10)
Text(theme.name)
}
.tag(theme.name)
}
} label: {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: themeManager.activeTheme.colors.heading1) ?? .purple)
.frame(width: 10, height: 10)
Text(themeManager.activeTheme.name)
.font(.system(size: 12))
}
}
.pickerStyle(.menu)
private func makeTOCItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .toc)
item.label = "TOC"
item.paletteLabel = "TOC"
item.toolTip = tocVisible ? "Hide Table of Contents" : "Show Table of Contents"
item.image = NSImage(systemSymbolName: tocVisible ? "list.bullet.circle.fill" : "list.bullet.circle", accessibilityDescription: "TOC")
item.target = self
item.action = #selector(toggleTOC)
item.menuFormRepresentation = toolbarMenuItem(title: "TOC", action: #selector(toggleTOC), state: tocVisible ? .on : .off)
return item
}
@ViewBuilder
private var editorButton: some View {
let validEditors = editorManager.editors.filter(\.exists)
private func makeTOCDepthItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .tocDepth)
item.label = "TOC Depth"
item.paletteLabel = "TOC Depth"
item.toolTip = "Table of Contents depth"
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 62, height: 26), pullsDown: false)
for depth in 1...6 {
popup.addItem(withTitle: "H\(depth)")
}
popup.selectItem(at: max(0, min(5, tocDepth - 1)))
popup.isEnabled = tocVisible
popup.target = self
popup.action = #selector(tocDepthChanged(_:))
item.view = popup
item.menuFormRepresentation = tocDepthMenuItem()
return item
}
if editorManager.editors.isEmpty {
Button {
} label: {
Label("Open in Editor", systemImage: "square.and.pencil")
}
.disabled(true)
.help("Configure an external editor in Settings")
private func makeMarkdownSourceItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .markdownSource)
item.label = "Markdown Source"
item.paletteLabel = "Markdown Source"
item.toolTip = rawVisible ? "Hide Markdown Source" : "Show Markdown Source"
item.image = NSImage(systemSymbolName: rawVisible ? "doc.plaintext.fill" : "doc.plaintext", accessibilityDescription: "Markdown Source")
item.target = self
item.action = #selector(toggleMarkdownSource)
item.menuFormRepresentation = toolbarMenuItem(title: "Markdown Source", action: #selector(toggleMarkdownSource), state: rawVisible ? .on : .off)
return item
}
private func makeZoomItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .zoom)
item.label = "Zoom"
item.paletteLabel = "Zoom"
item.toolTip = "Zoom"
item.view = zoomControlView()
item.target = self
item.action = #selector(showZoomMenuFromToolbar(_:))
item.menuFormRepresentation = zoomMenuItem()
return item
}
private func makeThemeItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .theme)
item.label = "Theme"
item.paletteLabel = "Theme"
item.toolTip = "Theme"
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 150, height: 26), pullsDown: false)
for theme in themeManager?.themes ?? [] {
popup.addItem(withTitle: theme.name)
}
popup.selectItem(withTitle: activeThemeName)
popup.target = self
popup.action = #selector(themeChanged(_:))
item.view = popup
item.menuFormRepresentation = themeMenuItem()
return item
}
private func makeExternalEditorItem() -> NSToolbarItem {
let editors = editorManager?.editors ?? []
let validEditors = editors.filter(\.exists)
let item = NSToolbarItem(itemIdentifier: .externalEditor)
item.label = externalEditorLabel
item.paletteLabel = "Open in Editor"
item.toolTip = externalEditorHelp
item.image = NSImage(systemSymbolName: "square.and.pencil", accessibilityDescription: "Open in Editor")
item.target = self
item.action = #selector(openExternalEditorFromToolbar(_:))
item.autovalidates = false
item.isEnabled = !validEditors.isEmpty
item.view = externalEditorButton(isEnabled: !validEditors.isEmpty)
if editors.isEmpty || validEditors.isEmpty {
item.isEnabled = false
item.menuFormRepresentation = toolbarMenuItem(title: externalEditorLabel, action: nil, isEnabled: false)
} else if validEditors.count == 1 {
Button {
openInEditor(validEditors[0])
} label: {
Label("Open in \(validEditors[0].name)", systemImage: "square.and.pencil")
}
.help("Open in \(validEditors[0].name)")
item.menuFormRepresentation = toolbarMenuItem(title: externalEditorLabel, action: #selector(openExternalEditorFromToolbar(_:)))
} else {
Menu {
ForEach(editorManager.editors) { editor in
Button {
openInEditor(editor)
} label: {
HStack {
Text(editor.name)
if editor.opensFolder {
Text("(folder)")
.foregroundStyle(.secondary)
}
}
}
}
} label: {
Label("Open in Editor", systemImage: "square.and.pencil")
}
.help("Open in external editor")
item.menuFormRepresentation = toolbarMenuItem(title: externalEditorLabel, action: #selector(openExternalEditorFromToolbar(_:)))
}
return item
}
private func openInEditor(_ editor: EditorConfig) {
guard let fileURL else { return }
private var externalEditorLabel: String {
let validEditors = editorManager?.editors.filter(\.exists) ?? []
if validEditors.count == 1 {
return "Open in \(validEditors[0].name)"
}
return "Open in Editor"
}
guard editor.exists else {
missingEditorName = editor.name
showMissingEditorAlert = true
private var hasValidExternalEditor: Bool {
editorManager?.editors.contains(where: \.exists) == true
}
private var externalEditorHelp: String {
if editorManager?.editors.isEmpty == true {
return "Configure an external editor in Settings"
}
return "Open in external editor"
}
private func externalEditorButton(isEnabled: Bool) -> NSButton {
let button = NSButton(
image: NSImage(systemSymbolName: "square.and.pencil", accessibilityDescription: "Open in Editor") ?? NSImage(),
target: self,
action: #selector(openExternalEditorFromToolbar(_:))
)
button.bezelStyle = .texturedRounded
button.imagePosition = .imageOnly
button.showsBorderOnlyWhileMouseInside = true
button.isEnabled = isEnabled
button.toolTip = externalEditorHelp
button.setAccessibilityLabel(externalEditorLabel)
button.frame = NSRect(x: 0, y: 0, width: 28, height: 28)
return button
}
private func zoomControlView() -> NSView {
ZoomToolbarControlView(control: zoomSegmentedControl())
}
private func zoomSegmentedControl() -> NSSegmentedControl {
let control = NSSegmentedControl(labels: ["", "\(zoomPercent)%", ""], trackingMode: .momentary, target: self, action: #selector(zoomSegmentSelected(_:)))
control.segmentStyle = .texturedRounded
control.setImage(NSImage(systemSymbolName: "minus.magnifyingglass", accessibilityDescription: "Zoom Out"), forSegment: 0)
control.setImage(NSImage(systemSymbolName: "plus.magnifyingglass", accessibilityDescription: "Zoom In"), forSegment: 2)
control.setToolTip("Zoom Out", forSegment: 0)
control.setToolTip("Actual Size", forSegment: 1)
control.setToolTip("Zoom In", forSegment: 2)
control.setWidth(28, forSegment: 0)
control.setWidth(56, forSegment: 1)
control.setWidth(28, forSegment: 2)
control.setAccessibilityLabel("Zoom")
return control
}
private var zoomPercent: Int {
Int(round(zoomScale * 100))
}
private func zoomMenuItem() -> NSMenuItem {
let item = NSMenuItem(title: "Zoom", action: nil, keyEquivalent: "")
item.submenu = zoomMenu()
return item
}
private func zoomMenu() -> NSMenu {
let menu = NSMenu(title: "Zoom")
menu.addItem(NSMenuItem(title: "Zoom Out", action: #selector(zoomOut), keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Actual Size", action: #selector(actualSize), keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Zoom In", action: #selector(zoomIn), keyEquivalent: ""))
for menuItem in menu.items {
menuItem.target = self
}
return menu
}
private func tocDepthMenuItem() -> NSMenuItem {
let item = NSMenuItem(title: "TOC Depth", action: nil, keyEquivalent: "")
let menu = NSMenu(title: "TOC Depth")
for depth in 1...6 {
let menuItem = NSMenuItem(title: "H\(depth)", action: #selector(setTOCDepthFromMenu(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.tag = depth
menuItem.state = tocDepth == depth ? .on : .off
menu.addItem(menuItem)
}
item.submenu = menu
return item
}
private func themeMenuItem() -> NSMenuItem {
let item = NSMenuItem(title: "Theme", action: nil, keyEquivalent: "")
let menu = NSMenu(title: "Theme")
for theme in themeManager?.themes ?? [] {
let menuItem = NSMenuItem(title: theme.name, action: #selector(themeChangedFromMenu(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.state = theme.name == activeThemeName ? .on : .off
menu.addItem(menuItem)
}
item.submenu = menu
return item
}
private func externalEditorMenu() -> NSMenu {
let menu = NSMenu(title: "Open in Editor")
for editor in editorManager?.editors ?? [] {
let menuItem = NSMenuItem(title: editor.opensFolder ? "\(editor.name) (folder)" : editor.name, action: #selector(openExternalEditorFromMenu(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.representedObject = editor.id
menuItem.isEnabled = editor.exists
menu.addItem(menuItem)
}
return menu
}
private func toolbarMenuItem(title: String, action: Selector?, state: NSControl.StateValue = .off, isEnabled: Bool = true) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.state = state
item.isEnabled = isEnabled
return item
}
@objc private func showZoomMenuFromToolbar(_ sender: Any?) {
let menu = zoomMenu()
if let view = sender as? NSView {
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: view.bounds.height), in: view)
return
}
editorManager.openFile(fileURL, with: editor)
guard let event = NSApp.currentEvent,
let contentView = event.window?.contentView
else { return }
let point = contentView.convert(event.locationInWindow, from: nil)
menu.popUp(positioning: nil, at: point, in: contentView)
}
@objc private func toggleTOC() {
setTocVisible?(!tocVisible)
}
@objc private func tocDepthChanged(_ sender: NSPopUpButton) {
setTocDepth?(sender.indexOfSelectedItem + 1)
}
@objc private func setTOCDepthFromMenu(_ sender: NSMenuItem) {
setTocDepth?(sender.tag)
}
@objc private func toggleMarkdownSource() {
setRawVisible?(!rawVisible)
}
@objc private func themeChanged(_ sender: NSPopUpButton) {
guard let title = sender.selectedItem?.title else { return }
setActiveThemeName?(title)
}
@objc private func themeChangedFromMenu(_ sender: NSMenuItem) {
setActiveThemeName?(sender.title)
}
@objc private func openExternalEditorFromToolbar(_ sender: Any?) {
let validEditors = editorManager?.editors.filter(\.exists) ?? []
if validEditors.count == 1 {
open(validEditors[0])
} else if validEditors.count > 1 {
showExternalEditorMenu(from: sender)
}
}
@objc private func openExternalEditorFromMenu(_ sender: NSMenuItem) {
guard let id = sender.representedObject as? UUID,
let editor = editorManager?.editors.first(where: { $0.id == id })
else { return }
open(editor)
}
private func open(_ editor: EditorConfig) {
guard let fileURL else { return }
guard editor.exists else {
showMissingEditor?(editor.name)
return
}
editorManager?.openFile(fileURL, with: editor)
}
private func showExternalEditorMenu(from sender: Any?) {
let menu = externalEditorMenu()
if let view = sender as? NSView {
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: view.bounds.height), in: view)
return
}
guard let contentView = window?.contentView else { return }
let point: NSPoint
if let event = NSApp.currentEvent, event.window === window {
point = contentView.convert(event.locationInWindow, from: nil)
} else if let window {
point = contentView.convert(window.mouseLocationOutsideOfEventStream, from: nil)
} else {
point = NSPoint(x: contentView.bounds.midX, y: contentView.bounds.maxY)
}
menu.popUp(positioning: nil, at: point, in: contentView)
}
@objc private func zoomSegmentSelected(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0:
setZoomScale?(max(zoomScale / 1.1, 0.3))
case 1:
setZoomScale?(1.0)
case 2:
setZoomScale?(min(zoomScale * 1.1, 5.0))
default:
break
}
}
@objc private func zoomOut() {
setZoomScale?(max(zoomScale / 1.1, 0.3))
}
@objc private func actualSize() {
setZoomScale?(1.0)
}
@objc private func zoomIn() {
setZoomScale?(min(zoomScale * 1.1, 5.0))
}
private func updateVisibleItems() {
guard let toolbar = window?.toolbar else { return }
for item in toolbar.items {
switch item.itemIdentifier {
case .toc:
item.image = NSImage(systemSymbolName: tocVisible ? "list.bullet.circle.fill" : "list.bullet.circle", accessibilityDescription: "TOC")
item.toolTip = tocVisible ? "Hide Table of Contents" : "Show Table of Contents"
item.target = self
item.action = #selector(toggleTOC)
item.menuFormRepresentation = toolbarMenuItem(title: "TOC", action: #selector(toggleTOC), state: tocVisible ? .on : .off)
case .tocDepth:
if let popup = item.view as? NSPopUpButton {
popup.selectItem(at: max(0, min(5, tocDepth - 1)))
popup.isEnabled = tocVisible
popup.target = self
popup.action = #selector(tocDepthChanged(_:))
}
item.menuFormRepresentation = tocDepthMenuItem()
case .markdownSource:
item.image = NSImage(systemSymbolName: rawVisible ? "doc.plaintext.fill" : "doc.plaintext", accessibilityDescription: "Markdown Source")
item.toolTip = rawVisible ? "Hide Markdown Source" : "Show Markdown Source"
item.target = self
item.action = #selector(toggleMarkdownSource)
item.menuFormRepresentation = toolbarMenuItem(title: "Markdown Source", action: #selector(toggleMarkdownSource), state: rawVisible ? .on : .off)
case .zoom:
item.view = zoomControlView()
item.menuFormRepresentation = zoomMenuItem()
case .theme:
if let popup = item.view as? NSPopUpButton {
popup.removeAllItems()
for theme in themeManager?.themes ?? [] {
popup.addItem(withTitle: theme.name)
}
popup.selectItem(withTitle: activeThemeName)
popup.target = self
popup.action = #selector(themeChanged(_:))
}
item.menuFormRepresentation = themeMenuItem()
case .externalEditor:
let replacement = makeExternalEditorItem()
item.label = replacement.label
item.paletteLabel = replacement.paletteLabel
item.toolTip = replacement.toolTip
item.image = replacement.image
item.target = replacement.target
item.action = replacement.action
item.view = replacement.view
item.menuFormRepresentation = replacement.menuFormRepresentation
item.isEnabled = replacement.isEnabled
default:
break
}
}
}
}
private extension NSToolbarItem.Identifier {
static let toc = NSToolbarItem.Identifier("toc")
static let tocDepth = NSToolbarItem.Identifier("toc-depth")
static let markdownSource = NSToolbarItem.Identifier("markdown-source")
static let zoom = NSToolbarItem.Identifier("zoom")
static let theme = NSToolbarItem.Identifier("theme")
static let externalEditor = NSToolbarItem.Identifier("external-editor")
}
private extension NSToolbar.Identifier {
static let documentToolbar = NSToolbar.Identifier("document-toolbar-v6")
}
private let legacyToolbarIdentifiers = [
"document-toolbar",
"document-toolbar-v2",
"document-toolbar-v3",
"document-toolbar-v4",
"document-toolbar-v5"
]
private final class ZoomToolbarControlView: NSView {
private let control: NSSegmentedControl
init(control: NSSegmentedControl) {
self.control = control
super.init(frame: NSRect(origin: .zero, size: control.fittingSize))
addSubview(control)
setAccessibilityLabel("Zoom")
}
required init?(coder: NSCoder) {
nil
}
override var intrinsicContentSize: NSSize {
control.intrinsicContentSize
}
override func layout() {
super.layout()
control.frame = bounds
}
}
+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`).
+179 -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) {
@@ -70,54 +79,197 @@ 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)
}
static func shouldApplyDocumentFrame(isTabbedWindow: Bool) -> Bool {
!isTabbedWindow
}
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 }
let autosaveName = Self.autosaveName(for: fileURL)
guard context.coordinator.needsConfiguration(for: window, autosaveName: autosaveName) else { return }
if Self.shouldApplyDocumentFrame(isTabbedWindow: Self.isTabbedWindow(window)) {
let restoredFrame = window.setFrameUsingName(autosaveName)
if !restoredFrame {
window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))
window.center()
}
}
window.setFrameAutosaveName(autosaveName)
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
}
private static func isTabbedWindow(_ window: NSWindow) -> Bool {
window.tabbedWindows?.contains { $0 !== window } == true
}
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)
}
deinit {
removeObservers()
}
private func removeObservers() {
let center = NotificationCenter.default
observerTokens.forEach { center.removeObserver($0) }
observerTokens = []
}
}
}
class HelpWindowController: NSObject, NSWindowDelegate {
@@ -170,10 +322,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 }
+108 -19
View File
@@ -28,8 +28,23 @@ struct SettingsView: View {
}
}
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 +52,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 +125,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 +202,8 @@ struct GeneralSettingsView: View {
.padding(.top, 12)
Spacer()
}
}
.padding(20)
.onAppear {
checkIfDefault()
}
@@ -207,6 +248,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 +309,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 +321,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 +369,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 +408,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 +461,8 @@ struct MarkdownSettingsView: View {
}
Spacer()
}
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
@@ -414,10 +507,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) {
+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
}
}
@@ -1,4 +1,5 @@
import XCTest
@testable import markdownViewr
final class TemplateScrollbarTests: XCTestCase {
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
@@ -70,6 +71,354 @@ 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: true))
}
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, .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.4.0"
CURRENT_PROJECT_VERSION: "4"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon