diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index c4c27f0..d5181cb 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true @AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true @AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true + @AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true @AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true @AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @@ -197,6 +198,16 @@ struct SettingsView: View { .foregroundStyle(.secondary) } .frame(maxWidth: 480, alignment: .leading) + + Divider().frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + Toggle("Pointer Cursor", isOn: $isPointerCursorEnabled) + Text("Show a pointing-hand cursor when hovering sidebar rows and links in a document, instead of the default arrow or I-beam.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) } } diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index d627c0c..70832b9 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -157,6 +157,7 @@ private struct DocumentNodeRow: View { .frame(width: 12) } .buttonStyle(.plain) + .pointerCursorOnHover() } Button { @@ -186,6 +187,7 @@ private struct DocumentNodeRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .pointerCursorOnHover() } .padding(.vertical, 6) .padding(.horizontal, 4) diff --git a/Outpost/Features/Collections/CollectionListContent.swift b/Outpost/Features/Collections/CollectionListContent.swift index c2399ab..34dfc36 100644 --- a/Outpost/Features/Collections/CollectionListContent.swift +++ b/Outpost/Features/Collections/CollectionListContent.swift @@ -34,6 +34,7 @@ struct CollectionListContent: View { CollectionRowView(collection: collection) } .buttonStyle(.plain) + .pointerCursorOnHover() } } } diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index f015d76..d37a8ce 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -116,6 +116,7 @@ struct CollectionOverviewView: View { ) } .buttonStyle(.plain) + .pointerCursorOnHover() } Spacer(minLength: 0) } @@ -152,6 +153,7 @@ struct CollectionOverviewView: View { DocumentRowView(document: document) } .buttonStyle(.plain) + .pointerCursorOnHover() } } } @@ -184,6 +186,7 @@ struct CollectionOverviewView: View { .padding(.vertical, 2) } .buttonStyle(.plain) + .pointerCursorOnHover() } } } diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 515363b..5c699bd 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -56,6 +56,7 @@ struct CollectionTreeRow: View { ) } .buttonStyle(.plain) + .pointerCursorOnHover() .animation(.easeInOut(duration: 0.15), value: isExpanded) .contextMenu { contextMenuContent } diff --git a/Outpost/Features/Collections/CommandPaletteView.swift b/Outpost/Features/Collections/CommandPaletteView.swift index c34c3e1..fc7d5e7 100644 --- a/Outpost/Features/Collections/CommandPaletteView.swift +++ b/Outpost/Features/Collections/CommandPaletteView.swift @@ -134,6 +134,7 @@ struct CommandPaletteView: View { resultRow(result, isSelected: index == selectedIndex) .id(index) .contentShape(Rectangle()) + .pointerCursorOnHover() .onTapGesture { selectedIndex = index selectCurrent() diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 1d1a3e5..a1b3643 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -23,6 +23,7 @@ struct DocumentReaderView: View { @AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true @AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true @AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true + @AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true /// `ImagePlaygroundViewController.isAvailable` gates on both OS version /// (macOS 15.1+) and actual device/region support (Apple Intelligence @@ -557,7 +558,8 @@ struct DocumentReaderView: View { textSubstitution: editorTextSubstitution, textCompletion: editorTextCompletion, writingTools: editorWritingTools, - heightBehavior: .fitsContent + heightBehavior: .fitsContent, + pointerCursorOverLinksWhileEditing: isPointerCursorEnabled ), documentId: viewModel.documentId, isEditable: viewModel.isEffectivelyEditable, diff --git a/Outpost/Features/Search/GlobalSearchResultsView.swift b/Outpost/Features/Search/GlobalSearchResultsView.swift index 5780e4e..9922872 100644 --- a/Outpost/Features/Search/GlobalSearchResultsView.swift +++ b/Outpost/Features/Search/GlobalSearchResultsView.swift @@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View { .padding(.vertical, 2) } .buttonStyle(.plain) + .pointerCursorOnHover() } } } diff --git a/Outpost/Support/PointerCursorHover.swift b/Outpost/Support/PointerCursorHover.swift new file mode 100644 index 0000000..ccea195 --- /dev/null +++ b/Outpost/Support/PointerCursorHover.swift @@ -0,0 +1,38 @@ +import SwiftUI + +#if os(macOS) +import AppKit + +/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row, +/// gated by the "Pointer Cursor" setting (Settings → Editor → Pointer Cursor). +/// `didPush` tracks whether this instance actually pushed a cursor, so a +/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop +/// stack unbalanced — pop only fires for a push this same hover made. +private struct PointerCursorOnHover: ViewModifier { + @AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true + @State private var didPush = false + + func body(content: Content) -> some View { + content.onHover { hovering in + if hovering { + guard isEnabled else { return } + NSCursor.pointingHand.push() + didPush = true + } else if didPush { + NSCursor.pop() + didPush = false + } + } + } +} + +extension View { + func pointerCursorOnHover() -> some View { + modifier(PointerCursorOnHover()) + } +} +#else +extension View { + func pointerCursorOnHover() -> some View { self } +} +#endif diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 87dd96d..5d7d97c 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -94,6 +94,14 @@ public struct MarkdownEditorConfiguration: Sendable { /// so this stays the embedder's explicit decision rather than something the /// engine infers from a color it happens to see. public var cursorFollowsSpanInk: Bool + /// Show a pointing-hand cursor over links while editing (not just in + /// read-only mode, where it always shows regardless of this flag). + /// + /// On by default. The embedder's "pointer cursor" preference maps + /// straight to this — off just means the I-beam stays over links like + /// any other text, since a link's edge zone still repositions the caret + /// for editing rather than navigating (see `clickedOnLink`). + public var pointerCursorOverLinksWhileEditing: Bool public init( theme: MarkdownEditorTheme = .default, @@ -123,7 +131,8 @@ public struct MarkdownEditorConfiguration: Sendable { heightBehavior: HeightBehavior = .scrolls, rawSourceMode: Bool = false, extensions: [any MarkdownExtension] = [], - cursorFollowsSpanInk: Bool = false + cursorFollowsSpanInk: Bool = false, + pointerCursorOverLinksWhileEditing: Bool = true ) { self.theme = theme self.services = services @@ -153,6 +162,7 @@ public struct MarkdownEditorConfiguration: Sendable { self.rawSourceMode = rawSourceMode self.extensions = extensions self.cursorFollowsSpanInk = cursorFollowsSpanInk + self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing } public static let `default` = MarkdownEditorConfiguration() diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift index 07e8e2b..0fe2fe5 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+CursorRects.swift @@ -75,11 +75,17 @@ extension NativeTextView { } /// In read-only mode, override NSTextView's I-beam: pointing hand over a - /// `.link` range, arrow everywhere else. + /// `.link` range, arrow everywhere else. In edit mode, only the pointing + /// hand part applies (gated on the embedder's preference) — everywhere + /// else keeps the I-beam, which `super` already set. private func applyReadOnlyCursor(for event: NSEvent) { - guard isSelectable, !isEditable else { return } // edit mode: keep I-beam + guard isSelectable else { return } let viewPoint = convert(event.locationInWindow, from: nil) - if isOverLink(at: viewPoint) { + if isEditable { + guard configuration.pointerCursorOverLinksWhileEditing, + isOverLink(at: viewPoint) else { return } // keep I-beam + NSCursor.pointingHand.set() + } else if isOverLink(at: viewPoint) { NSCursor.pointingHand.set() } else { NSCursor.arrow.set()