feat: pointer cursor preference

App-side chrome: new "Pointer Cursor" toggle (Settings -> Editor,
outpost.pointerCursorEnabled, on by default) driving a shared
.pointerCursorOnHover() view modifier (push/pop NSCursor.pointingHand,
macOS-only, no-op on iOS) wired onto every clickable sidebar/list row:
collection tree rows + disclosure chevron, flat collection list,
document outline rows, collection-overview tab bar + document/search
rows, global search results, command palette rows.

In-document link hover: MarkdownEditorConfiguration gets a new
pointerCursorOverLinksWhileEditing flag (default true). Read-only mode
already showed a pointing hand over links unconditionally; editable
mode never did (I-beam only) until now - applyReadOnlyCursor in
NativeTextView+CursorRects.swift now applies the same over any .link
range while editing too, gated by the flag. Wiki links already carry
.link alongside their own custom .wikiLinkID, so they're covered with
no extra work.

Closes out the last item from the original preferences-wiring scope.
This commit is contained in:
2026-08-20 22:21:36 +01:00
parent 283186b28f
commit f01c2b9467
11 changed files with 81 additions and 5 deletions
@@ -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)
}
}
@@ -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)
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
CollectionRowView(collection: collection)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -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()
}
}
}
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
@@ -134,6 +134,7 @@ struct CommandPaletteView: View {
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.pointerCursorOnHover()
.onTapGesture {
selectedIndex = index
selectCurrent()
@@ -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,
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
+38
View File
@@ -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
@@ -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()
@@ -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()