Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f4ac723c | ||
|
|
4199c8b375 | ||
|
|
baac7e71a8 | ||
|
|
1abc51eb25 | ||
|
|
91985c3fbc | ||
|
|
39a08ae42e | ||
|
|
8373722174 | ||
|
|
e06facc915 | ||
|
|
71d71df7dd | ||
|
|
842d6bf025 | ||
|
|
28458b0892 | ||
|
|
ad76a0c8e1 |
No files matched your search
+9
-8
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user