From f01c2b9467684a0c5dbb0f929fe3298720f50879 Mon Sep 17 00:00:00 2001 From: psmattas Date: Thu, 20 Aug 2026 22:21:36 +0100 Subject: [PATCH] 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. --- Outpost/Features/Account/SettingsView.swift | 11 ++++++ .../CollectionDocumentsOutline.swift | 2 + .../Collections/CollectionListContent.swift | 1 + .../Collections/CollectionOverviewView.swift | 3 ++ .../Collections/CollectionTreeRow.swift | 1 + .../Collections/CommandPaletteView.swift | 1 + .../Collections/DocumentReaderView.swift | 4 +- .../Search/GlobalSearchResultsView.swift | 1 + Outpost/Support/PointerCursorHover.swift | 38 +++++++++++++++++++ .../MarkdownEditorConfiguration.swift | 12 +++++- .../NativeTextView+CursorRects.swift | 12 ++++-- 11 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 Outpost/Support/PointerCursorHover.swift 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()