3 Commits
Author SHA1 Message Date
Puranjay Savar Mattas c36c70382b redesign: pinned document cards on Home
PinnedDocumentCard was a squat single-line HStack chip (12/9 padding,
cornerRadius 8, flat tertiary fill) - in a grid of several it read as a
row of tab/segmented-control buttons rather than distinct documents.
Restyled to the same tall VStack card shape as DocumentCardView (the
tab grids below it): icon row, 2-line headline title, relative-date
caption, subtle border. The one thing that still marks these as
pinned vs. the grids below is an accent-filled circular pin badge in
the corner, replacing the old plain gray pin glyph.

Also wired the new pointerCursorOnHover() modifier onto the pinned
card buttons, same as every other clickable row in the sidebar/lists.
2026-08-20 22:26:21 +01:00
Puranjay Savar Mattas f01c2b9467 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.
2026-08-20 22:21:36 +01:00
Puranjay Savar Mattas 283186b28f feat: code block language picker
Top-left pill overlay on every code block (opposite corner from the
engine's own copy button) showing the current language; tapping opens
a curated ~20-language menu that rewrites just the fence line,
content untouched.

New engine mechanism to support it: CodeBlockSelection now exposes
fenceRange (the opening ```lang line's range, straight from the
tokenizer's own markerRanges[0] - was already computed, just never
exposed) plus a generic TextRangeReplacementRequest/
pendingTextRangeReplacement, since the existing pendingTextInsertion
only replaces at the current caret/selection, not an arbitrary
already-known range like a fence line the user isn't necessarily
positioned at.

Horizontal scroll for long lines investigated and dropped, per
explicit instruction after weighing it: the only viable path (a real,
selectable/editable independently-scrolling text region) needs an
embedded NSTextAttachmentViewProvider-backed NSTextView per code
block, which would split selection/copy/undo into two contexts
(the code block's own vs. the surrounding document's) instead of one
unified editing surface - a different regression, not a clean win.
Code blocks keep wrapping.
2026-08-20 22:07:48 +01:00
19 changed files with 300 additions and 35 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
@@ -123,6 +124,9 @@ struct DocumentReaderView: View {
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
/// line when the user switches its language from the picker.
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
@@ -547,13 +551,15 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent
heightBehavior: .fitsContent,
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
@@ -568,6 +574,13 @@ struct DocumentReaderView: View {
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
if viewModel.isEffectivelyEditable {
ForEach(readerCodeBlocks) { selection in
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
pendingCodeBlockLanguageChange = request
}
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
@@ -934,6 +947,81 @@ struct DocumentReaderView: View {
/// Thin vertical bar next to an anchored comment's text same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
/// Top-left pill overlay on each code block opposite corner from the
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
/// list (not the full ~190-language highlight.js catalog same "small
/// curated set, not the whole thing" call as the emoji reaction picker);
/// picking one rewrites just the fence line via
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
private struct CodeBlockLanguagePicker: View {
let selection: CodeBlockSelection
let documentId: String
let onRequest: (TextRangeReplacementRequest) -> Void
private static let languages: [(label: String, tag: String?)] = [
("Plain Text", nil),
("Swift", "swift"),
("Python", "python"),
("JavaScript", "javascript"),
("TypeScript", "typescript"),
("Bash", "bash"),
("JSON", "json"),
("YAML", "yaml"),
("HTML", "html"),
("CSS", "css"),
("SQL", "sql"),
("Java", "java"),
("Kotlin", "kotlin"),
("C", "c"),
("C++", "cpp"),
("C#", "csharp"),
("Go", "go"),
("Rust", "rust"),
("Ruby", "ruby"),
("PHP", "php"),
("Markdown", "markdown"),
]
private var currentLabel: String {
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
}
var body: some View {
Color.clear
.frame(width: selection.rect.width, height: selection.rect.height)
.overlay(alignment: .topLeading) {
Menu {
ForEach(Self.languages, id: \.label) { entry in
Button(entry.label) {
onRequest(TextRangeReplacementRequest(
documentId: documentId,
range: selection.fenceRange,
replacement: "```\(entry.tag ?? "")\n"
))
}
}
} label: {
Text(currentLabel.uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.top, 6)
.padding(.leading, 8)
}
.position(
x: selection.rect.midX,
y: selection.rect.midY
)
}
}
private struct CommentAnchorMarker: View {
let rect: CGRect
let onTap: () -> Void
+1
View File
@@ -101,6 +101,7 @@ struct HomeView: View {
PinnedDocumentCard(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
.padding(.horizontal, 24)
+29 -17
View File
@@ -2,45 +2,57 @@
import SwiftUI
import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for
/// a quick scan of a small curated set, not browsing, so this is a dense
/// single-line row rather than a tall card, with an explicit pin glyph so
/// it doesn't read the same as the tab grids below it.
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
/// pinned section reads as a set of cards, not a row of tab-like chips the
/// accent-filled pin badge is the one thing that marks these as pinned.
struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
if let emoji = document.emoji {
Text(emoji)
.font(.title3)
.font(.title2)
} else {
Image(systemName: "doc.text")
.font(.body)
.font(.title3)
.foregroundStyle(.secondary)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium))
.lineLimit(1)
Spacer(minLength: 0)
Spacer()
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.font(.caption)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(5)
.background(Color.accentColor, in: Circle())
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
}
#endif
@@ -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()
@@ -24,12 +24,19 @@ public struct CodeBlockSelection: Identifiable, Sendable {
/// Plain text content of the block, suitable for putting on the
/// pasteboard.
public let code: String
/// Range of the opening fence line (`` ```lang\n `` or `` ```\n ``) in
/// the text view's DISPLAY-form string `token.markerRanges[0]` from
/// the parser. Lets an embedder safely rewrite just the language tag
/// (e.g. a language-picker menu) without touching the block's content,
/// which `code`/`rect` alone don't give enough to do.
public let fenceRange: NSRange
public init(id: Int, rect: CGRect, language: String?, code: String) {
public init(id: Int, rect: CGRect, language: String?, code: String, fenceRange: NSRange) {
self.id = id
self.rect = rect
self.language = language
self.code = code
self.fenceRange = fenceRange
}
}
@@ -77,6 +77,7 @@ extension NativeTextViewCoordinator {
guard !activeTokenIndices.contains(originalIndex) else { return nil }
if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil }
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
guard let fenceRange = token.markerRanges.first else { return nil }
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
boundingRect.size.width = textContainer.containerSize.width
@@ -85,7 +86,8 @@ extension NativeTextViewCoordinator {
id: originalIndex,
rect: boundingRect,
language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string),
code: nsText.substring(with: token.contentRange)
code: nsText.substring(with: token.contentRange),
fenceRange: fenceRange
)
}
@@ -469,6 +469,34 @@ extension NativeTextViewCoordinator {
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
}
/// Replaces `request.range` verbatim unlike `applyTextInsertion`, does
/// NOT touch the current caret/selection at all, since the range being
/// replaced (e.g. a code block's fence line) usually isn't wherever the
/// caret happens to be. No-ops if `range` no longer fits inside the
/// current document (the doc changed shape since the request was built
/// safer to drop the edit than risk corrupting an unrelated range).
func applyTextRangeReplacement(_ request: TextRangeReplacementRequest, to textView: NSTextView) {
lastAppliedTextRangeReplacementID = request.id
let documentLength = (textView.string as NSString).length
guard request.range.location != NSNotFound,
NSMaxRange(request.range) <= documentLength else { return }
textView.breakUndoCoalescing()
isProgrammaticEdit = true
defer { isProgrammaticEdit = false }
guard textView.shouldChangeText(in: request.range, replacementString: request.replacement) else {
return
}
textView.textStorage?.replaceCharacters(in: request.range, with: request.replacement)
textView.didChangeText()
textView.undoManager?.setActionName("Change Code Block Language")
textView.breakUndoCoalescing()
}
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
lastAppliedInlineReplacementID = request.id
@@ -110,6 +110,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var wtPostUndoSnapshot: String?
var lastAppliedInlineReplacementID: UUID?
var lastAppliedTextInsertionID: UUID?
var lastAppliedTextRangeReplacementID: UUID?
var activeTokenIndices: Set<Int> = []
var previousActiveTokenIndices: Set<Int> = []
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
@@ -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()
@@ -120,3 +120,36 @@ public struct TextInsertionRequest: Sendable {
self.text = text
}
}
/// Request to replace a SPECIFIC, already-known range e.g. a code block's
/// fence line (``CodeBlockSelection/fenceRange``) when switching its
/// language regardless of where the caret currently is. Unlike
/// ``TextInsertionRequest``, this doesn't touch the current selection at
/// all.
///
/// Embedders push one of these into
/// ``NativeTextViewWrapper/pendingTextRangeReplacement`` to commit it. The
/// range is in DISPLAY-form coordinates (the same space `CodeBlockSelection`
/// and every other rect/range the engine hands back use) stale by the
/// time it's applied only if the document changed shape in between, in
/// which case the engine no-ops rather than risk corrupting an unrelated
/// range.
public struct TextRangeReplacementRequest: Sendable {
/// Stable identifier so the engine can detect already-applied requests
/// across SwiftUI re-renders.
public let id: UUID
/// Document the replacement targets. Ignored if it doesn't match the
/// editor's current `documentId` (prevents cross-document writes).
public let documentId: String
/// Range to replace, in the display-form string's coordinates.
public let range: NSRange
/// Text to put in `range`'s place.
public let replacement: String
public init(id: UUID = UUID(), documentId: String, range: NSRange, replacement: String) {
self.id = id
self.documentId = documentId
self.range = range
self.replacement = replacement
}
}
@@ -59,6 +59,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextInsertionRequest``.
@Binding public var pendingTextInsertion: TextInsertionRequest?
/// Push a specific-range replacement (e.g. rewriting a code block's
/// fence line to change its language) by setting this to a non-nil
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextRangeReplacementRequest``.
@Binding public var pendingTextRangeReplacement: TextRangeReplacementRequest?
/// The full editor configuration (theme + services + style toggles). Engine
/// embedders construct this themselves and pass it in; the wrapper does
/// not read UserDefaults or know about app-specific colors/services.
@@ -159,6 +164,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
isWikiLinkActive: Binding<Bool> = .constant(false),
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
pendingTextRangeReplacement: Binding<TextRangeReplacementRequest?> = .constant(nil),
configuration: MarkdownEditorConfiguration = .default,
fontName: String = "SF Pro",
fontSize: CGFloat = 16,
@@ -188,6 +194,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self._isWikiLinkActive = isWikiLinkActive
self._pendingInlineReplacement = pendingInlineReplacement
self._pendingTextInsertion = pendingTextInsertion
self._pendingTextRangeReplacement = pendingTextRangeReplacement
self.configuration = configuration
self.fontName = fontName
self.fontSize = fontSize
@@ -636,6 +643,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
}
return
}
if let pendingTextRangeReplacement {
if pendingTextRangeReplacement.documentId == documentId,
context.coordinator.lastAppliedTextRangeReplacementID != pendingTextRangeReplacement.id {
context.coordinator.applyTextRangeReplacement(pendingTextRangeReplacement, to: textView)
}
DispatchQueue.main.async {
if self.pendingTextRangeReplacement?.id == pendingTextRangeReplacement.id {
self.pendingTextRangeReplacement = nil
}
}
return
}
if context.coordinator.didInitialFormatting
&& context.coordinator.lastSyncedText == text
&& !fontChanged {