From c1fcf1356442181d1d7d977bc8e198625a4e3675 Mon Sep 17 00:00:00 2001 From: Paul Scandariato Date: Sun, 30 Aug 2026 15:07:16 -0400 Subject: [PATCH] Sync every style field of the configuration in updateNSView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateNSView` used to copy only a handful of `MarkdownEditorConfiguration` fields — `heightBehavior`, `rawSourceMode`, `lists`, and the fingerprint-gated `services` / `extensions` / `directives`. Everything else — `theme`, `paragraph`, `link`, `markers`, `codeBlock`, `inlineCode`, `taskCheckbox`, `blockquote`, `headings`, `imageEmbed`, `blockLatex`, `inlineLatex`, `thematicBreak`, `cursorFollowsSpanInk` — was captured once in `makeCoordinator` and never refreshed, so an embedder flipping between two configurations (a light/dark palette toggle, a heading-metric change, a link-ink update) kept whatever those fields were at first mount: the styler kept reading them off a stale snapshot, and each `[StyledRange]` rebuild painted the old colors and metrics back over the fresh storage. The reconcile in `updateNSView` compares the pure-style fields via an internal `styleSignature`, copies them across when they differ, and flips `didInitialFormatting` off so the rebuild path below runs and the new palette / metrics reach text storage. The fingerprint-gated paths above stay as they were — services and grammar changes still restyle through their own branches — and a configuration whose style fields are unchanged pays only a struct compare. Value-typed configuration sub-structs (`MarkdownEditorTheme`, `MarkerStyle`, `HeadingStyle`, `LinkStyle`, `ParagraphStyle`, and the rest) now conform to `Equatable` so the signature compare is a synthesized member-by-member check. `InlineLatexStyle` has a hand-written `==` because its stored `Void` placeholder can't be synthesized against. `Tests/MarkdownEngineTests/ConfigurationStyleSyncTests.swift` pins the three pieces the fix relies on: the signature flips on every pure-style field and stays put for grammar/service/lifecycle fields, `adoptStyleFields` copies exactly the style fields and leaves the rest alone, and a rebuild after swapping the theme paints body text with the new color rather than the one captured at first mount. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01LNvrf6TPFQ571njp1G98wt --- CHANGELOG.md | 21 +++ .../MarkdownEditorConfiguration.swift | 110 +++++++++++--- .../Configuration/MarkdownEditorTheme.swift | 2 +- .../TextView/NativeTextViewWrapper.swift | 18 +++ .../ConfigurationStyleSyncTests.swift | 143 ++++++++++++++++++ 5 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 Tests/MarkdownEngineTests/ConfigurationStyleSyncTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fbc83f..bbdddb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 line-break, programmatic, and undo/redo edits still widen ordered-list runs when downstream display numbers can change. +### Fixed +- A live swap of `MarkdownEditorConfiguration` now reaches the styler. Only a + handful of fields (`heightBehavior`, `rawSourceMode`, `lists`, and the + fingerprint-gated `services` / `extensions` / `directives`) used to sync in + `updateNSView`; everything else — `theme`, `paragraph`, `link`, `markers`, + `codeBlock`, `inlineCode`, `taskCheckbox`, `blockquote`, `headings`, + `imageEmbed`, `blockLatex`, `inlineLatex`, `thematicBreak`, + `cursorFollowsSpanInk` — was captured once in `makeCoordinator` and never + refreshed, so an embedder flipping between two configurations (a light/dark + palette toggle, a heading-metric change, a link-ink update) kept whatever + those fields were at first mount. `updateNSView` now compares the pure-style + fields via an internal `styleSignature`, copies them across when they + differ, and forces a rebuild so the new palette / metrics reach text + storage. The fingerprint-gated paths above stay as they were — services and + grammar changes still restyle through their own branches, and a + configuration whose style fields are unchanged pays only a struct compare. + Value-typed configuration sub-structs (`MarkdownEditorTheme`, `MarkerStyle`, + `HeadingStyle`, `LinkStyle`, `ParagraphStyle`, and the rest) now conform to + `Equatable` so the compare is a synthesized member-by-member check rather + than a bespoke traversal. + ## [0.12.0] - 2026-08-10 ### Added diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 2f90699d..4017c6e9 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -155,12 +155,79 @@ public struct MarkdownEditorConfiguration: Sendable { public static let `default` = MarkdownEditorConfiguration() } +// MARK: - Style-only reconciliation + +extension MarkdownEditorConfiguration { + /// Snapshot of the value-typed style fields, compared in `updateNSView` + /// to decide whether a live swap needs a restyle. Excludes fingerprint- + /// gated fields (services, extensions/directives) and lifecycle knobs — + /// they have their own sync paths. + internal var styleSignature: StyleSignature { + StyleSignature( + theme: theme, + markers: markers, + codeBlock: codeBlock, + inlineCode: inlineCode, + taskCheckbox: taskCheckbox, + headings: headings, + imageEmbed: imageEmbed, + blockLatex: blockLatex, + inlineLatex: inlineLatex, + blockquote: blockquote, + thematicBreak: thematicBreak, + link: link, + paragraph: paragraph, + cursorFollowsSpanInk: cursorFollowsSpanInk + ) + } + + /// Copy every style-only field from `other` into `self`. Fields that + /// already have their own sync path in `updateNSView` are left alone. + internal mutating func adoptStyleFields(from other: MarkdownEditorConfiguration) { + theme = other.theme + markers = other.markers + codeBlock = other.codeBlock + inlineCode = other.inlineCode + taskCheckbox = other.taskCheckbox + headings = other.headings + imageEmbed = other.imageEmbed + blockLatex = other.blockLatex + inlineLatex = other.inlineLatex + blockquote = other.blockquote + thematicBreak = other.thematicBreak + link = other.link + paragraph = other.paragraph + cursorFollowsSpanInk = other.cursorFollowsSpanInk + } + + /// Snapshot of the pure-style fields, compared to decide whether a live + /// configuration swap needs a restyle. Internal because the layout that + /// makes it convenient here is an implementation detail — callers ask + /// via `styleSignature` and let Equatable do the compare. + internal struct StyleSignature: Equatable { + let theme: MarkdownEditorTheme + let markers: MarkerStyle + let codeBlock: CodeBlockStyle + let inlineCode: InlineCodeStyle + let taskCheckbox: TaskCheckboxStyle + let headings: HeadingStyle + let imageEmbed: ImageEmbedStyle + let blockLatex: BlockLatexStyle + let inlineLatex: InlineLatexStyle + let blockquote: BlockquoteStyle + let thematicBreak: ThematicBreakStyle + let link: LinkStyle + let paragraph: ParagraphStyle + let cursorFollowsSpanInk: Bool + } +} + // MARK: - Spell checking /// Initial state for the three "Spelling and Grammar" toggles. Only consulted /// at `makeNSView` time; afterwards the user's context-menu choices take /// precedence and are surfaced via ``NativeTextViewWrapper/onSpellCheckingPolicyChanged``. -public struct SpellCheckingPolicy: Sendable { +public struct SpellCheckingPolicy: Sendable, Equatable { /// Mirrors `NSTextView.isContinuousSpellCheckingEnabled`. public var continuousSpellChecking: Bool /// Mirrors `NSTextView.isGrammarCheckingEnabled`. @@ -184,7 +251,7 @@ public struct SpellCheckingPolicy: Sendable { // MARK: - Scroll bars /// Scroll bar visibility. Default: vertical only, autohide on. -public struct ScrollersPolicy: Sendable { +public struct ScrollersPolicy: Sendable, Equatable { public var hasVerticalScroller: Bool public var hasHorizontalScroller: Bool public var autohidesScrollers: Bool @@ -213,7 +280,7 @@ public struct ScrollersPolicy: Sendable { // MARK: - Text insets /// Margins inside the text view (`NSTextView.textContainerInset`). Scroll bar stays at the outer edge. -public struct TextInsets: Sendable { +public struct TextInsets: Sendable, Equatable { public var horizontal: CGFloat public var vertical: CGFloat @@ -235,7 +302,7 @@ public struct TextInsets: Sendable { /// any range translation between displayed and stored text — cursor movement, /// find/replace, selection, and copy/paste all stay trivially correct. /// The trade-off is a sub-pixel residue at extreme zoom levels. -public struct MarkerStyle: Sendable { +public struct MarkerStyle: Sendable, Equatable { /// Font size used for "hidden" inline markers. Effectively invisible at /// normal zoom while keeping displayed-range == stored-range. public var hiddenMarkerFontSize: CGFloat @@ -261,7 +328,7 @@ public struct MarkerStyle: Sendable { // MARK: - Code blocks /// Styling for fenced code blocks (```language ... ```). -public struct CodeBlockStyle: Sendable { +public struct CodeBlockStyle: Sendable, Equatable { /// Code-block font size as a fraction of the document base font size. public var fontSizeScale: CGFloat /// Vertical paragraph spacing applied above and below the code block. @@ -285,7 +352,7 @@ public struct CodeBlockStyle: Sendable { // MARK: - Inline code /// Styling for inline `` `code` `` spans. -public struct InlineCodeStyle: Sendable { +public struct InlineCodeStyle: Sendable, Equatable { /// Inline-code reuses the code block font size scale by default. public var fontSizeScale: CGFloat @@ -299,7 +366,7 @@ public struct InlineCodeStyle: Sendable { // MARK: - Lists /// Behavior toggles and metrics for ordered / unordered list editing. -public struct ListStyle: Sendable { +public struct ListStyle: Sendable, Equatable { /// Master switch for list-related editing helpers (auto-continue, /// auto-indent, marker conversion). When `false`, lists are still /// rendered, but typing-time conveniences are skipped. @@ -340,7 +407,7 @@ public struct ListStyle: Sendable { /// typo degrades to the stock look instead of drawing nothing. Tint colors /// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked, /// `MarkdownEditorTheme/bodyText` checked). -public struct TaskCheckboxStyle: Sendable { +public struct TaskCheckboxStyle: Sendable, Equatable { /// SF Symbol drawn for an unchecked task item (`[ ]`). public var uncheckedSymbolName: String /// SF Symbol drawn for a checked task item (`[x]`). @@ -380,7 +447,7 @@ public struct TaskCheckboxStyle: Sendable { /// The mark is presentation only. The source text is untouched, the caret /// still reveals the raw `***` when it enters the line, and copy, export and /// find all see the original characters. -public struct ThematicBreakStyle: Sendable { +public struct ThematicBreakStyle: Sendable, Equatable { /// A centered mark and the size it draws at. /// @@ -453,7 +520,7 @@ extension ThematicBreakStyle { /// Per-level heading metrics. Defaults follow the historical Nodes ratios, /// which are loosely based on browser default heading sizes. -public struct HeadingStyle: Sendable { +public struct HeadingStyle: Sendable, Equatable { /// Font-size multiplier per heading level (1...6). public var fontMultipliers: [CGFloat] /// Top spacing in `em` units per heading level (1...6). @@ -483,7 +550,7 @@ public struct HeadingStyle: Sendable { // MARK: - Image embeds (![[...]]) /// Sizing and spacing rules for `![[Name]]` image embeds. -public struct ImageEmbedStyle: Sendable { +public struct ImageEmbedStyle: Sendable, Equatable { /// Minimum allowed display width (points) for an embedded image. public var minimumWidth: CGFloat /// Fallback maximum width if no usable text container width is available. @@ -515,7 +582,7 @@ public struct ImageEmbedStyle: Sendable { // MARK: - LaTeX /// Vertical spacing for block-LaTeX `$$...$$` paragraphs. -public struct BlockLatexStyle: Sendable { +public struct BlockLatexStyle: Sendable, Equatable { /// Top spacing for $$...$$ block paragraphs. public var paragraphSpacingBefore: CGFloat /// Bottom spacing for $$...$$ block paragraphs. @@ -538,13 +605,18 @@ public struct BlockLatexStyle: Sendable { /// Reserved for future inline-LaTeX (`$...$`) tuning. Currently has no /// effect; inline LaTeX inherits font size from the surrounding context. -public struct InlineLatexStyle: Sendable { +public struct InlineLatexStyle: Sendable, Equatable { /// Reserved for future inline-LaTeX tuning — currently the engine inherits /// font size from the surrounding heading context. public var placeholder: Void public init() { self.placeholder = () } + /// Two `InlineLatexStyle` values are always equal — the struct exists as + /// a reservation point and carries no state. Written by hand because the + /// synthesized conformance can't compare `Void`. + public static func == (lhs: InlineLatexStyle, rhs: InlineLatexStyle) -> Bool { true } + public static let `default` = InlineLatexStyle() } @@ -556,7 +628,7 @@ public struct InlineLatexStyle: Sendable { /// extra spacing. Set `extraLineHeight` to add breathing room, matching /// the pattern used by `ListStyle.extraLineHeight` and /// `ParagraphStyle.lineHeightExtraSpacing`. -public struct BlockquoteStyle: Sendable { +public struct BlockquoteStyle: Sendable, Equatable { /// Extra height (points) added to the default line height for blockquote lines. public var extraLineHeight: CGFloat @@ -570,7 +642,7 @@ public struct BlockquoteStyle: Sendable { // MARK: - Links /// Foreground alpha values applied to link content in different states. -public struct LinkStyle: Sendable { +public struct LinkStyle: Sendable, Equatable { /// Foreground alpha for the visible label of an active markdown link. public var activeLinkAlpha: CGFloat /// Foreground alpha applied to "incomplete" link content (e.g. `[text]` @@ -588,7 +660,7 @@ public struct LinkStyle: Sendable { // MARK: - Paragraphs /// Default paragraph spacing and line height applied to body text. -public struct ParagraphStyle: Sendable { +public struct ParagraphStyle: Sendable, Equatable { /// Extra paragraph spacing as a fraction of the document's default line height. public var spacingFactor: CGFloat /// Extra height (points) added to the default paragraph line height. @@ -607,7 +679,7 @@ public struct ParagraphStyle: Sendable { /// Controls the empty space below the last line so that typing at the bottom /// of a long document remains comfortable instead of pinning to the viewport /// bottom edge. -public struct OverscrollPolicy: Sendable { +public struct OverscrollPolicy: Sendable, Equatable { /// Desired overscroll as a fraction of the visible viewport height. public var percent: CGFloat /// Hard upper bound for the overscroll in points. @@ -640,7 +712,7 @@ public struct OverscrollPolicy: Sendable { /// Tuning for the auto-scroll boost that engages while the user drags a /// selection past the visible viewport edges. -public struct DragSelectionPolicy: Sendable { +public struct DragSelectionPolicy: Sendable, Equatable { /// Movement threshold (points) before the auto-scroll boost engages. public var movementThreshold: CGFloat /// Distance from the window edge that triggers the boost. @@ -668,7 +740,7 @@ public struct DragSelectionPolicy: Sendable { // MARK: - Safe-area insets /// Reserves space on the scroll view for system overlays (e.g. a translucent toolbar to scroll underneath). Maps to `NSScrollView.contentInsets`; scroll bar follows the inset. -public struct SafeAreaInsets: Sendable { +public struct SafeAreaInsets: Sendable, Equatable { public var top: CGFloat public var leading: CGFloat public var trailing: CGFloat diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..6e667639 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -24,7 +24,7 @@ import Foundation /// single override is enough to retheme the entire editor. The defaults /// reproduce a system-native macOS look using `NSColor` dynamic system /// colors, so light/dark-mode switching keeps working without extra code. -public struct MarkdownEditorTheme: Sendable { +public struct MarkdownEditorTheme: Sendable, Equatable { // MARK: Text colors diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index e081003e..7834f72d 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -556,6 +556,24 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.restyleParagraphs([fullRange], in: textView) } } + // Reconcile the pure-style fields (theme, paragraph, link, markers, + // codeBlock, inlineCode, taskCheckbox, headings, imageEmbed, blockLatex, + // inlineLatex, blockquote, thematicBreak, cursorFollowsSpanInk) so an + // embedder that swaps configurations at runtime — a light/dark palette + // toggle, a heading-metric change, a link-ink update — actually repaints. + // Without this the coordinator's `configuration` is stuck at whatever + // `makeCoordinator` captured, and the styler reads `theme.bodyText` etc. + // from that stale snapshot on every rebuild. The grammar/services fields + // above have their own fingerprint-gated paths; here we handle everything + // else that only affects how already-parsed content is drawn. + // + // Flipping `didInitialFormatting` off skips the early-return guard below, + // so the full rebuild path runs and applies the new attributes to storage. + if context.coordinator.configuration.styleSignature != configuration.styleSignature { + context.coordinator.configuration.adoptStyleFields(from: configuration) + textView.configuration.adoptStyleFields(from: configuration) + context.coordinator.didInitialFormatting = false + } textView.isEditable = isEditable textView.isSelectable = true // Keep the caret ink the selection handler resolved (an extension span diff --git a/Tests/MarkdownEngineTests/ConfigurationStyleSyncTests.swift b/Tests/MarkdownEngineTests/ConfigurationStyleSyncTests.swift new file mode 100644 index 00000000..bec25f43 --- /dev/null +++ b/Tests/MarkdownEngineTests/ConfigurationStyleSyncTests.swift @@ -0,0 +1,143 @@ +// +// ConfigurationStyleSyncTests.swift +// MarkdownEngineTests +// +// `updateNSView` used to copy only a handful of `MarkdownEditorConfiguration` +// fields into the coordinator, so an embedder that swapped configurations at +// runtime — a light/dark theme toggle, a heading-metric change, a link-ink +// update — got a stale styler on every rebuild. These tests pin the two +// pieces the sync path relies on: the style-signature comparison and the +// field-by-field copy, plus an end-to-end proof that the styler actually +// reads the coordinator's live theme when a rebuild fires. +// + +import AppKit +import SwiftUI +import Testing +@testable import MarkdownEngine + +@MainActor +@Suite("Configuration style-only sync") +struct ConfigurationStyleSyncTests { + + // MARK: - Signature comparison + + @Test("changing any pure-style field flips the style signature") + func styleSignatureDetectsEveryStyleField() { + let base = MarkdownEditorConfiguration.default + + var themed = base + themed.theme = MarkdownEditorTheme(bodyText: .red) + #expect(base.styleSignature != themed.styleSignature) + + var paragraphed = base + paragraphed.paragraph = ParagraphStyle(spacingFactor: 0.9) + #expect(base.styleSignature != paragraphed.styleSignature) + + var linked = base + linked.link = LinkStyle(activeLinkAlpha: 0.1) + #expect(base.styleSignature != linked.styleSignature) + + var headed = base + headed.headings = HeadingStyle(fontMultipliers: [3, 2, 1.5, 1, 1, 1]) + #expect(base.styleSignature != headed.styleSignature) + + var marked = base + marked.markers = MarkerStyle(hiddenMarkerFontSize: 5) + #expect(base.styleSignature != marked.styleSignature) + + var caret = base + caret.cursorFollowsSpanInk.toggle() + #expect(base.styleSignature != caret.styleSignature) + } + + @Test("changing grammar/service/lifecycle fields does not flip the style signature") + func styleSignatureIgnoresNonStyleFields() { + // These fields have their own sync paths in `updateNSView` — the style + // signature must not drag them into its own compare, or every services + // fingerprint bump would double-fire a full restyle. + let base = MarkdownEditorConfiguration.default + + var lists = base + lists.lists = ListStyle(helpersEnabled: false) + #expect(base.styleSignature == lists.styleSignature) + + var height = base + height.heightBehavior = .fitsContent + #expect(base.styleSignature == height.styleSignature) + + var raw = base + raw.rawSourceMode = true + #expect(base.styleSignature == raw.styleSignature) + + var insets = base + insets.safeAreaInsets = SafeAreaInsets(top: 40) + #expect(base.styleSignature == insets.styleSignature) + } + + // MARK: - Field copy + + @Test("adoptStyleFields copies every style field, and only those") + func adoptStyleFieldsCopiesTheRightFields() { + var target = MarkdownEditorConfiguration.default + // Give the target a distinctive non-style state so we can prove those + // fields are left alone. + target.heightBehavior = .fitsContent + target.rawSourceMode = true + target.lists = ListStyle(helpersEnabled: false) + + var source = MarkdownEditorConfiguration.default + source.theme = MarkdownEditorTheme(bodyText: .systemPurple, link: .systemPink) + source.paragraph = ParagraphStyle(spacingFactor: 0.9, lineHeightExtraSpacing: 4) + source.link = LinkStyle(activeLinkAlpha: 0.11, incompleteLinkAlpha: 0.22) + source.headings = HeadingStyle(fontMultipliers: [3, 2, 1.5, 1, 1, 1]) + source.cursorFollowsSpanInk = true + + target.adoptStyleFields(from: source) + + #expect(target.theme == source.theme) + #expect(target.paragraph == source.paragraph) + #expect(target.link == source.link) + #expect(target.headings == source.headings) + #expect(target.cursorFollowsSpanInk == source.cursorFollowsSpanInk) + // Non-style fields are left as they were — they belong to other paths. + #expect(target.heightBehavior == .fitsContent) + #expect(target.rawSourceMode == true) + #expect(target.lists.helpersEnabled == false) + } + + // MARK: - End-to-end: a theme swap actually repaints + + @Test("a rebuild after a theme swap paints body text with the new color") + func rebuildAfterThemeSwapUsesNewColor() { + _ = NSApplication.shared + let text = "plain body text" + let coordinator = NativeTextViewCoordinator( + text: .constant(text), fontName: "SF Pro", fontSize: 16, + isWikiLinkActive: .constant(false), onLinkClick: nil, onInlineSelectionChange: nil + ) + let tv = NativeTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) + tv.isEditable = true + tv.delegate = coordinator + coordinator.textView = tv + coordinator.configuration = MarkdownEditorConfiguration.default + tv.configuration = coordinator.configuration + coordinator.rebuildTextStorageAndStyle(tv, from: text) + + // The default theme's bodyText is `.labelColor`, so an equality check + // against a fresh distinct color is a valid before/after proof. + let newTheme = MarkdownEditorTheme(bodyText: NSColor(calibratedRed: 0.2, green: 0.4, blue: 0.6, alpha: 1)) + + // The same two lines `updateNSView` runs when the style signature moves. + var next = MarkdownEditorConfiguration.default + next.theme = newTheme + coordinator.configuration.adoptStyleFields(from: next) + tv.configuration.adoptStyleFields(from: next) + + // A rebuild picks the theme up from `configuration.theme`, so the + // newly painted body foreground must equal the swapped-in color. + coordinator.rebuildTextStorageAndStyle(tv, from: text) + let painted = tv.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + #expect(painted == newTheme.bodyText) + } +}