feat: wire smart text replacements, autocomplete, Writing Tools toggles

Adds TextSubstitutionPolicy/TextCompletionPolicy/WritingToolsPolicy to
MarkdownEditorConfiguration (previously hardcoded AppKit calls with no
config surface). Outline's existing synced smartText preference now
actually drives smart quotes/dashes. Autocomplete and Writing Tools
get new local-only Settings -> Editor toggles (writingToolsBehavior
was already on unconditionally with no way to turn it off).
This commit is contained in:
2026-08-20 13:53:53 +01:00
parent 3c3a5865bd
commit b60e7ec94e
5 changed files with 120 additions and 5 deletions
@@ -15,6 +15,8 @@ struct SettingsView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true @AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false @AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -166,6 +168,26 @@ struct SettingsView: View {
Divider().frame(maxWidth: 480) Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Autocomplete", isOn: $isAutocompleteEnabled)
Text("Show inline predictive-text suggestions while typing, same as Notes and TextEdit. Accept with Tab or →.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Writing Tools", isOn: $isWritingToolsEnabled)
Text("Proofread, rewrite, summarize, or compose text using system-provided Apple Intelligence tools. Available on supported Macs and macOS versions.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled) Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.") Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
@@ -17,6 +17,8 @@ struct DocumentReaderView: View {
/// Local-only Outpost setting (Settings Editor), not synced to /// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`. /// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
/// Outline's own "Show line numbers" preference (synced, read via /// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the /// `session.userPreferences`, not `@AppStorage` this one's the
@@ -32,6 +34,20 @@ struct DocumentReaderView: View {
private var editorCodeBlockStyle: CodeBlockStyle { private var editorCodeBlockStyle: CodeBlockStyle {
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
} }
/// Outline's own "Smart text replacements" preference (synced) smart
/// quotes/dashes while typing. Only meaningful on the editable pane.
private var editorTextSubstitution: TextSubstitutionPolicy {
let enabled = session.userPreferences?.smartText ?? false
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
}
/// Local-only Outpost settings (Settings Editor) not synced to
/// Outline, same as Split View above.
private var editorTextCompletion: TextCompletionPolicy {
.init(isEnabled: isAutocompleteEnabled)
}
private var editorWritingTools: WritingToolsPolicy {
.init(isEnabled: isWritingToolsEnabled)
}
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -337,6 +353,9 @@ struct DocumentReaderView: View {
configuration: .init( configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle, codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent heightBehavior: .fitsContent
), ),
documentId: viewModel.documentId, documentId: viewModel.documentId,
@@ -48,6 +48,16 @@ public struct MarkdownEditorConfiguration: Sendable {
/// Centered reading-column width; wide tables break out to full width. nil = full width (default). /// Centered reading-column width; wide tables break out to full width. nil = full width (default).
public var readingWidth: CGFloat? public var readingWidth: CGFloat?
public var spellChecking: SpellCheckingPolicy public var spellChecking: SpellCheckingPolicy
/// Smart quote/dash substitution while typing. Independent of `spellChecking`
/// AppKit tracks these as separate `NSTextView` flags.
public var textSubstitution: TextSubstitutionPolicy
/// Inline predictive-text completion (the ghost-text suggestion AppKit
/// shows as you type, same feature as Notes/TextEdit). Mirrors
/// `NSTextView.isAutomaticTextCompletionEnabled`.
public var textCompletion: TextCompletionPolicy
/// System Writing Tools (proofread/rewrite/summarize). Mirrors
/// `NSTextView.writingToolsBehavior` `.none` when disabled.
public var writingTools: WritingToolsPolicy
/// How the editor resolves its own height. /// How the editor resolves its own height.
/// ///
/// - `.scrolls` (default): the editor scrolls internally within whatever /// - `.scrolls` (default): the editor scrolls internally within whatever
@@ -107,6 +117,9 @@ public struct MarkdownEditorConfiguration: Sendable {
textInsets: TextInsets = .default, textInsets: TextInsets = .default,
readingWidth: CGFloat? = nil, readingWidth: CGFloat? = nil,
spellChecking: SpellCheckingPolicy = .default, spellChecking: SpellCheckingPolicy = .default,
textSubstitution: TextSubstitutionPolicy = .default,
textCompletion: TextCompletionPolicy = .default,
writingTools: WritingToolsPolicy = .default,
heightBehavior: HeightBehavior = .scrolls, heightBehavior: HeightBehavior = .scrolls,
rawSourceMode: Bool = false, rawSourceMode: Bool = false,
extensions: [any MarkdownExtension] = [], extensions: [any MarkdownExtension] = [],
@@ -133,6 +146,9 @@ public struct MarkdownEditorConfiguration: Sendable {
self.textInsets = textInsets self.textInsets = textInsets
self.readingWidth = readingWidth self.readingWidth = readingWidth
self.spellChecking = spellChecking self.spellChecking = spellChecking
self.textSubstitution = textSubstitution
self.textCompletion = textCompletion
self.writingTools = writingTools
self.heightBehavior = heightBehavior self.heightBehavior = heightBehavior
self.rawSourceMode = rawSourceMode self.rawSourceMode = rawSourceMode
self.extensions = extensions self.extensions = extensions
@@ -168,6 +184,59 @@ public struct SpellCheckingPolicy: Sendable {
public static let `default` = SpellCheckingPolicy() public static let `default` = SpellCheckingPolicy()
} }
// MARK: - Text substitution
/// Smart quote/dash substitution while typing. Mirrors the historical
/// hardcoded `NativeTextViewWrapper` behavior (quotes on, dashes off) as the
/// default, now exposed as a config knob instead of fixed AppKit calls.
public struct TextSubstitutionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticQuoteSubstitutionEnabled`.
public var quoteSubstitution: Bool
/// Mirrors `NSTextView.isAutomaticDashSubstitutionEnabled`.
public var dashSubstitution: Bool
public init(
quoteSubstitution: Bool = true,
dashSubstitution: Bool = false
) {
self.quoteSubstitution = quoteSubstitution
self.dashSubstitution = dashSubstitution
}
public static let `default` = TextSubstitutionPolicy()
}
// MARK: - Text completion
/// Inline predictive-text completion while typing (ghost-text suggestion,
/// accepted with Tab/ same feature as Notes/TextEdit on macOS 14+).
public struct TextCompletionPolicy: Sendable {
/// Mirrors `NSTextView.isAutomaticTextCompletionEnabled`.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = TextCompletionPolicy()
}
// MARK: - Writing Tools
/// System Writing Tools (proofread / rewrite / summarize / compose), backed
/// by Apple Intelligence where available.
public struct WritingToolsPolicy: Sendable {
/// When `false`, `NSTextView.writingToolsBehavior` is set to `.none`
/// instead of `.limited` hides Writing Tools entirely for this view.
public var isEnabled: Bool
public init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
}
public static let `default` = WritingToolsPolicy()
}
// MARK: - Scroll bars // MARK: - Scroll bars
/// Scroll bar visibility. Default: vertical only, autohide on. /// Scroll bar visibility. Default: vertical only, autohide on.
@@ -62,8 +62,12 @@ extension NativeTextViewCoordinator {
textView.isGrammarCheckingEnabled = shouldDisableSpelling textView.isGrammarCheckingEnabled = shouldDisableSpelling
? false ? false
: userPrefersGrammarChecking : userPrefersGrammarChecking
textView.isAutomaticQuoteSubstitutionEnabled = !shouldDisableSpelling textView.isAutomaticQuoteSubstitutionEnabled = shouldDisableSpelling
textView.isAutomaticDashSubstitutionEnabled = false ? false
: configuration.textSubstitution.quoteSubstitution
textView.isAutomaticDashSubstitutionEnabled = shouldDisableSpelling
? false
: configuration.textSubstitution.dashSubstitution
} }
func isInsideCode(range: NSRange, in text: String) -> Bool { func isInsideCode(range: NSRange, in text: String) -> Bool {
@@ -280,16 +280,17 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
textView.isAutomaticSpellingCorrectionEnabled = configuration.spellChecking.automaticSpellingCorrection textView.isAutomaticSpellingCorrectionEnabled = configuration.spellChecking.automaticSpellingCorrection
textView.isContinuousSpellCheckingEnabled = configuration.spellChecking.continuousSpellChecking textView.isContinuousSpellCheckingEnabled = configuration.spellChecking.continuousSpellChecking
textView.isGrammarCheckingEnabled = configuration.spellChecking.grammarChecking textView.isGrammarCheckingEnabled = configuration.spellChecking.grammarChecking
textView.isAutomaticQuoteSubstitutionEnabled = true textView.isAutomaticQuoteSubstitutionEnabled = configuration.textSubstitution.quoteSubstitution
textView.isAutomaticDataDetectionEnabled = true textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = configuration.textSubstitution.dashSubstitution
textView.onPasteImage = onPasteImage textView.onPasteImage = onPasteImage
textView.isAutomaticTextCompletionEnabled = configuration.textCompletion.isEnabled
if #available(macOS 15.1, *) { if #available(macOS 15.1, *) {
// `.limited` = the Writing Tools popover panel; `.complete` = the inline // `.limited` = the Writing Tools popover panel; `.complete` = the inline
// experience that morphs the text with an animation. We use `.limited` so // experience that morphs the text with an animation. We use `.limited` so
// rewrites/proofread land in the popover (no in-text animation) it also // rewrites/proofread land in the popover (no in-text animation) it also
// sidesteps the inline-rewrite flicker that dims text below the selection. // sidesteps the inline-rewrite flicker that dims text below the selection.
textView.writingToolsBehavior = .limited textView.writingToolsBehavior = configuration.writingTools.isEnabled ? .limited : .none
} }
// Create TextKit 2 layout bridge // Create TextKit 2 layout bridge
let bridge = LayoutBridge(textLayoutManager) let bridge = LayoutBridge(textLayoutManager)