From 5b99bf6bca3963e981ff7c4ce5d84d41783988fd Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:34:54 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .../edmund-architecture-contract/SKILL.md | 365 +++++ .agents/skills/edmund-build-and-env/SKILL.md | 258 ++++ .../edmund-caret-integrity-campaign/SKILL.md | 267 ++++ .agents/skills/edmund-change-control/SKILL.md | 210 +++ .../skills/edmund-config-and-flags/SKILL.md | 220 +++ .../skills/edmund-debugging-playbook/SKILL.md | 305 ++++ .../skills/edmund-docs-and-writing/SKILL.md | 268 ++++ .../edmund-external-positioning/SKILL.md | 204 +++ .../edmund-failure-archaeology/SKILL.md | 442 ++++++ .../SKILL.md | 287 ++++ .../scripts/capture-window.sh | 46 + .../scripts/check-live-instance.sh | 22 + .../scripts/grep-trace.sh | 27 + .../scripts/launch-debug.sh | 67 + .../edmund-release-and-operate/SKILL.md | 400 ++++++ .../skills/edmund-research-frontier/SKILL.md | 196 +++ .../edmund-research-methodology/SKILL.md | 196 +++ .../skills/edmund-validation-and-qa/SKILL.md | 208 +++ .../skills/textkit2-appkit-reference/SKILL.md | 279 ++++ .claude/commands/ship.md | 48 + .../edmund-architecture-contract/SKILL.md | 365 +++++ .claude/skills/edmund-build-and-env/SKILL.md | 258 ++++ .../edmund-caret-integrity-campaign/SKILL.md | 267 ++++ .claude/skills/edmund-change-control/SKILL.md | 210 +++ .../skills/edmund-config-and-flags/SKILL.md | 220 +++ .../skills/edmund-debugging-playbook/SKILL.md | 305 ++++ .../skills/edmund-docs-and-writing/SKILL.md | 268 ++++ .../edmund-external-positioning/SKILL.md | 204 +++ .../edmund-failure-archaeology/SKILL.md | 442 ++++++ .../SKILL.md | 287 ++++ .../scripts/capture-window.sh | 46 + .../scripts/check-live-instance.sh | 22 + .../scripts/grep-trace.sh | 27 + .../scripts/launch-debug.sh | 67 + .../edmund-release-and-operate/SKILL.md | 400 ++++++ .../skills/edmund-research-frontier/SKILL.md | 196 +++ .../edmund-research-methodology/SKILL.md | 196 +++ .../skills/edmund-validation-and-qa/SKILL.md | 208 +++ .../skills/textkit2-appkit-reference/SKILL.md | 279 ++++ .codex/hooks.json | 27 + .github/ISSUE_TEMPLATE/bug_report.md | 31 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/SECURITY.md | 38 + .github/workflows/ci.yml | 45 + .github/workflows/release.yml | 133 ++ .gitignore | 31 + AGENTS.md | 30 + CHANGELOG.md | 115 ++ CLAUDE.md | 30 + Info.plist | 68 + LICENSE | 201 +++ LICENSES/lucide.txt | 43 + Package.resolved | 42 + Package.swift | 30 + README.md | 105 ++ README.wehub.md | 7 + Resources/AppIcon.icns | Bin 0 -> 680447 bytes .../AccentColor.colorset/Contents.json | 20 + Resources/Assets.xcassets/Contents.json | 6 + .../Diagnostics/CrashReporter.swift | 82 ++ Sources/EdmundCore/Diagnostics/Log.swift | 261 ++++ ...ditorTextView+BlockquoteContinuation.swift | 59 + .../EditorTextView+FormattingCommands.swift | 503 +++++++ .../EditorTextView+FormattingCore.swift | 527 +++++++ .../Editing/EditorTextView+Indentation.swift | 224 +++ .../EditorTextView+ListContinuation.swift | 102 ++ .../EditorTextView+ListRenumbering.swift | 177 +++ Sources/EdmundCore/Export/DocumentHTML.swift | 252 ++++ Sources/EdmundCore/Export/HTMLRenderer.swift | 768 ++++++++++ Sources/EdmundCore/Export/HTMLTheme.swift | 363 +++++ .../EdmundCore/Export/MarkdownPrinter.swift | 153 ++ .../EdmundCore/Export/ReadModeWebView.swift | 184 +++ .../EdmundCore/Export/ReadRenderOptions.swift | 34 + Sources/EdmundCore/Model/Block.swift | 50 + Sources/EdmundCore/Model/Callout.swift | 170 +++ Sources/EdmundCore/Model/EditorTheme.swift | 239 ++++ Sources/EdmundCore/Model/LineEnding.swift | 53 + .../Model/LinkDefinitionState.swift | 95 ++ .../EdmundCore/Model/ListIndentState.swift | 49 + Sources/EdmundCore/Model/LucideIcons.swift | 105 ++ Sources/EdmundCore/Model/SVGPath.swift | 278 ++++ Sources/EdmundCore/Model/StatusBarPrefs.swift | 62 + Sources/EdmundCore/Parsing/BlockParser.swift | 714 ++++++++++ .../EdmundCore/Parsing/CodeHighlighter.swift | 135 ++ .../Parsing/CodeSyntaxPalette.swift | 47 + .../SyntaxHighlighter+CustomParsers.swift | 727 ++++++++++ .../Parsing/SyntaxHighlighter+Walker.swift | 549 ++++++++ .../SyntaxHighlighter+WalkerInline.swift | 202 +++ .../Parsing/SyntaxHighlighter.swift | 202 +++ .../EditorTextView+CalloutRendering.swift | 472 +++++++ .../EditorTextView+CodeHighlighting.swift | 39 + .../EditorTextView+ImageRendering.swift | 211 +++ .../EditorTextView+ListMarkerRendering.swift | 93 ++ .../EditorTextView+ListRendering.swift | 202 +++ .../EditorTextView+MathRendering.swift | 184 +++ .../Rendering/EditorTextView+Rendering.swift | 750 ++++++++++ .../EditorTextView+TableRendering.swift | 257 ++++ .../EditorTextView+TableSupport.swift | 129 ++ .../Rendering/EditorTextView+WikiLinks.swift | 149 ++ .../TextView/EditorTextStorage.swift | 146 ++ .../TextView/EditorTextView+Composition.swift | 254 ++++ .../EditorTextView+ContentWidth.swift | 50 + .../TextView/EditorTextView+Diagnostics.swift | 96 ++ .../TextView/EditorTextView+EditFlow.swift | 284 ++++ .../TextView/EditorTextView+LazyStyling.swift | 220 +++ .../EditorTextView+SelectionTracking.swift | 96 ++ .../TextView/EditorTextView+TextKit2.swift | 666 +++++++++ .../EditorTextView+TypewriterScroll.swift | 238 ++++ .../TextView/EditorTextView+Undo.swift | 174 +++ .../EdmundCore/TextView/EditorTextView.swift | 525 +++++++ Sources/edmd/About/AboutView.swift | 40 + .../edmd/About/AboutWindowController.swift | 15 + Sources/edmd/App/Document.swift | 632 +++++++++ Sources/edmd/App/DocumentController.swift | 88 ++ Sources/edmd/App/FormatMenu.swift | 189 +++ Sources/edmd/App/ReproScript.swift | 207 +++ Sources/edmd/App/ViewMenu.swift | 55 + Sources/edmd/App/main.swift | 289 ++++ .../edmd/Settings/AdvancedSettingsView.swift | 135 ++ Sources/edmd/Settings/AntialiasingText.swift | 88 ++ Sources/edmd/Settings/AppSettings.swift | 324 +++++ .../Settings/AppearanceSettingsView.swift | 237 ++++ Sources/edmd/Settings/FontSettings.swift | 129 ++ .../edmd/Settings/GeneralSettingsView.swift | 77 + Sources/edmd/Settings/SettingsControls.swift | 15 + .../Settings/SettingsWindowController.swift | 92 ++ Sources/edmd/Views/StatusBarView.swift | 215 +++ .../EdmundTests/ActiveBulletMarkerTests.swift | 50 + Tests/EdmundTests/BlockParserTests.swift | 780 +++++++++++ Tests/EdmundTests/BlockStylingTests.swift | 871 ++++++++++++ .../BlockquoteContinuationTests.swift | 96 ++ .../EdmundTests/BlockquoteDeletionTests.swift | 56 + .../BlockquoteLazyContinuationTests.swift | 111 ++ .../BlockquoteRenderingTests.swift | 158 +++ Tests/EdmundTests/BypassedEditSyncTests.swift | 88 ++ .../CalloutLastBlockRenderingTests.swift | 74 + Tests/EdmundTests/CalloutRenderingTests.swift | 167 +++ Tests/EdmundTests/CalloutTests.swift | 156 +++ Tests/EdmundTests/CodeHighlighterTests.swift | 42 + Tests/EdmundTests/CommentRenderingTests.swift | 56 + Tests/EdmundTests/ContentWidthTests.swift | 76 + Tests/EdmundTests/CrashReporterTests.swift | 59 + Tests/EdmundTests/DocumentHTMLTests.swift | 195 +++ .../EdmundTests/EditorDiagnosticsTests.swift | 73 + Tests/EdmundTests/EditorDocumentTests.swift | 127 ++ Tests/EdmundTests/EditorFeatureTests.swift | 484 +++++++ .../EdmundTests/EditorIndentationTests.swift | 400 ++++++ Tests/EdmundTests/EditorRenderingTests.swift | 868 ++++++++++++ Tests/EdmundTests/EditorTextViewTests.swift | 206 +++ Tests/EdmundTests/EditorThemeTests.swift | 36 + Tests/EdmundTests/EditorUndoTests.swift | 212 +++ Tests/EdmundTests/EmojiRenderingTests.swift | 89 ++ Tests/EdmundTests/EscapeRenderingTests.swift | 80 ++ Tests/EdmundTests/FileIntegrationTests.swift | 477 +++++++ Tests/EdmundTests/FootnoteTests.swift | 61 + Tests/EdmundTests/FormattingTests.swift | 558 ++++++++ Tests/EdmundTests/HTMLRendererTests.swift | 482 +++++++ Tests/EdmundTests/HTMLTagRenderingTests.swift | 282 ++++ Tests/EdmundTests/HTMLThemeTests.swift | 75 + Tests/EdmundTests/HeightStabilityTests.swift | 181 +++ Tests/EdmundTests/ImageRenderingTests.swift | 173 +++ .../IncrementalParseFuzzTests.swift | 68 + Tests/EdmundTests/InlineStylingTests.swift | 409 ++++++ .../EdmundTests/InternationalInputTests.swift | 98 ++ Tests/EdmundTests/LazyRenderingTests.swift | 110 ++ Tests/EdmundTests/LineEndingTests.swift | 69 + Tests/EdmundTests/ListContinuationTests.swift | 161 +++ Tests/EdmundTests/ListRenumberingTests.swift | 213 +++ Tests/EdmundTests/LogTests.swift | 172 +++ Tests/EdmundTests/LucideIconsTests.swift | 116 ++ Tests/EdmundTests/MarkedTextDesyncTests.swift | 68 + Tests/EdmundTests/MathRenderingTests.swift | 198 +++ .../EdmundTests/NestedBlockStylingTests.swift | 242 ++++ .../NewListItemAlignmentTests.swift | 56 + Tests/EdmundTests/PendingEditTests.swift | 98 ++ Tests/EdmundTests/PerfHarnessTests.swift | 104 ++ Tests/EdmundTests/ReadModeWebViewTests.swift | 48 + .../RecomposeEquivalenceTests.swift | 203 +++ Tests/EdmundTests/RecomposeTests.swift | 121 ++ .../ReferenceLinkIntegrationTests.swift | 35 + Tests/EdmundTests/ReferenceLinkTests.swift | 144 ++ .../RenderingRegressionTests.swift | 362 +++++ Tests/EdmundTests/ScrollStabilityTests.swift | 101 ++ Tests/EdmundTests/StatusBarPrefsTests.swift | 57 + .../EdmundTests/SyntaxHighlighterTests.swift | 1239 +++++++++++++++++ Tests/EdmundTests/TableAlignmentTests.swift | 181 +++ .../EdmundTests/TableWrapRenderingTests.swift | 120 ++ Tests/EdmundTests/TestHelpers.swift | 433 ++++++ .../TypewriterCenteringTests.swift | 85 ++ Tests/EdmundTests/UndoRedoViewportTests.swift | 253 ++++ Tests/EdmundTests/UnmatchedDebugTests.swift | 94 ++ Tests/EdmundTests/ViewModeTests.swift | 52 + Tests/EdmundTests/WikiLinkTests.swift | 128 ++ .../WrappedParagraphCaretTests.swift | 77 + appcast.xml | 111 ++ docs/ARCHITECTURE.md | 599 ++++++++ docs/ROADMAP.md | 84 ++ docs/architecture/README.md | 87 ++ docs/architecture/editor-pipeline.md | 145 ++ docs/architecture/extensibility.md | 321 +++++ docs/architecture/sandboxing.md | 261 ++++ docs/architecture/text-system.md | 117 ++ docs/assets/AppIcon/AppIcon_128x128.png | Bin 0 -> 7519 bytes docs/assets/AppIcon/AppIcon_128x128@2x.png | Bin 0 -> 25993 bytes docs/assets/AppIcon/AppIcon_16x16.png | Bin 0 -> 597 bytes docs/assets/AppIcon/AppIcon_16x16@2x.png | Bin 0 -> 1342 bytes docs/assets/AppIcon/AppIcon_256x256.png | Bin 0 -> 25993 bytes docs/assets/AppIcon/AppIcon_256x256@2x.png | Bin 0 -> 91637 bytes docs/assets/AppIcon/AppIcon_32x32.png | Bin 0 -> 1342 bytes docs/assets/AppIcon/AppIcon_32x32@2x.png | Bin 0 -> 2975 bytes docs/assets/AppIcon/AppIcon_512x512.png | Bin 0 -> 91637 bytes docs/assets/AppIcon/AppIcon_512x512@2x.png | Bin 0 -> 334082 bytes docs/assets/installation.png | Bin 0 -> 275579 bytes docs/assets/v0.1.0_basic.png | Bin 0 -> 6310937 bytes docs/assets/v0.1.0_gfm-syntax.png | Bin 0 -> 7805993 bytes docs/assets/v0.1.0_more-syntax.png | Bin 0 -> 6392039 bytes docs/assets/v0.1.0_read-mode.png | Bin 0 -> 6079558 bytes docs/assets/v0.1.0_video.mp4 | Bin 0 -> 517090 bytes docs/dev-guides/live-repro-guide.md | 231 +++ .../callout-bottom-line-investigation.md | 151 ++ .../callout-title-wrap-investigation.md | 103 ++ .../delete-drift-investigation.md | 549 ++++++++ .../viewport-glitch-investigation.md | 222 +++ scripts/build-app.sh | 97 ++ scripts/changelog-to-html.py | 97 ++ scripts/release.sh | 136 ++ 226 files changed, 42157 insertions(+) create mode 100644 .agents/skills/edmund-architecture-contract/SKILL.md create mode 100644 .agents/skills/edmund-build-and-env/SKILL.md create mode 100644 .agents/skills/edmund-caret-integrity-campaign/SKILL.md create mode 100644 .agents/skills/edmund-change-control/SKILL.md create mode 100644 .agents/skills/edmund-config-and-flags/SKILL.md create mode 100644 .agents/skills/edmund-debugging-playbook/SKILL.md create mode 100644 .agents/skills/edmund-docs-and-writing/SKILL.md create mode 100644 .agents/skills/edmund-external-positioning/SKILL.md create mode 100644 .agents/skills/edmund-failure-archaeology/SKILL.md create mode 100644 .agents/skills/edmund-live-repro-and-diagnostics/SKILL.md create mode 100755 .agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh create mode 100755 .agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh create mode 100755 .agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh create mode 100755 .agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh create mode 100644 .agents/skills/edmund-release-and-operate/SKILL.md create mode 100644 .agents/skills/edmund-research-frontier/SKILL.md create mode 100644 .agents/skills/edmund-research-methodology/SKILL.md create mode 100644 .agents/skills/edmund-validation-and-qa/SKILL.md create mode 100644 .agents/skills/textkit2-appkit-reference/SKILL.md create mode 100644 .claude/commands/ship.md create mode 100644 .claude/skills/edmund-architecture-contract/SKILL.md create mode 100644 .claude/skills/edmund-build-and-env/SKILL.md create mode 100644 .claude/skills/edmund-caret-integrity-campaign/SKILL.md create mode 100644 .claude/skills/edmund-change-control/SKILL.md create mode 100644 .claude/skills/edmund-config-and-flags/SKILL.md create mode 100644 .claude/skills/edmund-debugging-playbook/SKILL.md create mode 100644 .claude/skills/edmund-docs-and-writing/SKILL.md create mode 100644 .claude/skills/edmund-external-positioning/SKILL.md create mode 100644 .claude/skills/edmund-failure-archaeology/SKILL.md create mode 100644 .claude/skills/edmund-live-repro-and-diagnostics/SKILL.md create mode 100755 .claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh create mode 100755 .claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh create mode 100755 .claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh create mode 100755 .claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh create mode 100644 .claude/skills/edmund-release-and-operate/SKILL.md create mode 100644 .claude/skills/edmund-research-frontier/SKILL.md create mode 100644 .claude/skills/edmund-research-methodology/SKILL.md create mode 100644 .claude/skills/edmund-validation-and-qa/SKILL.md create mode 100644 .claude/skills/textkit2-appkit-reference/SKILL.md create mode 100644 .codex/hooks.json create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/SECURITY.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Info.plist create mode 100644 LICENSE create mode 100644 LICENSES/lucide.txt create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 Resources/AppIcon.icns create mode 100644 Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Resources/Assets.xcassets/Contents.json create mode 100644 Sources/EdmundCore/Diagnostics/CrashReporter.swift create mode 100644 Sources/EdmundCore/Diagnostics/Log.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+Indentation.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift create mode 100644 Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift create mode 100644 Sources/EdmundCore/Export/DocumentHTML.swift create mode 100644 Sources/EdmundCore/Export/HTMLRenderer.swift create mode 100644 Sources/EdmundCore/Export/HTMLTheme.swift create mode 100644 Sources/EdmundCore/Export/MarkdownPrinter.swift create mode 100644 Sources/EdmundCore/Export/ReadModeWebView.swift create mode 100644 Sources/EdmundCore/Export/ReadRenderOptions.swift create mode 100644 Sources/EdmundCore/Model/Block.swift create mode 100644 Sources/EdmundCore/Model/Callout.swift create mode 100644 Sources/EdmundCore/Model/EditorTheme.swift create mode 100644 Sources/EdmundCore/Model/LineEnding.swift create mode 100644 Sources/EdmundCore/Model/LinkDefinitionState.swift create mode 100644 Sources/EdmundCore/Model/ListIndentState.swift create mode 100644 Sources/EdmundCore/Model/LucideIcons.swift create mode 100644 Sources/EdmundCore/Model/SVGPath.swift create mode 100644 Sources/EdmundCore/Model/StatusBarPrefs.swift create mode 100644 Sources/EdmundCore/Parsing/BlockParser.swift create mode 100644 Sources/EdmundCore/Parsing/CodeHighlighter.swift create mode 100644 Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift create mode 100644 Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift create mode 100644 Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift create mode 100644 Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift create mode 100644 Sources/EdmundCore/Parsing/SyntaxHighlighter.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift create mode 100644 Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextStorage.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+Composition.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView+Undo.swift create mode 100644 Sources/EdmundCore/TextView/EditorTextView.swift create mode 100644 Sources/edmd/About/AboutView.swift create mode 100644 Sources/edmd/About/AboutWindowController.swift create mode 100644 Sources/edmd/App/Document.swift create mode 100644 Sources/edmd/App/DocumentController.swift create mode 100644 Sources/edmd/App/FormatMenu.swift create mode 100644 Sources/edmd/App/ReproScript.swift create mode 100644 Sources/edmd/App/ViewMenu.swift create mode 100644 Sources/edmd/App/main.swift create mode 100644 Sources/edmd/Settings/AdvancedSettingsView.swift create mode 100644 Sources/edmd/Settings/AntialiasingText.swift create mode 100644 Sources/edmd/Settings/AppSettings.swift create mode 100644 Sources/edmd/Settings/AppearanceSettingsView.swift create mode 100644 Sources/edmd/Settings/FontSettings.swift create mode 100644 Sources/edmd/Settings/GeneralSettingsView.swift create mode 100644 Sources/edmd/Settings/SettingsControls.swift create mode 100644 Sources/edmd/Settings/SettingsWindowController.swift create mode 100644 Sources/edmd/Views/StatusBarView.swift create mode 100644 Tests/EdmundTests/ActiveBulletMarkerTests.swift create mode 100644 Tests/EdmundTests/BlockParserTests.swift create mode 100644 Tests/EdmundTests/BlockStylingTests.swift create mode 100644 Tests/EdmundTests/BlockquoteContinuationTests.swift create mode 100644 Tests/EdmundTests/BlockquoteDeletionTests.swift create mode 100644 Tests/EdmundTests/BlockquoteLazyContinuationTests.swift create mode 100644 Tests/EdmundTests/BlockquoteRenderingTests.swift create mode 100644 Tests/EdmundTests/BypassedEditSyncTests.swift create mode 100644 Tests/EdmundTests/CalloutLastBlockRenderingTests.swift create mode 100644 Tests/EdmundTests/CalloutRenderingTests.swift create mode 100644 Tests/EdmundTests/CalloutTests.swift create mode 100644 Tests/EdmundTests/CodeHighlighterTests.swift create mode 100644 Tests/EdmundTests/CommentRenderingTests.swift create mode 100644 Tests/EdmundTests/ContentWidthTests.swift create mode 100644 Tests/EdmundTests/CrashReporterTests.swift create mode 100644 Tests/EdmundTests/DocumentHTMLTests.swift create mode 100644 Tests/EdmundTests/EditorDiagnosticsTests.swift create mode 100644 Tests/EdmundTests/EditorDocumentTests.swift create mode 100644 Tests/EdmundTests/EditorFeatureTests.swift create mode 100644 Tests/EdmundTests/EditorIndentationTests.swift create mode 100644 Tests/EdmundTests/EditorRenderingTests.swift create mode 100644 Tests/EdmundTests/EditorTextViewTests.swift create mode 100644 Tests/EdmundTests/EditorThemeTests.swift create mode 100644 Tests/EdmundTests/EditorUndoTests.swift create mode 100644 Tests/EdmundTests/EmojiRenderingTests.swift create mode 100644 Tests/EdmundTests/EscapeRenderingTests.swift create mode 100644 Tests/EdmundTests/FileIntegrationTests.swift create mode 100644 Tests/EdmundTests/FootnoteTests.swift create mode 100644 Tests/EdmundTests/FormattingTests.swift create mode 100644 Tests/EdmundTests/HTMLRendererTests.swift create mode 100644 Tests/EdmundTests/HTMLTagRenderingTests.swift create mode 100644 Tests/EdmundTests/HTMLThemeTests.swift create mode 100644 Tests/EdmundTests/HeightStabilityTests.swift create mode 100644 Tests/EdmundTests/ImageRenderingTests.swift create mode 100644 Tests/EdmundTests/IncrementalParseFuzzTests.swift create mode 100644 Tests/EdmundTests/InlineStylingTests.swift create mode 100644 Tests/EdmundTests/InternationalInputTests.swift create mode 100644 Tests/EdmundTests/LazyRenderingTests.swift create mode 100644 Tests/EdmundTests/LineEndingTests.swift create mode 100644 Tests/EdmundTests/ListContinuationTests.swift create mode 100644 Tests/EdmundTests/ListRenumberingTests.swift create mode 100644 Tests/EdmundTests/LogTests.swift create mode 100644 Tests/EdmundTests/LucideIconsTests.swift create mode 100644 Tests/EdmundTests/MarkedTextDesyncTests.swift create mode 100644 Tests/EdmundTests/MathRenderingTests.swift create mode 100644 Tests/EdmundTests/NestedBlockStylingTests.swift create mode 100644 Tests/EdmundTests/NewListItemAlignmentTests.swift create mode 100644 Tests/EdmundTests/PendingEditTests.swift create mode 100644 Tests/EdmundTests/PerfHarnessTests.swift create mode 100644 Tests/EdmundTests/ReadModeWebViewTests.swift create mode 100644 Tests/EdmundTests/RecomposeEquivalenceTests.swift create mode 100644 Tests/EdmundTests/RecomposeTests.swift create mode 100644 Tests/EdmundTests/ReferenceLinkIntegrationTests.swift create mode 100644 Tests/EdmundTests/ReferenceLinkTests.swift create mode 100644 Tests/EdmundTests/RenderingRegressionTests.swift create mode 100644 Tests/EdmundTests/ScrollStabilityTests.swift create mode 100644 Tests/EdmundTests/StatusBarPrefsTests.swift create mode 100644 Tests/EdmundTests/SyntaxHighlighterTests.swift create mode 100644 Tests/EdmundTests/TableAlignmentTests.swift create mode 100644 Tests/EdmundTests/TableWrapRenderingTests.swift create mode 100644 Tests/EdmundTests/TestHelpers.swift create mode 100644 Tests/EdmundTests/TypewriterCenteringTests.swift create mode 100644 Tests/EdmundTests/UndoRedoViewportTests.swift create mode 100644 Tests/EdmundTests/UnmatchedDebugTests.swift create mode 100644 Tests/EdmundTests/ViewModeTests.swift create mode 100644 Tests/EdmundTests/WikiLinkTests.swift create mode 100644 Tests/EdmundTests/WrappedParagraphCaretTests.swift create mode 100644 appcast.xml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/editor-pipeline.md create mode 100644 docs/architecture/extensibility.md create mode 100644 docs/architecture/sandboxing.md create mode 100644 docs/architecture/text-system.md create mode 100644 docs/assets/AppIcon/AppIcon_128x128.png create mode 100644 docs/assets/AppIcon/AppIcon_128x128@2x.png create mode 100644 docs/assets/AppIcon/AppIcon_16x16.png create mode 100644 docs/assets/AppIcon/AppIcon_16x16@2x.png create mode 100644 docs/assets/AppIcon/AppIcon_256x256.png create mode 100644 docs/assets/AppIcon/AppIcon_256x256@2x.png create mode 100644 docs/assets/AppIcon/AppIcon_32x32.png create mode 100644 docs/assets/AppIcon/AppIcon_32x32@2x.png create mode 100644 docs/assets/AppIcon/AppIcon_512x512.png create mode 100644 docs/assets/AppIcon/AppIcon_512x512@2x.png create mode 100644 docs/assets/installation.png create mode 100644 docs/assets/v0.1.0_basic.png create mode 100644 docs/assets/v0.1.0_gfm-syntax.png create mode 100644 docs/assets/v0.1.0_more-syntax.png create mode 100644 docs/assets/v0.1.0_read-mode.png create mode 100644 docs/assets/v0.1.0_video.mp4 create mode 100644 docs/dev-guides/live-repro-guide.md create mode 100644 docs/investigations/archives/callout-bottom-line-investigation.md create mode 100644 docs/investigations/archives/callout-title-wrap-investigation.md create mode 100644 docs/investigations/delete-drift-investigation.md create mode 100644 docs/investigations/viewport-glitch-investigation.md create mode 100755 scripts/build-app.sh create mode 100755 scripts/changelog-to-html.py create mode 100755 scripts/release.sh diff --git a/.agents/skills/edmund-architecture-contract/SKILL.md b/.agents/skills/edmund-architecture-contract/SKILL.md new file mode 100644 index 0000000..50a798a --- /dev/null +++ b/.agents/skills/edmund-architecture-contract/SKILL.md @@ -0,0 +1,365 @@ +--- +name: edmund-architecture-contract +description: > + The load-bearing design contract of the Edmund Markdown editor. Load BEFORE + any non-trivial code change in this repo; when asking "why is it built this + way"; before proposing a new mechanism, subsystem, or refactor; whenever you + are tempted to insert/strip display characters, use NSTextAttachment, touch + NSTextView.layoutManager, store NSTextBlock/NSTextTable attributes, or add a + new overlay/decoration; and before designing anything that syncs storage, + selection, undo, or the viewport. Covers the two hard invariants + (storage == rawSource; TextKit 2 only), the render pipeline, edit/undo flow, + the TextKit 2 drawing model, the read-mode contract, and the known weak + points. Not for build/run/release mechanics, debugging triage, or live-repro + drivers — see "When NOT to use this skill". +--- + +# Edmund architecture contract + +Edmund is a native macOS Markdown editor with live preview: AppKit + +TextKit 2, SwiftPM, macOS 14+. Two targets (`Package.swift`): + +| Target | Role | +| --- | --- | +| `EdmundCore` | Library: parsing, rendering, `EditorTextView`, all tests. Most work happens here. | +| `edmd` | Executable: `NSDocument` app shell, Settings (SwiftUI), menus. Note: `edmd` is the Mach-O binary name; the app is "Edmund". | + +Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of +Markdown editors". Bias toward polish of the editing experience over feature +count. The hardest live problem class to date is **delete-drift** +(caret/selection integrity, 6 investigation rounds); the costliest failures +were delete-drift and undo/redo viewport drift. Every rule below traces to one +of those scars. + +Ground truth this file distills: `docs/ARCHITECTURE.md` (repo root). +Treat that doc as authoritative if the two ever disagree, and fix this skill. + +## Glossary (each term defined once) + +- **rawSource** — the document's Markdown text, the single source of truth + (`EditorTextView.rawSource`). +- **storage** — the `NSTextStorage` the text view displays + (`EditorTextStorage`, `Sources/EdmundCore/TextView/EditorTextStorage.swift`). +- **Block** — one logical Markdown block (paragraph, heading, list run, code + fence, table, quote/callout run). Model: `Sources/EdmundCore/Model/Block.swift`. +- **Active block** — the block under the caret; it renders its *raw* markdown + (delimiters visible/editable) while all others render styled. +- **Recompose** — restyling storage from `blocks` + (`Sources/EdmundCore/TextView/EditorTextView+Composition.swift`). +- **Fragment** — an `NSTextLayoutFragment`, TextKit 2's per-paragraph layout + unit. Off-screen fragments have *estimated* heights until laid out. +- **Overlay** — an image or stroked path drawn at a character's laid-out + position by the custom fragment class (see §4), replacing what + `NSTextAttachment` would do in TextKit 1. +- **Delete-drift** — the bug class where `rawSource`/storage/selection desync + and every later edit lands the caret in the wrong place. Chronicle: + `docs/investigations/delete-drift-investigation.md`. +- **IME composition** — an input method's provisional "marked text" + (`hasMarkedText()`), present in storage before the user commits it. + +--- + +## 1. The two non-negotiable invariants + +Break either and the editor misbehaves in subtle, delayed ways. Every design +review starts here. + +### Invariant 1 — storage == rawSource (attribute-only rendering) + +The displayed text storage is *always* character-identical to `rawSource`. +Rendering only ever adds/changes **attributes**. Delimiters (`**`, `` ` ``, +`[!note]`, …) are **hidden, never stripped**: `hiddenFont` (0.01pt system +font, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55`) plus a +clear `foregroundColor` makes them invisible without touching the string. + +**Rationale.** Identity mapping between display offsets and raw offsets means +there is no offset-translation layer — caret math, selection, undo diffs, +incremental reparse, and autosave all operate on one coordinate system. + +**Consequences you must respect:** + +| Consequence | Why | +| --- | --- | +| No `NSTextAttachment`, ever | TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which `rawSource` never contains. Images, math, bullets, checkboxes, icons are drawn as **overlays** instead (§4). | +| No inserted display characters | A synthesized `
`, bullet glyph, or padding character would desync offsets. Use attributes (`.kern`, paragraph styles) or overlays. | +| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is *transiently* false and `didChangeText` defers syncing. Styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard `!hasMarkedText()`. | + +**The incident.** The original delete-drift bug: an async restyle fired during +IME composition, stranded the marked text, `didChangeText` kept bailing on its +own guard, and the invariant stayed silently broken — caret drift on every +subsequent edit. `becomeFirstResponder` now resyncs from storage as a +catch-all. Full write-up: `docs/investigations/delete-drift-investigation.md`. + +### Invariant 2 — TextKit 2 only + +Never touch `NSTextView.layoutManager` (the TextKit 1 `NSLayoutManager` +accessor) and never store `NSTextBlock`/`NSTextTable` attributes. Either one +**silently and permanently reverts the view to TextKit 1** — no error, no log, +just different (and wrong-for-us) layout from then on. + +**Rationale.** All custom drawing rides `NSTextLayoutFragment` subclassing +(§4), and viewport-based layout (only on-screen content laid out) is what +makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback +kills them. + +**The tripwire.** DEBUG builds observe +`NSTextView.willSwitchToNSLayoutManagerNotification` and `assertionFailure` +if the fallback ever triggers — +`textKit1FallbackTripwire(_:)`, +`Sources/EdmundCore/TextView/EditorTextView.swift:272-303`. If you see that +assertion, some code path you touched used a TK1 API or attribute. Find it; +do not suppress the assert. + +Corollary: Edit-mode tables cannot use `NSTextTable`. Alignment is done by +distributing slack via `.kern` on hidden pipe glyphs — +`Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift`. + +--- + +## 2. Render pipeline + +``` +rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage + │ + └─ SyntaxHighlighter (swift-markdown walker + + custom parsers: callouts, ==highlight==, + wikilinks, comments, footnotes, math, + backslash escapes, inline HTML tags) +``` + +| Stage | Where | +| --- | --- | +| Block splitting | `Sources/EdmundCore/Parsing/BlockParser.swift` — `parse(_:previous:)`, `parseWithDiff(...)` | +| Span production | `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift` + `+Walker.swift` / `+WalkerInline.swift` / `+CustomParsers.swift` | +| One-block render | `styleBlock(_:cursorPosition:...)`, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151`; per-feature extensions in `Sources/EdmundCore/Rendering/` (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) | +| Orchestration | `Sources/EdmundCore/TextView/EditorTextView+Composition.swift` | + +Recompose entry points (pick the narrowest that works — a full `recompose` +resets every fragment height to an estimate, see §6): + +| Function | Scope | Used for | +| --- | --- | --- | +| `recompose(cursorInRaw:)` | Whole document | Load, indent — never for undo (§3) | +| `recomposeDirty(_:cursorInRaw:)` | A set of block indices, in place | The workhorse; attribute-only | +| `recomposeIncremental(cursorInRaw:...)` | The block(s) the caret moved between | Most cursor moves | +| `recomposeReplacing(oldRange:with:...)` | One contiguous text span | Undo/redo restore | + +**Lazy styling** (`Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift`): +a large dirty set styles only the viewport synchronously; the rest is finished +by the **idle drain** (time-budgeted main-thread slices) and **scroll +promotion** (style blocks as they enter the viewport). + +Gotcha: attribute-only changes do **not** re-measure geometry in TextKit 2. +If a restyle changed a block's height/indent, call `invalidateLayout(for:)` +on its range or the fragment keeps a stale frame. `recomposeDirty` and the +idle drain already do this; any new path must too. + +--- + +## 3. Edit flow & undo + +Normal edit: `shouldChangeText` (records a coalesced undo snapshot) → +NSTextView mutates storage → `didChangeText` syncs `rawSource` and restyles +the edited block(s) (`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`, +`+Composition.swift`). Edits capture a `pendingEdit` on `EditorTextStorage` +and reparse a window, not the whole document. + +**Undo/redo is custom** (`Sources/EdmundCore/TextView/EditorTextView+Undo.swift`): +stacks of `rawSource` snapshots, bypassing NSTextView's built-in undo. +Restoring diffs the snapshot against current text (`textDiff(old:new:)`, +single contiguous span) and applies it with the range-bounded +`recomposeReplacing` — **never a full `recompose`**, because a full recompose +resets every fragment to a TextKit 2 height estimate and the follow-up scroll +lands wrong (this was the undo/redo viewport-drift failure). The changed text +drives the viewport: hold if any of it is on-screen, else center it. + +**AppKit does NOT pair every storage mutation with `didChangeText`.** Proven +incident (delete-drift round 4): a drag-move of selected text dropped on no +valid target deletes the dragged range via `shouldChangeText` → +`replaceCharacters` and never calls `didChangeText` — silently freezing +`rawSource`/`blocks`; every later edit drifts the caret and autosave writes +stale text. The heal: `shouldChangeText` schedules a next-run-loop +**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`) — a +`pendingEdit` still unconsumed by then means the closing `didChangeText` +never came, and the editor runs the same sync itself. Breadcrumb in +`~/.edmund/logs`: `healing storage edit that bypassed didChangeText`. +**Never build a sync path on the assumption that `didChangeText` follows +every edit.** + +Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection +fixup (`_fixSelectionAfterChangeInCharacterRange:`) queued; it fires at the +**next** `endEditing` — even an attribute-only restyle — and leaps the caret +blocks away, moving even a freshly set valid caret. The heal sets the caret +before the sync **and re-asserts it after** (`+EditFlow.swift`). This class +does not reproduce headless; see the routing in §8. + +--- + +## 4. TextKit 2 drawing model + +All custom visuals are drawn by `DecoratedTextLayoutFragment` (custom +`NSTextLayoutFragment`, +`Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160`), vended via +the layout-manager delegate. Two custom attribute keys (same file, lines +28/32): + +| Attribute | Level | Draws | Rules | +| --- | --- | --- | --- | +| `.blockDecoration` | Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments **tile vertically** so a multi-line run reads as one continuous box/bar. A box's `bottomPad` grows the **last** fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. | +| `.fragmentOverlay` | Character | An image **or stroked vector path** at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (`hiddenFont` + clear color) and `.kern` reserves the drawing's advance width — the same trick the table renderer uses. | + +**The image-wedge constraint (open, not solved).** Drawing an *image* overlay +on a *multi-line (wrapping)* fragment re-triggers a layout pass that wedges +the fragment to one line. Drawing a *shape* (stroked `CGPath`) does not. +That is why the wrapping callout custom-title icon is a stroked path parsed +from vendored Lucide geometry, never an image. **Any new overlay that could +share a line with wrapping text must be a shape, not an image.** Full saga: +`docs/investigations/archives/callout-title-wrap-investigation.md`. + +--- + +## 5. Read mode contract + +Read mode is a separate `WKWebView`, not an editor styling mode +(`Sources/EdmundCore/Export/`). The contract: **one parser, two back-ends** — +the *same* swift-markdown `Document` the editor parses is walked by +`SyntaxHighlighter.SpanCollector` (→ editor attributes) and by `HTMLRenderer` +(→ HTML), themed from the *same* `EditorTheme` via `HTMLTheme`, so the two +renderings cannot drift. When adding a feature, implement it in **both** +back-ends or document the divergence. + +Hard properties of the web view (keep them): +- **JavaScript disabled**; every asset inlined (math as high-DPI PNG data + URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs + no file/network reach. Remote images off by default + (`Sources/EdmundCore/Export/ReadRenderOptions.swift`). +- **Private URL schemes** route navigation without JS: + `x-edmund-wiki:` / `x-edmund-link:` + (`Sources/EdmundCore/Export/HTMLRenderer.swift:26,31`). +- Inline HTML: only the whitelist `SyntaxHighlighter.htmlFormatTags` + (`u`/`kbd`/`mark`/`sub`/`sup`, + `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22`) renders in either + mode; everything else stays escaped/color-only. A real `
` break would + need to mutate storage — forbidden by Invariant 1. +- Export/Print run the same HTML through `WKWebView.printOperation` + (`Sources/EdmundCore/Export/MarkdownPrinter.swift`) for vector text. + +--- + +## 6. Known weak points (open as of 2026-07-05) + +State these plainly when designing near them; none is solved. + +1. **TextKit 2 height estimates** are the root of most viewport glitches: an + off-screen fragment's frame (and the total document height) is an estimate + until layout reaches it — scroller jumps, scroll-to-target lands wrong (a + documented TK2 limitation; TextEdit shows it too). Mitigations in place, + not cures: documents ≤ `fullLayoutMaxLength` (100k UTF-16, + `Sources/EdmundCore/TextView/EditorTextView.swift:80`) are kept fully laid + out by `scheduleFullLayoutSettle()` wrapped in `preservingViewportAnchor` + (`+LazyStyling.swift:121`, `+TypewriterScroll.swift:22`); + `repairContentAboveOrigin()` (`+LazyStyling.swift:151`) fixes content + stranded above y=0; `centerViewportOnCaret` re-measures after its first + scroll. **Never trust an off-screen fragment's y-coordinate without laying + out the span first.** +2. **The image-wedge constraint** (§4) applies to every new overlay. +3. **Open bugs** (`misc/backlog.md`): callout at end-of-file renders an extra + un-prefixed line in the callout color (live incremental-restyle path, not + static rendering); footnotes don't render in either mode; attached images + create blank space below; math doesn't render in read mode (and has wrong + padding in edit mode); delete caret drift and viewport-estimate glitches + remain on the ongoing list. +4. **Crash reporter endpoint is a placeholder**: + `CrashReporter.reportingEndpoint` is `https://REPLACE-ME.invalid/crash` + (`Sources/EdmundCore/Diagnostics/CrashReporter.swift:27`) and the + Settings ▸ Advanced toggle is commented out + (`Sources/edmd/Settings/AdvancedSettingsView.swift`). Do not treat crash + uploading as live. + +--- + +## 7. Before you design something new — checklist + +Run this before proposing any new mechanism, subsystem, or refactor: + +- [ ] **Invariant 1**: does it insert/strip characters, use + `NSTextAttachment`, or mutate storage outside the + shouldChangeText→didChangeText path (or during IME composition)? If + yes, redesign as attributes/overlays. +- [ ] **Invariant 2**: does it touch `NSTextView.layoutManager` or store + `NSTextBlock`/`NSTextTable`? If yes, stop. +- [ ] **Sync assumptions**: does it assume `didChangeText` follows every + mutation, or that a set caret stays put across the next `endEditing`? + Both assumptions are proven false (§3). +- [ ] **Geometry**: does it read an off-screen fragment frame, or restyle + without `invalidateLayout(for:)` when height changed? (§2, §6.) +- [ ] **Both back-ends**: does a rendering feature cover Edit *and* Read + (§5)? +- [ ] **Prior art**: check ARCHITECTURE.md §14 — especially + [nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine), + an independent AppKit+TextKit 2 live-preview engine solving the same + problems — before inventing a new mechanism for an editing-experience + problem. +- [ ] **Weak points** (§6): does the design lean on anything listed there? + Label it as such; unproven mitigations are "open/candidate", never + "fixed". +- [ ] **Verification plan**: unit test if headless can repro; otherwise plan + a live repro (ReproScript) — do not ship a caret/IME/viewport fix on + reasoning alone. Visual claims are measured from `screencapture` + pixels, not eyeballed. + +Process rules live in sibling skills, but never contradict them here: branch +per fix off `main`; never auto-push/PR/merge; `swift test` green + visual +verification before commit; never blanket `pkill -x edmd` (the maintainer's +daily-driver app shares the binary name — `pgrep` and kill only your own +PID); never request macOS Computer Access permissions. + +--- + +## When NOT to use this skill + +| You need… | Use instead | +| --- | --- | +| Whether/how to gate a change, commit discipline, scope control | `edmund-change-control` | +| A symptom → cause triage path for a bug you're seeing | `edmund-debugging-playbook` | +| The blow-by-blow history of a past investigation | `edmund-failure-archaeology` | +| TextKit 2 / AppKit theory beyond Edmund's specific contract | `textkit2-appkit-reference` | +| Launch flags, debug bundles, defaults keys | `edmund-config-and-flags` | +| Build, stale-binary cures, screencapture mechanics, environment setup | `edmund-build-and-env` | +| Cutting a release, Sparkle/appcast/CI | `edmund-release-and-operate` | +| Driving a live repro (ReproScript, CGEvent, log tracing) | `edmund-live-repro-and-diagnostics` | +| Test-writing patterns, QA passes | `edmund-validation-and-qa` | +| Docs style, ARCHITECTURE.md upkeep | `edmund-docs-and-writing` | +| Positioning, comparisons, marketing claims | `edmund-external-positioning` | +| The caret/selection-integrity campaign specifically | `edmund-caret-integrity-campaign` | +| How to investigate an unknown (method, not facts) | `edmund-research-methodology` / `edmund-research-frontier` | + +--- + +## Provenance and maintenance + +Facts verified against the repo on 2026-07-05. If a grep below stops +matching, the fact drifted — update this file and cite the new location. + +```bash +# Invariant 2 tripwire still present +grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift +# Recompose entry points +grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift +# Bypass heal +grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +# Custom draw attributes + fragment class +grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift +# hiddenFont hiding trick +grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift +# Viewport mitigations +grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift +grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift +# Read-mode schemes + HTML whitelist +grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift +grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift +# Crash-reporter placeholder (delete §6.4 once this is a real URL) +grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift +# Open-bug list +sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md +``` diff --git a/.agents/skills/edmund-build-and-env/SKILL.md b/.agents/skills/edmund-build-and-env/SKILL.md new file mode 100644 index 0000000..91421b3 --- /dev/null +++ b/.agents/skills/edmund-build-and-env/SKILL.md @@ -0,0 +1,258 @@ +--- +name: edmund-build-and-env +description: > + Build, environment, and toolchain runbook for the Edmund repo (native macOS + Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting + up the environment from scratch (fresh clone, new machine, CI mirror); a + build fails or a "successful" build behaves stale (change "doesn't take", + old code runs, Build complete! but nothing changed); the app crashes on + launch or the instant it renders LaTeX; you need to construct the debug + bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just + built for a visual check or repro run. Covers swift build/test, + build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), + the stale-build disease and its detection, safe launch/kill hygiene around + the user's live instance, and the CI environment. +--- + +# Edmund — build & environment runbook + +All paths relative to the repo root. +Facts date-stamped 2026-07-05 are volatile — re-verify per the last section. + +## When NOT to use this skill + +| You actually need | Go to | +|---|---| +| The storage==rawSource / TextKit-2-only invariants, render pipeline | `edmund-architecture-contract` | +| Branch/commit/PR rules, what you may touch | `edmund-change-control` | +| Diagnosing a bug (not the build) | `edmund-debugging-playbook` | +| ReproScript / CGEvent live-repro driving | `edmund-live-repro-and-diagnostics` | +| Launch flags, defaults keys, settings | `edmund-config-and-flags` | +| Cutting a release, DMG, Sparkle appcast | `edmund-release-and-operate` | +| Screencapture verification method, test policy | `edmund-validation-and-qa` | + +## 1. Environment from scratch + +Requirements (verified 2026-07-05): + +| Tool | Version | Why | Check | +|---|---|---|---| +| macOS | 14+ | `Package.swift` platforms `.macOS(.v14)` | `sw_vers` | +| Xcode | 16+ (full Xcode, not just CLT) | `swift-tools-version: 6.0`; `build-app.sh` needs `actool` | `swift --version` (local: Swift 6.0.3) | +| gh CLI | any recent | releases, PR ops | `gh --version` | +| Node | ≥20 | releases only | `node --version` | +| create-dmg | **npm** package | releases only | `npm install --global create-dmg` | + +**create-dmg trap**: install via npm, NOT Homebrew. `brew install create-dmg` +is a *different tool* with an incompatible CLI. Not needed for dev work — +only for cutting releases (see `edmund-release-and-operate`). + +Dependencies are fetched by SPM on first build — nothing to install by hand +(verified against `Package.swift` / `Package.resolved`, 2026-07-05): + +- `swift-markdown` ≥0.5.0 (CommonMark/GFM parsing; pulls `swift-cmark` transitively) +- `SwiftMath` ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3) +- `Sparkle` ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4) + +Two SPM targets: **EdmundCore** (library + all tests; most work happens here) +and **edmd** (the app shell executable). The binary is named `edmd` even +though the app presents as "Edmund" — deliberate, see the comment in +`Package.swift`. + +## 2. Core commands + +```bash +swift build # debug build of both targets +swift test # full suite: ~750+ tests, ~10s (2026-07-05) +swift test --filter Callout # one suite +./scripts/build-app.sh # release build → build/Edmund.app +``` + +`swift test` also runs automatically as a Stop hook after code-touching turns. + +## 3. What build-app.sh actually does (and why the order matters) + +`scripts/build-app.sh` → `build/Edmund.app`. Steps, in order: + +1. `swift build -c release` +2. Assemble `build/Edmund.app/Contents/{MacOS,Resources}`; copy + `.build/release/edmd`, `Info.plist`, `Resources/AppIcon.icns`. +3. Compile `Resources/Assets.xcassets` with `actool` (falls back to + `/Applications/Xcode.app/.../actool` if xcode-select points at the CLT). +4. Embed `Sparkle.framework` into `Contents/Frameworks/` (found under + `.build/`; SwiftPM links Sparkle but never copies the framework — without + it the updater crashes on first check) and `install_name_tool -add_rpath + "@executable_path/../Frameworks"` so `@rpath` resolves post-install. +5. Codesign **inside-out**: Sparkle.framework first (nested XPC helpers must + be signed before macOS will launch them), then the whole `.app` (ad-hoc, + `--deep`, identifier `com.i7t5.edmd`). Sealing the *bundle* — not just the + binary — is what Sparkle's update validator requires. +6. **Only after sealing**: copy `.build/release/*.bundle` (SwiftMath's math + fonts) into the `.app` **root**. + +Why step 6 is last and at the root — two constraints collide: + +- `codesign` refuses to seal a bundle with any extra item at the `.app` root + ("unsealed contents present in the bundle root"), so the seal must happen + while the root holds only `Contents/`. +- SwiftMath's generated `Bundle.module` accessor hardcodes + `Bundle.main.bundleURL` — the `.app` root — with only a hardcoded absolute + `.build` path as fallback. So the bundle *must* sit at the root. + +Resolution: seal first, copy after. The one unsealed root item makes +`codesign --verify` and `--strict` complain, but Sparkle's actual check is +non-strict and tolerates it (verified end-to-end; details in +`docs/ARCHITECTURE.md` §8). + +**Missing SwiftMath bundle = instant crash the moment the app renders any +LaTeX.** App launches fine, opens documents fine, dies on the first math +block. If you see that crash, check `ls build/Edmund.app/*.bundle` first. + +## 4. Debug bundle fast path (EdmundDbg.app) + +For live runs of a *debug* build, skip `build-app.sh` and hand-assemble +(from `docs/dev-guides/live-repro-guide.md` §4): + +```bash +swift build +mkdir -p build/EdmundDbg.app/Contents/MacOS +cp Info.plist build/EdmundDbg.app/Contents/ +cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/ +cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/ +``` + +- **Sparkle.framework must sit next to the binary** — dyld aborts without it. +- A bare `.build/debug/edmd` runs but **never creates a window**. It needs + the bundle (Info.plist) around it. +- **Launch by direct exec of the bundle binary, never `open -a`**: + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \ + -ApplePersistenceIgnoreState YES & +``` + + LaunchServices (`open -a`) can silently run a stale cached/translocated + copy — you'd be executing last hour's code. Direct exec runs exactly the + binary you just copied. `-ApplePersistenceIgnoreState YES` stops state + restoration from reopening previous (possibly mutated) documents. +- Recreate the test document fresh before every run — autosave mutates it. + +## 5. THE STALE BUILD DISEASE + +The single most expensive trap in this repo: it has produced entire wrong +debugging conclusions ("my fix doesn't work" when the fix was never in the +binary). + +**Symptom**: `swift build` prints `Build complete!` having compiled a changed +file but **not relinked `edmd`**. The app then runs old code. Release builds +(`swift build -c release` / `build-app.sh`) reuse stale objects too. + +**Detection — before trusting ANY binary you just built:** + +```bash +# 1. Grep for a LONG string literal unique to your new code: +strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal' +# 2. Hash before/after the build: +shasum .build/arm64-apple-macosx/debug/edmd +``` + +**Literal length matters**: string literals ≤15 bytes are stored inline in +the Mach-O on arm64 and *never appear* in `strings` output. A short probe +literal gives a false "stale" verdict. Use a long one (a distinctive log +message works well). + +**Cure:** + +```bash +swift package clean # first resort +rm -rf .build # visual change "doesn't take" → nuke it all +``` + +**Never hand-delete `.build/…/edmd.build/`** — that corrupts SwiftPM's +output-file-map and wedges the target until a full clean anyway. + +## 6. Running for visual checks — launch/kill hygiene + +**The user's daily-driver app has the same binary name (`edmd`).** A blanket +`pkill -x edmd` kills their live session. Always, in order: + +```bash +# 1. Who is running, and since when? +pgrep -lx edmd +ps -o lstart=,command= -p +# 2. Kill ONLY your own PID — or, if you launched the debug bundle: +pkill -f EdmundDbg +``` + +Other run gotchas: + +- `open Edmund.app` **foregrounds a running instance instead of + relaunching** — you'll be looking at the old binary. Kill your instance + first or direct-exec the binary. +- Always pass `-ApplePersistenceIgnoreState YES` (see §4). +- After many rapid launch/kill cycles the window server can glitch (tiny + windows, broken state restoration): + `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState` + and relaunch. +- Verification method (window-id screencapture, offscreen render fallback): + `edmund-validation-and-qa` and `docs/ARCHITECTURE.md` §8. + +## 7. CI environment + +`.github/workflows/ci.yml` (verified 2026-07-05): runs `swift test` on +`macos-14` with `latest-stable` Xcode (Swift 6.0 needs Xcode 16+), triggered +on PRs and pushes to `main`. + +- **SPM cache**: `.build` is cached keyed on + `spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}`. The `v2` + token exists because the repo rename `md` → `Edmund` changed the checkout + path and invalidated absolute paths baked into the cached module cache — + bump the token to discard a poisoned cache. +- **Concurrency**: `cancel-in-progress: true` per branch/PR — private-repo + macOS minutes bill at 10x, so superseded commits' runs are cancelled. +- Release pipeline (`release.yml`, tag-triggered) is a separate beast: + `edmund-release-and-operate` / `docs/ARCHITECTURE.md` §13. + +## 8. Checklists + +### Fresh clone to green + +- [ ] `sw_vers` — macOS 14+; `swift --version` — Swift 6.x (Xcode 16+) +- [ ] `git clone` + `cd Edmund` +- [ ] `swift build` — SPM fetches swift-markdown, SwiftMath, Sparkle +- [ ] `swift test` — ~750+ tests green in ~10s +- [ ] Read `docs/ARCHITECTURE.md` (mandated by AGENTS.md before non-trivial work) +- [ ] Visual work planned? `./scripts/build-app.sh`, confirm + `build/Edmund.app` exists and `ls build/Edmund.app/*.bundle` shows the + SwiftMath bundle +- [ ] Releases planned? `node --version` ≥20, `npm install --global create-dmg` + +### Before trusting any run + +- [ ] Binary is fresh: `strings | grep ''` + and/or `shasum` changed since the edit (§5) +- [ ] `pgrep -lx edmd` — user's live instance identified; you will kill only + your own PID (§6) +- [ ] Launched by direct exec, not `open -a` (§4) +- [ ] `-ApplePersistenceIgnoreState YES` passed +- [ ] Test document recreated fresh (autosave mutated the last one) +- [ ] Debug bundle: Sparkle.framework sits next to the binary +- [ ] Release bundle: SwiftMath `*.bundle` at the `.app` root (or the first + LaTeX render crashes) + +## Provenance and maintenance + +Every claim above was read from the files below on 2026-07-05. Re-verify: + +- Toolchain/deps: `cat Package.swift` (tools-version, platforms, dep + versions); `grep identity Package.resolved` +- Build anatomy: `cat scripts/build-app.sh` (step order, sealing comments) +- Test count/time: `swift test 2>&1 | tail -3` +- Debug bundle recipe: `docs/dev-guides/live-repro-guide.md` §4 +- Stale-build disease, launch gotchas: `docs/ARCHITECTURE.md` §8 + ("Stale release builds", "open Edmund.app", savedState) +- CI facts: `cat .github/workflows/ci.yml` (cache key comment, concurrency) +- create-dmg quirks: `docs/ARCHITECTURE.md` §8 ("create-dmg — npm only") + +If a command here disagrees with those files, the files win — update this +skill. diff --git a/.agents/skills/edmund-caret-integrity-campaign/SKILL.md b/.agents/skills/edmund-caret-integrity-campaign/SKILL.md new file mode 100644 index 0000000..d549906 --- /dev/null +++ b/.agents/skills/edmund-caret-integrity-campaign/SKILL.md @@ -0,0 +1,267 @@ +--- +name: edmund-caret-integrity-campaign +description: > + The executable, decision-gated campaign for Edmund's hardest live problem: + the delete-drift class (caret/selection integrity in the live NSTextView / + TextKit 2 / input-context layer). Load when the caret or selection lands in + the wrong place after a delete/type/IME/drag interaction, when text edits + corrupt or desync, when autosave writes stale content, or when logs show + ⚠︎LEN-MISMATCH or the "healing storage edit that bypassed didChangeText" + breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, + fence off six settled battles, capture evidence, build a deterministic repro, + pick from a ranked solution menu with proof obligations, then validate and + promote through change control. Not for viewport/scroll drift (that's + edmund-debugging-playbook → viewport docs) unless the caret itself moves. +--- + +# Caret-integrity campaign (the delete-drift class) + +Six rounds are settled; **new rounds are expected**. This class does **not +reproduce in headless tests** — the harness runs AppKit's deferred machinery +synchronously, so a green unit test proves nothing here. Success is measured by +`PASS/FAIL` grep and byte counts, **never by eye or by reasoning**. + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md` (456 lines) and +the code. Read that doc fully before a deep dive. + +--- + +## QUICK CARD (the whole loop) + +``` +0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook. +1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them. +2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS; + traceSelectionOrigin names who moved the caret; rebuild the doc. +3. REPRO ReproScript: needles not offsets; real events; replicate internal + call sequences verbatim. Freeze a script whose logsel/assertcaret + DISCRIMINATES broken vs current build. No repro → hypothesis wrong. +4. SOLVE rank candidates (missing guard < extend heal < reorder fixup < + structural). Each states what it must PROVE. +5. PROMOTE fix flips frozen repro to PASS same run → soak 4–5 cycles, + byte-identical rawLen → swift test green → add test (locks headless + contract even if non-discriminating) + keep .repro → update the + investigation doc → branch/commit via change-control. Never ship on + reasoning alone. +``` + +--- + +## PHASE 0 — Classify: is it actually this class? + +Grep `~/.edmund/logs/edmund-.log` (run the app with +`-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES`): + +| Signature | Meaning | +|---|---| +| `healing storage edit that bypassed didChangeText` | a bypass fired (round-4 class) | +| **persisting** `⚠︎LEN-MISMATCH` (not just transient between shouldChangeText→synced) | storage/rawSource desync stuck | +| `selectionDidChange` with `up=Y` at a surprising position | selection moved mid-recompose (round-6 class) | +| a `shouldChangeText` with **no** `synced`/`SKIPPED`/`DEFERRED` after it | a bypassed `didChangeText` | + +`scripts/grep-trace.sh` in **edmund-live-repro-and-diagnostics** surfaces all +four at once. + +**If none match and the symptom is scroll/viewport-shaped** (jump, wrong +landing, can't-scroll-up) with the caret *not* leaping → this is the viewport +class, not caret integrity → **edmund-debugging-playbook** + +`docs/investigations/viewport-glitch-investigation.md`. Do not run this campaign on a viewport +bug. + +--- + +## PHASE 1 — Fence off the six settled battles + +Confirm each shipped guard **still holds** before hypothesizing a new mechanism. +The most likely round-7 shape is a **new code path that lacks an existing +guard**, not a new mechanism. + +| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path | +|---|---|---| +| 1–3 | IME **marked-text stranding**: styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` while `hasMarkedText()` strands the composition; `didChangeText` then bails forever and every edit drifts | **every storage-touching styling path guards `!hasMarkedText()`** — including async ones scheduled *before* composition began (`+SelectionTracking` caret-move restyle). `becomeFirstResponder` resyncs as catch-all | +| 4 | **Drag-move bypass**: a drag-move whose drop has no valid target deletes via `shouldChangeText`→`replaceCharacters` with **no `didChangeText`**; `rawSource`/`blocks` freeze; autosave writes stale text | `scheduleBypassedEditSyncCheck` (`+EditFlow.swift`): a next-run-loop check finds an unconsumed storage `pendingEdit` and runs the sync (breadcrumb above) | +| 5 | **Heal leaped the caret**: the round-4 heal ran against a **stale** selection and moved the caret | heal collapses/derives the caret from the `pendingEdit` hull before syncing | +| 6 | **Queued selection fixup**: TK2's `_fixSelectionAfterChangeInCharacterRange` stays queued after a bypass and fires at the **next `endEditing`** (even an attribute-only restyle), remapping the **stale** selection and leaping even a *freshly set, valid* caret | heal sets the caret from the pendingEdit hull **before** the sync **AND re-asserts it after** (`+EditFlow.swift`) | +| 7 | **Same queued fixup on the NORMAL edit path** (not the heal): armed by a **cross-block caret move** (schedules the async caret-move restyle), the fixup fires during `syncRawSourceFromDisplay`→`recomposeDirty`'s `endEditing` on an ordinary keystroke and leaps the caret to the **block boundary**; the normal path styles `settingSelection=false` and never re-asserts, so it **persists** | `syncRawSourceFromDisplay` captures the pendingEdit-hull caret before `consumePendingEdit`, re-asserts it after `recomposeDirty` if the fixup moved it (`+EditFlow.swift`; breadcrumb `re-asserting caret after fixup leap (normal path)`) — **fix not yet confirmed by a deterministic repro**, live-verifiable via the breadcrumb | + +Confirm the guards exist: +```bash +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +``` + +### FENCED WRONG PATHS (each proven dead — do not retry) + +- **Fixing by nudging the caret at symptom time.** The symptom is armed + seconds-to-minutes *earlier* (round 6: drift at 22:13 armed at 22:11:57). You'd + patch the wrong instant. +- **Trusting a headless regression test.** The round-6 test passes **with and + without** the fix. Headless runs the fixup synchronously. +- **Shipping on reasoning alone.** Rounds 1–5 all came back. Round 6's first fix + candidate "worked by reasoning" and **failed in the repro within a minute**. +- **Assuming `didChangeText` pairs with every mutation.** AppKit violates this + (round 4). +- **Mutating storage mid-composition.** That is the original bug (rounds 1–3). +- **Expecting a scripted keystroke replay to arm round 7.** Round 7 was replayed + faithfully from a reconstructed `t0` (every keystroke + capped pauses through + the whole drift window) and it did **not** produce the block-end leap. + Programmatic caret moves (`clickoff`) and imprecise synthetic clicks + (`realclickoff` — lands off-by-a-few, diverges, crashes) do not fully arm it. + The arming needs **real mouse clicks at real positions** and probably the + real session's **two-document window switching** (`becomeFirstResponder` + resync is the prime suspect). Round-8 lead: two open docs + real clicks, or + instrument `becomeFirstResponder`/the caret-move restyle to catch the next + live occurrence. Compressed replays also coalesce a bypass with the next + keystroke into one `pendingEdit` hull (never happens live — the heal runs + first), producing off-by-one breadcrumb noise; don't trust it as a repro. + +--- + +## PHASE 2 — Capture evidence + +1. Launch with verbose diagnostics (debug bundle; file arg is `argv[1]`): + ```bash + build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -ApplePersistenceIgnoreState YES + ``` +2. **Decode the trace fields:** `sel/active/marked/up/undo/blocks/storLen/rawLen`. + `up=Y` = event arrived mid-recompose (suspicious). Healthy ordering: + `shouldChangeText` → `selectionDidChange (up=N)` → `synced`; a transient + `⚠︎LEN-MISMATCH` between those is normal, a persisting one is not. +3. **Find the FIRST bad line, then walk BACKWARDS.** The visible symptom is often + the second half of a two-part mechanism. +4. **`traceSelectionOrigin`** logs the call stack of whoever moved the selection + mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. If + the caret moves and you don't know who moved it, this answers it in one run. +5. **Reconstruct the document.** Wrapped-paragraph geometry and block kinds + matter — repro against a lookalike, never `"hello world"`. + +**Gate:** you have a candidate trigger hypothesis + the first-bad-line timestamp. +Otherwise **instrument** (add a `Log.shouldTrace` breadcrumb — one call stack or +state dump beats ten speculative fixes) and wait for the next occurrence. + +--- + +## PHASE 3 — Build the deterministic repro + +Use the in-process **ReproScript** driver (`Sources/edmd/App/ReproScript.swift`, +DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands: +`sleep / caret / type / backspace / bypassdelete / assertcaret / logsel`. + +**Worked example — the round-6 minimal repro** (the first deterministic repro in +six rounds): +``` +sleep 2000 +bypassdelete Sizemore, +sleep 800 +logsel # broken build: 321 fixed build: 290 +backspace 2 +logsel +``` +The deciding output was `logsel` **321 → 290** with the fix, every run, window +not even visible. + +Rules that make repros survive editing: +- **Needles, not offsets** — offsets go stale the instant the script edits. +- **Real events, not method shortcuts** — `insertText("")` skips + `deleteBackward`'s selection machinery, exactly where round 6 lived. +- **Replicate AppKit-internal call sequences verbatim** — `bypassdelete` does + `shouldChangeText`→`replaceCharacters`, no `didChangeText`, not an + approximation. For a *new* internal path, pin its real sequence from a + `traceSelectionOrigin` stack first, then replay it (extend ReproScript ~10 + lines). + +**Gate:** a **frozen** script (exact commands + document) whose `logsel` / +`assertcaret PASS/FAIL` output **discriminates** the broken build from the +current one. + +**No repro after honest attempts?** The hypothesis is wrong, or fidelity is too +low — a mouse-only path (real drag-select/drag-move) needs the **CGEvent driver** +(edmund-live-repro-and-diagnostics §4). Loop back to Phase 2. + +--- + +## PHASE 4 — Solution menu (ranked; each with a proof obligation) + +Pick by the observation pattern. Cheaper is higher. + +1. **Add a missing guard on an existing invariant** (cheapest, most likely for a + new round). Guard inventory: `!hasMarkedText()` on the offending styling path; + bypass-check scheduling on a new mutation entry point; caret re-assertion + after a restyle. *Proof:* the frozen repro flips to PASS **and** no other + `.repro` regresses. *Selects when:* a specific new code path shows the + round-1/4/6 signature that its siblings already guard. +2. **Extend the heal to a new bypass source.** *Proof:* first add a ReproScript + command that replays the new source's **exact** call sequence, show it + reproduces the freeze, then show the extended heal fixes it. *Selects when:* + trace shows a `shouldChangeText` with no close-out from a path other than + drag-move. +3. **Intercept/reorder the queued fixup.** *Proof obligation:* explain **when** + TK2 queues and fires `_fixSelectionAfterChange` and show the reorder does not + fight AppKit's machinery (no oscillation, no double-move). *Selects when:* + `traceSelectionOrigin` names the fixup and the caret is valid before it fires. + Higher risk — you are stepping into private AppKit ordering. +4. **Structural: make rawSource sync independent of `didChangeText` pairing** + (e.g. a storage-version counter that reconciles regardless of which callbacks + fired). Biggest change; **candidate, unproven** — route through + **edmund-research-frontier** (frontier item "caret integrity by + construction"). *Proof:* all historical `.repro` scripts + a randomized + bypass-fuzzer soak stay green with the callback-pairing assumption removed. + +--- + +## PHASE 5 — Validate and promote + +1. Fix candidate **flips the frozen repro to PASS within the same run**. (If it + only "works by reasoning," it is not done — round history.) +2. **Soak** (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5 + trigger cycles at different document positions with ordinary editing between, + `assertcaret` after every predictable step. Green across all cycles **and + byte-identical final `rawLen` across repeated runs** = deterministic. +3. **`swift test` green** (also the Stop hook). +4. **Add a regression test even if it can't discriminate the live mechanism** — + it locks the headless contract (assert `rawSource == string`; see the + `BypassedEditSyncTests` / `MarkedTextDesyncTests` family). **Keep the `.repro` + script** in the repo for the live half. +5. **Update `docs/investigations/delete-drift-investigation.md`** with the new round (symptom → + root cause → repro recipe → fix → status), and `docs/ARCHITECTURE.md` §8 if an + invariant changed — **same PR**. +6. Route through **edmund-change-control**: branch `fix/…` off `main`, small + commits, **never auto-push/PR/merge**. (No screencapture unless the fix also + draws.) + +**Never promote a fix that only "works by reasoning."** + +--- + +## When NOT to use this skill + +- Viewport/scroll drift where the caret itself does not leap → + **edmund-debugging-playbook** + `docs/investigations/viewport-glitch-investigation.md`. +- Just need the repro driver mechanics / trace decoding → + **edmund-live-repro-and-diagnostics**. +- The AppKit theory behind the mechanisms → **textkit2-appkit-reference**. +- The historical record of prior rounds → **edmund-failure-archaeology**. +- The structural (round-∞) redesign as a research project → + **edmund-research-frontier** + **edmund-research-methodology**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`, +`Sources/edmd/App/ReproScript.swift`, and +`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`. + +```bash +grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +# round-6 discriminating numbers (321 broken / 290 fixed): +grep -n '321\|290' docs/investigations/delete-drift-investigation.md +``` + +When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list. diff --git a/.agents/skills/edmund-change-control/SKILL.md b/.agents/skills/edmund-change-control/SKILL.md new file mode 100644 index 0000000..6b41c7f --- /dev/null +++ b/.agents/skills/edmund-change-control/SKILL.md @@ -0,0 +1,210 @@ +--- +name: edmund-change-control +description: > + How changes are classified, gated, and reviewed in the Edmund repo. Load at + the START of any task that will modify the repo; before committing, branching, + or proposing a merge or release; when deciding whether a change needs a test, + a screencapture visual check, or a live repro; when unsure what requires + explicitly asking the maintainer (push, PR, merge, release, deleting + uncommitted work, touching test-files/, adding dependencies). Contains the + non-negotiable rules with the historical incident behind each one. +--- + +# Edmund change control + +Last verified: 2026-07-05, against `main` @ fe8a1f5 (release 0.1.3). + +This is the gatekeeping doc: what class of change you are making, which gate it +must pass, and the rules that are never traded away. The rules exist because +each was paid for — the incident column is not decoration. + +## 0. Before you start (task setup) + +``` +[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule) +[ ] git status — note any uncommitted work; never clobber it +[ ] Not on main? Fine. On main? Branch NOW, before the first edit +[ ] Classify the change (§1) so you know the gate before you write code +[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't + reproduce the bug, you can't prove the fix (§3, last row) +``` + +## 1. Classify the change, apply the gate + +Classify FIRST, before writing code. The class decides the verification bar. + +| Class | Examples | Gate before commit | +|---|---|---| +| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. `swift test` still runs as a Stop hook; ignore no failures it surfaces. | +| Code (logic) change | Parser, block model, helpers, non-drawing refactor | `swift test` green. New behavior or bug fix → add a test that fails without the change. | +| Visually-drawing change | Anything in `Rendering/`, overlays, decorations, padding, fonts, layout fragments | All of the above, PLUS build the app and `screencapture` the result (window-by-id method — see `edmund-live-repro-and-diagnostics`). Headless layout is not proof for anything that draws. | +| Edit-pipeline / selection behavior | `+EditFlow`, `+Composition`, `+SelectionTracking`, `+Undo`, caret, IME, drag, viewport timing | All of the above, PLUS a live repro or soak script (`-debug.reproScript`, see `edmund-caret-integrity-campaign` and `docs/dev-guides/live-repro-guide.md`). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred. | +| Release | Version bump, tag, appcast | Run `misc/before-you-release.md` top to bottom, then `misc/how-to-release.md`. See `edmund-release-and-operate`. Never start a release without being asked. | + +Notes on the gates: + +- `swift test` (~750+ tests, ~10s) also runs automatically as a **Stop hook** + (`.Codex/settings.json`: `swift test 2>&1 | tail -5`) at the end of every + turn. That is a safety net, not the gate — run it yourself before committing + so the failure is yours to see, not the hook's. +- A change can be in multiple classes. Apply the union of gates. A caret fix + that also moves a decoration needs test + screencapture + live repro. +- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a + human could see the diff, screenshot it. + +Classification edge cases that have gone wrong before: + +- "It's just an attribute change" is NOT automatically the code-logic class. + If the attributes change measured geometry (font size, paragraph spacing, + hidden-delimiter width), it draws AND it needs `invalidateLayout(for:)` — + see §3. +- "It's just a restyle helper" that runs `beginEditing`/`setAttributes` on + storage is edit-pipeline class if it can fire during IME composition or + after a bypassed edit. When in doubt, grep for `hasMarkedText` guards on + the sibling paths and match them. +- A test-only change is docs-class for gating purposes (nothing to screenshot), + but the Stop hook still must pass — a broken test is a broken commit. +- Release-adjacent edits (`Info.plist` versions, `CHANGELOG.md`, `appcast.xml`, + `scripts/release.sh`, `.github/workflows/`) are release class even when tiny. + The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, + not from app code. + +## 2. Git discipline + +From AGENTS.md + ARCHITECTURE §12 + the repo's own history (`git branch -a`). + +- **Branch off `main` for every fix. Never commit straight to `main`.** + One feature/fix per branch. +- Branch prefixes actually in use (verified): `fix/`, `feat/`, `feature/`, + `docs/`, `chore/`, `uiux/`, `ui/`, `ux/`, `markdown/`, `bug/`, `refactor/`, + `ci/`, `release/`. Prefer `fix/`, `feat/`, `docs/`, `chore/` for new work; + `uiux/` for visual polish; `markdown/` for syntax-feature work. +- **Small, logical commits; commit frequently.** A commit that mixes the fix + with a drive-by refactor is two commits done wrong. +- **NEVER auto-push, open a PR, or merge.** Only when the maintainer + explicitly asks. No exceptions for "it's just docs." +- **Never discard uncommitted changes.** No `git checkout -- .`, + `git reset --hard`, `git clean` on a dirty tree without explicit permission. +- Commit messages follow the observed style: `fix(editor): …`, `docs: …`, + `ui: …`, `chore: …` — short imperative subject. + +## 3. The non-negotiables + +Each rule was established by an incident. Verify against the cited doc before +arguing an exception. + +| Rule | Rationale | Incident | +|---|---|---| +| Text storage always equals `rawSource`; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. | Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. `docs/investigations/delete-drift-investigation.md`. | +| TextKit 2 only. Never touch `NSTextView.layoutManager`; never store `NSTextBlock`/`NSTextTable` attributes. | Either one **silently and permanently** reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. | +| No `NSTextAttachment`. Images/icons are drawn as overlays. | TK2 only honors attachments on U+FFFC, which `rawSource` never contains (see rule 1). | ARCHITECTURE §2, §5. | +| Never draw images on wrapping (multi-line) fragments; use stroked `CGPath`s instead. | A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: `docs/investigations/archives/callout-title-wrap-investigation.md`; fix in ae61644 (stroked path, not image). | +| Every storage-touching styling path guards `!hasMarkedText()` — including async paths scheduled before composition began. | Mutating storage mid-IME-composition strands the marked text; `didChangeText` then bails forever on its own guard and every later edit drifts. | Delete-drift rounds 1–2 (IME stranding cascade). `docs/investigations/delete-drift-investigation.md`. | +| Never assume `didChangeText` follows every edit. Sync paths must survive a bypassed edit. | AppKit's drag-move-to-nowhere deletes via `shouldChangeText` → `replaceCharacters` and never calls `didChangeText`, silently freezing `rawSource`. The heal (`scheduleBypassedEditSyncCheck` in `+EditFlow`) exists for this. | Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself. | +| Attribute-only restyles that change geometry must `invalidateLayout(for:)` the range. | TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; `recomposeDirty` and the idle drain already do this — new paths must too. | +| Undo restore is diff-based `recomposeReplacing` — never a full `recompose`. | Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (`fix(undo): select + center the changed text; diff-based snapshot restore`). | +| Never blanket `pkill -x edmd`. `pgrep -x edmd` first, check start times, kill only the PID you launched. | The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's `pkill -x edmd` shorthand predates this rule — don't copy it.) | Established after the maintainer's own instance was killed during a debugging session. | +| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | AGENTS.md "Environment"; the `-debug.reproScript` driver exists precisely so repros need no new TCC grants. | +| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). | +| Files in `test-files/` are the maintainer's manual test corpus. Never rewrite them for automation; `test-files/todo.md` especially is owner-edited. | They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or `Tests/`. | Standing maintainer rule. | +| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. `docs/dev-guides/live-repro-guide.md`. | + +### The two incidents that shaped this table + +Worth knowing as stories, because the rules read as pedantry until you see +the cost: + +- **Delete-drift (six rounds).** The hardest live problem this repo has had. + A caret that drifted after deletes. Round 1 blamed IME stranding — plausible + fix, shipped, recurred. Round 2 disabled remaining marked-text sources — + recurred. Round 3 stopped guessing and built diagnostics (selection tracing, + event logs). Round 4's diagnostics caught a drag-move deleting storage with + no `didChangeText` — the heal was born. Round 5: the heal itself leaped the + caret via a stale selection. Round 6 found the actual drift mechanism — + TextKit 2's queued `_fixSelectionAfterChange` firing at the *next* + `endEditing` — and held only because the ReproScript driver could replay the + exact keystroke sequence deterministically. Five shipped fixes failed; the + one preceded by a frozen repro stuck. That asymmetry IS the change-control + policy for this bug class. +- **Undo/redo viewport drift.** Undo restored a snapshot via full `recompose`, + which reset every fragment to a TK2 height estimate; the follow-up + scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) + diffs the snapshot against current text and applies only the changed span + with `recomposeReplacing`. Moral: in TK2, layout state is part of the + document state you must preserve — "re-render everything" is never the safe + fallback here, it is the bug. + +## 4. Review expectations + +- **ARCHITECTURE.md is updated in the same PR** whenever you learn something + non-obvious or change an invariant. The doc's own header demands this; the + gotchas in §8 all arrived this way. +- **Quirks are documented as comments at the code site** — the edge case, the + workaround, the *why*. Not in commit messages, not in AGENTS.md. +- **New known issues** go in ARCHITECTURE §9 with a one-line repro and a + pointer to any deeper write-up in `docs/`. +- Big investigations (multi-round bugs) get a `docs/*-investigation.md` + chronicle — see `edmund-failure-archaeology` for the pattern. + +## 5. Pre-commit checklist (copy-paste) + +The workflow that worked (ARCHITECTURE §12 + AGENTS.md). Run it verbatim: + +``` +[ ] swift test — all green (also enforced by the Stop hook; don't rely on it) +[ ] New behavior / bug fix → a test exists that fails without the change +[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG +[ ] Edit-pipeline / selection change? → live repro or soak script passed +[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main +[ ] Diff touches only what the task needs; style matches surroundings +[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change +[ ] Quirk introduced/found? → comment at the code site +[ ] Commit is small and logical; message matches repo style (fix(scope): …) +[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked) +``` + +## 6. Ask the maintainer first — always + +Never do these unprompted; ask and wait for an explicit yes: + +| Action | Why it's gated | +|---|---| +| `git push`, opening a PR, merging anything | Standing rule in AGENTS.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. | +| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. | +| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. | +| Editing anything in `test-files/` | Manual test corpus; `todo.md` there is owner-edited. Make fixtures elsewhere. | +| Adding a dependency | Current set is deliberately three (`swift-markdown`, `SwiftMath`, `Sparkle`); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). | +| Changing `.Codex/settings.json` hooks or permissions | Alters what runs automatically on the maintainer's machine. | + +## When NOT to use this skill + +| You need… | Go to | +|---|---| +| The invariants' full technical statement and render pipeline | `edmund-architecture-contract` | +| To debug a failure, read traces/logs | `edmund-debugging-playbook` | +| The history of a past incident in depth | `edmund-failure-archaeology` | +| TextKit 2 / AppKit API behavior details | `textkit2-appkit-reference` | +| Launch flags, debug bundle, settings | `edmund-config-and-flags` | +| Build issues, stale binaries, environment | `edmund-build-and-env` | +| Executing a release / operating the app | `edmund-release-and-operate` | +| The screencapture / ReproScript mechanics | `edmund-live-repro-and-diagnostics` | +| Test-writing patterns and QA strategy | `edmund-validation-and-qa` | +| Writing docs / chronicles | `edmund-docs-and-writing` | +| Caret/selection bug-class specifics | `edmund-caret-integrity-campaign` | + +## Provenance and maintenance + +- Sources: `AGENTS.md` (repo root), `docs/ARCHITECTURE.md` §1 §2 §8 §9 §12, + `.Codex/settings.json` (Stop hook), `misc/before-you-release.md`, + `misc/how-to-release.md`, `docs/investigations/delete-drift-investigation.md` (rounds 1–6), + `docs/investigations/archives/callout-title-wrap-investigation.md`, `git log` / `git branch -a` as of + fe8a1f5 (2026-07-05). +- Commits cited were verified in `git log`: 5bb2b40 (diff-based undo restore), + 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a + (round-6 caret re-assert). +- Two rules rest on maintainer statements rather than repo docs: the + measure-from-pixels rule and the `test-files/` ownership rule. If either gets + written into ARCHITECTURE.md, point at it here. +- Maintain: when a new incident produces a new rule, add a row to §3 with the + incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When + the Stop hook in `.Codex/settings.json` changes, update §1's note. diff --git a/.agents/skills/edmund-config-and-flags/SKILL.md b/.agents/skills/edmund-config-and-flags/SKILL.md new file mode 100644 index 0000000..9e06f3f --- /dev/null +++ b/.agents/skills/edmund-config-and-flags/SKILL.md @@ -0,0 +1,220 @@ +--- +name: edmund-config-and-flags +description: > + Catalog of every configuration axis in the Edmund Markdown editor — user + settings (UserDefaults keys, defaults, where consumed), launch arguments + (diagnostic + repro flags), compile-time gates, and logging config. Load + when adding or changing a setting, hunting which flag controls a behavior, + launching the app with debug flags, auditing defaults, or wiring a new + preference into the live editor. This skill drifts fastest of the set — + every table ends with a re-verification grep. Not for the invariants + (see edmund-architecture-contract), release flags (edmund-release-and-operate), + or how to READ the logs (edmund-live-repro-and-diagnostics). +--- + +# Edmund configuration & flags + +Ground-truth catalog of every knob. **Code wins over docs** — every value +below was read from source on 2026-07-05; re-verify with the greps at the end +before trusting a value in a decision. + +Two source-of-truth files: +- `Sources/edmd/Settings/AppSettings.swift` — every UserDefaults key + typed accessor. +- `Sources/edmd/Settings/*View.swift` (Appearance / General / Advanced) + `FontSettings` — the SwiftUI panes (`@AppStorage`). + +Definitions used below: **UserDefaults** = macOS per-app persisted key/value store; **argument domain** = passing `- ` on the command line overrides that default for one launch; **`@AppStorage`** = SwiftUI wrapper binding a view to a UserDefaults key. + +--- + +## 1. User settings (UserDefaults keys) + +Every key is a `static let` in `AppSettings.swift`. The key **string** (not the +Swift name) is what you pass as a launch arg. + +| Swift name | Key string | Purpose | +|---|---|---| +| `reopenWindows` | `settings.general.reopenWindows` | Reopen last windows on launch | +| `startupAction` | `settings.general.startupAction` | What to do at startup (new doc / reopen / nothing) | +| `autoSaveWithVersions` | `settings.general.autoSaveWithVersions` | NSDocument autosave-in-place vs versions | +| `conflictResolution` | `settings.general.conflictResolution` | File-changed-on-disk handling | +| `suppressInconsistentLineEndingWarning` | `settings.general.suppressInconsistentLineEndingWarning` | Silence mixed-line-ending warning | +| `diagnosticLogging` | `settings.general.diagnosticLogging` | **On/off for file logging** (opt-out; see §4) | +| `logRetention` | `settings.general.logRetention` | Days of logs to keep (pruned on configure) | +| `appearanceMode` | `settings.appearance.mode` | Light / dark / system | +| `maxContentWidthCm` | `settings.appearance.maxContentWidthCm` | **Max column width, stored in CENTIMETRES** (see note) | +| `contentWidthUnit` | `settings.appearance.contentWidthUnit` | Display unit only (cm/in); the stored value is always cm | +| `renderBlankLinesAsBreaks` | `settings.reading.renderBlankLinesAsBreaks` | Read-mode blank-line handling | +| `sourceMode` | `settings.view.sourceMode` | When on, **Source replaces Edit** in the ⌘E toggle; honored on open | +| `verboseEditorDiagnostics` | `settings.advanced.verboseEditorDiagnostics` | **Verbose editor trace** (see §4; pairs with diagnosticLogging) | +| `sendCrashLogs` | `settings.advanced.sendCrashLogs` | Opt-in crash upload — **currently INERT** (see note) | +| `sentCrashReports` | `settings.advanced.sentCrashReports` | Dedup set of already-uploaded `.ips` filenames | +| `lastWindowHeight` | `settings.window.lastHeight` | Persisted window sizing (see the frame-not-content trap) | +| `automaticallyChecksForUpdates` | `SUAutomaticallyChecksForUpdates` | Sparkle's own key (not namespaced) | + +**Content width (the physical-column design):** persisted as **centimetres** +(`maxContentWidthCm`); `contentWidthUnit` is a display unit only. The column is +an **absolute physical** cap converted to points via the display's real PPI +(`NSScreen.physicalPPI`, from `CGDisplayScreenSize`), applied as a symmetric +`textContainerInset.width`. Default is locale-aware — **5 in (US) / 12 cm +(elsewhere)** — and doubles as the slider's magnetic snap point. Recomputed on +resize and on `NSWindow.didChangeScreenNotification`. Consumer path lives in +`EditorTextView+ContentWidth.swift`. + +**Window sizing trap:** persistence must round-trip the **frame** size, not the +`contentView.bounds` size — reapplying content size grows the window by the +title-bar + toolbar height on every reopen, and heights below `minSize` get +silently rejected. Save `window.frame.size`, reapply with `window.setFrame(_:)` +**after the toolbar is installed**. (Note: the key on disk is +`settings.window.lastHeight` — code, not the `lastWindowSize` some docs say.) + +**Crash uploading is inert:** `sendCrashLogs` defaults off AND the Settings ▸ +Advanced toggle is **commented out** in `AdvancedSettingsView.swift`, and +`CrashReporter.reportingEndpoint` is a `REPLACE-ME.invalid` placeholder +(`CrashReporter.swift:27`, `// TODO: real server`). Nothing uploads today. +Un-inert it only when a receiving server exists (see edmund-release-and-operate). + +--- + +## 2. Launch arguments + +macOS reads `- ` into the argument domain. Pass the +**key string** from §1, not the Swift name. The **file to open must be +`argv[1]`** (before the flags). + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -debug.reproScript SCRIPT.repro \ + -ApplePersistenceIgnoreState YES +``` + +| Flag | Effect | +|---|---| +| `-settings.general.diagnosticLogging YES` | Turn on file logging for this run | +| `-settings.advanced.verboseEditorDiagnostics YES` | Emit the verbose editor trace (sel/active/marked/up/…) | +| `-debug.reproScript ` | **DEBUG builds only** — replay a keystroke script (`ReproScript.swift`) | +| `-ApplePersistenceIgnoreState YES` | Apple's flag — stop state restoration reopening mutated docs | + +`-debug.reproScript` is the only Edmund-specific *debug* key; it does not have +an `AppSettings` accessor — it is read directly in `ReproScript.swift`. It is +compiled out of release builds. + +--- + +## 3. Compile-time axes + +`#if DEBUG` gates live in: `EditorTextView.swift`, `EditorTextView+EditFlow.swift`, +`Diagnostics/Log.swift`, `edmd/App/main.swift`, `edmd/App/ReproScript.swift`. +What they gate: + +- **The TextKit-1 tripwire** (`EditorTextView.swift:273`+): a DEBUG observer on + `NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the view + ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is + silent and permanent (see edmund-architecture-contract). +- **ReproScript** — the whole in-process keystroke driver. +- **Log level threshold** — `Log.swift` compiles a lower floor in DEBUG + (`debug`+) than release (`info`+); see §4. + +Named tuning constants (not user-facing, but they behave like knobs): + +| Constant | Value | Where | Meaning | +|---|---|---|---| +| `fullLayoutMaxLength` | `100_000` | `EditorTextView.swift:80` | Docs ≤ this many UTF-16 units are kept **fully laid out** (below the TK2 estimate regime). Consumed at `+LazyStyling.swift:133`. | + +--- + +## 4. Logging config + +Read `Sources/EdmundCore/Diagnostics/Log.swift` and +`EditorTextView+Diagnostics.swift`. + +- API: `Log.{debug,info,error}(_:category:)`, `Log.measure(_:) { … }`. +- File: `~/.edmund/logs/edmund-YYYY-MM-DD.log`, written on a private serial queue. +- Config flow: `AppSettings.applyLogging()` pushes the toggle + retention into + `Log.configure` at launch and on change; retention pruning happens there. +- **Two independent switches**: `diagnosticLogging` (writes anything at all) and + `verboseEditorDiagnostics` (adds the per-event editor trace). For a live + repro you almost always want **both** on. Verbose lines are gated behind + `Log.shouldTrace`. +- The log is **opt-out** (on by default), retention-pruned; the user only + toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics. + +Trace-field decoding (`sel/active/marked/up/undo/blocks/storLen/rawLen`, +`⚠︎LEN-MISMATCH`, `traceSelectionOrigin`) is covered in +**edmund-live-repro-and-diagnostics** and **edmund-debugging-playbook** — one +home per fact; this skill only says which flags turn the trace on. + +--- + +## 5. ReproScript command surface + +`Sources/edmd/App/ReproScript.swift`, DEBUG only. One command per line, `#` +comments allowed. + +| Command | Effect | +|---|---| +| `sleep ` | wait before next command | +| `caret ` | place caret before first occurrence of `` | +| `type ` | one real key event per char (~80 ms apart) | +| `backspace ` | n real delete keystrokes (~300 ms apart) | +| `bypassdelete ` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `PASS/FAIL` iff caret sits exactly before `` | +| `logsel` | log selection, rawSource length, doc count | + +Adding a command is ~10 lines in `ReproScript.swift`. Usage, soak patterns, and +launch recipe: **edmund-live-repro-and-diagnostics**. + +--- + +## 6. How to ADD a setting (checklist) + +Worked from an existing real path (`sourceMode` / content width). To add a +user-facing setting: + +1. Add a `static let` key + typed accessor in `AppSettings.swift` (namespace the + key string: `settings..`). +2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.)`. +3. If it must affect **open documents live**, add/extend an `applyTo…` broadcast + (see the font/line-height/content-width `applyTo…` helpers) so every open + `Document.editor` picks it up — a setting that only takes effect on next open + is usually a bug. +4. Pick a sane default (register it, or make the accessor default when absent). +5. New behavior needs a test + (if it draws) a screencapture check — route + through **edmund-change-control** and **edmund-validation-and-qa**. + +--- + +## When NOT to use this skill + +- Understanding *why* an invariant exists → **edmund-architecture-contract**. +- Release/signing/appcast flags, `RELEASE_TOKEN`, Sparkle keys → **edmund-release-and-operate**. +- Interpreting log output / running a repro → **edmund-live-repro-and-diagnostics**. +- Which change needs which gate → **edmund-change-control**. +- Build-time flags in the toolchain sense (stale builds, `swift package clean`) → **edmund-build-and-env**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against source. This skill drifts fastest — re-verify each +table before relying on it: + +```bash +# §1 all keys + strings: +grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift +# §1 crash toggle still commented out / endpoint still placeholder: +grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift +grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift +# §2 repro flag key: +grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift +# §3 tripwire + constant: +grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +# §5 repro commands: +grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +``` + +**Known doc-vs-code discrepancies (code wins):** ARCHITECTURE §7 calls the +window-size key `lastWindowSize`; the code key is `settings.window.lastHeight` +(`lastWindowHeight`). If you touch window persistence, trust the code. diff --git a/.agents/skills/edmund-debugging-playbook/SKILL.md b/.agents/skills/edmund-debugging-playbook/SKILL.md new file mode 100644 index 0000000..15a1c6d --- /dev/null +++ b/.agents/skills/edmund-debugging-playbook/SKILL.md @@ -0,0 +1,305 @@ +--- +name: edmund-debugging-playbook +description: >- + Load this FIRST when any bug report or unexpected behavior arrives in Edmund: + caret lands in the wrong place after delete/typing, viewport jumps or + scroll-to-target misses, rendering is wrong or missing, empty bands or clipped + lines, everything suddenly renders as plain text, app crashes or won't launch, + a code change "doesn't take" after rebuild, undo/redo lands the viewport + wrong, IME/CJK/accent input misbehaves, right-click shows the wrong menu, + window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage + table, the traps that cost real debugging time, discriminating trace checks, + the repro escalation ladder, and the open-bug inventory so known bugs are not + rediscovered fresh. +--- + +# Edmund debugging playbook + +Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the +triage table, run the discriminating first check *before* forming a theory, +and check the open-bug inventory before declaring a discovery. + +## When NOT to use + +- Making a change, not chasing a bug → `edmund-change-control`. +- You already know the bug is live-only and need to build a deterministic + repro → `edmund-live-repro-and-diagnostics` (full ladder detail; summary in + §5 here). +- Caret-drift class specifically, with its six-round history → + `edmund-caret-integrity-campaign`. +- Build/toolchain/stale-binary mechanics beyond the quick checks here → + `edmund-build-and-env`. +- Release, signing, appcast, Sparkle pipeline → `edmund-release-and-operate`. +- TextKit 2 / AppKit API semantics reference → `textkit2-appkit-reference`. +- Launch flags and settings keys reference → `edmund-config-and-flags`. +- How past investigations were run and why → `edmund-failure-archaeology`, + `edmund-research-methodology`. +- Pre-merge verification of a fix → `edmund-validation-and-qa`. + +## 1. First 15 minutes — any new bug + +Run this checklist before hypothesizing. Every step is cheap; skipping them +is how rounds 1–5 of delete-drift shipped fixes that came back. + +1. **Check the open-bug inventory (§6).** If the symptom matches a known open + bug, you are done triaging — link the backlog entry and its repro asset. +2. **Get the logs.** `ls -t ~/.edmund/logs/` and read the day's file + (`edmund-YYYY-MM-DD.log`). Grep for the three permanent breadcrumbs: + ```bash + grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log + ``` + Also grep for `invariant:` (the always-on storage==rawSource tripwire) and + `⚠︎LEN-MISMATCH`. +3. **Find the first bad line and walk BACKWARDS.** The user-visible symptom + is often the second half of a two-part mechanism — the round-6 caret drift + was armed by a silent bypass 80 seconds and dozens of healthy edits before + the leap. Never start reading at the symptom timestamp. +4. **Rule out a stale build** if this follows a rebuild: grep + `strings .build/arm64-apple-macosx/debug/edmd` for a long string literal + unique to the new code (≤15-byte literals are inlined on arm64 and never + appear); `shasum` the binary. See §3c. +5. **Check git history for prior art.** `git log --oneline -- ` + plus the investigation docs in `docs/`. The viewport-glitch investigation + found four earlier fixes all working around the same unnamed root cause. +6. **Reconstruct the document.** Get the user's file or rebuild it from the + trace's block counts/lengths. Wrapped-paragraph geometry and block kinds + matter; do not repro against "hello world". +7. **Before touching any running app**: `pgrep -lx edmd` then + `ps -o lstart=,command= -p `. The user's daily-driver app shares the + binary name. Never blanket `pkill -x edmd` — kill only the PID you + launched. +8. Row found in §2 → run its discriminating check. No row → escalate per §5, + and add the new row here when it's understood. + +## 2. The triage table + +| Symptom | Likely mechanism | Discriminating first check | Where next | +|---|---|---|---| +| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed `didChangeText` (drag-move to no valid target), or TextKit 2's queued `_fixSelectionAfterChangeInCharacterRange` fired at a later `endEditing` | `grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log` and read the trace around it; look for `selectionDidChange` with `up=Y` at a surprising position | `edmund-caret-integrity-campaign`; `docs/investigations/delete-drift-investigation.md` | +| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: `hasMarkedText()` stuck true, `didChangeText` bails forever, model frozen | Grep logs for `recovered stranded desync on focus regain`; check `storage.string == rawSource` | `docs/investigations/delete-drift-investigation.md` rounds 1–2 | +| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height *estimates* — off-screen frames are guesses corrected as layout reaches them | Doc length vs `fullLayoutMaxLength` (100k UTF-16, `EditorTextView.swift`) — ≤100k should be fully laid out by the settle; >100k is estimate territory | `docs/investigations/viewport-glitch-investigation.md` | +| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | `grep "repairing content above origin" ~/.edmund/logs/edmund-*.log` — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause | `docs/investigations/viewport-glitch-investigation.md` Bug 2 (repair unconfirmed live) | +| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (`5bb2b40`): a full `recompose` resets every fragment to an estimate, then the scroll measures the estimates | Confirm `restoreSnapshot` still routes through range-bounded `recomposeReplacing`, never full `recompose` (`+Undo.swift`); check the changed range, not the stored caret, drives the viewport | `docs/investigations/viewport-glitch-investigation.md` Bug 1 | +| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed `Build complete!` without relinking `edmd` | `strings .build/arm64-apple-macosx/debug/edmd \| grep ""`; `shasum` before/after | `edmund-build-and-env`; §3c | +| App crashes the instant any LaTeX renders | SwiftMath `*.bundle` missing from the `.app` root (its `Bundle.module` is hardcoded to `Bundle.main.bundleURL`) | `ls build/Edmund.app/*.bundle` | `scripts/build-app.sh` copy step; ARCHITECTURE §8 | +| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an `NSLayoutManager` API was touched or an `NSTextBlock`/`NSTextTable` attribute entered storage | DEBUG builds assert via the tripwire (`textKit1FallbackTripwire`, `willSwitchToNSLayoutManagerNotification`); audit recent diffs for `layoutManager` / `NSTextBlock` | ARCHITECTURE §2; `textkit2-appkit-reference` | +| Empty bands or clipped lines after a restyle | Attribute-only change without `invalidateLayout(for:)` — TK2 keeps the stale fragment frame | Is the misbehaving path a *new* styling path? `recomposeDirty` and the idle drain already invalidate; new paths must too | ARCHITECTURE §8 | +| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the `!hasMarkedText()` guard (including async work scheduled *before* composition began) | Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; `docs/investigations/delete-drift-investigation.md` | +| Callout at end of file shows an extra colored line not prefixed by `>` | KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | `misc/backlog.md` | +| Image leaves a large blank space below it | KNOWN OPEN BUG | `misc/bug-repros/image-blank-after.mov` | `misc/backlog.md` | +| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | `misc/backlog.md` | +| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | `NSToolbar` with `allowsUserCustomization` claims every secondary click over the toolbar, beating view-level handlers | Verify the `DocumentWindow.sendEvent(_:)` intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen | ARCHITECTURE §8 | +| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving `contentView.bounds.size` and re-applying as `contentRect` | Confirm `lastWindowSize` round-trips `window.frame.size` via `setFrame` *after* the toolbar is installed (`Document.swift`) | ARCHITECTURE §8 | +| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no `_CodeSignature/CodeResources`; or the EdDSA keypair diverged from `SUPublicEDKey` | Does `build-app.sh` still seal the whole `.app` before copying the SwiftMath bundle in? | `edmund-release-and-operate`; ARCHITECTURE §8/§13 | +| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an *image* on a wrapping fragment collapses its layout; shapes do not | Is the overlay an `NSImage` on a fragment that can wrap? Convert to a stroked `CGPath` (the custom-title callout icon fix) | `docs/investigations/archives/callout-title-wrap-investigation.md` | +| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging *inside* an existing selection is AppKit's drag-*move* gesture | Trace: was there a `selectionDidChange` with a large `sel` before the drag began? | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 1 | +| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the `scrollRangeToVisible` override still reveals the selection's *nearest* end (`+TypewriterScroll.swift`, commit `340fcbc`) | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 2 | +| Edits do nothing at all (not drift — dropped) | `isUpdating` stuck true would make `shouldChangeText` return false | Trace shows `shouldChangeText` never returning OK; distinct from the drift signature | `+EditFlow.swift` | +| Launching via `open` shows old behavior | LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | `pgrep -lx edmd` first; launch by direct exec of the bundle binary | §3e; `edmund-build-and-env` | + +## 3. The traps that cost real time + +Each of these burned hours to days. Read before shipping any fix. + +**(a) Shipping caret fixes on reasoning alone.** Delete-drift rounds 1–5 each +shipped a plausible, well-argued fix — and each came back. Only round 6, the +first with a frozen deterministic live repro (`bypassdelete` script), named +the actual mechanism (`_fixSelectionAfterChangeInCharacterRange` queued by a +bypassed edit, firing at the next `endEditing`) — and its first fix attempt +*failed in the repro within a minute*, which reasoning would never have +caught. Lesson: time spent making the failure cheap to observe beats time +spent reasoning about the fix. Freeze the repro before writing the fix. + +**(b) Undo/redo viewport drift.** Two defects hid behind one symptom: +`restoreSnapshot` ran a full `recompose` (discarding all TK2 layout, so the +follow-up scroll measured freshly manufactured estimates) and `performUndo` +recorded the redo snapshot with the caret *at undo-invocation time*, not at +the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain +stale-state bug stacked; fix and verify them separately. The contract since +`5bb2b40`: diff the snapshot, apply via `recomposeReplacing`, select the +changed text, let the changed range drive the viewport. + +**(c) Stale binaries produce false conclusions.** In round 6, `swift build` +twice printed `Build complete!` while linking a stale `edmd` — the compile +ran, the relink silently didn't — and two "failed" fix iterations were +phantoms. Detect: grep `strings` on the binary for a *long* literal unique to +the new code. Cure: `swift package clean` (or `rm -rf .build` for release +weirdness). Never hand-delete `.build/…/edmd.build/` — that corrupts the +output-file-map and wedges the target until a full clean. + +**(d) Headless-green ≠ fixed for input-layer bugs.** The round-6 unit test +reconstructs the exact document and gesture and *passes with and without the +fix*: the test harness runs AppKit's deferred selection fixup synchronously, +so the broken state never forms. A green test proves nothing about the live +NSTextView / TextKit 2 / input-context class. The scripted live repro is the +regression harness; the unit test is only a contract spec. + +**(e) `open -a` runs stale cached copies.** LaunchServices can foreground an +already-running instance instead of relaunching, and can execute a stale +cached/translocated copy of the bundle — you debug last hour's code. Always +launch by direct exec: `build/Edmund.app/Contents/MacOS/edmd file.md &` +(after the §1 step-7 pgrep check). + +**(f) Trusting off-screen fragment y-coordinates.** A TK2 fragment's frame is +real only once laid out; everything off-screen, plus total document height, +is an estimate. Any code that measures before ensuring layout of the +viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll +gotcha, and most historical viewport glitches). Ensure layout for the target +range first, then align to real geometry — and never verify a visual fix +from headless layout: measure from `screencapture` pixels. + +## 4. Discriminating experiments — cheap checks that split hypothesis spaces + +**Verbose diagnostics launch** (defaults keys are namespaced; the file must +be argv[1]): + +```bash +build/Edmund.app/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES +``` + +Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor +tracing"). Logs land in `~/.edmund/logs/edmund-YYYY-MM-DD.log`. + +**Trace field vocabulary** (source of truth: +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`, +`diagnosticState`). Every trace line ends with: + +| Field | Meaning | +|---|---| +| `sel={loc,len}` | current selection | +| `active=` | active block index (or `nil`) | +| `marked=` | marked-text range, `-` if none (non-`-` outside a live composition = stranded) | +| `up=Y/N` | `isUpdating` — `Y` means the event arrived MID-RECOMPOSE | +| `undo=Y/N` | `isUndoRedoing` | +| `blocks=` | block count | +| `storLen=` / `rawLen=` | storage vs rawSource lengths | +| `⚠︎LEN-MISMATCH` | appended when they differ | + +**Healthy vs suspect edit orderings:** + +- Healthy: `shouldChangeText` → `selectionDidChange` (`up=N`) → `synced`. + A *transient* `⚠︎LEN-MISMATCH` between those lines is normal (storage moves + before rawSource syncs). +- Suspect: a `selectionDidChange` with `up=Y` at a surprising position; a + *persisting* LEN-MISMATCH; a `shouldChangeText` with no + `synced`/`SKIPPED`/`DEFERRED` line after it (bypassed `didChangeText`); the + `healing storage edit that bypassed didChangeText` breadcrumb. + +**`traceSelectionOrigin`**: under verbose tracing, any selection change +arriving mid-recompose (`up=Y`) or with an unconsumed pendingEdit logs a +condensed call stack naming the AppKit path that moved the caret. This is +what named `_fixSelectionAfterChangeInCharacterRange` in round 6. If your bug +moves the caret and you don't know who moved it, this answers it in one run. + +**Walk backwards from the first bad line**, not forwards from the symptom. +The round-6 drift was armed 80 seconds before the visible leap. Find the +first line whose state is wrong, then read *earlier*. + +**`verifyEditorInvariants`** (same file): the O(1) length check +(`storage.length != rawSource.length`) logs an `error` whenever logging is on +— no verbose toggle needed — so a hard-invariant break always leaves an +`invariant:` line. The full structural checks (string equality, blocks +reconstruct rawSource, block ranges in bounds) run under verbose tracing and +assert in DEBUG. An `invariant:` error in a user's log is a model desync, +full stop; triage as the delete-drift class. + +If the existing logging didn't capture the deciding fact, add the log line +first and reproduce again — one breadcrumb beats ten speculative fixes. Keep +good ones behind `Log.shouldTrace` and ship them. + +## 5. The escalation ladder (summary) + +Full detail, ReproScript command reference, CGEvent driver, and soak-script +method: `edmund-live-repro-and-diagnostics` and `docs/dev-guides/live-repro-guide.md`. +Work down; stop at the first level that reproduces. + +1. **Plain unit test** (`makeEditor()`) — model/parsing/styling logic. +2. **Windowed unit test** (NSWindow + NSScrollView, real `deleteBackward`) — + adds layout, viewport, first-responder. **Failure to repro here is + evidence, not defeat**: it points at deferred/queued AppKit state and at + levels 3–4. +3. **In-process ReproScript** — DEBUG builds accept `-debug.reproScript + ` (`Sources/edmd/App/ReproScript.swift`); replays a keystroke script + through the real `window.sendEvent` path. No Accessibility/TCC needed, + works with the window on an invisible Space. Commands: `sleep`, `caret`, + `type`, `backspace`, `bypassdelete`, `assertcaret`, `logsel`. Launch with + `-ApplePersistenceIgnoreState YES` and recreate the test document fresh + each run. **The default for live bugs.** +4. **CGEvent driver** — only for paths that must originate as HID events + (drag-select, drag-move, autoscroll). TCC decides per session; if input + doesn't land after one test click, fall back to level 3 immediately. +5. **Instrumented field occurrence** — can't trigger it yourself: add the + decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days + of latency; make sure the *next* occurrence is decisive. + +After a fix: freeze the repro script, run a soak (several trigger cycles in +one app run with `assertcaret` checks), then full `swift test`. + +## 6. Open-bug inventory + +Known open bugs — check here before "discovering" one. Sources: +`misc/backlog.md` (authoritative list) and `docs/ROADMAP.md` (larger themes, +e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate +glitches are a known, partially-mitigated class). All entries below are OPEN +as of 2026-07-05. + +| Bug | Status | Repro asset | +|---|---|---| +| Delete caret drift (class) | Open as a class; rounds 1–6 fixed, watching for round 7 | `misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov}` + matching logs | +| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — | +| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | +| Image creates large empty space below | Open | `misc/bug-repros/image-blank-after.mov` | +| Footnotes don't render (edit or read mode) | Open | — | +| Math environments don't render in read mode | Open | `misc/bug-repros/math-baseline-read-mode-png.png` (related baseline issue) | +| Math environments have wrong padding in edit mode | Open | — | +| Max content width not applied to read mode | Open | — | +| Images should shrink when content size is small | Open | — | +| Tables should wrap when content size is small | Open | — | +| Table cell content wraps out of the cell | Open | — | +| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — | +| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — | +| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — | +| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — | + +Do not relabel any of these as fixed without a verified repro flip; no +oversell. When you fix one, update `misc/backlog.md` and this table in the +same branch. + +## 7. House rules while debugging + +- **Never blanket `pkill -x edmd`.** The user's daily-driver app shares the + binary name. `pgrep -lx edmd` + `ps -o lstart=,command= -p `, then + kill only your own PID (`pkill -f EdmundDbg` if you launched the debug + bundle). +- **Do not request Computer Access** — Screen Recording and Accessibility are + already granted. +- **Measure visuals from `screencapture` pixels** (capture by window id, see + ARCHITECTURE §8), never from headless layout alone. +- **Never mutate storage while `hasMarkedText()`** — including in any + diagnostic or repro code you add. +- **Never auto-push, PR, or merge.** Branch off `main` per fix; commit small + and often. +- Logs in `~/.edmund/logs` are app-owned and fair game to read and quote. + +## Provenance and maintenance + +Built 2026-07-05 from: `docs/ARCHITECTURE.md` (§2, §8, §9), +`docs/investigations/delete-drift-investigation.md` (rounds 1–6), +`docs/investigations/viewport-glitch-investigation.md`, +`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/dev-guides/live-repro-guide.md`, +`misc/backlog.md`, `docs/ROADMAP.md`, and +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. Log strings +(`healing storage edit that bypassed didChangeText`, `repairing content above +origin`, `recovered stranded desync on focus regain`), launch-flag keys, +`fullLayoutMaxLength`, ReproScript commands, the TK1 tripwire, and commit +`5bb2b40` were verified against the source tree on that date. + +Maintain it like the codebase docs: when a new bug class is understood, add +its triage row; when a trap costs real time, add its story to §3; when a +backlog bug opens or closes, sync §6 with `misc/backlog.md` in the same +branch. If a row's discriminating check stops matching the code (renamed log +string, moved file), fix the row — a stale runbook is worse than none. +Deeper mechanism detail belongs in the sibling skills and `docs/` +investigation files, not here; this file stays a triage surface. diff --git a/.agents/skills/edmund-docs-and-writing/SKILL.md b/.agents/skills/edmund-docs-and-writing/SKILL.md new file mode 100644 index 0000000..ed96b21 --- /dev/null +++ b/.agents/skills/edmund-docs-and-writing/SKILL.md @@ -0,0 +1,268 @@ +--- +name: edmund-docs-and-writing +description: > + Documentation of record for the Edmund repo: which doc owns which fact, and + how to write in the house style. Load whenever you are writing or updating + ANY project doc — docs/ARCHITECTURE.md, CHANGELOG.md, README.md, + docs/ROADMAP.md, misc/backlog.md, a docs/-investigation.md + write-up, release docs — or deciding WHERE a newly learned fact, gotcha, + bug, or feature idea belongs. Covers the docs-of-record map, the + fact-routing decision table, the investigation-doc template, CHANGELOG + format (machine-extracted for release notes), commit-message conventions, + and doc maintenance duties. Not for making the code change itself, release + mechanics, or debugging — see "When NOT to use this skill". +--- + +# Edmund docs and writing + +Date-stamped 2026-07-09. Every claim below was verified against the files on +`main` at that date; re-verify paths before trusting this after major +reorganizations. + +## When NOT to use this skill + +| You are actually doing | Use instead | +| --- | --- | +| Changing code / designing a mechanism | `edmund-architecture-contract` | +| Branch/commit/PR mechanics, pre-commit checklist | `edmund-change-control` | +| Cutting a release, appcast, Sparkle, CI | `edmund-release-and-operate` | +| Diagnosing a bug (not writing it up) | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` | +| Mining past investigations for technique | `edmund-failure-archaeology` | +| Marketing copy, positioning, alternatives research | `edmund-external-positioning` | +| Build flags, env, debug bundle | `edmund-build-and-env`, `edmund-config-and-flags` | + +This skill is for prose: what to write, where it lives, how it should read. + +## 1. The docs-of-record map — one home per fact + +Every fact has exactly one home; everywhere else gets a pointer. All paths +exist and are current as of 2026-07-09. + +| Doc | Owns | Notes | +| --- | --- | --- | +| `docs/ARCHITECTURE.md` | HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: **when you learn something non-obvious or change an invariant, edit this file in the same PR.** | +| `docs/architecture/README.md` | Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of `docs/architecture/`'s deep docs and the sibling `investigations/`/`dev-guides/` folders, common quirks (each a pointer, never a new claim), getting-started commands | The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. | +| `docs/architecture/.md` | Deep narrative write-up of one subsystem (e.g. `editor-pipeline.md`, `text-system.md`) | The "deep-doc" pattern: a fact's *statement* lives in `ARCHITECTURE.md`, its *explanation* lives here, each links to the other. | +| `docs/architecture/extensibility.md` | The design-of-record for themes/extensions: vision, current state (verified against `main` and the unmerged `feat/extensions-registry-and-tab` branch), themes/extensions design, staged implementation plan, honest risks | **Design only, not yet implemented on `main`.** `ARCHITECTURE.md` gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. | +| `docs/architecture/sandboxing.md` | The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the `~/.edmund/` onboarding grant, staged plan (SB0-SB4), open decisions | **Plan only, nothing sandboxed on `main`.** Same design-doc exception as `extensibility.md`: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to `ARCHITECTURE.md` in the same PR. | +| `README.md` | WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" `xattr -dr com.apple.quarantine` workaround), dependencies, alternatives, acknowledgements, license | User-facing; no internals. | +| `CHANGELOG.md` | User-facing version history, Keep-a-Changelog style | `## [x.y.z]` sections are machine-extracted for release notes — exact format matters (§4 below). | +| `docs/ROADMAP.md` | Versioned feature plan: `## v1.0.0`, `## v1.x`, `# v.2.0.0` sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) | Has a `Last updated: YYYY-MM-DD` line under the title — refresh it when you edit. | +| `misc/backlog.md` | The maintainer's working priority list: `## Now (small releases)` (Marketing / On-going bugs / Bugs / UI/UX / Features), `## Next`, `## Later`, roadmap mirrors, `### Lurking (Unreproduceable)`, `## Done` | Stated priority: **Marketing = Bugs >= UI/UX > Features**. Bug entries carry repro pointers (`misc/bug-repros/*.mov`, `.log`, or `~/Desktop` paths). | +| `docs/investigations/-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. | +| `docs/investigations/archives/-investigation.md` | Chronicles for closed/resolved bug classes | Existing: `callout-bottom-line-`, `callout-title-wrap-investigation.md`. | +| `docs/dev-guides/live-repro-guide.md` | Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. | +| `misc/before-you-release.md` | Pre-flight readiness checklist | Pairs with `how-to-release.md`; cross-ref `edmund-release-and-operate`. | +| `misc/how-to-release.md` | Release mechanics (CI tag path, local `release.sh`) | Same. | +| `AGENTS.md` (root) | Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. | +| `LICENSES/` | Vendored license texts (currently `lucide.txt` for the Lucide icon SVGs) | Add one when vendoring third-party assets. | +| `Info.plist` | `CFBundleShortVersionString` + `CFBundleVersion` — the version of record | Must match the CHANGELOG section header at release (see `misc/before-you-release.md` §3). | + +Note: `misc/backlog.md` and `docs/ROADMAP.md` currently duplicate the +v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the +public plan; backlog is the working list. When they disagree, treat ROADMAP as +the versioned commitment and backlog as scratch — and mention the drift to the +maintainer rather than silently reconciling. + +## 2. Where does a new fact go — decision table + +Route the fact FIRST, then write. One home; cross-reference from elsewhere. + +| You learned / produced | Home | How | +| --- | --- | --- | +| Code quirk, edge case, workaround, non-obvious *why* | **Comment at the code site** | House rule (root `AGENTS.md`): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." | +| Architectural gotcha that will bite the next agent | `ARCHITECTURE.md` §8 | Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. | +| New known issue / structural constraint | `ARCHITECTURE.md` §9 | It has an explicit placeholder: *"Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in `docs/`."* | +| Code debt / incomplete implementation | `ARCHITECTURE.md` §10 | Its footer says: track code-debt here, roadmap items in README/ROADMAP. | +| Changed invariant, new subsystem, new pipeline step | `ARCHITECTURE.md` §2–§7 (the relevant section) | Update in the same PR — header rule. | +| Multi-round investigation (2+ hypothesis cycles, live repro work) | New `docs/-investigation.md` | Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. | +| User-visible change (fix/feature/rename) | `CHANGELOG.md` under the next `## [x.y.z]` | Format in §4. Link the issue and any investigation doc. | +| New bug found (reproducible) | `misc/backlog.md` under `Bugs` | `- [ ] Bug: . See .` Drop repro assets (video/log) into `misc/bug-repros/`. | +| New bug found (unreproducible so far) | `misc/backlog.md` → `### Lurking (Unreproduceable)` | One line + "wait for screen record" style note. | +| Bug that is really code debt (design limitation) | `ARCHITECTURE.md` §9 | e.g. the image-on-wrapping-fragment constraint. | +| Feature idea, near-term (next few small releases) | `misc/backlog.md` (`Now`/`Next`/`Later`) | Sorted by priority + difficulty within category. | +| Feature idea, versioned/strategic | `docs/ROADMAP.md` under the right version | Refresh `Last updated`. | +| Repro method / debugging technique | `docs/dev-guides/live-repro-guide.md` | Method docs, not per-bug chronicles. | +| Release procedure change | `misc/how-to-release.md` / `misc/before-you-release.md` + `ARCHITECTURE.md` §13 | §13 owns the mechanism + failure modes; misc/ owns the operator checklist. | +| Agent workflow improvement | `ARCHITECTURE.md` §12 | Its footer invites this: "If you (the agent) improve this workflow... update this section." | +| Vendored third-party asset | `LICENSES/.txt` + a feature-map note in §6 | Follow the Lucide precedent. | +| Deep explanation of an existing subsystem | `docs/architecture/.md` | A fact's *statement* lives in `ARCHITECTURE.md`; its *explanation* lives in the deep doc; each links to the other. | + +**The same-PR rule is the load-bearing one.** Doc updates that ride the code +PR actually happen (see `cf10741`, `b600e12`, `c4a602b` in history); doc +updates deferred to "later" don't. + +## 3. House style + +Derived from reading `ARCHITECTURE.md` and the investigation docs. Match it. + +- **Dense, specific, evidence-first.** State the mechanism and the proof, not + vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps + and selection ranges quoted verbatim in investigation docs. +- **Bold lead-ins for gotcha bullets**, then the explanation: + `- **Stale release builds**: ...`. Scannable list, detail inline. +- **Backticks for every file, symbol, flag, and command**: + `` `recomposeDirty` ``, `` `+EditFlow` `` (the extension-file shorthand), + `` `-debug.reproScript` ``. +- **One-line repro pointers**, not embedded essays: "See + `misc/bug-repros/image-blank-after.mov`", "grep `~/.edmund/logs` for + `repairing content above origin`". +- **Honest status labels.** The docs say "unconfirmed live", "theory + + targeted repair, not a confirmed kill", "Verification limits (honest + gaps)", "the test documents intent; the leap only reproduces under live + layout". Never claim verification you didn't do. No oversell. +- **Section anchors as cross-refs**: "see §8", "ARCHITECTURE §13" — used + across ARCHITECTURE, AGENTS.md, before-you-release.md. If you renumber + sections, grep the repo for `§` and fix every reference. +- **Address "you", the next agent/engineer**: "will bite you", "Context for + anyone who sees the bug again", "Next time it happens: ...". +- **Record what failed, not just what worked** — investigation docs keep the + overturned theories and the phantom fixes (stale-binary trap) because the + dead ends are the reusable knowledge. + +### Commit messages (from `git log --oneline -50`) + +Mixed but patterned: conventional prefixes dominate for fixes and docs — +`fix(scope): ...` (scopes seen: `editor`, `layout`, `scroll`, `undo`, +`release-workflow`, `changelog-to-html`), `docs: ...`, occasional +`refactor:`, `appcast: add Edmund X.Y.Z`, `release X.Y.Z`. Chores and README +work often use plain imperative subjects ("Update README", "Add assets for +README"). Branches: `fix/`, `docs/`, `chore/`. When in +doubt: `fix(scope):` for behavior changes, `docs:` for doc-only commits, +plain imperative for chores. Never auto-push, PR, or merge — only when asked. + +## 4. CHANGELOG format — machine-read, get it exact + +`.github/workflows/release.yml` extracts release notes with: + +``` +awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" +``` + +So the section header MUST be `## [x.y.z]` at line start, version matching +`CFBundleShortVersionString` exactly; the section ends at the next `## [`. +`scripts/changelog-to-html.py` converts the same section to HTML for +Sparkle's update dialog (it folds wrapped bullet lines into their `
  • ` — +wrapping bullets is safe). Full pipeline: `edmund-release-and-operate`. + +House format (verify against the file; current entries follow this): + +```markdown +## [0.1.4] — 2026-07-XX + +### Fixed +- ([docs](docs/-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN) + +--- +``` + +- Em dash between version and ISO date; `---` separator between versions. +- Subsections used so far: `### Added`, `### Changed`, `### Fixed` + (Keep a Changelog 1.1.0 vocabulary). +- Entries describe the user-visible effect, not the mechanism; mechanism + lives in the linked investigation doc / ARCHITECTURE. +- An optional free-text line under the header is fine (0.1.2 has one) — the + awk extraction includes it. + +## 5. The investigation-doc template + +Derived from `docs/investigations/delete-drift-investigation.md` (6 rounds) and +`docs/investigations/viewport-glitch-investigation.md`. Both open with why the doc exists +("Context for anyone who sees the bug again... records the trail end to +end") and name the fixing commits/branch up front. Chronicle structure: each +recurrence is a new `## Round N` appended to the same doc — symptom → +diagnosis → root cause → fix → verification, with limits stated. + +Skeleton (copy-paste): + +```markdown +# "" — investigation notes + +Context for anyone who sees this again. + +Fixed on branch `fix/`, commits: `` — , ... + +## Symptom + +`, `~/.edmund/logs/...`.> + +## How it was diagnosed + +1. + +## Root cause + + + +## The fix + + + +## Verification + + + +## If it ever recurs + + + +## Round 2: ← append on recurrence, same structure +``` + +After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a +pointer, add the CHANGELOG entry with a `([docs](docs/...))` link, and check +the corresponding `misc/backlog.md` box (or move it under `On-going bugs`). + +## 6. Maintenance duties + +Do these whenever you touch the relevant doc; they rot otherwise. + +- **ARCHITECTURE placeholders**: §9 and §10 end with italic *"Add new ones + here"* / *"track code-debt here"* lines — keep them last in their lists so + the invitation stays visible. +- **ROADMAP `Last updated:`** — bump the date on any edit. +- **Backlog hygiene**: check `- [x]` boxes when a fix ships (move to + `## Done` only if following the existing pattern — completed items live + there); keep repro pointers valid; don't reorder the maintainer's priority + sorting. +- **README's inline HTML comments** are the maintainer's own edit notes + (e.g. ``) — leave them unless acting on + them. +- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast + `` must agree; the checklist is `misc/before-you-release.md`, the + mechanics `edmund-release-and-operate`. +- **Section renumbering** in ARCHITECTURE: grep the whole repo (docs, misc, + AGENTS.md, skills) for `§` references before and after. +- **Never edit `test-files/todo.md`** — the maintainer owns it. + +## Provenance and maintenance + +Written 2026-07-05 against `main` at `fe8a1f5` (release 0.1.3). Sources, all +read directly: `docs/ARCHITECTURE.md` (header, §8–§13), +`README.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `misc/backlog.md`, +`docs/investigations/delete-drift-investigation.md`, `docs/investigations/viewport-glitch-investigation.md`, +`docs/dev-guides/live-repro-guide.md` (§1), `misc/before-you-release.md`, +`misc/how-to-release.md`, root `AGENTS.md`, +`.github/workflows/release.yml` (awk extraction quoted verbatim), +`git log --oneline -50` (commit-style tally), directory listings of +`docs/` (`architecture/`, `investigations/` incl. `archives/`, `dev-guides/`), +`misc/`, `misc/bug-repros/`, `LICENSES/`. + +§1 map re-verified 2026-07-09 against the `docs/` reorg (investigation docs +split into `docs/investigations/` + `docs/investigations/archives/`; +`docs/live-repro-guide.md` moved to `docs/dev-guides/`). + +Maintain this skill when: a doc of record moves or splits (update the §1 +map), ARCHITECTURE sections are renumbered (fix every § reference here), +the CHANGELOG extraction in `release.yml` changes (§4 quotes it), or a new +investigation doc establishes a better template. Keep the one-home-per-fact +rule itself stable — it is the point of the skill. diff --git a/.agents/skills/edmund-external-positioning/SKILL.md b/.agents/skills/edmund-external-positioning/SKILL.md new file mode 100644 index 0000000..f51efd4 --- /dev/null +++ b/.agents/skills/edmund-external-positioning/SKILL.md @@ -0,0 +1,204 @@ +--- +name: edmund-external-positioning +description: > + Load when writing anything an outsider will read about Edmund — README edits, + blog posts, release notes, marketing copy, social posts, webpage text, Show HN + drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, + Nodes), deciding what may be publicly claimed vs. what is still unproven, + labeling a technique "novel", or planning ecosystem work (licenses, + attribution, notarization messaging, appcast, issue templates, discovery + listings). This skill is the overclaim firewall: what the positioning is, + what evidence backs each claim, and what must exist before a claim gets + stronger. +--- + +# Edmund external positioning — what we claim, what we can prove + +Governing rule: **nothing may be claimed publicly that an outsider cannot +reproduce from the repo + a release.** Unproven = "candidate", never "novel" +or "first". This skill exists to prevent overselling a beta. + +## When NOT to use this skill + +| You are doing | Use instead | +| --- | --- | +| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate | +| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing | +| Understanding the invariants / render pipeline itself | edmund-architecture-contract | +| Deciding whether a code change is allowed | edmund-change-control | +| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics | +| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology | +| Verifying behavior before shipping | edmund-validation-and-qa | + +## 1. The positioning (quote it exactly) + +Source of truth: `README.md`. As of 2026-07-05: + +- One-liner: **"Edmund is a minimal, file-based, native Markdown editor for + macOS with inline live preview."** + - README carries its own TODO comment on this line: *"Replace 'minimal' with + 'customizable' or 'lightweight' once more features are implemented"* — do + not do that replacement early; "minimal" is the honest word today. +- Goal statement: **"Our goal is to be the [CotEditor](https://coteditor.com) + of Markdown editors, i.e. elegant, powerful, configurable, and native inside + out."** +- Beta warning: **"⚠️ Edmund is currently in beta."** — this must stay visible + in the README and any landing page until v1.0.0 ships. +- Maintainer's blog post: ("more of + the motivation and design philosophy"). Cite it; do not paraphrase or invent + its content without fetching it. +- Ambition framing: **product-first**. "Beyond state of the art" means product + excellence — the TextKit 2 techniques are means, not ends. Never lead public + copy with internal mechanism names; lead with what the user gets. + +### The six claimed differentiators (README, verbatim, 2026-07-05) + +| # | Claim (verbatim) | +| --- | --- | +| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." | +| 2 | "File-based: Open `.md` files from anywhere. No vaults or dedicated folders required." | +| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." | +| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." | +| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" | +| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." | + +README also has a TODO comment after the list: *"Move 'Fast' and 'Extensible'? +Add 'integrations' section to Native after implementation"* — the list is +known-provisional; keep quotes synced to the file when you edit copy. + +## 2. Claims discipline + +Before strengthening any claim publicly, the evidence in the middle column +must be upgraded to the right column. Status as of 2026-07-05. + +| Claim | Current evidence | Required before strengthening | +| --- | --- | --- | +| Fast: "~1-2MB files with ease. No launch lag" | `Tests/EdmundTests/PerfHarnessTests.swift` (gated `MD_PERF=1`, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with `fullLayoutMaxLength = 100_000` regime (`EditorTextView.swift:80`) | A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (`MD_PERF=1 swift test`). No comparative "faster than X" claims without benchmarking X the same way. | +| Extensible: "Extensions system coming soon!" | Extensions API is `docs/ROADMAP.md` v1.0.0 — **not shipped**. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" | Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. | +| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (`ReadRenderOptions.allowRemoteImages = false`); inline HTML whitelisted via `HTMLRenderer.sanitizeInlineHTML` | Note: the "Block external images" Settings checkbox is a `misc/backlog.md` item, **not shipped**; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). | +| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". | +| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. | +| File-based: "No vaults" | Shipped by design | Safe. | +| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. | + +## 3. Novel vs. known — the honest inventory + +When writing a craft blog post or comparison, keep this ledger straight. +"Candidate" means blog-worthy pending proof; it is not "proven novel". + +### Known / prior art (never claim novelty here) + +- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is + the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience"). +- TextKit 2 viewport virtualization for Markdown: + [nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) + (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, + live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: + consult it **before inventing a new mechanism** and as a technique source + (drag-select autoscroll, overscroll). Its existence caps any "first TK2 + live-preview engine" claim at zero. +- The README's own Alternatives section credits ~15 editors. Public copy that + ignores them reads as either ignorant or dishonest. + +### Distinctive candidates (label as such; each needs proof before publishing) + +| Candidate | Why it might be blog-worthy | Proof needed first | +| --- | --- | --- | +| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples | +| Stroked-`CGPath` overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) | A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces | +| Bypassed-`didChangeText` heal + caret re-assertion (round-6 mechanism: TK2 leaves a `_fixSelectionAfterChange` queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) | Deep TK2 internals nobody has documented; the delete-drift chronicle (`docs/investigations/delete-drift-investigation.md`) already exists as raw material | The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) | +| Diff-based undo restore that preserves TK2 layout (snapshot restore *diffs* rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document | +| In-process ReproScript methodology (`-debug.reproScript`, keystroke replay without CGEvents/TCC; `docs/dev-guides/live-repro-guide.md`) | Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard | + +Reproducibility standard for any technical post: a reader with the repo and +the post must be able to reproduce every claim — frozen repro scripts, pinned +document fixtures, named macOS versions, measured numbers with the command +that produced them. If a claim can't meet that, cut it or mark it anecdotal. + +## 4. Ecosystem and license hygiene + +Verified against the repo, 2026-07-05: + +- **License: Apache 2.0** (`LICENSE`, README "License" section, and the 0.1.0 + changelog entry all agree). Say "Apache 2.0", never "MIT". +- **Lucide icons: vendored, ISC** (`LICENSES/lucide.txt`, © 2026 Lucide Icons + and Contributors; parts derived from Feather). Attribution duty: keep + `LICENSES/lucide.txt` shipping and credit Lucide where icons are discussed. +- **Why Lucide in both modes (SF Symbols constraint):** ARCHITECTURE §6 — + SF Symbols **cannot ship in exported PDFs** (license), so callout headers use + Lucide in both Read (inline SVG, `currentColor`-tinted) and Edit (rasterized + tinted `NSImage` overlay). App-chrome SF Symbols (toolbar/settings) are fine; + Edit-mode task checkboxes still use SF Symbols on-screen only. Don't + "simplify" copy or code in a way that breaks this split. +- **Dependencies to credit:** swift-markdown, SwiftMath, Sparkle (README + "Dependencies"). Acknowledgements section additionally credits + swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, + and others — preserve it when restructuring the README. +- **Not notarized (2026-07-05):** ad-hoc signed; users hit Gatekeeper + ("damaged app"). README's WARNING block gives the two workarounds + (System Settings → Privacy & Security → Open Anyway; or + `xattr -dr com.apple.quarantine /Applications/Edmund.app`, maybe `sudo`). + Keep those instructions accurate in every venue that mentions installing. + `misc/marketing/MARKETING.md` gates Show HN on fixing this (notarize, or + make the workaround idiot-proof). +- **GitHub issue templates exist:** `.github/ISSUE_TEMPLATE/bug_report.md`, + `feature_request.md`. Point users there, not at email. +- **`appcast.xml` is a public artifact** served raw from the repo (`SUFeedURL` + points at the raw GitHub URL). Anything committed to it is user-visible in + Sparkle's update dialog. Pipeline details: edmund-release-and-operate. + +## 5. Release-notes and public-writing style + +- **Pipeline:** `CHANGELOG.md` sections become both the GitHub release notes + (awk-extracted) and Sparkle's update-dialog HTML + (`scripts/changelog-to-html.py` → appcast ``). A CHANGELOG + entry IS public copy — write it that way. Mechanics: edmund-release-and-operate. +- **Actual house style** (read `CHANGELOG.md` 0.1.0–0.1.3 before writing): + Keep-a-Changelog headers (`### Added / Changed / Fixed`); one line per item, + sentence case, no trailing period enforced; links to issues (`[#156]`) and + investigation docs (`([docs](docs/investigations/delete-drift-investigation.md))`); + user-visible phrasing ("Redo now jumps to where changed text was instead of + caret") not internal jargon; occasional first-person maintainer notes with + personality ("trying to have Fable 5 fix all the big bugs while I still have + it with me"); 0.1.0 used bold **Feature** — one-line descriptions. Match this + voice: plain, specific, lightly informal, zero hype. +- **Screenshots/videos:** README embeds live in `docs/assets/` + (`v0.1.0_*.png`, `installation.png`, `v0.1.0_video.mp4`, `AppIcon/`). + Raw/source marketing assets live in `misc/marketing/`: `MARKETING.md` + (the plan), `reddit-post.md`, `demo-slide-v0.1.key`, `demo-src-files/`, + demo videos (`demo.mov`, `demo-video-v0.1-brown.mp4`), `_raw` screenshot + masters, `social-preview_figma.png`. New public screenshots: polished copy + → `docs/assets/`, raw master → `misc/marketing/`, versioned filenames. + +## 6. Marketing priority context (2026-07-05) + +From `misc/backlog.md` "Now": **"Priority: Marketing = Bugs >= UI/UX > +Features"** — marketing work is tied for top priority with bug fixes. +Open marketing items: screenshots/files and a webpage (reference: +kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart; +`misc/marketing/MARKETING.md` names GitHub stars (~69 at writing) as the goal +and metric, audience "developers who value craft", and holds Show HN in +reserve until first-run friction and a landing page are fixed. Its "craft +months" deep-dives are exactly the Section 3 candidates — which is why the +proof bar there matters. + +## Provenance and maintenance + +- Sources verified 2026-07-05 against: `README.md`, `docs/ROADMAP.md` + (last updated 2026-07-03), `misc/backlog.md`, `docs/ARCHITECTURE.md` + (§2, §6, §8, §13, §14), `CHANGELOG.md` (0.1.0–0.1.3), `LICENSE`, + `LICENSES/lucide.txt`, `.github/ISSUE_TEMPLATE/`, `misc/marketing/`, + `Tests/EdmundTests/PerfHarnessTests.swift`, + `Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift`, + `Sources/EdmundCore/TextView/EditorTextView.swift`. +- Volatile facts are date-stamped inline: version (0.1.3), beta status, + notarization status, shipped-vs-roadmap feature split, star count, README + wording. Re-verify each against the file before repeating it publicly. +- When README differentiators or the one-liner change, update the verbatim + quotes in §1 and re-run the §2 evidence check. +- If a §3 candidate ships as a published post, move it out of "candidate" and + link the post + its frozen repro. +- Cross-references: edmund-release-and-operate (release/appcast mechanics), + edmund-docs-and-writing (internal doc style), edmund-research-frontier + (novelty judgment for research direction), edmund-architecture-contract + (the invariants quoted here). diff --git a/.agents/skills/edmund-failure-archaeology/SKILL.md b/.agents/skills/edmund-failure-archaeology/SKILL.md new file mode 100644 index 0000000..779bca0 --- /dev/null +++ b/.agents/skills/edmund-failure-archaeology/SKILL.md @@ -0,0 +1,442 @@ +--- +name: edmund-failure-archaeology +description: > + The chronicle of every major bug investigation, dead end, rejected fix, and + revert in the Edmund Markdown editor. Load BEFORE re-investigating any + caret/selection symptom (drift, jump, desync), any viewport/scroll glitch + (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge + (clipped wrap, one-line collapse, blank space), or any release/update + failure (signing, appcast, Sparkle "improperly signed"). Load before + proposing a fix that might already have been tried and reverted, when a bug + report "looks familiar", when a test passes but the live app still misbehaves, + or when wondering why the code does something weird (a guard, a re-assert, a + deliberately-missing icon). Every entry: symptom, root cause, evidence + (commit hashes, docs), status, and what NOT to retry. +--- + +# Edmund failure archaeology + +Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives +symptom → root cause → evidence → status. Hashes are on `main` unless noted. +Dates are commit dates. Status vocabulary: **settled** (root-caused, fix +verified), **mitigated-unconfirmed** (fix shipped, never seen killing a live +occurrence), **open** (in `misc/backlog.md`), **reverted-pending-redo**. + +## When NOT to use this skill + +- Designing a change / asking "why is it built this way" → `edmund-architecture-contract`. +- Actively debugging a NEW symptom (method, not history) → `edmund-debugging-playbook`. +- Driving the live app to reproduce something → `edmund-live-repro-and-diagnostics` (and `docs/dev-guides/live-repro-guide.md`). +- TextKit 2 / AppKit API semantics → `textkit2-appkit-reference`. +- Cutting or fixing a release → `edmund-release-and-operate` (this file only records how 0.1.0 broke). +- Build environment, stale-link traps in depth → `edmund-build-and-env`. +- Deciding whether a change is safe to make at all → `edmund-change-control`. +- Test strategy / what the suite can and cannot catch → `edmund-validation-and-qa`. +- The ongoing caret-integrity program (forward-looking) → `edmund-caret-integrity-campaign`. +- Debug flags (`-debug.reproScript`, verbose tracing toggles) → `edmund-config-and-flags`. + +--- + +## 1. The delete-drift saga (rounds 1–6) — issue #156 + +The hardest bug in the project's history: pressing Delete moved the caret to a +different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04. +Full trail: `docs/investigations/delete-drift-investigation.md` (read it before touching +`+EditFlow` / `+SelectionTracking` / the heal). One symptom, FOUR distinct +root causes stacked on top of each other — each fix was real, and each round's +recurrence was a different mechanism underneath. + +**STATUS: settled through round 6** (shipped in 0.1.3, 2026-07-04). The class +— live-only caret/selection desync — remains the project's hardest problem; +new rounds are possible. Backlog still lists "Delete caret drift" under +On-going bugs as a class to watch, not a known unfixed defect. + +### Round 1 — stranded IME marked text (2026-06-26, `386604b` + docs `ef3d87e`) + +- **Symptom:** once it started, EVERY delete drifted; never at launch; cleared + by switching apps and back. Text stayed correct — caret-only desync. +- **Root cause:** every styling path bails on `hasMarkedText()` (correct during + live IME composition). A *stranded* composition (`hasMarkedText()` stuck true, + no live composition) made `didChangeText` bail forever → `rawSource`/`blocks` + froze while storage kept mutating → all caret math ran against a stale model. + Strander: the async active-block restyle in `+SelectionTracking` re-checked + `isUpdating` but not `hasMarkedText()`, so it could run `recomposeDirty` over + a live composition scheduled one turn earlier. +- **Fix:** (a) add the missing `!hasMarkedText()` guard to the async restyle; + (b) `becomeFirstResponder` recovery hook — `unmarkText()` + resync when the + invariant is broken on focus regain (made the user's accidental focus-switch + cure deterministic). +- **Why it came back:** the guard closed one strander; other marked-text + sources existed (round 2), and other desync mechanisms entirely (rounds 4–6). + +### Round 2 — marked text without "IME" (2026-06-27, `a1f3219`) + +- **Symptom:** recurred with no CJK/accent/emoji input. Focus-switch still cured it. +- **Root cause (by elimination, documented in the doc):** still stranded marked + text — from **automatic text completion / inline predictions**, which inject + provisional marked text on plain typing. +- **Fix:** `isAutomaticTextCompletionEnabled = false`, `inlinePredictionType = .no` + in `commonInit`, plus a permanent `Log.info` breadcrumb in the recovery hook. +- **Why it came back:** the next recurrence wasn't marked text at all. + +### Round 3 — no fix; built diagnostics instead (2026-06-28, `5dae387`, `3aaeb04`, PR #139) + +- **Symptom:** recurred on a build with rounds 1–2. NO `recovered stranded desync` + log line; a headless probe of the exact gesture showed the model was CORRECT. + Only appeared after minutes of editing in one window. +- **Conclusion:** the model/parse layer is sound; the drift is a live + NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing + it blind was declared the wrong move. +- **Shipped:** verbose editor tracing (Settings ▸ Advanced, `Log.trace`, + category `.edit`, per-event live-state prefix) + an always-on O(1) invariant + tripwire (`verifyEditorInvariants`, logs `error` on length mismatch). This + instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug + resists reproduction, ship diagnostics, not guesses. + +### Round 4 — drag-move deletes with NO `didChangeText` (2026-07-02, `9f99795`, PR #163) + +- **Symptom (from the round-3 trace):** drifting deletes showed + `shouldChangeText` → *nothing* → `selectionDidChange` mid-recompose with a + stale caret. Origin event: a drag-select, then `shouldChangeText OK repl=""`, + then LEN-MISMATCH forever — `didChangeText` never fired. +- **Root cause:** AppKit's drag-**move** gesture, when the drop lands past the + end of the document (or fumbles), performs the source deletion via + `shouldChangeText` → `replaceCharacters` and **never calls `didChangeText`**. + `rawSource` silently froze — and **autosave wrote the stale bytes**: this was + a data-corruption bug, not just a caret bug. +- **Fix:** `shouldChangeText` schedules a next-run-loop bypass check + (`RunLoop.main.perform`): a `pendingEdit` still unconsumed one pass later == + didChangeText was bypassed → run the same sync it would have, log + `healing storage edit that bypassed didChangeText` (release too). Exempt + while `isUpdating`/`isUndoRedoing`/`hasMarkedText()` (IME legitimately defers). +- **Why it came back:** the heal restored the *model* but rounds 5–6 found the + heal itself could move the caret. + +### Round 5 — the heal leaped the caret (stale selection) (2026-07-03, `422498f`, docs `c4a602b`, merged `222dd86`, PR #166) + +- **Symptom:** heal fired, invariant restored, bytes correct — but the caret + leaped to the END of the document at the heal moment. +- **Root cause:** when the bypassed deletion removes the *selected* text, AppKit + also skips its usual selection fix, so at heal time the selection still spans + deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes + AppKit re-resolve the invalid selection → clamps to document end. +- **Fix:** before syncing, the heal **collapses an out-of-bounds selection** to + the edit point. (Headless NSTextView clamps this itself — the test documents + intent; only live layout reproduces the leap.) +- **Why it came back:** this out-of-bounds clamp was a special case of the real + mechanism, found in round 6. + +### Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, `1b1420a`, branch `fix/wrapped-paragraph-caret-drift`, merged `218d922`, PR #169) + +- **Symptom:** typing mid wrapped paragraph, one backspace leaped the caret +43 + ("two viewport-lines down"); drift no longer continuous — one delete drifts, + the next ones don't. Model fine; a heal had fired 80 seconds EARLIER. +- **Root cause (named via a `traceSelectionOrigin` stack capture):** a normal + edit runs TextKit 2's `_fixSelectionAfterChangeInCharacterRange` synchronously + inside its own transaction. A didChangeText-bypassing mutation skips that too, + so the fixup **stays queued and fires at the NEXT `endEditing`** — the heal's + attribute-only restyle — where it maps the stale selection against post-edit + coordinates and drops the caret blocks away. Fires exactly once (state is + then synchronized), explaining "drifts once, then fine". Round 5's clamp was + the sub-case where the stale selection ran past the shrunk document end. +- **Fix:** the heal derives the correct caret from the pendingEdit hull and + sets it **both before AND after** `syncRawSourceFromDisplay()`. The + before-only version still leaped — **the queued fixer moves even a freshly + set, fully valid caret** during the sync's `endEditing`. The post-sync + re-assert is the load-bearing half; the pre-set keeps `cursorRaw`/active-block + styling correct. +- **The breakthrough repro** (first deterministic one in six rounds): + `ReproScript.swift` (DEBUG-only, `-debug.reproScript `) replays + keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an + invisible Space. `bypassdelete ` simulates the drag-move deletion + exactly; one bypass beforehand → the next delete always drifts. Typing alone + never drifts. See `edmund-live-repro-and-diagnostics`. + +### Do not retry (proven dead across the saga) + +- **Headless/unit tests for this class.** The test harness runs TextKit 2's + selection fixups synchronously, so the deferred-fixup state never forms. The + round-6 unit test **passes with and without the fix** — it is a contract + spec, not a regression guard. Only the ReproScript live repro discriminates. + Do not "add a test to catch it" and call the class covered. +- **CGEvent injection as the default live driver.** Round 6's session dropped + the events (per-session TCC), and the app's windows launch on an inactive + Space. Use ReproScript first. +- **DEBUG assertion in `didChangeText`'s marked-text guard** — rejected in + round 1: the invariant is legitimately broken during composition; it + false-fires on every IME keystroke. +- **"No explicit selection repair needed"** (round 4's claim) — wrong twice. + Any new heal-like path must handle selection explicitly, before and after. +- **`swift build` trusted after "Build complete!"** — round 6 hit a stale-link + relink failure TWICE; two "failed" fix iterations were phantoms running old + code. Verify with `strings` on a LONG literal (≤15-byte literals inline on + arm64 and never show), cure with `swift package clean`, never hand-delete + `edmd.build/`. Details: `edmund-build-and-env`. + +--- + +## 2. Undo/redo viewport drift — the costliest failure + +**STATUS: settled** (2026-07-02, `5bb2b40`, part of PR #164) — with one caveat: +`misc/backlog.md` "Lurking (Unreproduceable)" carries a later note "Undo/Redo +and Copy/Paste scrolling is failing again". No repro exists. Treat the +*mechanism* below as settled and any new report as a NEW investigation that +starts from this entry. + +- **Symptom:** undo scrolled too far down; redo centered on wherever the caret + sat before the undo; changed text never selected. +- **Root cause (two defects, found by code read before any experiment):** + 1. `restoreSnapshot` ran a **full `recompose`** — replacing the entire + storage discards every TextKit 2 layout fragment, resetting ALL geometry + to height estimates; the subsequent centering math measured estimates. + 2. `performUndo` recorded the redo snapshot with the caret *at undo + invocation time* (stale), and redo centered on it. +- **Fix:** `textDiff(old:new:)` single contiguous changed span → range-bounded + `recomposeReplacing` (layout outside the span stays real); the **changed + range** — never a stored caret — is selected and drives the viewport (hold if + visible, else center). +- **Load-bearing contract: never full-recompose on undo/redo.** Anyone + "simplifying" `restoreSnapshot` back to `recompose` reintroduces the bug. + Guarded by `TextDiffTests`, `UndoRedoSelectionTests`, `UndoRedoViewportTests`. +- **Prior art that treated symptoms without naming the estimate problem:** + `9aaa11b` (undo hold-or-center), `2778d6e`/`21cc284` (cursor-move lurch), + `84123e4` (pin scroll above viewport), `c49cd5c` (lazy viewport-first + styling). All sound, all workarounds; `5bb2b40` removed the manufactured + estimates at the source. + +--- + +## 3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02) + +Full trail: `docs/investigations/viewport-glitch-investigation.md`. Three reported symptoms, +one root cause: **every off-screen TextKit 2 frame is an estimate**; code that +discards layout, trusts off-screen y, or runs two scroll policies at once turns +estimate churn into visible jumps. Community-documented (Krzyżanowski / +STTextView, Apple forums) — even TextEdit exhibits it. + +- **Bug 1 (undo/redo):** entry 2 above. **STATUS: settled** (same caveat). +- **Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).** + Theory: TK2 assigns fragments **negative y** when estimates above the + viewport correct downward; scroller clamps at 0. Mitigations `217da5f` + (documents ≤100k UTF-16 kept **fully laid out** via a deferred + `scheduleFullLayoutSettle` — estimates never exist) and `8b4ecfe` + (`repairContentAboveOrigin`: first fragment `minY < -0.5` → re-lay + start→viewport-end inside `preservingViewportAnchor`; breadcrumb + `repairing content above origin`). + **STATUS: mitigated-unconfirmed.** The doc is explicit: Bug 2 was **never + reproduced live**; the repair had not been confirmed against a live + occurrence as of this writing (2026-07-05). If it recurs: grep + `~/.edmund/logs` for the breadcrumb — present means diagnosis confirmed but + repair raced/undersized; absent means different cause (scroller-only + estimate jumps, or `textContainerOrigin`). +- **Bug 3 (viewport oscillates during a steady drag-select).** Two scrollers + fighting: drag autoscroll pulling down vs the `scrollRangeToVisible` + override always revealing the selection **top** once the selection outgrew + the viewport. Fix `340fcbc`: reveal the **nearest** end. **STATUS: settled + by geometry/reasoning** — a live drag was never synthesized (that session + couldn't arm AppKit selection). Phase 1 of the same report ("can't select") + was NOT a bug: a whole-doc selection was active, so the drag was AppKit's + drag-move gesture — same family as delete-drift round 4. +- **Do not retry:** raising `fullLayoutMaxLength` without measuring + `ensureLayout` cost (full layout on large docs is the process-killing path + that motivated the lazy pipeline); running a full layout *inside* a caller's + `preservingViewportAnchor` (poisons its before/after measurement — the + tab-indent stability test caught a 366pt compensation; that's why the settle + is deferred). +- Backlog keeps "Inaccurate viewport estimates and things related" under + On-going bugs, plus a lurking "glitch when scrolling" — the estimate class + is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport + stabilization". + +--- + +## 4. Callout-title wrap / the image wedge (settled 2026-07-03, `aa45563` + `ae61644`, PR #165) + +Full trail: `docs/investigations/archives/callout-title-wrap-investigation.md` — including a 10-row +matrix of dead ends. + +- **Symptom/goal:** custom callout titles (`> [!type] Title`) should render as + real wrapping text with the type icon; any attempt to draw the icon clipped + the wrapped title to one line. +- **Root cause:** **drawing any IMAGE on a multi-line layout fragment wedges + that fragment to a single line** — an unexplained TextKit 2 reentrancy quirk. + Isolated exhaustively: fragment overlay, frame-relative draw, before/after + `super.draw`, raw `CGContext.draw`, editor-level `draw(_:)`, pre-rasterized + bitmap, transparent subview, layer-backed subview, CALayer `contents` — ALL + clip. Controls: no icon → wraps; positions computed but plain rect filled + instead of the image → wraps. Reading layout is fine; drawing a **shape** is + fine; drawing an **image** is not. +- **Fix:** the icon is a **stroked `CGPath`** — `SVGPath` parses the vendored + Lucide geometry, `FragmentOverlay` gained a path form, + `DecoratedTextLayoutFragment` strokes it in CG. Verified live: icon renders, + long titles wrap and re-wrap on resize. +- **Standing constraint:** any future overlay that can share a line with + wrapping text MUST use the path form, never the image form. Existing image + overlays (math, bullets, default callout header) survive only because they + sit on single-line fragments. +- **Do not retry:** any image-drawing mechanism from the matrix; bumping the + deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no + evidence, drops Sonoma incl. the dev machine). The whole-header-as-image + alternative is preserved on branch `fix/callout-title-image` + (tip `c7f5170`, unmerged): it sidesteps the wedge but has two unsolved + problems (~2× line height band above the title; a width-timing race). +- Still open nearby (backlog): callout icon baseline / crispness; the + callout-at-end-of-file extra colored line (live-path rendering bug). + +--- + +## 5. The 0.1.0 release failures (2026-06-26 → 2026-07-02) + +Reference: ARCHITECTURE §13. Five separate failures shipping and updating the +first releases. **STATUS: all settled**, with one time bomb (PAT expiry). + +1. **`sign_update -s` exits 1 for new keys** (`59565f5`, 2026-06-27, PR #135). + Sparkle deprecated `-s `; for keys generated after that change it + prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on + **stdin** — `echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - `. + Do not retry `-s`. +2. **Bundle not sealed → EVERY update failed "improperly signed"** + (`5e54b40`, 2026-06-29, issue #158). Sparkle re-validates the Apple code + signature at install (`SUUpdateValidator` → `SecStaticCodeCheckValidity`); + the build signed only the main binary, never sealed the bundle, so a valid + EdDSA signature didn't save it. Fix: `codesign --deep` the whole `.app` — + and because SwiftMath's resource bundle must sit at the `.app` root + (`Bundle.module` hardcodes `Bundle.main.bundleURL`) and codesign won't seal + a bundle with root items, **seal first, copy the SwiftMath bundle in + AFTER sealing**. The lone unsealed root item trips strict + `codesign --verify` but not Sparkle's non-strict check (verified against + that exact API). Do not "fix" the ordering or the failing strict verify. +3. **Appcast push to protected `main` rejected, `GH006`** (`e56a4dd`, + 2026-06-28, PR #140). `GITHUB_TOKEN` isn't admin; `enforce_admins: false` + means an admin PAT bypasses the required check. Fix: fine-grained admin PAT + in secret **`RELEASE_TOKEN`**, set on the **checkout step** (not the push + URL — `actions/checkout` persists an `extraheader` credential that + overrides inline-URL creds). **`RELEASE_TOKEN` expires 2027-06-27**; rotate + before then or releases fail at the appcast push. +4. **create-dmg quirks** (`098d8c0`, 2026-06-26, documented in §8): it's the + **npm** create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; + **exit code 2 for unsigned images is success**; space-in-filename + normalization. +5. **Release workflow YAML invalid** (`854f85d`, 2026-07-02, merged `232e6c8`, + PR #162). Literal multi-line bash strings inside a `run: |` block had + unindented lines — invalid in a YAML block scalar; the whole workflow file + failed to parse. Fix: build the strings with `printf` `\n` escapes; also + `$(...)` strips trailing newlines, so the separator newline lives in + `NEW_ITEM`'s format string, not `DESC_BLOCK`. + +--- + +## 6. Reverts and abandoned directions + +- **Selection tint** — `ee173f7` (2026-06-26): reverted an experimental + selection color back to **accent-derived (accent @ 30% alpha)**. + **STATUS: settled.** Don't re-hardcode a bespoke selection color. +- **`img.md-image { display:block }` in the export/Read HTML theme** — + `75d2824` (2026-06-25): reverted; it did NOT fix the image blank-space and + broke layout. The commit title itself records the verdict: **image + blank-space is a separate, STILL-OPEN bug** (backlog: "Attached image + padding… creates a large empty space below", + `misc/bug-repros/image-blank-after.mov`). Do not retry display:block for it. +- **Checkbox click-to-toggle + table borders** — `9991413` (2026-06-03): + pulled from a branch for separate passes. **STATUS: partially redone.** + Table work landed later (edit-mode table alignment shipped, per backlog + Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — + **reverted-pending-redo**. The original attempt lives on stale branch + `feature/checkbox-toggle` (merged history, `d6227e3`). +- **TextKit 2 migration regressions** — `b3a4b29` (2026-06-12): the TK1→TK2 + migration silently broke inline-math height, HR spacing, and scroll; fixed + with `RenderingRegressionTests` as the guard. Lesson: TK2 migrations + regress silently in geometry — extend that suite when touching layout. +- **Stale branches that look abandoned but are MERGED** (don't "rescue" them): + `feature/incremental-recompose` (tip `1222f79`, in main) and + `refactor/word-level-rendering` (merged via PR #8, `7eb6a21` — word-level + delimiter hiding became the shipped approach). The genuinely unmerged WIP + branches are `fix/callout-title-image` (entry 4) and dozens of old merged + topic branches never deleted. + +--- + +## 7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6 + +`fix/wrapped-paragraph-caret-drift` / merge `218d922` IS delete-drift round 6 +(entry 1): the branch name comes from the reporting symptom (backspace mid +wrapped paragraph), but the root cause was the queued TextKit 2 selection +fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was +incidental. **STATUS: settled** with `1b1420a`. If a caret drift is reported +"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a +preceding heal breadcrumb in `~/.edmund/logs` first. + +--- + +## 8. Smaller settled battles (one paragraph each; verified in git) + +- **Flaky math fit-width test — took TWO rounds** (`5421297` 2026-06-06 branch + `fix/flaky-math-test`, then `352bdc9` PR #65). First fix pinned the text + container width (tracking off) — the flake persisted ~1 in 3 runs. Real + cause: **shared theme-defaults state pollution** between tests; fixed with + isolated defaults. Lesson: a flake "fix" that doesn't name the shared state + isn't done. +- **Emoji rendered as missing-glyph boxes** (`0f5ffba`, 2026-06-06). + `EditorTextStorage.fixAttributes` is a deliberate no-op (so `.attachment` on + real characters survives) — which also disabled font substitution. Fix: + perform substitution manually (Apple Color Emoji per composed-character + sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework + `fixAttributes`; it strips the marker attachments. +- **Nested list hanging indent** (`8d2088f`, 2026-06-06, PR #64). + swift-markdown's list-item delimiter excludes leading indentation; the + visible spaces broke the hang. Fix: hide the leading whitespace in the + inactive branch; indentation comes entirely from the paragraph style. +- **Ordered-list deep nesting lost styling** (`b255903`, 2026-06-06). + swift-markdown parses ≥4-space indent as indented code; the rescue regex + only matched `[-*+]`. Extended to `\d{1,9}[.)]`. Any new list-ish syntax + must be added to the rescue parser too. +- **Window size persistence — three commits to get right** (`538ff6e` → + `d967bcf` → `678c5d6`, 2026-06-28, PR #144). Saving content-view size made + windows grow taller on reopen (titlebar double-counted); final form stores + the **full window frame** and restores via `setFrame`. +- **Toolbar right-click interception — three failed view-level attempts** + (`4eb604a`, `6723280`, then `f8472ca`, 2026-06-26). View `.menu`, + `rightMouseDown`, and a gesture recognizer all lost to the toolbar's + "Customize Toolbar…" menu. Working fix: `DocumentWindow` overrides + `NSWindow.sendEvent` — the documented funnel ahead of the toolbar — and + swallows secondary clicks on the view-mode button. Do not retry view-level + interception for anything the titlebar/toolbar claims. +- **Invisible CJK input** (`a3df387`): IME-composed text was invisible until + committed; fixed by keeping marked text visible. Related to (and predating) + the round-1 marked-text rules. + +--- + +## 9. Still OPEN — do not let this chronicle imply otherwise + +Per `misc/backlog.md` (cross-checked 2026-07-05): footnotes don't render +(edit or Read); math doesn't render in Read mode; math padding in edit mode; +**image blank-space below attached images** (see the `75d2824` revert); +callout-at-end-of-file extra colored line; max content width not applied to +Read mode; tables don't wrap/shrink at small content sizes; table-cell content +wraps out of the cell; "sometimes click to select / select+delete doesn't +work" (unreproduced); lurking scroll glitch from off-viewport height changes; +lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste +scrolling failing again" (see entry 2 caveat). Delete-caret-drift and +viewport-estimate classes stay on the watch list even though every known +mechanism is fixed. + +--- + +## Provenance and maintenance + +- Written 2026-07-05 by mining: `docs/investigations/delete-drift-investigation.md`, + `docs/investigations/viewport-glitch-investigation.md`, + `docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/ARCHITECTURE.md` §13, + `CHANGELOG.md`, `misc/backlog.md`, `docs/ROADMAP.md`, and `git log --all` + (every hash above verified with `git show` on that date). +- **Code and git win over prose.** If this file disagrees with a commit or the + current source, trust the commit, then fix this file. +- **Update triggers:** a new delete-drift round (append to entry 1 — never a + new doc); any live confirmation or refutation of `repairContentAboveOrigin` + (flip entry 3 Bug 2 off mitigated-unconfirmed); `RELEASE_TOKEN` rotation + (entry 5); any revert (entry 6); closing a backlog bug named in entry 9. +- Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". + No oversell — this file's value is that its claims can be trusted blind. +- Sibling map lives in "When NOT to use" above; keep it in sync as the skill + library grows. diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md b/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md new file mode 100644 index 0000000..89d960d --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md @@ -0,0 +1,287 @@ +--- +name: edmund-live-repro-and-diagnostics +description: > + How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, + interpretation guides, and working scripts. Load when a bug involves LIVE + behavior (caret, IME, drag, viewport timing), when a unit test cannot + reproduce a report, when you need to read diagnostic traces, drive the + running app with scripted keystrokes, or measure pixels from a screenshot. + Contains the repro escalation ladder, the ReproScript driver, the CGEvent + fallback, and screencapture measurement, plus scripts/ helpers. Not the + symptom→mechanism table (edmund-debugging-playbook), not the campaign + (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env). +--- + +# Edmund live repro & diagnostics + +A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context +layer (deferred selection fixups, IME composition, drag sessions, event-loop +timing). **Headless tests cannot form the broken state** — the test harness runs +AppKit's deferred machinery synchronously, so a green unit test proves nothing +about this class. This skill is how you make such a bug cheap to observe, then +deterministic. Primary source doc: `docs/dev-guides/live-repro-guide.md`. + +Verified 2026-07-05. + +--- + +## 0. Safety preamble (do this every time) + +- **Check for the user's live instance first.** The user's daily-driver app has + the same binary name (`edmd`). Run `scripts/check-live-instance.sh`. **Never + blanket `pkill -x edmd`** — kill only your own PID, or use `pkill -f + EdmundDbg` (only your debug bundle matches). +- **Recreate the test document fresh before every run** — autosave mutates it; + run 2 against run 1's leftovers produces garbage offsets. +- **Verify the binary is fresh** before trusting a run (SwiftPM sometimes prints + `Build complete!` without relinking `edmd`) — strings/shasum method in + **edmund-build-and-env**. +- **Do not request macOS Computer Access.** Screen Recording + Accessibility are + already granted for this project; ReproScript (§3) needs neither. + +--- + +## 1. The escalation ladder + +Work down; stop at the first level that reproduces. Each is more faithful and +more expensive than the one above. + +| # | Technique | Faithful to | Cost | Use when | +|---|---|---|---|---| +| 1 | Plain unit test (`makeEditor()`) | model/parse/style logic | seconds | anything not event-timing-dependent | +| 2 | Windowed unit test (NSWindow + NSScrollView, real `deleteBackward(nil)`) | + layout, viewport, first responder | seconds | viewport/lazy-styling bugs (`LazyRenderingTests` setup) | +| 3 | **In-process ReproScript** (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — **the default for live bugs** | +| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll | +| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit | + +Two levels deserve emphasis: +- **Level 2 failing to repro is evidence, not defeat** — it tells you the bug + needs deferred/queued AppKit state, pointing you at level 3–4. +- **Level 3 exists because level 4 is unreliable** — background/agent sessions + often have no TCC grant and synthetic keyboard events get dropped silently. + In-process injection needs no permission. + +--- + +## 2. Step 0 — make the trace tell you the trigger + +Never script blind. The recipe is usually already in `~/.edmund/logs`, if +verbose diagnostics were on. Launch flags (file arg **must** be `argv[1]`): + +```bash +/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES +``` + +**Trace-field decoder** (each verbose line carries these; from +`EditorTextView+Diagnostics.swift`): + +| Field | Meaning | +|---|---| +| `sel` | current selection `{location,length}` | +| `active` | active (caret) block index | +| `marked` | is there marked/IME text | +| `up` | `isUpdating` — **`up=Y` = event arrived mid-recompose** (suspicious) | +| `undo` | undo-stack depth | +| `blocks` | block count | +| `storLen` / `rawLen` | storage length vs rawSource length | + +- **Healthy edit ordering:** `shouldChangeText` → `selectionDidChange` (up=N) → + `synced`. A transient `⚠︎LEN-MISMATCH` *between* those lines is normal + (storage moves before rawSource syncs). +- **Suspect:** a `selectionDidChange` with `up=Y` at a surprising position; a + **persisting** LEN-MISMATCH; a `shouldChangeText` with **no** + `synced`/`SKIPPED`/`DEFERRED` after it (a bypassed `didChangeText`); the + `healing storage edit that bypassed didChangeText` breadcrumb. +- **`traceSelectionOrigin`** logs a **call stack** for any selection change that + lands mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. +- **Walk BACKWARDS from the first bad line, not forwards from the symptom.** The + round-6 drift was armed ~80 seconds and dozens of healthy edits before the + visible failure. The user-visible symptom is often the *second* half. + +If the log didn't capture the deciding fact, **add the log line first** (keep +good ones behind `Log.shouldTrace` and ship them) and reproduce again. Also +**reconstruct the document** — wrapped-paragraph geometry, block boundaries, and +block kinds all matter; repro against a lookalike, never `"hello world"`. + +`scripts/grep-trace.sh [YYYY-MM-DD]` surfaces the suspect patterns in one shot. + +--- + +## 3. The in-process ReproScript driver (default for live bugs) + +`Sources/edmd/App/ReproScript.swift`, **DEBUG builds only**. Replays a keystroke +script against the front document by synthesizing `NSEvent`s and pushing them +through `window.sendEvent(_:)` — the full authentic key route (keyDown → +`interpretKeyEvents` → `insertText:` / `deleteBackward:`). No Accessibility, no +visible window required (works on an inactive Space), real run-loop pacing. + +Launch: `scripts/launch-debug.sh FILE.md SCRIPT.repro` (assembles EdmundDbg.app, +guards the user's instance, direct-execs with all flags). Or by hand: + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -debug.reproScript "$SCRIPT.repro" \ + -ApplePersistenceIgnoreState YES & +``` + +**Command surface** (one per line, `#` comments allowed): + +| Command | Effect | +|---|---| +| `sleep ` | wait before the next command | +| `caret ` | place caret before the first occurrence of `` | +| `type ` | one real key event per char, ~80 ms apart | +| `backspace ` | n real delete keystrokes, ~300 ms apart | +| `bypassdelete ` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `` | +| `logsel` | log selection, rawSource length, doc count | + +Round-6 minimal repro (the worked example): the deciding output was **`logsel` +321 (broken) → 290 (fixed)**, every run, window not even visible. + +``` +sleep 2000 +bypassdelete Sizemore, +sleep 800 +logsel # broken: {321,…}; fixed: {290,…} +backspace 2 +logsel +``` + +**Design rules — keep them when extending:** +- **Address text by needle, never offset** — offsets go stale the moment a + script edits; needles survive (this is what makes soak scripts possible). +- **Real events over direct method calls** — `insertText("")` shortcuts skip + `deleteBackward`'s selection machinery, the exact place round 6 lived. +- **Simulate AppKit-internal paths by exact call sequence** — `bypassdelete` + replicates `shouldChangeText` → `replaceCharacters`, no `didChangeText` + *verbatim*, not an approximation. Pin any new internal path's real sequence + from a `traceSelectionOrigin` stack first, then replay it. +- **Asserts inside the app, results in the log** — the harness (you, or a shell + loop) only greps `PASS`/`FAIL`; the app is the oracle. +- New commands are ~10 lines each — extend `ReproScript.swift`, don't work around it. + +**Soak scripts** (§6): chain several trigger cycles at different positions with +ordinary editing between them, `assertcaret` after each predictable step, and +compare final `rawLen` across runs (byte-identical = deterministic). A soak green +across 4–5 cycles is far stronger than one clean repro — it catches bugs needing +*armed state* (round 6's queued fixup). + +``` +bypassdelete Sizemore, +assertcaret Strang +backspace 2 +type xy +bypassdelete widely +assertcaret used in various +logsel +``` + +--- + +## 4. CGEvent driver (mouse-only paths, TCC willing) + +For paths that must originate as HID events — real drag-select, drag-move, +autoscroll — keyboard replay can't cover them. A ~70-line `ui.swift` (compile +with `swiftc`) posting `CGEvent`s does: `bounds ` (window lookup via +`CGWindowListCopyWindowInfo`), `click x y`, `dragselect`, `dragmove` (mousedown + +**~400 ms hold** before moving, or AppKit never arms the text drag), `key`, +`type`. + +Caveats (all hit in practice): +- **TCC decides per session.** Background/agent sessions often can't post + keyboard events (dropped silently) or use System Events. Test with **one click + + log check**; if input doesn't land, fall back to §3 immediately — don't + iterate on driver variations. +- App windows are on an inactive Space until activated + (`kCGWindowIsOnscreen == false`); `osascript -e 'tell application ".app" + to activate'` (Apple Events, a separate TCC bucket) may work where System + Events is denied. +- Re-activate before every interaction batch; focus is lost between shell calls. + +--- + +## 5. Screencapture measurement + +**Visual judgments are measured, not eyeballed** — when the task says "balance +padding" or "align the icon", capture the window and measure pixels. + +`scripts/capture-window.sh out.png` finds the window id +(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l`. +Notes: +- Capture **by window id**, reliable even when not frontmost. +- **Crop by the detected window bounds** — the desktop wallpaper defeats + screencapture's brightness-based auto-crop. +- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for + the first/last colored row of a callout box). Report the pixel numbers, not an + impression. +- Window-server state can glitch (tiny windows, restoration) after many rapid + launch/kill cycles: `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState` + and relaunch. + +--- + +## 6. The loop, end to end + +1. Verbose trace from the occurrence → find the **first bad line**, walk + backwards, form a trigger hypothesis (§2). +2. Reconstruct the document; script the hypothesized trigger (§3). +3. **No repro?** Hypothesis wrong or fidelity too low — move down the ladder + (§1), or instrument and wait for the next hit. +4. **Repro in hand? Freeze it** (exact script + document), then let it falsify + fix candidates — round 6's first "fix by reasoning" failed in the repro within + a minute. +5. Fix verified → **soak** (§3) → full `swift test` → keep the script + new + diagnostics → update the relevant `docs/*-investigation.md`. + +**Meta-lesson from six rounds: time spent making the failure cheap to observe +beats time spent reasoning about the fix.** Every round that shipped on reasoning +alone came back; the round that shipped on a deterministic repro named the actual +mechanism. + +--- + +## scripts/ (in this skill dir) + +| Script | Purpose | Status | +|---|---|---| +| `check-live-instance.sh` | Report running `edmd`, exit 2 if any; never kills | logic verified; safe by construction | +| `grep-trace.sh [date]` | Surface suspect patterns in today's log | logic verified | +| `capture-window.sh ` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) | +| `launch-debug.sh [script.repro]` | Build + assemble EdmundDbg.app + direct-exec with flags | **verify on first use** (assumes arm64 debug triple; guards user instance) | + +All four pass `bash -n`. The two "verify on first use" scripts depend on live +system state (window server, build layout) that couldn't be exercised while +authoring; read the header comment before first run. + +--- + +## When NOT to use this skill + +- Deciding *which mechanism* a symptom implies → **edmund-debugging-playbook**. +- Running the full caret-integrity investigation → **edmund-caret-integrity-campaign**. +- Stale-binary detection / bundle internals → **edmund-build-and-env**. +- What counts as sufficient evidence to ship → **edmund-validation-and-qa**. +- The AppKit theory behind the fixup/marked-text mechanisms → **textkit2-appkit-reference**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against `docs/dev-guides/live-repro-guide.md`, +`Sources/edmd/App/ReproScript.swift`, and +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. + +```bash +grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift +grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift +grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +``` + +Re-verify the scripts against `docs/dev-guides/live-repro-guide.md` §4 if the debug-bundle +assembly recipe changes. diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh new file mode 100755 index 0000000..bc83000 --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# capture-window.sh +# Screenshot a specific window by id (reliable even when not frontmost). +# +# Mechanism: osascript(JXA) enumerates on-screen windows via the ObjC bridge +# to CGWindowListCopyWindowInfo, finds the first whose kCGWindowName contains +# the substring, prints " ", then `screencapture -x -o +# -l` grabs it. We crop by the detected window bounds (the desktop +# wallpaper defeats screencapture's brightness-based auto-crop). +# +# VERIFY ON FIRST USE: JXA ObjC bridging + the CGWindowList key names are +# stable AppKit API, but this script has not been executed in this session. +# If the JXA lookup prints nothing, the window title didn't match or Screen +# Recording permission is missing (it is granted for this project per +# CLAUDE.md — do NOT request Computer Access to "fix" it). +set -euo pipefail + +needle="${1:?usage: capture-window.sh }" +out="${2:?usage: capture-window.sh }" + +read -r wid x y w h < <(osascript -l JavaScript <&2 + exit 1 +fi + +screencapture -x -o -l"$wid" "$out" +echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}" diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh new file mode 100755 index 0000000..73e442a --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# check-live-instance.sh — report any running `edmd` processes, refuse to kill. +# +# The user's daily-driver Edmund shares the binary name `edmd`. NEVER blanket +# `pkill -x edmd`. Run this first; if it finds an instance you did not start, +# leave it alone and launch your own debug bundle instead (launch-debug.sh +# uses EdmundDbg so `pkill -f EdmundDbg` only ever hits yours). +# +# Exit: 0 = no edmd running; 2 = at least one edmd running (inspect, don't kill). +set -euo pipefail + +pids=$(pgrep -x edmd || true) +if [[ -z "$pids" ]]; then + echo "No running edmd instance." + exit 0 +fi + +echo "Found running edmd instance(s) — DO NOT pkill -x edmd:" +for pid in $pids; do + ps -o pid=,lstart=,command= -p "$pid" +done +exit 2 diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh new file mode 100755 index 0000000..eb84477 --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# grep-trace.sh [YYYY-MM-DD] — surface the suspect patterns in today's (or a +# given day's) Edmund diagnostic log. Read-only. +# +# Requires the app to have run with: +# -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES +set -euo pipefail + +day="${1:-$(date +%F)}" +log="$HOME/.edmund/logs/edmund-${day}.log" + +if [[ ! -f "$log" ]]; then + echo "No log for ${day} at ${log}" >&2 + exit 1 +fi + +echo "== $log ==" +echo "--- bypassed-didChangeText heals ---" +grep -n 'healing storage edit that bypassed didChangeText' "$log" || echo "(none)" +echo "--- content-above-origin repairs ---" +grep -n 'repairing content above origin' "$log" || echo "(none)" +echo "--- persistent length mismatches ---" +grep -n 'LEN-MISMATCH' "$log" || echo "(none)" +echo "--- repro asserts ---" +grep -n 'repro assertcaret' "$log" || echo "(none)" +echo "--- FAILs ---" +grep -n 'FAIL' "$log" || echo "(none)" diff --git a/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh new file mode 100755 index 0000000..809761e --- /dev/null +++ b/.agents/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# launch-debug.sh [script.repro] +# Assemble/refresh build/EdmundDbg.app, then direct-exec it with diagnostic +# flags (and optionally replay a ReproScript). Kills only its OWN prior debug +# instance, never the user's daily-driver edmd. +# +# Steps (from docs/dev-guides/live-repro-guide.md §4): +# 1. swift build (debug). +# 2. Assemble EdmundDbg.app: Info.plist copy + debug edmd + Sparkle.framework +# (dyld aborts without the framework; a bare .build/debug/edmd never makes +# a window). +# 3. Refuse to run if a non-ours edmd is live (check-live-instance.sh). +# 4. Direct-exec the bundle binary (never `open -a`: LaunchServices can run a +# stale translocated copy). +# +# VERIFY ON FIRST USE: paths assume the standard arm64 debug layout +# `.build/arm64-apple-macosx/debug/`. If your host resolves a different triple, +# adjust BUILD_DIR. Binary-freshness check is a reminder, not enforced here — +# see edmund-build-and-env for the strings/shasum method. +set -euo pipefail + +doc="${1:?usage: launch-debug.sh [script.repro]}" +repro="${2:-}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" # repo root +cd "$ROOT" +BUILD_DIR=".build/arm64-apple-macosx/debug" +APP="build/EdmundDbg.app" + +# 0. Never step on the user's instance. +here="$(dirname "${BASH_SOURCE[0]}")" +if pgrep -x edmd >/dev/null 2>&1; then + # Allow only if it's our own EdmundDbg (safe to replace). + if ! pgrep -f EdmundDbg >/dev/null 2>&1; then + echo "A non-EdmundDbg edmd is running — refusing to launch. Inspect:" >&2 + bash "$here/check-live-instance.sh" || true + exit 2 + fi + pkill -f EdmundDbg || true +fi + +# 1. Build. +swift build + +# 2. Assemble the bundle. +mkdir -p "$APP/Contents/MacOS" +cp Info.plist "$APP/Contents/Info.plist" +cp "$BUILD_DIR/edmd" "$APP/Contents/MacOS/edmd" +if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then + rm -rf "$APP/Contents/MacOS/Sparkle.framework" + cp -R "$BUILD_DIR/Sparkle.framework" "$APP/Contents/MacOS/Sparkle.framework" +else + echo "WARNING: $BUILD_DIR/Sparkle.framework not found; dyld may abort." >&2 +fi + +# 3. Launch by direct exec, background, with diagnostics on. +args=( "$doc" + -settings.general.diagnosticLogging YES + -settings.advanced.verboseEditorDiagnostics YES + -ApplePersistenceIgnoreState YES ) +if [[ -n "$repro" ]]; then + args+=( -debug.reproScript "$repro" ) +fi + +echo "Launching $APP with: ${args[*]}" +"$APP/Contents/MacOS/edmd" "${args[@]}" & +echo "Launched PID $! — tail with: scripts/grep-trace.sh" diff --git a/.agents/skills/edmund-release-and-operate/SKILL.md b/.agents/skills/edmund-release-and-operate/SKILL.md new file mode 100644 index 0000000..0191985 --- /dev/null +++ b/.agents/skills/edmund-release-and-operate/SKILL.md @@ -0,0 +1,400 @@ +--- +name: edmund-release-and-operate +description: > + Load when cutting or debugging an Edmund release, or operating the shipped + app. Triggers: version bump (Info.plist CFBundleShortVersionString / + CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml + / release.sh / build-app.sh, appcast.xml or Sparkle update failures + ("improperly signed", update never offered), sign_update / EdDSA keys / + SPARKLE_ED_PRIVATE_KEY / RELEASE_TOKEN, create-dmg or DMG naming problems, + Gatekeeper "damaged" reports, launching the built app, reading + ~/.edmund/logs, crash reports (edmd-*.ips), or roadmap/priority questions. +--- + +# Edmund — release & operate + +Date-stamped 2026-07-05. Verified against `.github/workflows/release.yml`, +`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`, +`appcast.xml`, `Info.plist`, `CHANGELOG.md`, `docs/ARCHITECTURE.md` §8/§13, and +the Settings/CrashReporter sources. Where a doc and a script disagree, the +script is the truth; disagreements are flagged inline. + +**House rule: releases happen only when the maintainer explicitly asks.** +Never tag, push, create a release, or merge on your own initiative — see +`edmund-change-control`. Everything in §1–§4 below is a runbook for when the +maintainer says "cut a release", not a standing instruction. + +## When NOT to use this skill + +| You actually need | Go to | +|---|---| +| Build/test commands, stale-build cures, launch mechanics in depth | `edmund-build-and-env` | +| Editing invariants, render pipeline, TextKit 2 rules | `edmund-architecture-contract`, `textkit2-appkit-reference` | +| Debugging a bug in the app itself | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` | +| Past incidents and why the sharp edges below exist | `edmund-failure-archaeology` | +| Debug flags / launch arguments | `edmund-config-and-flags` | +| Branch/commit/PR etiquette, what needs maintainer sign-off | `edmund-change-control` | +| Pre-merge QA method | `edmund-validation-and-qa` | +| README/website/positioning copy | `edmund-docs-and-writing`, `edmund-external-positioning` | + +--- + +## 1. Release flow — CI path (the normal one) + +Ship via a tag; CI does the rest. In order: + +1. **Bump versions in `Info.plist`** — both keys: + - `CFBundleShortVersionString` — marketing version, e.g. `0.1.3` + - `CFBundleVersion` — build number, **monotonic integer** (0.1.3 = `4`) +2. **Add a `## [x.y.z]` section to `CHANGELOG.md`** — format is load-bearing, + see §2. The version MUST match Info.plist exactly. +3. **Merge to `main`** and push (via the normal PR flow). +4. **Tag and push the tag:** + ```sh + git tag vX.Y.Z && git push origin vX.Y.Z + ``` +5. CI (`.github/workflows/release.yml`, trigger `push: tags: 'v*'`, runner + `macos-14`, job `release` / "Build & publish") runs the steps below. +6. Afterwards, verify per §4 post-flight. + +### release.yml step anatomy (actual step names) + +| Step | What it does | Sharp edge | +|---|---|---| +| `actions/checkout@v5` | `fetch-depth: 0`, `token: ${{ secrets.RELEASE_TOKEN }}` | The PAT must be on **this** step — see §3.4 | +| `maxim-lobanov/setup-xcode@v1` | latest-stable Xcode | | +| **Cache .build** | SPM cache keyed on `Package.resolved` | | +| **Build app bundle** | `./scripts/build-app.sh` — release build, bundle assembly, Sparkle embed, **bundle sealing** | §3.2 | +| `actions/setup-node@v4` | pins Node 20 | create-dmg 8.x needs Node ≥ 20 | +| **Install create-dmg** | `npm install --global create-dmg` (sindresorhus/create-dmg, **not** Homebrew's) | §3.5 | +| **Create DMG** | reads VERSION from Info.plist, `create-dmg build/Edmund.app build/ \|\| true`, renames `"Edmund .dmg"` → `Edmund-.dmg`, fails loudly if no dmg | §3.5 | +| **Sign archive (EdDSA)** | finds `sign_update` in `.build`, key on **stdin** via `--ed-key-file -`, exports `ED_SIG` and `LENGTH` | §3.1 | +| **Create GitHub Release** | awk-extracts the CHANGELOG section → `gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest` | §2 | +| **Update appcast.xml** | builds the new `` (HTML `` via `scripts/changelog-to-html.py`), inserts it before ``, commits as `github-actions[bot]`, `git push origin HEAD:main` | §3.4 | + +### Local path (`scripts/release.sh`) + +Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update +appcast.xml **locally** → `gh release create`. Two differences: + +- **Signing**: with `SPARKLE_ED_PRIVATE_KEY` in the env it uses the stdin path + (CI-style); otherwise `sign_update` pulls the key from the **login keychain** + (put there by Sparkle's `generate_keys`) with no flag at all. +- **The appcast commit/push is left to you.** The script ends with the exact + commands: `git add appcast.xml && git commit -m 'Release ' && git push`. + +Prereqs for the local path: `gh auth status` authenticated, npm `create-dmg` +installed, `swift build` has run at least once (so `sign_update` exists under +`.build`). + +> **Stale doc**: `misc/how-to-release.md` still says the artifact is a **zip** +> ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG +> switch. The truth is DMG throughout — per `release.yml`, `release.sh`, and +> ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it. + +--- + +## 2. CHANGELOG format contract (release notes are machine-extracted) + +Both `release.yml` and `release.sh` extract the GitHub Release body with: + +```sh +awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md +``` + +So the section header **must** start at column 0 as `## [x.y.z]` — literally +`## [0.1.3] — 2026-07-04` in house style (em dash + ISO date after the +bracket is fine; the match only requires the `^## \[x.y.z\]` prefix). +Extraction runs until the next `^## [` line. If nothing matches, the release +body falls back to "See CHANGELOG for details." — a silent-ish failure, so get +the header right. (Version dots are unescaped in the regex; harmless in +practice, don't rely on it.) + +The Sparkle update-dialog notes come from the **same section** via +`scripts/changelog-to-html.py `, a deliberately tiny converter that +only understands Keep-a-Changelog shapes: + +- `### Added` / `### Changed` / `### Fixed` → `

    ` — **use `###`, not `##`**. + The 0.1.2 appcast item literally shows `

    ## Changed

    ` because the + section used `##` subheads at release time; the converter passed them + through as paragraphs. +- `- ` / `* ` bullets → `
    • `; indented continuation lines fold into the + previous bullet. +- `` `code` `` and `**bold**` are converted. **Markdown links are NOT** — + `[docs](docs/foo.md)` appears literally in the update dialog (see the 0.1.2 + item). Keep appcast-facing notes link-free or accept the raw brackets. +- Blank lines and `---` are skipped; anything else becomes a `

      `. +- Missing section → empty output → the `` is simply omitted. + +House format (verified from `CHANGELOG.md`): Keep a Changelog 1.1.0 + SemVer, +newest first, sections separated by `---`. + +--- + +## 3. The sharp edges (each one killed or nearly killed a real release) + +### 3.1 `sign_update -s` is FATAL — key goes on stdin + +Sparkle deprecated `-s `; for newly generated keys it prints a +deprecation warning and **exits 1** ("no longer supported"). This killed the +first 0.1.0 release. The only correct invocation with a key-in-hand: + +```sh +echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - +``` + +Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back +to `-s`. Output format: `sparkle:edSignature="" length=""` — the +scripts grep those two attributes out for the appcast item. + +### 3.2 Bundle sealing (the "improperly signed" update failure) + +At install time Sparkle re-validates the update's **Apple** code signature +(`SUUpdateValidator`), independent of the EdDSA signature. A bundle that is +code-signed but not *sealed* (no `_CodeSignature/CodeResources`) fails that +check and every update dies with "The update is improperly signed and could +not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update +when the old script signed only the bare binary. + +`build-app.sh` therefore signs inside-out and in a very deliberate order: + +1. `codesign --force --deep --sign - Sparkle.framework` (nested XPC helpers + must be signed before macOS will launch them), +2. `codesign --force --deep --sign - --identifier "com.i7t5.edmd"` on the + whole `.app` **while its root holds only `Contents/`** — codesign refuses + to seal a bundle with extra items at the root, +3. copy the SwiftMath resource bundle to the `.app` root **after** sealing + (its generated `Bundle.module` looks at `Bundle.main.bundleURL`; without it + the app crashes on the first LaTeX render). + +Consequence: `codesign --verify` (CLI) and `--strict` **will complain** about +that one unsealed root item. That is expected and fine — Sparkle's actual +check is non-strict (`SecStaticCodeCheckValidityWithErrors` with +`kSecCSCheckAllArchitectures`) and tolerates it; verified end-to-end against +that API. Do not "fix" the verify warning by moving the SwiftMath bundle or +re-signing after the copy. + +### 3.3 Keypair discipline + +One EdDSA keypair, three places, all of which must agree: + +| Place | Used by | +|---|---| +| `Info.plist` `SUPublicEDKey` (`0XdLbbuO…`) | Every shipped app, to verify updates | +| Maintainer's **login keychain** | `release.sh` local signing (no flag) | +| GitHub secret **`SPARKLE_ED_PRIVATE_KEY`** | CI signing | + +If the signing key and `SUPublicEDKey` diverge, everything *looks* fine — the +DMG signs without error — but **every user's update fails signature +verification**. Sanity check when in doubt: +`sign_update --verify ` against the Info.plist public key. + +### 3.4 Appcast push to protected `main` — RELEASE_TOKEN + +The workflow's last step commits `appcast.xml` and pushes to `main`, which +requires the `test` status check. The default `GITHUB_TOKEN` / +`github-actions[bot]` is not an admin, so that push is rejected with +`GH006 … protected branch hook declined`. Branch protection has +`enforce_admins: false`, so an admin's push bypasses the check — hence the +fine-grained **admin PAT** in the `RELEASE_TOKEN` secret (Contents: +read/write), set as the `token:` on the **checkout step**, not on the push. +That placement matters: `actions/checkout` persists an +`http..extraheader` credential that overrides inline-URL credentials, +so rewriting the push URL would keep pushing with the bot token anyway. + +**`RELEASE_TOKEN` expires 2027-06-27.** Rotate it before then or every +release fails at the appcast push while the GitHub Release itself succeeds +(a confusing half-shipped state — see §4 post-flight). + +### 3.5 create-dmg quirks + +- It's the **npm** package `create-dmg` (sindresorhus), installed via + `npm install --global create-dmg`. Homebrew's `create-dmg` is a different + tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it). +- It exits **2** when it can't Developer-ID-sign/notarize the image (Edmund + ships ad-hoc) **but still produces the .dmg**. Both scripts run it with + `|| true` and then verify the file exists, failing loudly only if no dmg + was produced. Don't remove the `|| true`; don't trust the exit code. +- Output is named `"Edmund .dmg"` — with a **space**. Both scripts + rename to `Edmund-.dmg` (hyphen), which is the name the appcast + enclosure URL expects. If a rename is skipped, the release asset URL 404s + for every updater. + +--- + +## 4. Pre-flight and post-flight + +### Pre-flight (distilled from `misc/before-you-release.md` — read it too) + +- [ ] `swift test` green **on `main`**, not just the branch; `git status` clean. +- [ ] No debug flags / launch args left on (repro drivers, verbose tracing — + see `edmund-config-and-flags`, ARCHITECTURE §8). +- [ ] Visual sanity: build and screencapture the editor in **light and dark** + mode; click through everything the CHANGELOG claims ("fixed X" → actually + reproduce X and confirm). +- [ ] `CHANGELOG.md` has `## [x.y.z] — YYYY-MM-DD` for this release and the + version **matches Info.plist** (`CFBundleShortVersionString`); `###` + subheads, not `##` (§2). +- [ ] `CFBundleVersion` bumped (monotonic int). +- [ ] `RELEASE_TOKEN` not expired (**2027-06-27**). +- [ ] Local path only: `gh auth status` ok; keychain key matches + `SUPublicEDKey` (§3.3). + +### Post-flight + +- [ ] GitHub Release `vX.Y.Z` exists with the right notes and the + `Edmund-.dmg` asset (hyphenated name). +- [ ] `appcast.xml` on `main` got the new `` — with ``, + correct `sparkle:version` (= CFBundleVersion) and enclosure URL. +- [ ] Nothing to do for user prompts: Sparkle checks roughly daily; existing + users see the update within ~24 h. Don't panic if it isn't instant. + +If the Release exists but the appcast commit is missing, the release +half-shipped (usually §3.4). Fix the token, then add the `` manually or +re-run the job. + +--- + +## 5. Gatekeeper story (why users see "damaged") + +Edmund is **ad-hoc signed, not notarized** (no $99/yr Developer ID). First +launch of a downloaded copy trips Gatekeeper with the *"app is damaged"* +dialog. This is expected; the app is fine. The README documents both +workarounds (verified, README ~line 53): + +- `xattr -dr com.apple.quarantine /Applications/Edmund.app`, or +- right-click → Open. + +Known upgrade path (open/candidate, not scheduled): Developer ID certificate ++ notarization would remove the prompt entirely and also clean up the +non-strict-sealing compromise in §3.2. Don't promise it in user-facing text. + +--- + +## 6. Operating the app + +### Launching + +`open Edmund.app` **foregrounds a running instance instead of relaunching** — +you'll stare at old code. And never `pkill -x edmd` blindly: the maintainer's +own Edmund session may be running (the Mach-O is `edmd` for both). Check +first (`pgrep -x edmd`), kill only PIDs you started, or launch the binary +directly: `build/Edmund.app/Contents/MacOS/edmd file.md &`. Full launch / +stale-build / screencapture mechanics: `edmund-build-and-env`. + +### Logs — `~/.edmund/logs/edmund-YYYY-MM-DD.log` + +- One file per day, human-readable lines tagged `LEVEL [category]` + (categories: app, document, io, render, compose, selection, lazy, callout, + edit — grep by concern). +- Controlled by **Settings ▸ Advanced ▸ Diagnostics** ("Save diagnostic + logs"). The toggle **defaults OFF** (`AppSettings.diagnosticLogging` + defaults false) — i.e. opt-in in the shipped app, despite `Log.swift`'s + header comment calling it "always-on (opt-out)"; the code is the truth. + (The UserDefaults keys are named `settings.general.*` for legacy reasons; + the UI lives in Advanced.) +- Retention picker ("Clear logs after:") next to the toggle; a separate + "Verbose editor tracing" opt-in gates keystroke-level trace lines — leave + off except during repros (`edmund-live-repro-and-diagnostics`). +- Release builds write `info` and up; DEBUG builds also write `debug`. +- Logs may contain document text; they never leave the machine. + +### Crash reports — `~/Library/Logs/DiagnosticReports/edmd-*.ips` + +- macOS names crash reports after the **Mach-O executable**: look for + `edmd-.ips`, not "Edmund-…". +- **Uploading is opt-in and currently INERT.** The Settings toggle is + commented out in `Sources/edmd/Settings/AdvancedSettingsView.swift` ("dormant + until the receiving server exists"), and + `CrashReporter.reportingEndpoint` is a placeholder + (`https://REPLACE-ME.invalid/crash`). Nothing is ever sent in shipped + builds. Don't tell users crash reporting exists; don't uncomment the toggle + without a real server. Code: `Sources/EdmundCore/Diagnostics/CrashReporter.swift`. +- Reading works only because Edmund is **not sandboxed**; adopting App + Sandbox would force a MetricKit rewrite (noted in CrashReporter's header). +- **Triage of a user's `.ips`**: it's JSON — a one-line metadata header, then + the report body. Look at `exception` (type/signal), `faultingThread`, and + walk that thread's frames for images named `edmd` or `Sparkle`. Ad-hoc + builds ship no dSYM, so expect addresses rather than symbol names for app + frames; correlate with `~/.edmund/logs` from the same timestamp instead. + `.ips` files embed the user's home path and device model — treat as + mildly personal data. + +### Update mechanics (user side) + +- `SUFeedURL` = `https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml` + — the raw-GitHub URL of the checked-in appcast; committing to `main` *is* + publishing. +- `SUEnableAutomaticChecks` is true; no custom interval is set, so Sparkle + uses its default ~24 h cadence (plus a check on launch). +- Sparkle downloads the DMG enclosure, verifies EdDSA against `SUPublicEDKey`, + **mounts the DMG**, re-validates the Apple code signature (§3.2), installs. + +--- + +## 7. Versioning & appcast conventions + +| Thing | Convention | Current (2026-07-05) | +|---|---|---| +| Git tag | `vX.Y.Z` | `v0.1.3` pending its tag; last released 0.1.2 | +| `CFBundleShortVersionString` | SemVer marketing version | `0.1.3` | +| `CFBundleVersion` | monotonic integer, +1 per release | `4` | +| CHANGELOG | Keep a Changelog 1.1.0, `## [x.y.z] — YYYY-MM-DD`, `###` subheads, `---` separators | — | + +`appcast.xml` (checked into repo root): RSS 2.0 with the `sparkle:` namespace. +One `` (title/link/description/language) containing one `` per +release. Items are inserted **before ``**, so the file reads oldest +→ newest; Sparkle doesn't care about order — it picks by version. Per item: + +```xml + + Edmund 0.1.2 + Fri, 03 Jul 2026 19:03:59 +0000 + + + sparkle:shortVersionString="0.1.2" + sparkle:edSignature="…" + length="7608991" + type="application/x-apple-diskimage"/> + +``` + +`` is optional (omitted when the CHANGELOG section is missing). + +--- + +## 8. Roadmap context (for release-content judgment) + +- Edmund is in **beta** (0.1.x line, first public release 0.1.0 on + 2026-06-27). Small, frequent releases. +- **v0.2.0 goal: "Polished editing experience"** (`misc/backlog.md` § Now). +- **Priority ordering: Marketing = Bugs >= UI/UX > Features** — when deciding + what makes a release, bug fixes and polish beat new features. +- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork): + `docs/ROADMAP.md`; working backlog with per-bug detail: `misc/backlog.md`. + +--- + +## Provenance and maintenance + +Written 2026-07-05 from direct reads of: `.github/workflows/release.yml`, +`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`, +`appcast.xml`, `CHANGELOG.md`, `Info.plist`, `README.md`, +`docs/ARCHITECTURE.md` (§8, §13), `misc/how-to-release.md`, +`misc/before-you-release.md`, `docs/ROADMAP.md`, `misc/backlog.md`, +`Sources/EdmundCore/Diagnostics/CrashReporter.swift`, +`Sources/EdmundCore/Diagnostics/Log.swift`, +`Sources/edmd/Settings/AdvancedSettingsView.swift`, +`Sources/edmd/Settings/AppSettings.swift`. + +Known stale docs at time of writing: `misc/how-to-release.md` (zip vs DMG, +§1); `Log.swift` header ("always-on (opt-out)" vs the actual default-off +toggle, §6). Minor oddity, deliberate: `build-app.sh` signs with +`--identifier "com.i7t5.edmd"` while the bundle id is `com.i7t5.edmund`. + +Re-verify when any of these change: `release.yml` step names or secrets, +`build-app.sh` signing order, the CHANGELOG header format (the awk regex in +two places must match it), `SUFeedURL`, `RELEASE_TOKEN` rotation (hard +deadline 2027-06-27), notarization status, or the crash-report server going +live (which un-inerts §6's crash uploading and this skill's wording). diff --git a/.agents/skills/edmund-research-frontier/SKILL.md b/.agents/skills/edmund-research-frontier/SKILL.md new file mode 100644 index 0000000..a34f8f8 --- /dev/null +++ b/.agents/skills/edmund-research-frontier/SKILL.md @@ -0,0 +1,196 @@ +--- +name: edmund-research-frontier +description: > + Open problems where the Edmund Markdown editor could advance the state of the + art — product-first, everything labeled candidate/open, nothing proven. Load + when picking the next big problem, evaluating whether an ambitious idea is + worth starting, or answering "what would move this project beyond state of the + art". Each frontier: why current SOTA fails, Edmund's specific asset, the + first three concrete steps IN THIS REPO, and a falsifiable "you have a result + when…" milestone. Not for running an accepted investigation + (edmund-caret-integrity-campaign), the method of proof + (edmund-research-methodology), or shipping the change (edmund-change-control). +--- + +# Edmund research frontier + +Where Edmund could go past the state of the art. Ambition is **product-first**: +"the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are +**means, not ends** — a technique is worth pursuing when it makes the product +better, and it becomes publishable as a side effect. + +**Everything here is candidate / open. Nothing is proven.** Each item routes its +proof through **edmund-research-methodology** (hypothesis predicts numbers) and +its changes through **edmund-change-control**. Verified 2026-07-05 against the +repo; assets cited are real, outcomes are not. + +--- + +## Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16) + +**Why SOTA fails:** TK2 lays out only near the viewport; off-screen fragment +heights are **estimates** corrected as layout reaches them. This makes the +scroller jump and scroll-to-target miss in **every** TK2 app, including TextEdit +— a widely documented limitation. Above `fullLayoutMaxLength` (100k) Edmund +enters this regime. + +**Edmund's asset:** the mitigations already in `TextView/` — +`scheduleFullLayoutSettle`, `preservingViewportAnchor`, `repairContentAboveOrigin`, +`centerViewportOnCaret` re-measure, and the diff-based undo restore that avoids +resetting fragments to estimates; plus the `ScrollStabilityTests` / +`HeightStabilityTests` harnesses. (Note: `repairContentAboveOrigin` is +**mitigated-unconfirmed live** per `docs/investigations/viewport-glitch-investigation.md` — a +real result here would also *retire that honest gap*.) + +**First three steps in this repo:** +1. Build a large-doc fixture (`makeLargeMarkdown` in `TestHelpers.swift`) >100k + and a scripted scroll-accuracy metric (ReproScript `caret` + a `logsel`-style + position dump, or extend `PerfHarnessTests`). +2. Quantify the estimate-error distribution: for N scroll-to-target operations, + record predicted vs actual landing pixel offset. +3. Prototype persistent per-fragment height caching across the settle (or across + sessions) and re-measure the same distribution. + +**You have a result when:** scroll-to-target lands within a stated pixel budget +on a 1 MB document, measured by script, with **zero** `repairing content above +origin` events across a scroll soak — reproducibly. + +--- + +## Frontier 2 — Caret integrity by construction + +**Why SOTA fails:** every `NSTextView` consumer depends on `didChangeText` +pairing that **AppKit itself violates** (the drag-move bypass). The delete-drift +class is the symptom of building sync on a callback contract AppKit doesn't keep. + +**Edmund's asset:** the heal machinery, the `pendingEdit` model, six documented +rounds of mechanism knowledge, and the ReproScript + soak methodology. The +campaign skill runs *individual* rounds reactively; this frontier is the +**structural endgame** — eliminate the class. + +**First three steps:** +1. Inventory **every** storage-mutation entry point (grep `replaceCharacters`, + `setAttributes`, the edit-flow paths) and tabulate which currently rely on a + callback firing. +2. Design a sync layer keyed on a **storage-version counter** that reconciles + `rawSource` regardless of which callbacks fired (not a new guard per path). +3. Falsify it against **all** historical `.repro` scripts plus a new randomized + bypass-fuzzer script. + +**You have a result when:** all historical `.repro` scripts and a randomized +bypass soak stay green **with the callback-pairing assumption deleted from the +code**. **Candidate, big** — likely a multi-PR redesign; do not start without the +methodology skill's evidence bar in front of you. + +--- + +## Frontier 3 — The 10 MB class (performance headroom) + +**Why it matters:** README claims "~1–2 MB files"; native-with-no-Electron is the +differentiator, so headroom is a product claim, not vanity. + +**Edmund's asset:** viewport-based lazy styling, the idle drain, incremental +reparse (`pendingEdit` window), `PerfHarnessTests`. + +**First three steps:** +1. Extend `PerfHarnessTests` with 5/10 MB fixtures (`makeLargeMarkdown`). +2. Profile the block-parse and restyle hot paths (`Log.measure` single-line + durations are already in place). +3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle + at 10 MB. + +**You have a result when:** open + steady-state typing latency on a 10 MB file +meets a stated budget, measured by the harness (not hand-timed). + +--- + +## Frontier 4 — A native extensions API + +**Why SOTA fails:** Obsidian/VS Code plugin ecosystems are Electron; there is no +strong precedent for a **native, safe, fast** extension surface for live-preview +Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, +primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as +"official extension" candidates. + +**Edmund's asset:** the custom-parser seam +(`Parsing/SyntaxHighlighter+CustomParsers.swift`), the `BlockKind`/`styleBlock` +architecture, and already-modular opt-in syntax (math, Obsidian syntax). + +**First three steps:** +1. Catalog which existing features could be **re-implemented as extensions** + (dogfood: callouts? highlight? wikilinks?) — this defines the real extension + points. +2. Define the minimal seam: block parser? inline parser? theme hook? Draw the + line at what the custom-parser architecture already supports. +3. Spike **one** official extension behind a flag and measure restyle cost vs the + built-in. + +**You have a result when:** one built-in syntax feature runs as an extension with +**no measurable restyle regression** against the built-in baseline. + +--- + +## Frontier 5 — Accessibility / RTL / localization as a differentiator + +**Why it matters:** native apps *can* excel where Electron editors are weak; +ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width +already ships as precedent that the pipeline can be locale-sensitive. + +**Edmund's asset:** the attribute-only pipeline (structure is in the string, not +in inserted characters), the existing locale-aware content-width path. + +**First three steps:** +1. VoiceOver audit of `EditorTextView`'s custom drawing — does the accessibility + tree expose headings/lists/callouts, given they're drawn as decorations? +2. Test RTL behavior of the attribute-only styling on a right-to-left document. +3. Scriptable a11y check (structure read-out) as a regression guard. + +**You have a result when:** a scripted VoiceOver audit reads document structure +(headings, list items, callouts) correctly on a mixed document. + +--- + +## How to start one + +1. **Predict numbers first** (edmund-research-methodology §2) — every milestone + above is a *number*, not a vibe. +2. Instrument to make the current failure/limit cheap to measure. +3. Prototype behind a flag; measure predicted vs observed. +4. Route changes through **edmund-change-control** (branch, tests, no auto-push). +5. When the first milestone lands, the item **graduates to `misc/backlog.md` or + `docs/ROADMAP.md`** and stops being a frontier. + +## What NOT to start (no current asset) + +- **iOS / iPadOS port** — explicitly **TBD** in ROADMAP; no shared UI layer today. +- **Collaborative / real-time editing** — zero repo support (no CRDT, no sync, + single `NSDocument` model). Would be a new product, not a frontier of this one. +- Anything that requires breaking an invariant (storage == rawSource; TextKit 2 + only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract). + +--- + +## When NOT to use this skill + +- Running an *accepted* investigation (a known bug) → **edmund-caret-integrity-campaign** / **edmund-debugging-playbook**. +- The method of turning a hunch into proof → **edmund-research-methodology**. +- Whether a public claim is allowed yet → **edmund-external-positioning**. +- Shipping the change → **edmund-change-control**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. Assets exist; **outcomes are unproven by definition** — +never quote a milestone here as achieved. + +```bash +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/ +ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift +grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md +grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift +``` + +When an item's milestone lands, move it to ROADMAP/backlog and delete it here — +a frontier list that keeps solved problems is lying. diff --git a/.agents/skills/edmund-research-methodology/SKILL.md b/.agents/skills/edmund-research-methodology/SKILL.md new file mode 100644 index 0000000..5384816 --- /dev/null +++ b/.agents/skills/edmund-research-methodology/SKILL.md @@ -0,0 +1,196 @@ +--- +name: edmund-research-methodology +description: > + The discipline that turns a hunch into an accepted result in the Edmund + Markdown editor — the evidence bar, hypothesis-predicts-numbers, the + first-principles analysis recipes, the idea lifecycle, and where good ideas + historically came from. Load when forming a hypothesis about a bug's + mechanism, evaluating whether an investigation's conclusion is trustworthy, + deciding if a fix is actually proven, or turning an idea into an accepted + change. This skill also absorbs the proof-and-analysis toolkit (prove it, + don't just install it). Not the caret-integrity campaign itself + (edmund-caret-integrity-campaign), not the repro tooling + (edmund-live-repro-and-diagnostics). +--- + +# Edmund research methodology + +How a hunch becomes something you can ship without it coming back. Every method +here is grounded in a real episode from this repo's history — the discipline was +paid for in the six delete-drift rounds and the viewport work. + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`, +`docs/investigations/viewport-glitch-investigation.md`, and `docs/dev-guides/live-repro-guide.md`. + +--- + +## 1. The evidence bar + +A mechanism is **accepted** only when it clears both bars: + +1. **It explains ALL observations, including the negatives.** Not just "the caret + drifts" but *why headless tests pass, why it's intermittent, why it appears + minutes after the trigger.* A mechanism that explains the symptom but not the + negatives is incomplete — and incomplete mechanisms come back. +2. **It survives assigned adversarial refutation.** Before shipping, actively try + to **falsify** the supposed fix: run the frozen repro against it. Rounds 1–5 + of delete-drift shipped on reasoning and **all came back**; round 6's first + fix candidate "worked by reasoning" and **failed in the repro within a + minute** (`docs/investigations/delete-drift-investigation.md` round 6, "the iteration that + proved its shape"). + +Corollary: **headless-green is not the bar** for live-input bugs — the round-6 +regression test passes with and without the fix. See edmund-validation-and-qa +for the evidence hierarchy. + +--- + +## 2. Hypothesis predicts numbers BEFORE running + +State the expected observable **before** the experiment. An experiment without a +predicted number is a fishing trip. + +- **Worked example:** round-6 `logsel` predicted **321** on the broken build vs + **290** on the fixed build — a specific number, stated before the run, that the + repro then confirmed every time. +- **Determinism as a prediction:** a soak's final `rawLen` must be + **byte-identical** across runs; predict it, then check it. + +If you can't name the number your hypothesis predicts, you don't understand the +mechanism well enough to test it yet — go back to instrumentation (§3b). + +--- + +## 3. The analysis recipes (prove it, don't just install it) + +Each: when to use, the steps, and the episode that earned it. + +### 3a. Trace archaeology — walk BACKWARDS +**When:** any symptom with diagnostics on. **Steps:** find the first *bad* line, +then read *upward/earlier*, not forward from the symptom. **Episode:** the round-6 +drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of +healthy edits earlier. Source: `docs/dev-guides/live-repro-guide.md` §2. + +### 3b. Make the failure cheap to observe before reasoning about fixes +**When:** you're tempted to guess. **Steps:** add one breadcrumb (a call stack, a +state dump) behind `Log.shouldTrace` and ship it; reproduce; let the log name the +culprit. **Episode:** `traceSelectionOrigin` named `_fixSelectionAfterChange` in +a single run after five rounds of guessing. This is *the* meta-lesson: **time +spent making the failure cheap to observe beats time spent reasoning about the +fix.** + +### 3c. The fidelity ladder as an inference tool +**When:** a repro fails. **Steps:** a repro that fails at level N but succeeds at +N+1 **localizes** the mechanism to what N lacks. **Episode:** delete-drift not +reproducing in a windowed unit test (level 2) told the team it needed +deferred/queued AppKit state → pointed straight at level-3 in-process replay and +event-ordering hypotheses. Source: `docs/dev-guides/live-repro-guide.md` §1. + +### 3d. Invariant auditing +**When:** the behavior looks impossible. **Steps:** find which invariant +**transiently broke** — `storage == rawSource` during IME composition; +`didChangeText` pairing during a drag-move; grep the guard inventory +(`hasMarkedText`, bypass checks). Lock it with an equivalence/fuzz oracle: +`RecomposeEquivalenceTests` (`assertMatchesFullRecomposeOracle` — incremental +restyle must equal a full restyle) and `IncrementalParseFuzzTests` (random edits +keep window-reparse == full reparse). + +### 3e. Exact-sequence replication +**When:** simulating an AppKit-internal path. **Steps:** replay its **real call +sequence**, pinned from a stack trace, not an approximation of its *effect*. +**Episode:** `bypassdelete` reproduces `shouldChangeText`→`replaceCharacters` +with no `didChangeText` **verbatim** — an approximation would have hidden the bug. + +### 3f. Differential measurement over eyeballing +**When:** any visual or binary claim. **Steps:** screencapture **pixel** +measurement for visuals (edmund-live-repro-and-diagnostics §5); `shasum` +before/after for "did the binary actually change"; predicted-vs-observed tables +for numbers. **Episode:** the maintainer's rule — when told "balance the padding", +measure the top vs bottom pad in pixels, don't judge by eye. + +--- + +## 4. The idea lifecycle + +How an idea moves from hunch to settled (or to a documented dead end): + +``` +hunch + → cheap instrumentation / discriminating experiment (§3b, §3c) + → investigation-doc entry (docs/-investigation.md, round structure) + → frozen deterministic repro + → fix on its own fix/ branch, with test + soak (edmund-validation-and-qa) + → ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed) + → settled status in the chronicle (edmund-failure-archaeology) +``` + +**Retirement path for ideas that fail:** a documented dead end in the +investigation doc with a "do not retry" note and *why* (see the round chronicle +and the viewport doc's "Verification limits (honest gaps)" section — mitigations +that are real but **unconfirmed live** are labeled so, not oversold). + +**Where larger ideas are tracked:** `docs/ROADMAP.md` (versioned feature plan) +vs `misc/backlog.md` (near-term working list; priority order **Marketing = Bugs +>= UI/UX > Features**). A frontier idea graduates onto one of these when its +first milestone lands (edmund-research-frontier). + +--- + +## 5. Where good ideas historically came from + +Mine these seams first — they've paid out repeatedly: + +- **Making failures observable.** The diagnostics accumulated one breadcrumb at a + time; each named a mechanism the previous round guessed at. +- **Reading Apple's *actual* behavior, not the documented ideal.** Sparkle's + update validation is non-strict (`SecStaticCodeCheckValidityWithErrors`) — + discovered by testing the real API, which unblocked the whole update pipeline. + `NSWindow.sendEvent` as the pre-toolbar funnel solved the toolbar right-click + after `menu`/`rightMouseDown`/gestures all failed. +- **Prior art consulted before inventing.** `nodes-app/swift-markdown-engine` + (ARCHITECTURE §14) solves the same TK2 live-preview problems with different + trade-offs — a comparison point and technique source. MarkEdit/CotEditor as + product references (ROADMAP). +- **Field reports with logs + movs.** `misc/bug-repros/` holds the maintainer's + screen recordings and trace logs — the round-4 drag-move mechanism came + straight out of one such trace. + +--- + +## 6. Anti-patterns (each with its historical cost) + +| Anti-pattern | Cost paid | +|---|---| +| Fix at symptom time | Patches the wrong instant — symptom armed minutes earlier (round 6) | +| Trust headless green for a live-input bug | Round-6 test passes with *and* without the fix | +| Stack speculative fixes | Rounds 1–5 each "fixed" it; each came back | +| Skip document reconstruction | Geometry/block-kind-dependent bugs don't fire on `"hello world"` | +| Trust a possibly-stale binary | SwiftPM prints `Build complete!` without relinking → wrong conclusions | +| Declare victory without a soak | One clean repro misses armed-state / degradation bugs | + +--- + +## When NOT to use this skill + +- Actually running the caret investigation step by step → + **edmund-caret-integrity-campaign**. +- The repro drivers and trace decoding → **edmund-live-repro-and-diagnostics**. +- What evidence a change type requires + the test suite → **edmund-validation-and-qa**. +- The settled history you're checking against → **edmund-failure-archaeology**. +- Picking the next big problem → **edmund-research-frontier**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. + +```bash +grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md +grep -n '321\|290' docs/investigations/delete-drift-investigation.md # the predicted numbers (§2) +grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/ +ls misc/bug-repros/ # field-evidence seam (§5) +``` + +The methods are stable; the *episodes* they cite are the drift risk — if a new +round rewrites the delete-drift narrative, refresh the worked examples here. diff --git a/.agents/skills/edmund-validation-and-qa/SKILL.md b/.agents/skills/edmund-validation-and-qa/SKILL.md new file mode 100644 index 0000000..b888219 --- /dev/null +++ b/.agents/skills/edmund-validation-and-qa/SKILL.md @@ -0,0 +1,208 @@ +--- +name: edmund-validation-and-qa +description: > + What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. + Load when writing or adjusting tests, deciding what proof a fix needs before + it can ship, judging whether a change is actually "verified", interpreting + the test suite, or adding a golden/regression check. Covers the evidence + hierarchy (headless is necessary but not sufficient for live-input bugs), + the test-suite map, the Swift Testing helpers, how to add a test, the golden + inventory, visual QA, and performance evidence. Not the gating rules + themselves (edmund-change-control) or how to run the live repro + (edmund-live-repro-and-diagnostics). +--- + +# Edmund validation & QA + +The discipline of proof. The central lesson: **a green headless test is +necessary but not sufficient** for the bug classes that cost the most time here. +Know which evidence each change actually requires. + +Framework: **Swift Testing** (`import Testing`, `@Test`, `#expect`, `#require`) +— NOT XCTest. Verified 2026-07-05: 810 `@Test` cases across `Tests/EdmundTests/`. + +--- + +## 1. The evidence hierarchy + +Ordered weakest → strongest. The rule at the bottom names which class each +change type **requires**. + +| Class | What it proves | Blind spot | +|---|---|---| +| (a) Headless unit test (`makeEditor()`) | model/parse/style/undo logic | **Cannot** exercise deferred AppKit machinery — runs it synchronously | +| (b) Windowed unit test (real NSWindow + NSScrollView, real `deleteBackward`) | + layout, viewport, first responder | still not real event-loop pacing / IME / drag | +| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run | +| (d) Soak script green across 4–5 cycles, byte-identical final `rawLen` | armed-state + determinism | slow | +| (e) Screencapture pixel measurement | anything that DRAWS | manual | + +**The trap that defines this skill:** the delete-drift **round-6 regression test +passes with AND without the fix** — the harness runs the queued selection fixup +synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing +bugs, class (a) is not evidence of a fix; you need (c)+(d). + +**Required evidence by change type** (gates enforced in edmund-change-control): + +| Change | Requires | +|---|---| +| Pure logic (parse/style/model) | (a) | +| Viewport/lazy-layout | (b), often (e) | +| Anything that draws | (a where testable) + **(e)** in light AND dark | +| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract **+ (c)** frozen repro **+ (d)** soak | +| Release | see edmund-release-and-operate | + +--- + +## 2. Test-suite anatomy + +Run: `swift test` (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 `@Test` +cases as of 2026-07-05). One suite: `swift test --filter `. **`swift test` +also runs automatically as a Stop hook** (`.Codex/settings.json`) at the end of +any turn touching code, so failures surface before you commit. + +Map of `Tests/EdmundTests/` (what the files actually cover): + +- **Parsing:** `BlockParserTests`, `SyntaxHighlighterTests`, `IncrementalParseFuzzTests`, `PendingEditTests`, `EscapeRenderingTests`, `HTMLTagRenderingTests`, `EmojiRenderingTests`, `LineEndingTests`. +- **Rendering / styling:** `BlockStylingTests`, `CalloutRenderingTests`, `CalloutTests`, `CommentRenderingTests`, `NestedBlockStylingTests`, `InlineStylingTests`, `MathRenderingTests`, `ImageRenderingTests`, `CodeHighlighterTests`, `FootnoteTests`, `WikiLinkTests`, `TableAlignmentTests`, `RenderingRegressionTests`. +- **Editor behavior:** `EditorIndentationTests`, `ListContinuationTests`, `BlockquoteContinuationTests`, `BlockquoteDeletionTests`, `FormattingTests`, `ActiveBulletMarkerTests`, `NewListItemAlignmentTests`. +- **Edit-pipeline integrity (the costly class):** `BypassedEditSyncTests`, `MarkedTextDesyncTests`, `WrappedParagraphCaretTests`, `EditorDiagnosticsTests`, `UnmatchedDebugTests`, `InternationalInputTests`. +- **Viewport / layout / undo:** `LazyRenderingTests`, `ScrollStabilityTests`, `HeightStabilityTests`, `TypewriterCenteringTests`, `UndoRedoViewportTests`, `EditorUndoTests`, `RecomposeTests`, `RecomposeEquivalenceTests`. +- **Export / read mode:** `HTMLRendererTests`, `DocumentHTMLTests`, `ReadModeWebViewTests`, `HTMLThemeTests`, `EditorThemeTests`, `ViewModeTests`, `ContentWidthTests`. +- **Infra / harness:** `LogTests`, `CrashReporterTests`, `PerfHarnessTests`, `StatusBarPrefsTests`, `FileIntegrationTests`, `EditorDocumentTests`, `TestHelpers.swift`. `_RenderDump.swift` / `_RenderEdit.swift` are **local dev tools (gitignored intent)** that dump Read-mode HTML / edit output for `tmp/sample.md` — not part of the assertion suite. + +**Two invariant-guarding patterns worth copying:** +- **Recompose equivalence** (`RecomposeEquivalenceTests`, helper + `assertMatchesFullRecomposeOracle`): an incremental restyle must produce the + **same** result as a full recompose. This is the safety net for the lazy / + incremental styling paths. +- **Incremental-parse fuzz** (`IncrementalParseFuzzTests`): random edits must + keep the parser's window-reparse consistent with a full reparse. + +--- + +## 3. Test helpers (`TestHelpers.swift`) + +`@MainActor` helpers you build on (verified exports): + +| Helper | Use | +|---|---| +| `makeEditor()` | an `EditorTextView` on the real TK2 chain (mirrors `Document.makeWindowControllers`) | +| `ensureFullLayout(...)` | force layout so geometry is real, not estimated | +| `type(...)`, `paste(...)`, `pressEnter()`, `pressBackspace()` | drive edits | +| `activateBlock(...)` | move the caret / active block | +| `displayText(...)`, `attrs(...)`, `font(...)`, `fgColor(...)` | inspect styled output | +| `isHidden` / `isInvisible` / `isDimmed` | assert delimiter hiding | +| `blockDecoration(...)`, `textBlockDifference(...)` | inspect decorations / catch TK1-reverting table attrs | +| `expectedFullComposition(...)`, `drainAllStyling()`, `assertMatchesFullRecomposeOracle(...)` | equivalence-oracle checks | +| `makeLargeMarkdown(...)`, `sentence(...)` | build big fixtures for perf/viewport | + +`styleBlock(_:cursorPosition:)` is a method on `EditorTextView` +(`Rendering/EditorTextView+Rendering.swift`), called from tests — it renders one +block to an attributed string. + +--- + +## 4. How to add a test + +Skeleton (Swift Testing, `@MainActor` because the editor is main-thread): + +```swift +import Testing +import AppKit +@testable import EdmundCore + +@MainActor +@Test func deletingAtCalloutBottomKeepsInvariant() { + let editor = makeEditor() + editor.loadRawSource("> [!note]\n> body\n") + drainAllStyling() + // ... drive the edit ... + #expect(editor.rawSource == editor.string) // storage == rawSource invariant +} +``` + +Rules: +1. **Every bug fix ships with a test even if it can't discriminate a live-only + mechanism** — it still locks the headless contract so a *future* refactor + can't silently re-break the model half. For the live half, keep the frozen + `.repro` script alongside (edmund-live-repro-and-diagnostics). +2. Assert the **invariant** where you can (`rawSource == string`), not just the + surface symptom — that's what `BypassedEditSyncTests` / `MarkedTextDesyncTests` + do. +3. Name the test for the behavior/bug, put edit-pipeline repros next to their + siblings (the `*Desync*` / `*Bypassed*` / `*CaretTests` families). +4. New drawing behavior additionally needs a screencapture check (§6). + +--- + +## 5. Golden / certified inventory + +- **`RenderingRegressionTests`** + `RecomposeEquivalenceTests` are the closest + thing to golden checks — they pin styled output and incremental-vs-full + equivalence. +- **`test-files/*.md`** (`callout.md`, `decorations.md`, `math.md`, `menu.md`, + `test.md`) are the **user's manual test corpus** — hand-testing fodder. **Do + not rewrite them to fit an automated test**; build your own fixtures (helpers + in §3, or a scratch dir). +- **`misc/bug-repros/`** holds field evidence (`.mov` screen recordings + `.log` + traces) for open bugs — reference these when reproducing, don't delete them. + +--- + +## 6. Visual QA + +Anything that draws is verified by **screencapture pixel measurement**, in +**light AND dark mode** (per `misc/before-you-release.md`), never by eyeballing +headless layout. Method + scripts: **edmund-live-repro-and-diagnostics** §5. +Headless layout tests (`HeightStabilityTests`, `ScrollStabilityTests`) check +geometry numbers but **cannot** confirm the pixels are right. + +--- + +## 7. Flakiness & CI + +- A `fix/flaky-math-test` branch exists in history — math rendering has shown + timing flakiness; if a math test flakes, check that branch's approach before + inventing a new one (verify: `git log --oneline --all -- '*Math*'`). +- CI: `.github/workflows/ci.yml` on `macos-14`, latest-stable Xcode, SPM cache + keyed on `Package.resolved`, `concurrency: cancel-in-progress` (private-repo + macOS minutes bill 10×). CI runs the same `swift test`. + +--- + +## 8. Performance evidence + +- **`PerfHarnessTests`** measures the hot paths; `makeLargeMarkdown` builds big + fixtures. Use it (don't hand-time) for any perf claim. +- The README claim "handles ~1–2MB files" and `fullLayoutMaxLength = 100_000` + UTF-16 (`EditorTextView.swift:80`) mark the boundary between the **full-layout** + regime (≤100k, geometry is real) and the **estimate** regime (>100k, viewport + glitches possible). A perf/viewport claim must state which regime it was + measured in. + +--- + +## When NOT to use this skill + +- The gate/branch/commit rules → **edmund-change-control**. +- Running the live repro or measuring pixels → **edmund-live-repro-and-diagnostics**. +- The caret-integrity investigation end to end → **edmund-caret-integrity-campaign**. +- Why a mechanism works → **textkit2-appkit-reference** / **edmund-architecture-contract**. +- Release verification → **edmund-release-and-operate**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. + +```bash +grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases +grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest +grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3) +ls Tests/EdmundTests/ # suite map (§2) +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +``` + +If a helper in §3 no longer greps it was renamed; update §3 and any test +skeletons. Re-derive the suite map from `ls` if files are added/removed. diff --git a/.agents/skills/textkit2-appkit-reference/SKILL.md b/.agents/skills/textkit2-appkit-reference/SKILL.md new file mode 100644 index 0000000..8c6908a --- /dev/null +++ b/.agents/skills/textkit2-appkit-reference/SKILL.md @@ -0,0 +1,279 @@ +--- +name: textkit2-appkit-reference +description: > + Domain-theory pack for the AppKit text system as it applies to the Edmund + Markdown editor. Load when working on layout, selection, IME, undo, drag, or + eventing behavior; when TextKit 2 or NSTextView does something surprising; + or when terms like NSTextLayoutManager, layout fragment, marked text, queued + selection fixup, responder chain, or sendEvent appear and you lack AppKit + text-system background. Explains the mechanisms the invariants and gotchas + are built on. Not the invariants themselves (edmund-architecture-contract), + not a triage table (edmund-debugging-playbook), not the repro drivers + (edmund-live-repro-and-diagnostics). +--- + +# TextKit 2 / AppKit reference (as it applies to Edmund) + +The background a mid-level engineer or Sonnet-class model usually lacks. Each +concept: brief theory, then **where it bites in Edmund** with a verified file +pointer. This is not a textbook — it is only the parts that matter here. + +Facts checked against source 2026-07-05. Items labeled *(background)* are +general AppKit/TextKit behavior grounded in Apple's documentation, not directly +grep-able in this repo. + +--- + +## 1. The TextKit 2 object model *(background + repo)* + +TextKit 2 replaced the TextKit 1 `NSLayoutManager` stack. The players: + +- **`NSTextContentStorage`** — owns the backing string + attributes (the model). +- **`NSTextLayoutManager`** — lays text out (the TK2 analogue of the old layout manager). +- **`NSTextLayoutFragment`** — one laid-out chunk (≈ a paragraph); has a real + geometric frame **only once laid out**. +- **`NSTextElement` / `NSTextParagraph`** — the model-side elements fragments render. + +**Viewport-based layout** is the headline difference: TK2 lays out only the +content near the visible viewport, not the whole document. That is what makes +big documents fast — and it is the root of most viewport pain (§2). + +**Where it bites:** Edmund subclasses the fragment as `DecoratedTextLayoutFragment` +(`EditorTextView+TextKit2.swift`) to draw callout boxes, bars, and overlays. + +--- + +## 2. Height ESTIMATES — the master cause of viewport glitches + +A fragment that has not been laid out yet has an **estimated** height, not a +real one; the total document height is the sum of real + estimated fragment +heights. As layout reaches a fragment, its estimate is replaced by the true +value and everything below shifts. Consequences: the scroller thumb jumps, and +"scroll to offset Y" lands wrong because Y was computed from estimates. This is +a **widely documented TK2 limitation — even TextEdit shows it** *(background)*. + +**Where it bites / Edmund mitigations** (verify names by grep; all in `TextView/`): +- `fullLayoutMaxLength = 100_000` (`EditorTextView.swift:80`): documents ≤ 100k + UTF-16 units are kept **fully laid out** (no estimate regime) by a coalesced + next-run-loop settle. +- `scheduleFullLayoutSettle` / `preservingViewportAnchor`: the settle runs inside + an anchor block so corrections never shift what is on screen. +- `repairContentAboveOrigin` (`+LazyStyling.swift`, logs `repairing content + above origin`): fixes the case where an edit near the top strands the first + fragment at negative y (unreachable above the scroller top). +- `centerViewportOnCaret`: re-measures after its first scroll and corrects the + residual estimate error. + +**Rule:** never trust an off-screen fragment's y-coordinate without laying out +its span first. Deep write-up: `docs/investigations/viewport-glitch-investigation.md`. + +--- + +## 3. The TextKit 1 fallback trap + +An `NSTextView` can silently and **permanently** revert from TK2 to the legacy +TK1 stack. Two known triggers: **accessing `NSTextView.layoutManager`** (the +mere getter engages TK1), and **storing `NSTextBlock`/`NSTextTable` +attributes**. Once reverted, TK2 APIs still exist but do nothing useful, and the +whole editor misbehaves subtly. + +**Where it bites:** Edmund ships a **DEBUG tripwire** — an observer on +`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the switch +happens (`EditorTextView.swift:273`+, message "TextKit 1 fallback triggered"). +Never add code that reads `layoutManager` or stores table attributes; draw +tables as decorations instead (`EditorTextView+TableRendering.swift`). + +--- + +## 4. Attribute-only mutation semantics + +Edmund renders by writing **attributes** onto the storage, never by inserting or +deleting characters (the storage == rawSource invariant). Two consequences from +the text system: + +- **`setAttributes` does not re-measure geometry.** After a restyle that changes + a block's height or indent, you must call `invalidateLayout(for:)` on its + range or the fragment keeps a **stale frame** (empty bands / clipped lines). + `recomposeDirty` and the idle drain already do this; new paths must too. +- **`NSTextAttachment` is only honored on the `U+FFFC` object-replacement + character** *(background)*. `rawSource` never contains `U+FFFC`, so attachments + can't be used — Edmund draws images/icons as **overlays** (§5) instead. + +--- + +## 5. The custom drawing model (fragments, decorations, overlays) + +`DecoratedTextLayoutFragment` draws two attribute families behind/over text: + +- **`.blockDecoration`** (paragraph-level): callout boxes, quote bars, table + borders, thematic-break rules, code backgrounds. Fragments **tile vertically**, + so a multi-line run renders as one continuous box. A box's `bottomPad` grows + the **last** fragment's frame (TK2 omits trailing paragraph spacing from the + fragment, so padding done otherwise would be dead space). +- **`.fragmentOverlay`** (character-level): an image **or** a stroked vector path + drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, + callout header icon, the custom-title callout icon (path). The anchor glyph is + **hidden** (≈0.01 pt font + clear color) and `.kern` reserves the drawing's + advance width. + +**The image-on-wrapping-fragment wedge:** drawing an **image** overlay on a +**multi-line (wrapping)** fragment re-triggers a layout pass that collapses the +fragment to one line. Drawing a **shape/path** does not. So the wrapping +callout title's icon is a stroked `CGPath` (parsed by `SVGPath` from vendored +Lucide geometry), never an image. Full saga: +`docs/investigations/archives/callout-title-wrap-investigation.md`. This constraint holds for **any new +overlay** that could share a line with wrapping text. + +**Hiding text** = `hiddenFont` (≈0.01 pt) + clear `foregroundColor`. This is how +delimiters (`**`, `` ` ``, `[!note]`) vanish without changing the string. + +--- + +## 6. The queued selection fixup (the round-6 delete-drift mechanism) + +When you mutate an `NSTextView`'s storage, AppKit queues a private step, +`-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:]`, that repairs +the selection against the new character coordinates. Normally it fires promptly. +But if an edit **bypasses the normal close-out** (see §7), the fixup stays +**queued** and fires at the **next `endEditing`** — *even an attribute-only +restyle* — where it maps the now-**stale** selection against post-edit +coordinates and **leaps the caret blocks away**. It will move even a freshly set, +valid caret. + +**Where it bites:** this is delete-drift round 6. The heal must set the caret +(from the pendingEdit hull) **before** the sync **and re-assert it after** +(`EditorTextView+EditFlow.swift`). Recognize a variant by: a suspicious +selection change arriving mid-recompose (`up=Y` in traces); +`traceSelectionOrigin` will log the call stack of whoever moved it. + +**Critical for testing:** a headless test harness runs this deferred fixup +**synchronously**, so this bug class **cannot reproduce in a unit test** — the +round-6 regression test passes with *and* without the fix. Only the live +in-process repro discriminates (edmund-live-repro-and-diagnostics). + +--- + +## 7. The AppKit edit pipeline contract (and where AppKit breaks it) + +Normal edit: `shouldChangeText(in:replacementString:)` → the view calls +`replaceCharacters` → `didChangeText()`. Edmund's `didChangeText` syncs +`rawSource` from storage and restyles the edited block(s). + +**AppKit does NOT always send `didChangeText`.** A drag-move of selected text +whose drop lands on **no valid target** (e.g. released past the end of the +document) deletes the dragged range via `shouldChangeText` → `replaceCharacters` +and **never calls `didChangeText`** — silently freezing `rawSource`/`blocks`, +after which every edit drifts and autosave writes stale content (delete-drift +round 4). Edmund heals this: `shouldChangeText` schedules a next-run-loop +**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`); an +unconsumed storage `pendingEdit` by then means the close-out never came, and the +editor runs the sync itself (breadcrumb: `healing storage edit that bypassed +didChangeText`). + +**Never build a sync path on the assumption that `didChangeText` follows every +edit.** + +**The authentic key route** *(background + repo)*: keyDown → +`interpretKeyEvents` → `insertText:` / `deleteBackward:`. This is why the repro +driver synthesizes real `NSEvent`s and pushes them through `window.sendEvent(_:)` +rather than calling `insertText` directly — shortcuts skip `deleteBackward`'s +selection machinery, which is exactly where round 6 lived. + +--- + +## 8. IME / marked text lifecycle + +While an input method is composing (e.g. CJK, accents), the view holds +**provisional "marked" text** in storage; `hasMarkedText()` is true. During this +window `storage == rawSource` is **transiently false**, and `didChangeText` +defers syncing until the composition commits. + +**The cascade:** any styling path that runs +`beginEditing`/`setAttributes`/`invalidateLayout` **mid-composition** can strand +the marked text in the input context. After that, `didChangeText` keeps bailing +on its own guard and the invariant stays broken — so **every later edit drifts +the caret** (the original delete-drift bug). Therefore **every storage-touching +styling path must guard `!hasMarkedText()`** — including async ones scheduled +*before* composition began (the caret-move restyle in `+SelectionTracking`). +`becomeFirstResponder` resyncs from storage as a catch-all. Full write-up: +`docs/investigations/delete-drift-investigation.md`. + +--- + +## 9. Responder chain & nil-target actions *(background + repo)* + +The **responder chain** is AppKit's search order for who handles an action. +Menu items and toolbar buttons with a **nil target** send their action up the +chain until something responds. Edmund's Format menu (`FormatMenu.swift`) is a +declarative command table whose items use nil targets and route to the focused +`EditorTextView`'s `@objc format…` actions — the same wiring as undo/redo. The +first responder is normally the focused `EditorTextView`. + +--- + +## 10. `NSWindow.sendEvent` — the pre-toolbar event funnel + +Every event a window receives passes through `sendEvent(_:)` **before** the +toolbar acts. This matters because with `NSToolbar.allowsUserCustomization = +true`, the toolbar claims **any secondary (right/control) click over the +toolbar** — including a custom item view — for its own "Customize Toolbar…" menu, +downstream of view-level handlers (`menu`, `rightMouseDown`, gesture +recognizers all lose). Edmund's fix for the view-mode button: intercept in +`DocumentWindow.sendEvent(_:)`, pop the menu when the click is inside the +button's bounds, and swallow it (`return`); other clicks fall through to +`super`. (Caveat: true fullscreen moves the toolbar to a separate window, so +this main-window hook wouldn't cover it.) + +--- + +## 11. Drag sessions *(background + repo)* + +- **Text drag-move arming:** AppKit only starts a text drag after a **mouse-down + hold (~400 ms)**; a CGEvent driver must hold before moving or the drag never + arms. +- **Drag-select autoscroll:** dragging past the viewport edge autoscrolls. +- **Reveal at nearest end:** a selection taller than the viewport must be + revealed at its **nearest** end (Edmund's `scrollRangeToVisible` override) — + always revealing the top fought the drag-select autoscroll and oscillated the + viewport mid-drag. + +--- + +## 12. swift-markdown walker model *(brief)* + +Edmund parses with `apple/swift-markdown` (CommonMark/GFM) and walks the +resulting `Document` with **two back-ends**: a `SpanCollector`-style walk that +produces editor attributes, and `HTMLRenderer` that produces HTML for Read mode. +One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark) +syntax — callouts, `==highlight==`, wikilinks, comments, footnotes, math — is +handled by `SyntaxHighlighter+CustomParsers.swift`. + +--- + +## When NOT to use this skill + +- The project's **rules/invariants** (what you must not do) → **edmund-architecture-contract**. +- **Which** symptom means which mechanism → **edmund-debugging-playbook**. +- **Reproducing** a live bug / reading traces → **edmund-live-repro-and-diagnostics**. +- The **history** of how these mechanisms were discovered → **edmund-failure-archaeology**. +- Running the caret-integrity campaign → **edmund-caret-integrity-campaign**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. Re-verify the load-bearing identifiers: + +```bash +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/ +grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/ +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +``` + +Items marked *(background)* are AppKit/TextKit behavioral facts documented by +Apple and in `docs/*-investigation.md`, not directly observable by grep. If any +Edmund mitigation name above no longer greps, it was renamed — update this file +and `docs/ARCHITECTURE.md` §5/§8 together. diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 0000000..e354497 --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,48 @@ +--- +description: Branch, test, commit, push, open a PR, and enable auto-merge for the current working-tree changes +argument-hint: [short description of the change] +allowed-tools: Bash, Read, Edit +--- + +Ship the current uncommitted changes in this repo as a self-merging PR. The +change is described as: **$ARGUMENTS** + +Run all git/gh commands from the repository root. Follow these steps exactly, +stopping and reporting if any step fails: + +1. **Survey.** `git status --short` and `git diff --stat`. Confirm there are + changes to ship. If `$ARGUMENTS` is empty, infer a concise description from + the diff. + +2. **Test first (project rule).** Run `swift test`. If anything fails, stop and + show the failure — do not commit. + +3. **Branch.** If currently on `main`, create a topic branch named + `fix/…`, `feature/…`, `ci/…`, or `chore/…` as fits the change (kebab-case, + derived from the description). If already on a non-main branch, reuse it. + +4. **Commit.** Stage only the files that belong to this change (surgical — no + unrelated files). Write a commit message: a concise imperative subject line, + a body explaining the *why*, and the trailer: + ``` + Co-Authored-By: Claude Opus 4.8 + ``` + Keep it one logical change per commit. + +5. **Push.** `git push -u origin `. + +6. **Open the PR.** `gh pr create` with a title matching the subject and a body + that explains what and why, ending with: + ``` + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + ``` + +7. **Auto-merge.** `gh pr merge <#> --auto --merge --delete-branch`. Branch + protection requires the `test` check, so this queues the PR to merge itself + the moment CI passes — no manual merge needed. + +8. **Report.** Print the PR URL and state that it will merge automatically when + CI is green. Do **not** sit and poll CI unless asked. + +Never force-push, never touch `main` directly, and never bypass the failing-test +stop in step 2. diff --git a/.claude/skills/edmund-architecture-contract/SKILL.md b/.claude/skills/edmund-architecture-contract/SKILL.md new file mode 100644 index 0000000..50a798a --- /dev/null +++ b/.claude/skills/edmund-architecture-contract/SKILL.md @@ -0,0 +1,365 @@ +--- +name: edmund-architecture-contract +description: > + The load-bearing design contract of the Edmund Markdown editor. Load BEFORE + any non-trivial code change in this repo; when asking "why is it built this + way"; before proposing a new mechanism, subsystem, or refactor; whenever you + are tempted to insert/strip display characters, use NSTextAttachment, touch + NSTextView.layoutManager, store NSTextBlock/NSTextTable attributes, or add a + new overlay/decoration; and before designing anything that syncs storage, + selection, undo, or the viewport. Covers the two hard invariants + (storage == rawSource; TextKit 2 only), the render pipeline, edit/undo flow, + the TextKit 2 drawing model, the read-mode contract, and the known weak + points. Not for build/run/release mechanics, debugging triage, or live-repro + drivers — see "When NOT to use this skill". +--- + +# Edmund architecture contract + +Edmund is a native macOS Markdown editor with live preview: AppKit + +TextKit 2, SwiftPM, macOS 14+. Two targets (`Package.swift`): + +| Target | Role | +| --- | --- | +| `EdmundCore` | Library: parsing, rendering, `EditorTextView`, all tests. Most work happens here. | +| `edmd` | Executable: `NSDocument` app shell, Settings (SwiftUI), menus. Note: `edmd` is the Mach-O binary name; the app is "Edmund". | + +Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of +Markdown editors". Bias toward polish of the editing experience over feature +count. The hardest live problem class to date is **delete-drift** +(caret/selection integrity, 6 investigation rounds); the costliest failures +were delete-drift and undo/redo viewport drift. Every rule below traces to one +of those scars. + +Ground truth this file distills: `docs/ARCHITECTURE.md` (repo root). +Treat that doc as authoritative if the two ever disagree, and fix this skill. + +## Glossary (each term defined once) + +- **rawSource** — the document's Markdown text, the single source of truth + (`EditorTextView.rawSource`). +- **storage** — the `NSTextStorage` the text view displays + (`EditorTextStorage`, `Sources/EdmundCore/TextView/EditorTextStorage.swift`). +- **Block** — one logical Markdown block (paragraph, heading, list run, code + fence, table, quote/callout run). Model: `Sources/EdmundCore/Model/Block.swift`. +- **Active block** — the block under the caret; it renders its *raw* markdown + (delimiters visible/editable) while all others render styled. +- **Recompose** — restyling storage from `blocks` + (`Sources/EdmundCore/TextView/EditorTextView+Composition.swift`). +- **Fragment** — an `NSTextLayoutFragment`, TextKit 2's per-paragraph layout + unit. Off-screen fragments have *estimated* heights until laid out. +- **Overlay** — an image or stroked path drawn at a character's laid-out + position by the custom fragment class (see §4), replacing what + `NSTextAttachment` would do in TextKit 1. +- **Delete-drift** — the bug class where `rawSource`/storage/selection desync + and every later edit lands the caret in the wrong place. Chronicle: + `docs/investigations/delete-drift-investigation.md`. +- **IME composition** — an input method's provisional "marked text" + (`hasMarkedText()`), present in storage before the user commits it. + +--- + +## 1. The two non-negotiable invariants + +Break either and the editor misbehaves in subtle, delayed ways. Every design +review starts here. + +### Invariant 1 — storage == rawSource (attribute-only rendering) + +The displayed text storage is *always* character-identical to `rawSource`. +Rendering only ever adds/changes **attributes**. Delimiters (`**`, `` ` ``, +`[!note]`, …) are **hidden, never stripped**: `hiddenFont` (0.01pt system +font, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55`) plus a +clear `foregroundColor` makes them invisible without touching the string. + +**Rationale.** Identity mapping between display offsets and raw offsets means +there is no offset-translation layer — caret math, selection, undo diffs, +incremental reparse, and autosave all operate on one coordinate system. + +**Consequences you must respect:** + +| Consequence | Why | +| --- | --- | +| No `NSTextAttachment`, ever | TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which `rawSource` never contains. Images, math, bullets, checkboxes, icons are drawn as **overlays** instead (§4). | +| No inserted display characters | A synthesized `
      `, bullet glyph, or padding character would desync offsets. Use attributes (`.kern`, paragraph styles) or overlays. | +| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is *transiently* false and `didChangeText` defers syncing. Styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard `!hasMarkedText()`. | + +**The incident.** The original delete-drift bug: an async restyle fired during +IME composition, stranded the marked text, `didChangeText` kept bailing on its +own guard, and the invariant stayed silently broken — caret drift on every +subsequent edit. `becomeFirstResponder` now resyncs from storage as a +catch-all. Full write-up: `docs/investigations/delete-drift-investigation.md`. + +### Invariant 2 — TextKit 2 only + +Never touch `NSTextView.layoutManager` (the TextKit 1 `NSLayoutManager` +accessor) and never store `NSTextBlock`/`NSTextTable` attributes. Either one +**silently and permanently reverts the view to TextKit 1** — no error, no log, +just different (and wrong-for-us) layout from then on. + +**Rationale.** All custom drawing rides `NSTextLayoutFragment` subclassing +(§4), and viewport-based layout (only on-screen content laid out) is what +makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback +kills them. + +**The tripwire.** DEBUG builds observe +`NSTextView.willSwitchToNSLayoutManagerNotification` and `assertionFailure` +if the fallback ever triggers — +`textKit1FallbackTripwire(_:)`, +`Sources/EdmundCore/TextView/EditorTextView.swift:272-303`. If you see that +assertion, some code path you touched used a TK1 API or attribute. Find it; +do not suppress the assert. + +Corollary: Edit-mode tables cannot use `NSTextTable`. Alignment is done by +distributing slack via `.kern` on hidden pipe glyphs — +`Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift`. + +--- + +## 2. Render pipeline + +``` +rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage + │ + └─ SyntaxHighlighter (swift-markdown walker + + custom parsers: callouts, ==highlight==, + wikilinks, comments, footnotes, math, + backslash escapes, inline HTML tags) +``` + +| Stage | Where | +| --- | --- | +| Block splitting | `Sources/EdmundCore/Parsing/BlockParser.swift` — `parse(_:previous:)`, `parseWithDiff(...)` | +| Span production | `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift` + `+Walker.swift` / `+WalkerInline.swift` / `+CustomParsers.swift` | +| One-block render | `styleBlock(_:cursorPosition:...)`, `Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151`; per-feature extensions in `Sources/EdmundCore/Rendering/` (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) | +| Orchestration | `Sources/EdmundCore/TextView/EditorTextView+Composition.swift` | + +Recompose entry points (pick the narrowest that works — a full `recompose` +resets every fragment height to an estimate, see §6): + +| Function | Scope | Used for | +| --- | --- | --- | +| `recompose(cursorInRaw:)` | Whole document | Load, indent — never for undo (§3) | +| `recomposeDirty(_:cursorInRaw:)` | A set of block indices, in place | The workhorse; attribute-only | +| `recomposeIncremental(cursorInRaw:...)` | The block(s) the caret moved between | Most cursor moves | +| `recomposeReplacing(oldRange:with:...)` | One contiguous text span | Undo/redo restore | + +**Lazy styling** (`Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift`): +a large dirty set styles only the viewport synchronously; the rest is finished +by the **idle drain** (time-budgeted main-thread slices) and **scroll +promotion** (style blocks as they enter the viewport). + +Gotcha: attribute-only changes do **not** re-measure geometry in TextKit 2. +If a restyle changed a block's height/indent, call `invalidateLayout(for:)` +on its range or the fragment keeps a stale frame. `recomposeDirty` and the +idle drain already do this; any new path must too. + +--- + +## 3. Edit flow & undo + +Normal edit: `shouldChangeText` (records a coalesced undo snapshot) → +NSTextView mutates storage → `didChangeText` syncs `rawSource` and restyles +the edited block(s) (`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`, +`+Composition.swift`). Edits capture a `pendingEdit` on `EditorTextStorage` +and reparse a window, not the whole document. + +**Undo/redo is custom** (`Sources/EdmundCore/TextView/EditorTextView+Undo.swift`): +stacks of `rawSource` snapshots, bypassing NSTextView's built-in undo. +Restoring diffs the snapshot against current text (`textDiff(old:new:)`, +single contiguous span) and applies it with the range-bounded +`recomposeReplacing` — **never a full `recompose`**, because a full recompose +resets every fragment to a TextKit 2 height estimate and the follow-up scroll +lands wrong (this was the undo/redo viewport-drift failure). The changed text +drives the viewport: hold if any of it is on-screen, else center it. + +**AppKit does NOT pair every storage mutation with `didChangeText`.** Proven +incident (delete-drift round 4): a drag-move of selected text dropped on no +valid target deletes the dragged range via `shouldChangeText` → +`replaceCharacters` and never calls `didChangeText` — silently freezing +`rawSource`/`blocks`; every later edit drifts the caret and autosave writes +stale text. The heal: `shouldChangeText` schedules a next-run-loop +**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`) — a +`pendingEdit` still unconsumed by then means the closing `didChangeText` +never came, and the editor runs the same sync itself. Breadcrumb in +`~/.edmund/logs`: `healing storage edit that bypassed didChangeText`. +**Never build a sync path on the assumption that `didChangeText` follows +every edit.** + +Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection +fixup (`_fixSelectionAfterChangeInCharacterRange:`) queued; it fires at the +**next** `endEditing` — even an attribute-only restyle — and leaps the caret +blocks away, moving even a freshly set valid caret. The heal sets the caret +before the sync **and re-asserts it after** (`+EditFlow.swift`). This class +does not reproduce headless; see the routing in §8. + +--- + +## 4. TextKit 2 drawing model + +All custom visuals are drawn by `DecoratedTextLayoutFragment` (custom +`NSTextLayoutFragment`, +`Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160`), vended via +the layout-manager delegate. Two custom attribute keys (same file, lines +28/32): + +| Attribute | Level | Draws | Rules | +| --- | --- | --- | --- | +| `.blockDecoration` | Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments **tile vertically** so a multi-line run reads as one continuous box/bar. A box's `bottomPad` grows the **last** fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. | +| `.fragmentOverlay` | Character | An image **or stroked vector path** at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (`hiddenFont` + clear color) and `.kern` reserves the drawing's advance width — the same trick the table renderer uses. | + +**The image-wedge constraint (open, not solved).** Drawing an *image* overlay +on a *multi-line (wrapping)* fragment re-triggers a layout pass that wedges +the fragment to one line. Drawing a *shape* (stroked `CGPath`) does not. +That is why the wrapping callout custom-title icon is a stroked path parsed +from vendored Lucide geometry, never an image. **Any new overlay that could +share a line with wrapping text must be a shape, not an image.** Full saga: +`docs/investigations/archives/callout-title-wrap-investigation.md`. + +--- + +## 5. Read mode contract + +Read mode is a separate `WKWebView`, not an editor styling mode +(`Sources/EdmundCore/Export/`). The contract: **one parser, two back-ends** — +the *same* swift-markdown `Document` the editor parses is walked by +`SyntaxHighlighter.SpanCollector` (→ editor attributes) and by `HTMLRenderer` +(→ HTML), themed from the *same* `EditorTheme` via `HTMLTheme`, so the two +renderings cannot drift. When adding a feature, implement it in **both** +back-ends or document the divergence. + +Hard properties of the web view (keep them): +- **JavaScript disabled**; every asset inlined (math as high-DPI PNG data + URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs + no file/network reach. Remote images off by default + (`Sources/EdmundCore/Export/ReadRenderOptions.swift`). +- **Private URL schemes** route navigation without JS: + `x-edmund-wiki:` / `x-edmund-link:` + (`Sources/EdmundCore/Export/HTMLRenderer.swift:26,31`). +- Inline HTML: only the whitelist `SyntaxHighlighter.htmlFormatTags` + (`u`/`kbd`/`mark`/`sub`/`sup`, + `Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22`) renders in either + mode; everything else stays escaped/color-only. A real `
      ` break would + need to mutate storage — forbidden by Invariant 1. +- Export/Print run the same HTML through `WKWebView.printOperation` + (`Sources/EdmundCore/Export/MarkdownPrinter.swift`) for vector text. + +--- + +## 6. Known weak points (open as of 2026-07-05) + +State these plainly when designing near them; none is solved. + +1. **TextKit 2 height estimates** are the root of most viewport glitches: an + off-screen fragment's frame (and the total document height) is an estimate + until layout reaches it — scroller jumps, scroll-to-target lands wrong (a + documented TK2 limitation; TextEdit shows it too). Mitigations in place, + not cures: documents ≤ `fullLayoutMaxLength` (100k UTF-16, + `Sources/EdmundCore/TextView/EditorTextView.swift:80`) are kept fully laid + out by `scheduleFullLayoutSettle()` wrapped in `preservingViewportAnchor` + (`+LazyStyling.swift:121`, `+TypewriterScroll.swift:22`); + `repairContentAboveOrigin()` (`+LazyStyling.swift:151`) fixes content + stranded above y=0; `centerViewportOnCaret` re-measures after its first + scroll. **Never trust an off-screen fragment's y-coordinate without laying + out the span first.** +2. **The image-wedge constraint** (§4) applies to every new overlay. +3. **Open bugs** (`misc/backlog.md`): callout at end-of-file renders an extra + un-prefixed line in the callout color (live incremental-restyle path, not + static rendering); footnotes don't render in either mode; attached images + create blank space below; math doesn't render in read mode (and has wrong + padding in edit mode); delete caret drift and viewport-estimate glitches + remain on the ongoing list. +4. **Crash reporter endpoint is a placeholder**: + `CrashReporter.reportingEndpoint` is `https://REPLACE-ME.invalid/crash` + (`Sources/EdmundCore/Diagnostics/CrashReporter.swift:27`) and the + Settings ▸ Advanced toggle is commented out + (`Sources/edmd/Settings/AdvancedSettingsView.swift`). Do not treat crash + uploading as live. + +--- + +## 7. Before you design something new — checklist + +Run this before proposing any new mechanism, subsystem, or refactor: + +- [ ] **Invariant 1**: does it insert/strip characters, use + `NSTextAttachment`, or mutate storage outside the + shouldChangeText→didChangeText path (or during IME composition)? If + yes, redesign as attributes/overlays. +- [ ] **Invariant 2**: does it touch `NSTextView.layoutManager` or store + `NSTextBlock`/`NSTextTable`? If yes, stop. +- [ ] **Sync assumptions**: does it assume `didChangeText` follows every + mutation, or that a set caret stays put across the next `endEditing`? + Both assumptions are proven false (§3). +- [ ] **Geometry**: does it read an off-screen fragment frame, or restyle + without `invalidateLayout(for:)` when height changed? (§2, §6.) +- [ ] **Both back-ends**: does a rendering feature cover Edit *and* Read + (§5)? +- [ ] **Prior art**: check ARCHITECTURE.md §14 — especially + [nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine), + an independent AppKit+TextKit 2 live-preview engine solving the same + problems — before inventing a new mechanism for an editing-experience + problem. +- [ ] **Weak points** (§6): does the design lean on anything listed there? + Label it as such; unproven mitigations are "open/candidate", never + "fixed". +- [ ] **Verification plan**: unit test if headless can repro; otherwise plan + a live repro (ReproScript) — do not ship a caret/IME/viewport fix on + reasoning alone. Visual claims are measured from `screencapture` + pixels, not eyeballed. + +Process rules live in sibling skills, but never contradict them here: branch +per fix off `main`; never auto-push/PR/merge; `swift test` green + visual +verification before commit; never blanket `pkill -x edmd` (the maintainer's +daily-driver app shares the binary name — `pgrep` and kill only your own +PID); never request macOS Computer Access permissions. + +--- + +## When NOT to use this skill + +| You need… | Use instead | +| --- | --- | +| Whether/how to gate a change, commit discipline, scope control | `edmund-change-control` | +| A symptom → cause triage path for a bug you're seeing | `edmund-debugging-playbook` | +| The blow-by-blow history of a past investigation | `edmund-failure-archaeology` | +| TextKit 2 / AppKit theory beyond Edmund's specific contract | `textkit2-appkit-reference` | +| Launch flags, debug bundles, defaults keys | `edmund-config-and-flags` | +| Build, stale-binary cures, screencapture mechanics, environment setup | `edmund-build-and-env` | +| Cutting a release, Sparkle/appcast/CI | `edmund-release-and-operate` | +| Driving a live repro (ReproScript, CGEvent, log tracing) | `edmund-live-repro-and-diagnostics` | +| Test-writing patterns, QA passes | `edmund-validation-and-qa` | +| Docs style, ARCHITECTURE.md upkeep | `edmund-docs-and-writing` | +| Positioning, comparisons, marketing claims | `edmund-external-positioning` | +| The caret/selection-integrity campaign specifically | `edmund-caret-integrity-campaign` | +| How to investigate an unknown (method, not facts) | `edmund-research-methodology` / `edmund-research-frontier` | + +--- + +## Provenance and maintenance + +Facts verified against the repo on 2026-07-05. If a grep below stops +matching, the fact drifted — update this file and cite the new location. + +```bash +# Invariant 2 tripwire still present +grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift +# Recompose entry points +grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift +# Bypass heal +grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +# Custom draw attributes + fragment class +grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift +# hiddenFont hiding trick +grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift +# Viewport mitigations +grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift +grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift +# Read-mode schemes + HTML whitelist +grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift +grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift +# Crash-reporter placeholder (delete §6.4 once this is a real URL) +grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift +# Open-bug list +sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md +``` diff --git a/.claude/skills/edmund-build-and-env/SKILL.md b/.claude/skills/edmund-build-and-env/SKILL.md new file mode 100644 index 0000000..2a8831f --- /dev/null +++ b/.claude/skills/edmund-build-and-env/SKILL.md @@ -0,0 +1,258 @@ +--- +name: edmund-build-and-env +description: > + Build, environment, and toolchain runbook for the Edmund repo (native macOS + Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting + up the environment from scratch (fresh clone, new machine, CI mirror); a + build fails or a "successful" build behaves stale (change "doesn't take", + old code runs, Build complete! but nothing changed); the app crashes on + launch or the instant it renders LaTeX; you need to construct the debug + bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just + built for a visual check or repro run. Covers swift build/test, + build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), + the stale-build disease and its detection, safe launch/kill hygiene around + the user's live instance, and the CI environment. +--- + +# Edmund — build & environment runbook + +All paths relative to the repo root. +Facts date-stamped 2026-07-05 are volatile — re-verify per the last section. + +## When NOT to use this skill + +| You actually need | Go to | +|---|---| +| The storage==rawSource / TextKit-2-only invariants, render pipeline | `edmund-architecture-contract` | +| Branch/commit/PR rules, what you may touch | `edmund-change-control` | +| Diagnosing a bug (not the build) | `edmund-debugging-playbook` | +| ReproScript / CGEvent live-repro driving | `edmund-live-repro-and-diagnostics` | +| Launch flags, defaults keys, settings | `edmund-config-and-flags` | +| Cutting a release, DMG, Sparkle appcast | `edmund-release-and-operate` | +| Screencapture verification method, test policy | `edmund-validation-and-qa` | + +## 1. Environment from scratch + +Requirements (verified 2026-07-05): + +| Tool | Version | Why | Check | +|---|---|---|---| +| macOS | 14+ | `Package.swift` platforms `.macOS(.v14)` | `sw_vers` | +| Xcode | 16+ (full Xcode, not just CLT) | `swift-tools-version: 6.0`; `build-app.sh` needs `actool` | `swift --version` (local: Swift 6.0.3) | +| gh CLI | any recent | releases, PR ops | `gh --version` | +| Node | ≥20 | releases only | `node --version` | +| create-dmg | **npm** package | releases only | `npm install --global create-dmg` | + +**create-dmg trap**: install via npm, NOT Homebrew. `brew install create-dmg` +is a *different tool* with an incompatible CLI. Not needed for dev work — +only for cutting releases (see `edmund-release-and-operate`). + +Dependencies are fetched by SPM on first build — nothing to install by hand +(verified against `Package.swift` / `Package.resolved`, 2026-07-05): + +- `swift-markdown` ≥0.5.0 (CommonMark/GFM parsing; pulls `swift-cmark` transitively) +- `SwiftMath` ≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3) +- `Sparkle` ≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4) + +Two SPM targets: **EdmundCore** (library + all tests; most work happens here) +and **edmd** (the app shell executable). The binary is named `edmd` even +though the app presents as "Edmund" — deliberate, see the comment in +`Package.swift`. + +## 2. Core commands + +```bash +swift build # debug build of both targets +swift test # full suite: ~750+ tests, ~10s (2026-07-05) +swift test --filter Callout # one suite +./scripts/build-app.sh # release build → build/Edmund.app +``` + +`swift test` also runs automatically as a Stop hook after code-touching turns. + +## 3. What build-app.sh actually does (and why the order matters) + +`scripts/build-app.sh` → `build/Edmund.app`. Steps, in order: + +1. `swift build -c release` +2. Assemble `build/Edmund.app/Contents/{MacOS,Resources}`; copy + `.build/release/edmd`, `Info.plist`, `Resources/AppIcon.icns`. +3. Compile `Resources/Assets.xcassets` with `actool` (falls back to + `/Applications/Xcode.app/.../actool` if xcode-select points at the CLT). +4. Embed `Sparkle.framework` into `Contents/Frameworks/` (found under + `.build/`; SwiftPM links Sparkle but never copies the framework — without + it the updater crashes on first check) and `install_name_tool -add_rpath + "@executable_path/../Frameworks"` so `@rpath` resolves post-install. +5. Codesign **inside-out**: Sparkle.framework first (nested XPC helpers must + be signed before macOS will launch them), then the whole `.app` (ad-hoc, + `--deep`, identifier `com.i7t5.edmd`). Sealing the *bundle* — not just the + binary — is what Sparkle's update validator requires. +6. **Only after sealing**: copy `.build/release/*.bundle` (SwiftMath's math + fonts) into the `.app` **root**. + +Why step 6 is last and at the root — two constraints collide: + +- `codesign` refuses to seal a bundle with any extra item at the `.app` root + ("unsealed contents present in the bundle root"), so the seal must happen + while the root holds only `Contents/`. +- SwiftMath's generated `Bundle.module` accessor hardcodes + `Bundle.main.bundleURL` — the `.app` root — with only a hardcoded absolute + `.build` path as fallback. So the bundle *must* sit at the root. + +Resolution: seal first, copy after. The one unsealed root item makes +`codesign --verify` and `--strict` complain, but Sparkle's actual check is +non-strict and tolerates it (verified end-to-end; details in +`docs/ARCHITECTURE.md` §8). + +**Missing SwiftMath bundle = instant crash the moment the app renders any +LaTeX.** App launches fine, opens documents fine, dies on the first math +block. If you see that crash, check `ls build/Edmund.app/*.bundle` first. + +## 4. Debug bundle fast path (EdmundDbg.app) + +For live runs of a *debug* build, skip `build-app.sh` and hand-assemble +(from `docs/dev-guides/live-repro-guide.md` §4): + +```bash +swift build +mkdir -p build/EdmundDbg.app/Contents/MacOS +cp Info.plist build/EdmundDbg.app/Contents/ +cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/ +cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/ +``` + +- **Sparkle.framework must sit next to the binary** — dyld aborts without it. +- A bare `.build/debug/edmd` runs but **never creates a window**. It needs + the bundle (Info.plist) around it. +- **Launch by direct exec of the bundle binary, never `open -a`**: + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \ + -ApplePersistenceIgnoreState YES & +``` + + LaunchServices (`open -a`) can silently run a stale cached/translocated + copy — you'd be executing last hour's code. Direct exec runs exactly the + binary you just copied. `-ApplePersistenceIgnoreState YES` stops state + restoration from reopening previous (possibly mutated) documents. +- Recreate the test document fresh before every run — autosave mutates it. + +## 5. THE STALE BUILD DISEASE + +The single most expensive trap in this repo: it has produced entire wrong +debugging conclusions ("my fix doesn't work" when the fix was never in the +binary). + +**Symptom**: `swift build` prints `Build complete!` having compiled a changed +file but **not relinked `edmd`**. The app then runs old code. Release builds +(`swift build -c release` / `build-app.sh`) reuse stale objects too. + +**Detection — before trusting ANY binary you just built:** + +```bash +# 1. Grep for a LONG string literal unique to your new code: +strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal' +# 2. Hash before/after the build: +shasum .build/arm64-apple-macosx/debug/edmd +``` + +**Literal length matters**: string literals ≤15 bytes are stored inline in +the Mach-O on arm64 and *never appear* in `strings` output. A short probe +literal gives a false "stale" verdict. Use a long one (a distinctive log +message works well). + +**Cure:** + +```bash +swift package clean # first resort +rm -rf .build # visual change "doesn't take" → nuke it all +``` + +**Never hand-delete `.build/…/edmd.build/`** — that corrupts SwiftPM's +output-file-map and wedges the target until a full clean anyway. + +## 6. Running for visual checks — launch/kill hygiene + +**The user's daily-driver app has the same binary name (`edmd`).** A blanket +`pkill -x edmd` kills their live session. Always, in order: + +```bash +# 1. Who is running, and since when? +pgrep -lx edmd +ps -o lstart=,command= -p +# 2. Kill ONLY your own PID — or, if you launched the debug bundle: +pkill -f EdmundDbg +``` + +Other run gotchas: + +- `open Edmund.app` **foregrounds a running instance instead of + relaunching** — you'll be looking at the old binary. Kill your instance + first or direct-exec the binary. +- Always pass `-ApplePersistenceIgnoreState YES` (see §4). +- After many rapid launch/kill cycles the window server can glitch (tiny + windows, broken state restoration): + `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState` + and relaunch. +- Verification method (window-id screencapture, offscreen render fallback): + `edmund-validation-and-qa` and `docs/ARCHITECTURE.md` §8. + +## 7. CI environment + +`.github/workflows/ci.yml` (verified 2026-07-05): runs `swift test` on +`macos-14` with `latest-stable` Xcode (Swift 6.0 needs Xcode 16+), triggered +on PRs and pushes to `main`. + +- **SPM cache**: `.build` is cached keyed on + `spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}`. The `v2` + token exists because the repo rename `md` → `Edmund` changed the checkout + path and invalidated absolute paths baked into the cached module cache — + bump the token to discard a poisoned cache. +- **Concurrency**: `cancel-in-progress: true` per branch/PR — private-repo + macOS minutes bill at 10x, so superseded commits' runs are cancelled. +- Release pipeline (`release.yml`, tag-triggered) is a separate beast: + `edmund-release-and-operate` / `docs/ARCHITECTURE.md` §13. + +## 8. Checklists + +### Fresh clone to green + +- [ ] `sw_vers` — macOS 14+; `swift --version` — Swift 6.x (Xcode 16+) +- [ ] `git clone` + `cd Edmund` +- [ ] `swift build` — SPM fetches swift-markdown, SwiftMath, Sparkle +- [ ] `swift test` — ~750+ tests green in ~10s +- [ ] Read `docs/ARCHITECTURE.md` (mandated by CLAUDE.md before non-trivial work) +- [ ] Visual work planned? `./scripts/build-app.sh`, confirm + `build/Edmund.app` exists and `ls build/Edmund.app/*.bundle` shows the + SwiftMath bundle +- [ ] Releases planned? `node --version` ≥20, `npm install --global create-dmg` + +### Before trusting any run + +- [ ] Binary is fresh: `strings | grep ''` + and/or `shasum` changed since the edit (§5) +- [ ] `pgrep -lx edmd` — user's live instance identified; you will kill only + your own PID (§6) +- [ ] Launched by direct exec, not `open -a` (§4) +- [ ] `-ApplePersistenceIgnoreState YES` passed +- [ ] Test document recreated fresh (autosave mutated the last one) +- [ ] Debug bundle: Sparkle.framework sits next to the binary +- [ ] Release bundle: SwiftMath `*.bundle` at the `.app` root (or the first + LaTeX render crashes) + +## Provenance and maintenance + +Every claim above was read from the files below on 2026-07-05. Re-verify: + +- Toolchain/deps: `cat Package.swift` (tools-version, platforms, dep + versions); `grep identity Package.resolved` +- Build anatomy: `cat scripts/build-app.sh` (step order, sealing comments) +- Test count/time: `swift test 2>&1 | tail -3` +- Debug bundle recipe: `docs/dev-guides/live-repro-guide.md` §4 +- Stale-build disease, launch gotchas: `docs/ARCHITECTURE.md` §8 + ("Stale release builds", "open Edmund.app", savedState) +- CI facts: `cat .github/workflows/ci.yml` (cache key comment, concurrency) +- create-dmg quirks: `docs/ARCHITECTURE.md` §8 ("create-dmg — npm only") + +If a command here disagrees with those files, the files win — update this +skill. diff --git a/.claude/skills/edmund-caret-integrity-campaign/SKILL.md b/.claude/skills/edmund-caret-integrity-campaign/SKILL.md new file mode 100644 index 0000000..d549906 --- /dev/null +++ b/.claude/skills/edmund-caret-integrity-campaign/SKILL.md @@ -0,0 +1,267 @@ +--- +name: edmund-caret-integrity-campaign +description: > + The executable, decision-gated campaign for Edmund's hardest live problem: + the delete-drift class (caret/selection integrity in the live NSTextView / + TextKit 2 / input-context layer). Load when the caret or selection lands in + the wrong place after a delete/type/IME/drag interaction, when text edits + corrupt or desync, when autosave writes stale content, or when logs show + ⚠︎LEN-MISMATCH or the "healing storage edit that bypassed didChangeText" + breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, + fence off six settled battles, capture evidence, build a deterministic repro, + pick from a ranked solution menu with proof obligations, then validate and + promote through change control. Not for viewport/scroll drift (that's + edmund-debugging-playbook → viewport docs) unless the caret itself moves. +--- + +# Caret-integrity campaign (the delete-drift class) + +Six rounds are settled; **new rounds are expected**. This class does **not +reproduce in headless tests** — the harness runs AppKit's deferred machinery +synchronously, so a green unit test proves nothing here. Success is measured by +`PASS/FAIL` grep and byte counts, **never by eye or by reasoning**. + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md` (456 lines) and +the code. Read that doc fully before a deep dive. + +--- + +## QUICK CARD (the whole loop) + +``` +0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook. +1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them. +2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS; + traceSelectionOrigin names who moved the caret; rebuild the doc. +3. REPRO ReproScript: needles not offsets; real events; replicate internal + call sequences verbatim. Freeze a script whose logsel/assertcaret + DISCRIMINATES broken vs current build. No repro → hypothesis wrong. +4. SOLVE rank candidates (missing guard < extend heal < reorder fixup < + structural). Each states what it must PROVE. +5. PROMOTE fix flips frozen repro to PASS same run → soak 4–5 cycles, + byte-identical rawLen → swift test green → add test (locks headless + contract even if non-discriminating) + keep .repro → update the + investigation doc → branch/commit via change-control. Never ship on + reasoning alone. +``` + +--- + +## PHASE 0 — Classify: is it actually this class? + +Grep `~/.edmund/logs/edmund-.log` (run the app with +`-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES`): + +| Signature | Meaning | +|---|---| +| `healing storage edit that bypassed didChangeText` | a bypass fired (round-4 class) | +| **persisting** `⚠︎LEN-MISMATCH` (not just transient between shouldChangeText→synced) | storage/rawSource desync stuck | +| `selectionDidChange` with `up=Y` at a surprising position | selection moved mid-recompose (round-6 class) | +| a `shouldChangeText` with **no** `synced`/`SKIPPED`/`DEFERRED` after it | a bypassed `didChangeText` | + +`scripts/grep-trace.sh` in **edmund-live-repro-and-diagnostics** surfaces all +four at once. + +**If none match and the symptom is scroll/viewport-shaped** (jump, wrong +landing, can't-scroll-up) with the caret *not* leaping → this is the viewport +class, not caret integrity → **edmund-debugging-playbook** + +`docs/investigations/viewport-glitch-investigation.md`. Do not run this campaign on a viewport +bug. + +--- + +## PHASE 1 — Fence off the six settled battles + +Confirm each shipped guard **still holds** before hypothesizing a new mechanism. +The most likely round-7 shape is a **new code path that lacks an existing +guard**, not a new mechanism. + +| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path | +|---|---|---| +| 1–3 | IME **marked-text stranding**: styling that runs `beginEditing`/`setAttributes`/`invalidateLayout` while `hasMarkedText()` strands the composition; `didChangeText` then bails forever and every edit drifts | **every storage-touching styling path guards `!hasMarkedText()`** — including async ones scheduled *before* composition began (`+SelectionTracking` caret-move restyle). `becomeFirstResponder` resyncs as catch-all | +| 4 | **Drag-move bypass**: a drag-move whose drop has no valid target deletes via `shouldChangeText`→`replaceCharacters` with **no `didChangeText`**; `rawSource`/`blocks` freeze; autosave writes stale text | `scheduleBypassedEditSyncCheck` (`+EditFlow.swift`): a next-run-loop check finds an unconsumed storage `pendingEdit` and runs the sync (breadcrumb above) | +| 5 | **Heal leaped the caret**: the round-4 heal ran against a **stale** selection and moved the caret | heal collapses/derives the caret from the `pendingEdit` hull before syncing | +| 6 | **Queued selection fixup**: TK2's `_fixSelectionAfterChangeInCharacterRange` stays queued after a bypass and fires at the **next `endEditing`** (even an attribute-only restyle), remapping the **stale** selection and leaping even a *freshly set, valid* caret | heal sets the caret from the pendingEdit hull **before** the sync **AND re-asserts it after** (`+EditFlow.swift`) | +| 7 | **Same queued fixup on the NORMAL edit path** (not the heal): armed by a **cross-block caret move** (schedules the async caret-move restyle), the fixup fires during `syncRawSourceFromDisplay`→`recomposeDirty`'s `endEditing` on an ordinary keystroke and leaps the caret to the **block boundary**; the normal path styles `settingSelection=false` and never re-asserts, so it **persists** | `syncRawSourceFromDisplay` captures the pendingEdit-hull caret before `consumePendingEdit`, re-asserts it after `recomposeDirty` if the fixup moved it (`+EditFlow.swift`; breadcrumb `re-asserting caret after fixup leap (normal path)`) — **fix not yet confirmed by a deterministic repro**, live-verifiable via the breadcrumb | + +Confirm the guards exist: +```bash +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +``` + +### FENCED WRONG PATHS (each proven dead — do not retry) + +- **Fixing by nudging the caret at symptom time.** The symptom is armed + seconds-to-minutes *earlier* (round 6: drift at 22:13 armed at 22:11:57). You'd + patch the wrong instant. +- **Trusting a headless regression test.** The round-6 test passes **with and + without** the fix. Headless runs the fixup synchronously. +- **Shipping on reasoning alone.** Rounds 1–5 all came back. Round 6's first fix + candidate "worked by reasoning" and **failed in the repro within a minute**. +- **Assuming `didChangeText` pairs with every mutation.** AppKit violates this + (round 4). +- **Mutating storage mid-composition.** That is the original bug (rounds 1–3). +- **Expecting a scripted keystroke replay to arm round 7.** Round 7 was replayed + faithfully from a reconstructed `t0` (every keystroke + capped pauses through + the whole drift window) and it did **not** produce the block-end leap. + Programmatic caret moves (`clickoff`) and imprecise synthetic clicks + (`realclickoff` — lands off-by-a-few, diverges, crashes) do not fully arm it. + The arming needs **real mouse clicks at real positions** and probably the + real session's **two-document window switching** (`becomeFirstResponder` + resync is the prime suspect). Round-8 lead: two open docs + real clicks, or + instrument `becomeFirstResponder`/the caret-move restyle to catch the next + live occurrence. Compressed replays also coalesce a bypass with the next + keystroke into one `pendingEdit` hull (never happens live — the heal runs + first), producing off-by-one breadcrumb noise; don't trust it as a repro. + +--- + +## PHASE 2 — Capture evidence + +1. Launch with verbose diagnostics (debug bundle; file arg is `argv[1]`): + ```bash + build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -ApplePersistenceIgnoreState YES + ``` +2. **Decode the trace fields:** `sel/active/marked/up/undo/blocks/storLen/rawLen`. + `up=Y` = event arrived mid-recompose (suspicious). Healthy ordering: + `shouldChangeText` → `selectionDidChange (up=N)` → `synced`; a transient + `⚠︎LEN-MISMATCH` between those is normal, a persisting one is not. +3. **Find the FIRST bad line, then walk BACKWARDS.** The visible symptom is often + the second half of a two-part mechanism. +4. **`traceSelectionOrigin`** logs the call stack of whoever moved the selection + mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. If + the caret moves and you don't know who moved it, this answers it in one run. +5. **Reconstruct the document.** Wrapped-paragraph geometry and block kinds + matter — repro against a lookalike, never `"hello world"`. + +**Gate:** you have a candidate trigger hypothesis + the first-bad-line timestamp. +Otherwise **instrument** (add a `Log.shouldTrace` breadcrumb — one call stack or +state dump beats ten speculative fixes) and wait for the next occurrence. + +--- + +## PHASE 3 — Build the deterministic repro + +Use the in-process **ReproScript** driver (`Sources/edmd/App/ReproScript.swift`, +DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands: +`sleep / caret / type / backspace / bypassdelete / assertcaret / logsel`. + +**Worked example — the round-6 minimal repro** (the first deterministic repro in +six rounds): +``` +sleep 2000 +bypassdelete Sizemore, +sleep 800 +logsel # broken build: 321 fixed build: 290 +backspace 2 +logsel +``` +The deciding output was `logsel` **321 → 290** with the fix, every run, window +not even visible. + +Rules that make repros survive editing: +- **Needles, not offsets** — offsets go stale the instant the script edits. +- **Real events, not method shortcuts** — `insertText("")` skips + `deleteBackward`'s selection machinery, exactly where round 6 lived. +- **Replicate AppKit-internal call sequences verbatim** — `bypassdelete` does + `shouldChangeText`→`replaceCharacters`, no `didChangeText`, not an + approximation. For a *new* internal path, pin its real sequence from a + `traceSelectionOrigin` stack first, then replay it (extend ReproScript ~10 + lines). + +**Gate:** a **frozen** script (exact commands + document) whose `logsel` / +`assertcaret PASS/FAIL` output **discriminates** the broken build from the +current one. + +**No repro after honest attempts?** The hypothesis is wrong, or fidelity is too +low — a mouse-only path (real drag-select/drag-move) needs the **CGEvent driver** +(edmund-live-repro-and-diagnostics §4). Loop back to Phase 2. + +--- + +## PHASE 4 — Solution menu (ranked; each with a proof obligation) + +Pick by the observation pattern. Cheaper is higher. + +1. **Add a missing guard on an existing invariant** (cheapest, most likely for a + new round). Guard inventory: `!hasMarkedText()` on the offending styling path; + bypass-check scheduling on a new mutation entry point; caret re-assertion + after a restyle. *Proof:* the frozen repro flips to PASS **and** no other + `.repro` regresses. *Selects when:* a specific new code path shows the + round-1/4/6 signature that its siblings already guard. +2. **Extend the heal to a new bypass source.** *Proof:* first add a ReproScript + command that replays the new source's **exact** call sequence, show it + reproduces the freeze, then show the extended heal fixes it. *Selects when:* + trace shows a `shouldChangeText` with no close-out from a path other than + drag-move. +3. **Intercept/reorder the queued fixup.** *Proof obligation:* explain **when** + TK2 queues and fires `_fixSelectionAfterChange` and show the reorder does not + fight AppKit's machinery (no oscillation, no double-move). *Selects when:* + `traceSelectionOrigin` names the fixup and the caret is valid before it fires. + Higher risk — you are stepping into private AppKit ordering. +4. **Structural: make rawSource sync independent of `didChangeText` pairing** + (e.g. a storage-version counter that reconciles regardless of which callbacks + fired). Biggest change; **candidate, unproven** — route through + **edmund-research-frontier** (frontier item "caret integrity by + construction"). *Proof:* all historical `.repro` scripts + a randomized + bypass-fuzzer soak stay green with the callback-pairing assumption removed. + +--- + +## PHASE 5 — Validate and promote + +1. Fix candidate **flips the frozen repro to PASS within the same run**. (If it + only "works by reasoning," it is not done — round history.) +2. **Soak** (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5 + trigger cycles at different document positions with ordinary editing between, + `assertcaret` after every predictable step. Green across all cycles **and + byte-identical final `rawLen` across repeated runs** = deterministic. +3. **`swift test` green** (also the Stop hook). +4. **Add a regression test even if it can't discriminate the live mechanism** — + it locks the headless contract (assert `rawSource == string`; see the + `BypassedEditSyncTests` / `MarkedTextDesyncTests` family). **Keep the `.repro` + script** in the repo for the live half. +5. **Update `docs/investigations/delete-drift-investigation.md`** with the new round (symptom → + root cause → repro recipe → fix → status), and `docs/ARCHITECTURE.md` §8 if an + invariant changed — **same PR**. +6. Route through **edmund-change-control**: branch `fix/…` off `main`, small + commits, **never auto-push/PR/merge**. (No screencapture unless the fix also + draws.) + +**Never promote a fix that only "works by reasoning."** + +--- + +## When NOT to use this skill + +- Viewport/scroll drift where the caret itself does not leap → + **edmund-debugging-playbook** + `docs/investigations/viewport-glitch-investigation.md`. +- Just need the repro driver mechanics / trace decoding → + **edmund-live-repro-and-diagnostics**. +- The AppKit theory behind the mechanisms → **textkit2-appkit-reference**. +- The historical record of prior rounds → **edmund-failure-archaeology**. +- The structural (round-∞) redesign as a research project → + **edmund-research-frontier** + **edmund-research-methodology**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`, +`Sources/edmd/App/ReproScript.swift`, and +`Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift`. + +```bash +grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +# round-6 discriminating numbers (321 broken / 290 fixed): +grep -n '321\|290' docs/investigations/delete-drift-investigation.md +``` + +When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list. diff --git a/.claude/skills/edmund-change-control/SKILL.md b/.claude/skills/edmund-change-control/SKILL.md new file mode 100644 index 0000000..13f9151 --- /dev/null +++ b/.claude/skills/edmund-change-control/SKILL.md @@ -0,0 +1,210 @@ +--- +name: edmund-change-control +description: > + How changes are classified, gated, and reviewed in the Edmund repo. Load at + the START of any task that will modify the repo; before committing, branching, + or proposing a merge or release; when deciding whether a change needs a test, + a screencapture visual check, or a live repro; when unsure what requires + explicitly asking the maintainer (push, PR, merge, release, deleting + uncommitted work, touching test-files/, adding dependencies). Contains the + non-negotiable rules with the historical incident behind each one. +--- + +# Edmund change control + +Last verified: 2026-07-05, against `main` @ fe8a1f5 (release 0.1.3). + +This is the gatekeeping doc: what class of change you are making, which gate it +must pass, and the rules that are never traded away. The rules exist because +each was paid for — the incident column is not decoration. + +## 0. Before you start (task setup) + +``` +[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule) +[ ] git status — note any uncommitted work; never clobber it +[ ] Not on main? Fine. On main? Branch NOW, before the first edit +[ ] Classify the change (§1) so you know the gate before you write code +[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't + reproduce the bug, you can't prove the fix (§3, last row) +``` + +## 1. Classify the change, apply the gate + +Classify FIRST, before writing code. The class decides the verification bar. + +| Class | Examples | Gate before commit | +|---|---|---| +| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. `swift test` still runs as a Stop hook; ignore no failures it surfaces. | +| Code (logic) change | Parser, block model, helpers, non-drawing refactor | `swift test` green. New behavior or bug fix → add a test that fails without the change. | +| Visually-drawing change | Anything in `Rendering/`, overlays, decorations, padding, fonts, layout fragments | All of the above, PLUS build the app and `screencapture` the result (window-by-id method — see `edmund-live-repro-and-diagnostics`). Headless layout is not proof for anything that draws. | +| Edit-pipeline / selection behavior | `+EditFlow`, `+Composition`, `+SelectionTracking`, `+Undo`, caret, IME, drag, viewport timing | All of the above, PLUS a live repro or soak script (`-debug.reproScript`, see `edmund-caret-integrity-campaign` and `docs/dev-guides/live-repro-guide.md`). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred. | +| Release | Version bump, tag, appcast | Run `misc/before-you-release.md` top to bottom, then `misc/how-to-release.md`. See `edmund-release-and-operate`. Never start a release without being asked. | + +Notes on the gates: + +- `swift test` (~750+ tests, ~10s) also runs automatically as a **Stop hook** + (`.claude/settings.json`: `swift test 2>&1 | tail -5`) at the end of every + turn. That is a safety net, not the gate — run it yourself before committing + so the failure is yours to see, not the hook's. +- A change can be in multiple classes. Apply the union of gates. A caret fix + that also moves a decoration needs test + screencapture + live repro. +- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a + human could see the diff, screenshot it. + +Classification edge cases that have gone wrong before: + +- "It's just an attribute change" is NOT automatically the code-logic class. + If the attributes change measured geometry (font size, paragraph spacing, + hidden-delimiter width), it draws AND it needs `invalidateLayout(for:)` — + see §3. +- "It's just a restyle helper" that runs `beginEditing`/`setAttributes` on + storage is edit-pipeline class if it can fire during IME composition or + after a bypassed edit. When in doubt, grep for `hasMarkedText` guards on + the sibling paths and match them. +- A test-only change is docs-class for gating purposes (nothing to screenshot), + but the Stop hook still must pass — a broken test is a broken commit. +- Release-adjacent edits (`Info.plist` versions, `CHANGELOG.md`, `appcast.xml`, + `scripts/release.sh`, `.github/workflows/`) are release class even when tiny. + The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, + not from app code. + +## 2. Git discipline + +From CLAUDE.md + ARCHITECTURE §12 + the repo's own history (`git branch -a`). + +- **Branch off `main` for every fix. Never commit straight to `main`.** + One feature/fix per branch. +- Branch prefixes actually in use (verified): `fix/`, `feat/`, `feature/`, + `docs/`, `chore/`, `uiux/`, `ui/`, `ux/`, `markdown/`, `bug/`, `refactor/`, + `ci/`, `release/`. Prefer `fix/`, `feat/`, `docs/`, `chore/` for new work; + `uiux/` for visual polish; `markdown/` for syntax-feature work. +- **Small, logical commits; commit frequently.** A commit that mixes the fix + with a drive-by refactor is two commits done wrong. +- **NEVER auto-push, open a PR, or merge.** Only when the maintainer + explicitly asks. No exceptions for "it's just docs." +- **Never discard uncommitted changes.** No `git checkout -- .`, + `git reset --hard`, `git clean` on a dirty tree without explicit permission. +- Commit messages follow the observed style: `fix(editor): …`, `docs: …`, + `ui: …`, `chore: …` — short imperative subject. + +## 3. The non-negotiables + +Each rule was established by an incident. Verify against the cited doc before +arguing an exception. + +| Rule | Rationale | Incident | +|---|---|---| +| Text storage always equals `rawSource`; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. | Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. `docs/investigations/delete-drift-investigation.md`. | +| TextKit 2 only. Never touch `NSTextView.layoutManager`; never store `NSTextBlock`/`NSTextTable` attributes. | Either one **silently and permanently** reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. | +| No `NSTextAttachment`. Images/icons are drawn as overlays. | TK2 only honors attachments on U+FFFC, which `rawSource` never contains (see rule 1). | ARCHITECTURE §2, §5. | +| Never draw images on wrapping (multi-line) fragments; use stroked `CGPath`s instead. | A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: `docs/investigations/archives/callout-title-wrap-investigation.md`; fix in ae61644 (stroked path, not image). | +| Every storage-touching styling path guards `!hasMarkedText()` — including async paths scheduled before composition began. | Mutating storage mid-IME-composition strands the marked text; `didChangeText` then bails forever on its own guard and every later edit drifts. | Delete-drift rounds 1–2 (IME stranding cascade). `docs/investigations/delete-drift-investigation.md`. | +| Never assume `didChangeText` follows every edit. Sync paths must survive a bypassed edit. | AppKit's drag-move-to-nowhere deletes via `shouldChangeText` → `replaceCharacters` and never calls `didChangeText`, silently freezing `rawSource`. The heal (`scheduleBypassedEditSyncCheck` in `+EditFlow`) exists for this. | Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself. | +| Attribute-only restyles that change geometry must `invalidateLayout(for:)` the range. | TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; `recomposeDirty` and the idle drain already do this — new paths must too. | +| Undo restore is diff-based `recomposeReplacing` — never a full `recompose`. | Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (`fix(undo): select + center the changed text; diff-based snapshot restore`). | +| Never blanket `pkill -x edmd`. `pgrep -x edmd` first, check start times, kill only the PID you launched. | The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's `pkill -x edmd` shorthand predates this rule — don't copy it.) | Established after the maintainer's own instance was killed during a debugging session. | +| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | CLAUDE.md "Environment"; the `-debug.reproScript` driver exists precisely so repros need no new TCC grants. | +| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). | +| Files in `test-files/` are the maintainer's manual test corpus. Never rewrite them for automation; `test-files/todo.md` especially is owner-edited. | They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or `Tests/`. | Standing maintainer rule. | +| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. `docs/dev-guides/live-repro-guide.md`. | + +### The two incidents that shaped this table + +Worth knowing as stories, because the rules read as pedantry until you see +the cost: + +- **Delete-drift (six rounds).** The hardest live problem this repo has had. + A caret that drifted after deletes. Round 1 blamed IME stranding — plausible + fix, shipped, recurred. Round 2 disabled remaining marked-text sources — + recurred. Round 3 stopped guessing and built diagnostics (selection tracing, + event logs). Round 4's diagnostics caught a drag-move deleting storage with + no `didChangeText` — the heal was born. Round 5: the heal itself leaped the + caret via a stale selection. Round 6 found the actual drift mechanism — + TextKit 2's queued `_fixSelectionAfterChange` firing at the *next* + `endEditing` — and held only because the ReproScript driver could replay the + exact keystroke sequence deterministically. Five shipped fixes failed; the + one preceded by a frozen repro stuck. That asymmetry IS the change-control + policy for this bug class. +- **Undo/redo viewport drift.** Undo restored a snapshot via full `recompose`, + which reset every fragment to a TK2 height estimate; the follow-up + scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) + diffs the snapshot against current text and applies only the changed span + with `recomposeReplacing`. Moral: in TK2, layout state is part of the + document state you must preserve — "re-render everything" is never the safe + fallback here, it is the bug. + +## 4. Review expectations + +- **ARCHITECTURE.md is updated in the same PR** whenever you learn something + non-obvious or change an invariant. The doc's own header demands this; the + gotchas in §8 all arrived this way. +- **Quirks are documented as comments at the code site** — the edge case, the + workaround, the *why*. Not in commit messages, not in CLAUDE.md. +- **New known issues** go in ARCHITECTURE §9 with a one-line repro and a + pointer to any deeper write-up in `docs/`. +- Big investigations (multi-round bugs) get a `docs/*-investigation.md` + chronicle — see `edmund-failure-archaeology` for the pattern. + +## 5. Pre-commit checklist (copy-paste) + +The workflow that worked (ARCHITECTURE §12 + CLAUDE.md). Run it verbatim: + +``` +[ ] swift test — all green (also enforced by the Stop hook; don't rely on it) +[ ] New behavior / bug fix → a test exists that fails without the change +[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG +[ ] Edit-pipeline / selection change? → live repro or soak script passed +[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main +[ ] Diff touches only what the task needs; style matches surroundings +[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change +[ ] Quirk introduced/found? → comment at the code site +[ ] Commit is small and logical; message matches repo style (fix(scope): …) +[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked) +``` + +## 6. Ask the maintainer first — always + +Never do these unprompted; ask and wait for an explicit yes: + +| Action | Why it's gated | +|---|---| +| `git push`, opening a PR, merging anything | Standing rule in CLAUDE.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. | +| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. | +| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. | +| Editing anything in `test-files/` | Manual test corpus; `todo.md` there is owner-edited. Make fixtures elsewhere. | +| Adding a dependency | Current set is deliberately three (`swift-markdown`, `SwiftMath`, `Sparkle`); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). | +| Changing `.claude/settings.json` hooks or permissions | Alters what runs automatically on the maintainer's machine. | + +## When NOT to use this skill + +| You need… | Go to | +|---|---| +| The invariants' full technical statement and render pipeline | `edmund-architecture-contract` | +| To debug a failure, read traces/logs | `edmund-debugging-playbook` | +| The history of a past incident in depth | `edmund-failure-archaeology` | +| TextKit 2 / AppKit API behavior details | `textkit2-appkit-reference` | +| Launch flags, debug bundle, settings | `edmund-config-and-flags` | +| Build issues, stale binaries, environment | `edmund-build-and-env` | +| Executing a release / operating the app | `edmund-release-and-operate` | +| The screencapture / ReproScript mechanics | `edmund-live-repro-and-diagnostics` | +| Test-writing patterns and QA strategy | `edmund-validation-and-qa` | +| Writing docs / chronicles | `edmund-docs-and-writing` | +| Caret/selection bug-class specifics | `edmund-caret-integrity-campaign` | + +## Provenance and maintenance + +- Sources: `CLAUDE.md` (repo root), `docs/ARCHITECTURE.md` §1 §2 §8 §9 §12, + `.claude/settings.json` (Stop hook), `misc/before-you-release.md`, + `misc/how-to-release.md`, `docs/investigations/delete-drift-investigation.md` (rounds 1–6), + `docs/investigations/archives/callout-title-wrap-investigation.md`, `git log` / `git branch -a` as of + fe8a1f5 (2026-07-05). +- Commits cited were verified in `git log`: 5bb2b40 (diff-based undo restore), + 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a + (round-6 caret re-assert). +- Two rules rest on maintainer statements rather than repo docs: the + measure-from-pixels rule and the `test-files/` ownership rule. If either gets + written into ARCHITECTURE.md, point at it here. +- Maintain: when a new incident produces a new rule, add a row to §3 with the + incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When + the Stop hook in `.claude/settings.json` changes, update §1's note. diff --git a/.claude/skills/edmund-config-and-flags/SKILL.md b/.claude/skills/edmund-config-and-flags/SKILL.md new file mode 100644 index 0000000..9e06f3f --- /dev/null +++ b/.claude/skills/edmund-config-and-flags/SKILL.md @@ -0,0 +1,220 @@ +--- +name: edmund-config-and-flags +description: > + Catalog of every configuration axis in the Edmund Markdown editor — user + settings (UserDefaults keys, defaults, where consumed), launch arguments + (diagnostic + repro flags), compile-time gates, and logging config. Load + when adding or changing a setting, hunting which flag controls a behavior, + launching the app with debug flags, auditing defaults, or wiring a new + preference into the live editor. This skill drifts fastest of the set — + every table ends with a re-verification grep. Not for the invariants + (see edmund-architecture-contract), release flags (edmund-release-and-operate), + or how to READ the logs (edmund-live-repro-and-diagnostics). +--- + +# Edmund configuration & flags + +Ground-truth catalog of every knob. **Code wins over docs** — every value +below was read from source on 2026-07-05; re-verify with the greps at the end +before trusting a value in a decision. + +Two source-of-truth files: +- `Sources/edmd/Settings/AppSettings.swift` — every UserDefaults key + typed accessor. +- `Sources/edmd/Settings/*View.swift` (Appearance / General / Advanced) + `FontSettings` — the SwiftUI panes (`@AppStorage`). + +Definitions used below: **UserDefaults** = macOS per-app persisted key/value store; **argument domain** = passing `- ` on the command line overrides that default for one launch; **`@AppStorage`** = SwiftUI wrapper binding a view to a UserDefaults key. + +--- + +## 1. User settings (UserDefaults keys) + +Every key is a `static let` in `AppSettings.swift`. The key **string** (not the +Swift name) is what you pass as a launch arg. + +| Swift name | Key string | Purpose | +|---|---|---| +| `reopenWindows` | `settings.general.reopenWindows` | Reopen last windows on launch | +| `startupAction` | `settings.general.startupAction` | What to do at startup (new doc / reopen / nothing) | +| `autoSaveWithVersions` | `settings.general.autoSaveWithVersions` | NSDocument autosave-in-place vs versions | +| `conflictResolution` | `settings.general.conflictResolution` | File-changed-on-disk handling | +| `suppressInconsistentLineEndingWarning` | `settings.general.suppressInconsistentLineEndingWarning` | Silence mixed-line-ending warning | +| `diagnosticLogging` | `settings.general.diagnosticLogging` | **On/off for file logging** (opt-out; see §4) | +| `logRetention` | `settings.general.logRetention` | Days of logs to keep (pruned on configure) | +| `appearanceMode` | `settings.appearance.mode` | Light / dark / system | +| `maxContentWidthCm` | `settings.appearance.maxContentWidthCm` | **Max column width, stored in CENTIMETRES** (see note) | +| `contentWidthUnit` | `settings.appearance.contentWidthUnit` | Display unit only (cm/in); the stored value is always cm | +| `renderBlankLinesAsBreaks` | `settings.reading.renderBlankLinesAsBreaks` | Read-mode blank-line handling | +| `sourceMode` | `settings.view.sourceMode` | When on, **Source replaces Edit** in the ⌘E toggle; honored on open | +| `verboseEditorDiagnostics` | `settings.advanced.verboseEditorDiagnostics` | **Verbose editor trace** (see §4; pairs with diagnosticLogging) | +| `sendCrashLogs` | `settings.advanced.sendCrashLogs` | Opt-in crash upload — **currently INERT** (see note) | +| `sentCrashReports` | `settings.advanced.sentCrashReports` | Dedup set of already-uploaded `.ips` filenames | +| `lastWindowHeight` | `settings.window.lastHeight` | Persisted window sizing (see the frame-not-content trap) | +| `automaticallyChecksForUpdates` | `SUAutomaticallyChecksForUpdates` | Sparkle's own key (not namespaced) | + +**Content width (the physical-column design):** persisted as **centimetres** +(`maxContentWidthCm`); `contentWidthUnit` is a display unit only. The column is +an **absolute physical** cap converted to points via the display's real PPI +(`NSScreen.physicalPPI`, from `CGDisplayScreenSize`), applied as a symmetric +`textContainerInset.width`. Default is locale-aware — **5 in (US) / 12 cm +(elsewhere)** — and doubles as the slider's magnetic snap point. Recomputed on +resize and on `NSWindow.didChangeScreenNotification`. Consumer path lives in +`EditorTextView+ContentWidth.swift`. + +**Window sizing trap:** persistence must round-trip the **frame** size, not the +`contentView.bounds` size — reapplying content size grows the window by the +title-bar + toolbar height on every reopen, and heights below `minSize` get +silently rejected. Save `window.frame.size`, reapply with `window.setFrame(_:)` +**after the toolbar is installed**. (Note: the key on disk is +`settings.window.lastHeight` — code, not the `lastWindowSize` some docs say.) + +**Crash uploading is inert:** `sendCrashLogs` defaults off AND the Settings ▸ +Advanced toggle is **commented out** in `AdvancedSettingsView.swift`, and +`CrashReporter.reportingEndpoint` is a `REPLACE-ME.invalid` placeholder +(`CrashReporter.swift:27`, `// TODO: real server`). Nothing uploads today. +Un-inert it only when a receiving server exists (see edmund-release-and-operate). + +--- + +## 2. Launch arguments + +macOS reads `- ` into the argument domain. Pass the +**key string** from §1, not the Swift name. The **file to open must be +`argv[1]`** (before the flags). + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -debug.reproScript SCRIPT.repro \ + -ApplePersistenceIgnoreState YES +``` + +| Flag | Effect | +|---|---| +| `-settings.general.diagnosticLogging YES` | Turn on file logging for this run | +| `-settings.advanced.verboseEditorDiagnostics YES` | Emit the verbose editor trace (sel/active/marked/up/…) | +| `-debug.reproScript ` | **DEBUG builds only** — replay a keystroke script (`ReproScript.swift`) | +| `-ApplePersistenceIgnoreState YES` | Apple's flag — stop state restoration reopening mutated docs | + +`-debug.reproScript` is the only Edmund-specific *debug* key; it does not have +an `AppSettings` accessor — it is read directly in `ReproScript.swift`. It is +compiled out of release builds. + +--- + +## 3. Compile-time axes + +`#if DEBUG` gates live in: `EditorTextView.swift`, `EditorTextView+EditFlow.swift`, +`Diagnostics/Log.swift`, `edmd/App/main.swift`, `edmd/App/ReproScript.swift`. +What they gate: + +- **The TextKit-1 tripwire** (`EditorTextView.swift:273`+): a DEBUG observer on + `NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the view + ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is + silent and permanent (see edmund-architecture-contract). +- **ReproScript** — the whole in-process keystroke driver. +- **Log level threshold** — `Log.swift` compiles a lower floor in DEBUG + (`debug`+) than release (`info`+); see §4. + +Named tuning constants (not user-facing, but they behave like knobs): + +| Constant | Value | Where | Meaning | +|---|---|---|---| +| `fullLayoutMaxLength` | `100_000` | `EditorTextView.swift:80` | Docs ≤ this many UTF-16 units are kept **fully laid out** (below the TK2 estimate regime). Consumed at `+LazyStyling.swift:133`. | + +--- + +## 4. Logging config + +Read `Sources/EdmundCore/Diagnostics/Log.swift` and +`EditorTextView+Diagnostics.swift`. + +- API: `Log.{debug,info,error}(_:category:)`, `Log.measure(_:) { … }`. +- File: `~/.edmund/logs/edmund-YYYY-MM-DD.log`, written on a private serial queue. +- Config flow: `AppSettings.applyLogging()` pushes the toggle + retention into + `Log.configure` at launch and on change; retention pruning happens there. +- **Two independent switches**: `diagnosticLogging` (writes anything at all) and + `verboseEditorDiagnostics` (adds the per-event editor trace). For a live + repro you almost always want **both** on. Verbose lines are gated behind + `Log.shouldTrace`. +- The log is **opt-out** (on by default), retention-pruned; the user only + toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics. + +Trace-field decoding (`sel/active/marked/up/undo/blocks/storLen/rawLen`, +`⚠︎LEN-MISMATCH`, `traceSelectionOrigin`) is covered in +**edmund-live-repro-and-diagnostics** and **edmund-debugging-playbook** — one +home per fact; this skill only says which flags turn the trace on. + +--- + +## 5. ReproScript command surface + +`Sources/edmd/App/ReproScript.swift`, DEBUG only. One command per line, `#` +comments allowed. + +| Command | Effect | +|---|---| +| `sleep ` | wait before next command | +| `caret ` | place caret before first occurrence of `` | +| `type ` | one real key event per char (~80 ms apart) | +| `backspace ` | n real delete keystrokes (~300 ms apart) | +| `bypassdelete ` | simulate drag-move source deletion: `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `PASS/FAIL` iff caret sits exactly before `` | +| `logsel` | log selection, rawSource length, doc count | + +Adding a command is ~10 lines in `ReproScript.swift`. Usage, soak patterns, and +launch recipe: **edmund-live-repro-and-diagnostics**. + +--- + +## 6. How to ADD a setting (checklist) + +Worked from an existing real path (`sourceMode` / content width). To add a +user-facing setting: + +1. Add a `static let` key + typed accessor in `AppSettings.swift` (namespace the + key string: `settings..`). +2. Bind it in the relevant SwiftUI pane with `@AppStorage(AppSettings.)`. +3. If it must affect **open documents live**, add/extend an `applyTo…` broadcast + (see the font/line-height/content-width `applyTo…` helpers) so every open + `Document.editor` picks it up — a setting that only takes effect on next open + is usually a bug. +4. Pick a sane default (register it, or make the accessor default when absent). +5. New behavior needs a test + (if it draws) a screencapture check — route + through **edmund-change-control** and **edmund-validation-and-qa**. + +--- + +## When NOT to use this skill + +- Understanding *why* an invariant exists → **edmund-architecture-contract**. +- Release/signing/appcast flags, `RELEASE_TOKEN`, Sparkle keys → **edmund-release-and-operate**. +- Interpreting log output / running a repro → **edmund-live-repro-and-diagnostics**. +- Which change needs which gate → **edmund-change-control**. +- Build-time flags in the toolchain sense (stale builds, `swift package clean`) → **edmund-build-and-env**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against source. This skill drifts fastest — re-verify each +table before relying on it: + +```bash +# §1 all keys + strings: +grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift +# §1 crash toggle still commented out / endpoint still placeholder: +grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift +grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift +# §2 repro flag key: +grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift +# §3 tripwire + constant: +grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +# §5 repro commands: +grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +``` + +**Known doc-vs-code discrepancies (code wins):** ARCHITECTURE §7 calls the +window-size key `lastWindowSize`; the code key is `settings.window.lastHeight` +(`lastWindowHeight`). If you touch window persistence, trust the code. diff --git a/.claude/skills/edmund-debugging-playbook/SKILL.md b/.claude/skills/edmund-debugging-playbook/SKILL.md new file mode 100644 index 0000000..15a1c6d --- /dev/null +++ b/.claude/skills/edmund-debugging-playbook/SKILL.md @@ -0,0 +1,305 @@ +--- +name: edmund-debugging-playbook +description: >- + Load this FIRST when any bug report or unexpected behavior arrives in Edmund: + caret lands in the wrong place after delete/typing, viewport jumps or + scroll-to-target misses, rendering is wrong or missing, empty bands or clipped + lines, everything suddenly renders as plain text, app crashes or won't launch, + a code change "doesn't take" after rebuild, undo/redo lands the viewport + wrong, IME/CJK/accent input misbehaves, right-click shows the wrong menu, + window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage + table, the traps that cost real debugging time, discriminating trace checks, + the repro escalation ladder, and the open-bug inventory so known bugs are not + rediscovered fresh. +--- + +# Edmund debugging playbook + +Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the +triage table, run the discriminating first check *before* forming a theory, +and check the open-bug inventory before declaring a discovery. + +## When NOT to use + +- Making a change, not chasing a bug → `edmund-change-control`. +- You already know the bug is live-only and need to build a deterministic + repro → `edmund-live-repro-and-diagnostics` (full ladder detail; summary in + §5 here). +- Caret-drift class specifically, with its six-round history → + `edmund-caret-integrity-campaign`. +- Build/toolchain/stale-binary mechanics beyond the quick checks here → + `edmund-build-and-env`. +- Release, signing, appcast, Sparkle pipeline → `edmund-release-and-operate`. +- TextKit 2 / AppKit API semantics reference → `textkit2-appkit-reference`. +- Launch flags and settings keys reference → `edmund-config-and-flags`. +- How past investigations were run and why → `edmund-failure-archaeology`, + `edmund-research-methodology`. +- Pre-merge verification of a fix → `edmund-validation-and-qa`. + +## 1. First 15 minutes — any new bug + +Run this checklist before hypothesizing. Every step is cheap; skipping them +is how rounds 1–5 of delete-drift shipped fixes that came back. + +1. **Check the open-bug inventory (§6).** If the symptom matches a known open + bug, you are done triaging — link the backlog entry and its repro asset. +2. **Get the logs.** `ls -t ~/.edmund/logs/` and read the day's file + (`edmund-YYYY-MM-DD.log`). Grep for the three permanent breadcrumbs: + ```bash + grep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.log + ``` + Also grep for `invariant:` (the always-on storage==rawSource tripwire) and + `⚠︎LEN-MISMATCH`. +3. **Find the first bad line and walk BACKWARDS.** The user-visible symptom + is often the second half of a two-part mechanism — the round-6 caret drift + was armed by a silent bypass 80 seconds and dozens of healthy edits before + the leap. Never start reading at the symptom timestamp. +4. **Rule out a stale build** if this follows a rebuild: grep + `strings .build/arm64-apple-macosx/debug/edmd` for a long string literal + unique to the new code (≤15-byte literals are inlined on arm64 and never + appear); `shasum` the binary. See §3c. +5. **Check git history for prior art.** `git log --oneline -- ` + plus the investigation docs in `docs/`. The viewport-glitch investigation + found four earlier fixes all working around the same unnamed root cause. +6. **Reconstruct the document.** Get the user's file or rebuild it from the + trace's block counts/lengths. Wrapped-paragraph geometry and block kinds + matter; do not repro against "hello world". +7. **Before touching any running app**: `pgrep -lx edmd` then + `ps -o lstart=,command= -p `. The user's daily-driver app shares the + binary name. Never blanket `pkill -x edmd` — kill only the PID you + launched. +8. Row found in §2 → run its discriminating check. No row → escalate per §5, + and add the new row here when it's understood. + +## 2. The triage table + +| Symptom | Likely mechanism | Discriminating first check | Where next | +|---|---|---|---| +| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed `didChangeText` (drag-move to no valid target), or TextKit 2's queued `_fixSelectionAfterChangeInCharacterRange` fired at a later `endEditing` | `grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log` and read the trace around it; look for `selectionDidChange` with `up=Y` at a surprising position | `edmund-caret-integrity-campaign`; `docs/investigations/delete-drift-investigation.md` | +| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: `hasMarkedText()` stuck true, `didChangeText` bails forever, model frozen | Grep logs for `recovered stranded desync on focus regain`; check `storage.string == rawSource` | `docs/investigations/delete-drift-investigation.md` rounds 1–2 | +| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height *estimates* — off-screen frames are guesses corrected as layout reaches them | Doc length vs `fullLayoutMaxLength` (100k UTF-16, `EditorTextView.swift`) — ≤100k should be fully laid out by the settle; >100k is estimate territory | `docs/investigations/viewport-glitch-investigation.md` | +| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | `grep "repairing content above origin" ~/.edmund/logs/edmund-*.log` — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause | `docs/investigations/viewport-glitch-investigation.md` Bug 2 (repair unconfirmed live) | +| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (`5bb2b40`): a full `recompose` resets every fragment to an estimate, then the scroll measures the estimates | Confirm `restoreSnapshot` still routes through range-bounded `recomposeReplacing`, never full `recompose` (`+Undo.swift`); check the changed range, not the stored caret, drives the viewport | `docs/investigations/viewport-glitch-investigation.md` Bug 1 | +| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed `Build complete!` without relinking `edmd` | `strings .build/arm64-apple-macosx/debug/edmd \| grep ""`; `shasum` before/after | `edmund-build-and-env`; §3c | +| App crashes the instant any LaTeX renders | SwiftMath `*.bundle` missing from the `.app` root (its `Bundle.module` is hardcoded to `Bundle.main.bundleURL`) | `ls build/Edmund.app/*.bundle` | `scripts/build-app.sh` copy step; ARCHITECTURE §8 | +| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an `NSLayoutManager` API was touched or an `NSTextBlock`/`NSTextTable` attribute entered storage | DEBUG builds assert via the tripwire (`textKit1FallbackTripwire`, `willSwitchToNSLayoutManagerNotification`); audit recent diffs for `layoutManager` / `NSTextBlock` | ARCHITECTURE §2; `textkit2-appkit-reference` | +| Empty bands or clipped lines after a restyle | Attribute-only change without `invalidateLayout(for:)` — TK2 keeps the stale fragment frame | Is the misbehaving path a *new* styling path? `recomposeDirty` and the idle drain already invalidate; new paths must too | ARCHITECTURE §8 | +| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the `!hasMarkedText()` guard (including async work scheduled *before* composition began) | Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; `docs/investigations/delete-drift-investigation.md` | +| Callout at end of file shows an extra colored line not prefixed by `>` | KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | `misc/backlog.md` | +| Image leaves a large blank space below it | KNOWN OPEN BUG | `misc/bug-repros/image-blank-after.mov` | `misc/backlog.md` | +| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | `misc/backlog.md` | +| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | `NSToolbar` with `allowsUserCustomization` claims every secondary click over the toolbar, beating view-level handlers | Verify the `DocumentWindow.sendEvent(_:)` intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen | ARCHITECTURE §8 | +| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving `contentView.bounds.size` and re-applying as `contentRect` | Confirm `lastWindowSize` round-trips `window.frame.size` via `setFrame` *after* the toolbar is installed (`Document.swift`) | ARCHITECTURE §8 | +| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no `_CodeSignature/CodeResources`; or the EdDSA keypair diverged from `SUPublicEDKey` | Does `build-app.sh` still seal the whole `.app` before copying the SwiftMath bundle in? | `edmund-release-and-operate`; ARCHITECTURE §8/§13 | +| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an *image* on a wrapping fragment collapses its layout; shapes do not | Is the overlay an `NSImage` on a fragment that can wrap? Convert to a stroked `CGPath` (the custom-title callout icon fix) | `docs/investigations/archives/callout-title-wrap-investigation.md` | +| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging *inside* an existing selection is AppKit's drag-*move* gesture | Trace: was there a `selectionDidChange` with a large `sel` before the drag began? | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 1 | +| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the `scrollRangeToVisible` override still reveals the selection's *nearest* end (`+TypewriterScroll.swift`, commit `340fcbc`) | `docs/investigations/viewport-glitch-investigation.md` Bug 3 phase 2 | +| Edits do nothing at all (not drift — dropped) | `isUpdating` stuck true would make `shouldChangeText` return false | Trace shows `shouldChangeText` never returning OK; distinct from the drift signature | `+EditFlow.swift` | +| Launching via `open` shows old behavior | LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | `pgrep -lx edmd` first; launch by direct exec of the bundle binary | §3e; `edmund-build-and-env` | + +## 3. The traps that cost real time + +Each of these burned hours to days. Read before shipping any fix. + +**(a) Shipping caret fixes on reasoning alone.** Delete-drift rounds 1–5 each +shipped a plausible, well-argued fix — and each came back. Only round 6, the +first with a frozen deterministic live repro (`bypassdelete` script), named +the actual mechanism (`_fixSelectionAfterChangeInCharacterRange` queued by a +bypassed edit, firing at the next `endEditing`) — and its first fix attempt +*failed in the repro within a minute*, which reasoning would never have +caught. Lesson: time spent making the failure cheap to observe beats time +spent reasoning about the fix. Freeze the repro before writing the fix. + +**(b) Undo/redo viewport drift.** Two defects hid behind one symptom: +`restoreSnapshot` ran a full `recompose` (discarding all TK2 layout, so the +follow-up scroll measured freshly manufactured estimates) and `performUndo` +recorded the redo snapshot with the caret *at undo-invocation time*, not at +the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain +stale-state bug stacked; fix and verify them separately. The contract since +`5bb2b40`: diff the snapshot, apply via `recomposeReplacing`, select the +changed text, let the changed range drive the viewport. + +**(c) Stale binaries produce false conclusions.** In round 6, `swift build` +twice printed `Build complete!` while linking a stale `edmd` — the compile +ran, the relink silently didn't — and two "failed" fix iterations were +phantoms. Detect: grep `strings` on the binary for a *long* literal unique to +the new code. Cure: `swift package clean` (or `rm -rf .build` for release +weirdness). Never hand-delete `.build/…/edmd.build/` — that corrupts the +output-file-map and wedges the target until a full clean. + +**(d) Headless-green ≠ fixed for input-layer bugs.** The round-6 unit test +reconstructs the exact document and gesture and *passes with and without the +fix*: the test harness runs AppKit's deferred selection fixup synchronously, +so the broken state never forms. A green test proves nothing about the live +NSTextView / TextKit 2 / input-context class. The scripted live repro is the +regression harness; the unit test is only a contract spec. + +**(e) `open -a` runs stale cached copies.** LaunchServices can foreground an +already-running instance instead of relaunching, and can execute a stale +cached/translocated copy of the bundle — you debug last hour's code. Always +launch by direct exec: `build/Edmund.app/Contents/MacOS/edmd file.md &` +(after the §1 step-7 pgrep check). + +**(f) Trusting off-screen fragment y-coordinates.** A TK2 fragment's frame is +real only once laid out; everything off-screen, plus total document height, +is an estimate. Any code that measures before ensuring layout of the +viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll +gotcha, and most historical viewport glitches). Ensure layout for the target +range first, then align to real geometry — and never verify a visual fix +from headless layout: measure from `screencapture` pixels. + +## 4. Discriminating experiments — cheap checks that split hypothesis spaces + +**Verbose diagnostics launch** (defaults keys are namespaced; the file must +be argv[1]): + +```bash +build/Edmund.app/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES +``` + +Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor +tracing"). Logs land in `~/.edmund/logs/edmund-YYYY-MM-DD.log`. + +**Trace field vocabulary** (source of truth: +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`, +`diagnosticState`). Every trace line ends with: + +| Field | Meaning | +|---|---| +| `sel={loc,len}` | current selection | +| `active=` | active block index (or `nil`) | +| `marked=` | marked-text range, `-` if none (non-`-` outside a live composition = stranded) | +| `up=Y/N` | `isUpdating` — `Y` means the event arrived MID-RECOMPOSE | +| `undo=Y/N` | `isUndoRedoing` | +| `blocks=` | block count | +| `storLen=` / `rawLen=` | storage vs rawSource lengths | +| `⚠︎LEN-MISMATCH` | appended when they differ | + +**Healthy vs suspect edit orderings:** + +- Healthy: `shouldChangeText` → `selectionDidChange` (`up=N`) → `synced`. + A *transient* `⚠︎LEN-MISMATCH` between those lines is normal (storage moves + before rawSource syncs). +- Suspect: a `selectionDidChange` with `up=Y` at a surprising position; a + *persisting* LEN-MISMATCH; a `shouldChangeText` with no + `synced`/`SKIPPED`/`DEFERRED` line after it (bypassed `didChangeText`); the + `healing storage edit that bypassed didChangeText` breadcrumb. + +**`traceSelectionOrigin`**: under verbose tracing, any selection change +arriving mid-recompose (`up=Y`) or with an unconsumed pendingEdit logs a +condensed call stack naming the AppKit path that moved the caret. This is +what named `_fixSelectionAfterChangeInCharacterRange` in round 6. If your bug +moves the caret and you don't know who moved it, this answers it in one run. + +**Walk backwards from the first bad line**, not forwards from the symptom. +The round-6 drift was armed 80 seconds before the visible leap. Find the +first line whose state is wrong, then read *earlier*. + +**`verifyEditorInvariants`** (same file): the O(1) length check +(`storage.length != rawSource.length`) logs an `error` whenever logging is on +— no verbose toggle needed — so a hard-invariant break always leaves an +`invariant:` line. The full structural checks (string equality, blocks +reconstruct rawSource, block ranges in bounds) run under verbose tracing and +assert in DEBUG. An `invariant:` error in a user's log is a model desync, +full stop; triage as the delete-drift class. + +If the existing logging didn't capture the deciding fact, add the log line +first and reproduce again — one breadcrumb beats ten speculative fixes. Keep +good ones behind `Log.shouldTrace` and ship them. + +## 5. The escalation ladder (summary) + +Full detail, ReproScript command reference, CGEvent driver, and soak-script +method: `edmund-live-repro-and-diagnostics` and `docs/dev-guides/live-repro-guide.md`. +Work down; stop at the first level that reproduces. + +1. **Plain unit test** (`makeEditor()`) — model/parsing/styling logic. +2. **Windowed unit test** (NSWindow + NSScrollView, real `deleteBackward`) — + adds layout, viewport, first-responder. **Failure to repro here is + evidence, not defeat**: it points at deferred/queued AppKit state and at + levels 3–4. +3. **In-process ReproScript** — DEBUG builds accept `-debug.reproScript + ` (`Sources/edmd/App/ReproScript.swift`); replays a keystroke script + through the real `window.sendEvent` path. No Accessibility/TCC needed, + works with the window on an invisible Space. Commands: `sleep`, `caret`, + `type`, `backspace`, `bypassdelete`, `assertcaret`, `logsel`. Launch with + `-ApplePersistenceIgnoreState YES` and recreate the test document fresh + each run. **The default for live bugs.** +4. **CGEvent driver** — only for paths that must originate as HID events + (drag-select, drag-move, autoscroll). TCC decides per session; if input + doesn't land after one test click, fall back to level 3 immediately. +5. **Instrumented field occurrence** — can't trigger it yourself: add the + decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days + of latency; make sure the *next* occurrence is decisive. + +After a fix: freeze the repro script, run a soak (several trigger cycles in +one app run with `assertcaret` checks), then full `swift test`. + +## 6. Open-bug inventory + +Known open bugs — check here before "discovering" one. Sources: +`misc/backlog.md` (authoritative list) and `docs/ROADMAP.md` (larger themes, +e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate +glitches are a known, partially-mitigated class). All entries below are OPEN +as of 2026-07-05. + +| Bug | Status | Repro asset | +|---|---|---| +| Delete caret drift (class) | Open as a class; rounds 1–6 fixed, watching for round 7 | `misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov}` + matching logs | +| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — | +| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | `misc/bug-repros/callout-extra-line-rendered-at-bottom.mov` | +| Image creates large empty space below | Open | `misc/bug-repros/image-blank-after.mov` | +| Footnotes don't render (edit or read mode) | Open | — | +| Math environments don't render in read mode | Open | `misc/bug-repros/math-baseline-read-mode-png.png` (related baseline issue) | +| Math environments have wrong padding in edit mode | Open | — | +| Max content width not applied to read mode | Open | — | +| Images should shrink when content size is small | Open | — | +| Tables should wrap when content size is small | Open | — | +| Table cell content wraps out of the cell | Open | — | +| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — | +| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — | +| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — | +| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — | + +Do not relabel any of these as fixed without a verified repro flip; no +oversell. When you fix one, update `misc/backlog.md` and this table in the +same branch. + +## 7. House rules while debugging + +- **Never blanket `pkill -x edmd`.** The user's daily-driver app shares the + binary name. `pgrep -lx edmd` + `ps -o lstart=,command= -p `, then + kill only your own PID (`pkill -f EdmundDbg` if you launched the debug + bundle). +- **Do not request Computer Access** — Screen Recording and Accessibility are + already granted. +- **Measure visuals from `screencapture` pixels** (capture by window id, see + ARCHITECTURE §8), never from headless layout alone. +- **Never mutate storage while `hasMarkedText()`** — including in any + diagnostic or repro code you add. +- **Never auto-push, PR, or merge.** Branch off `main` per fix; commit small + and often. +- Logs in `~/.edmund/logs` are app-owned and fair game to read and quote. + +## Provenance and maintenance + +Built 2026-07-05 from: `docs/ARCHITECTURE.md` (§2, §8, §9), +`docs/investigations/delete-drift-investigation.md` (rounds 1–6), +`docs/investigations/viewport-glitch-investigation.md`, +`docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/dev-guides/live-repro-guide.md`, +`misc/backlog.md`, `docs/ROADMAP.md`, and +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. Log strings +(`healing storage edit that bypassed didChangeText`, `repairing content above +origin`, `recovered stranded desync on focus regain`), launch-flag keys, +`fullLayoutMaxLength`, ReproScript commands, the TK1 tripwire, and commit +`5bb2b40` were verified against the source tree on that date. + +Maintain it like the codebase docs: when a new bug class is understood, add +its triage row; when a trap costs real time, add its story to §3; when a +backlog bug opens or closes, sync §6 with `misc/backlog.md` in the same +branch. If a row's discriminating check stops matching the code (renamed log +string, moved file), fix the row — a stale runbook is worse than none. +Deeper mechanism detail belongs in the sibling skills and `docs/` +investigation files, not here; this file stays a triage surface. diff --git a/.claude/skills/edmund-docs-and-writing/SKILL.md b/.claude/skills/edmund-docs-and-writing/SKILL.md new file mode 100644 index 0000000..7abec53 --- /dev/null +++ b/.claude/skills/edmund-docs-and-writing/SKILL.md @@ -0,0 +1,268 @@ +--- +name: edmund-docs-and-writing +description: > + Documentation of record for the Edmund repo: which doc owns which fact, and + how to write in the house style. Load whenever you are writing or updating + ANY project doc — docs/ARCHITECTURE.md, CHANGELOG.md, README.md, + docs/ROADMAP.md, misc/backlog.md, a docs/-investigation.md + write-up, release docs — or deciding WHERE a newly learned fact, gotcha, + bug, or feature idea belongs. Covers the docs-of-record map, the + fact-routing decision table, the investigation-doc template, CHANGELOG + format (machine-extracted for release notes), commit-message conventions, + and doc maintenance duties. Not for making the code change itself, release + mechanics, or debugging — see "When NOT to use this skill". +--- + +# Edmund docs and writing + +Date-stamped 2026-07-09. Every claim below was verified against the files on +`main` at that date; re-verify paths before trusting this after major +reorganizations. + +## When NOT to use this skill + +| You are actually doing | Use instead | +| --- | --- | +| Changing code / designing a mechanism | `edmund-architecture-contract` | +| Branch/commit/PR mechanics, pre-commit checklist | `edmund-change-control` | +| Cutting a release, appcast, Sparkle, CI | `edmund-release-and-operate` | +| Diagnosing a bug (not writing it up) | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` | +| Mining past investigations for technique | `edmund-failure-archaeology` | +| Marketing copy, positioning, alternatives research | `edmund-external-positioning` | +| Build flags, env, debug bundle | `edmund-build-and-env`, `edmund-config-and-flags` | + +This skill is for prose: what to write, where it lives, how it should read. + +## 1. The docs-of-record map — one home per fact + +Every fact has exactly one home; everywhere else gets a pointer. All paths +exist and are current as of 2026-07-09. + +| Doc | Owns | Notes | +| --- | --- | --- | +| `docs/ARCHITECTURE.md` | HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: **when you learn something non-obvious or change an invariant, edit this file in the same PR.** | +| `docs/architecture/README.md` | Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of `docs/architecture/`'s deep docs and the sibling `investigations/`/`dev-guides/` folders, common quirks (each a pointer, never a new claim), getting-started commands | The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. | +| `docs/architecture/.md` | Deep narrative write-up of one subsystem (e.g. `editor-pipeline.md`, `text-system.md`) | The "deep-doc" pattern: a fact's *statement* lives in `ARCHITECTURE.md`, its *explanation* lives here, each links to the other. | +| `docs/architecture/extensibility.md` | The design-of-record for themes/extensions: vision, current state (verified against `main` and the unmerged `feat/extensions-registry-and-tab` branch), themes/extensions design, staged implementation plan, honest risks | **Design only, not yet implemented on `main`.** `ARCHITECTURE.md` gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. | +| `docs/architecture/sandboxing.md` | The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the `~/.edmund/` onboarding grant, staged plan (SB0-SB4), open decisions | **Plan only, nothing sandboxed on `main`.** Same design-doc exception as `extensibility.md`: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to `ARCHITECTURE.md` in the same PR. | +| `README.md` | WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" `xattr -dr com.apple.quarantine` workaround), dependencies, alternatives, acknowledgements, license | User-facing; no internals. | +| `CHANGELOG.md` | User-facing version history, Keep-a-Changelog style | `## [x.y.z]` sections are machine-extracted for release notes — exact format matters (§4 below). | +| `docs/ROADMAP.md` | Versioned feature plan: `## v1.0.0`, `## v1.x`, `# v.2.0.0` sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) | Has a `Last updated: YYYY-MM-DD` line under the title — refresh it when you edit. | +| `misc/backlog.md` | The maintainer's working priority list: `## Now (small releases)` (Marketing / On-going bugs / Bugs / UI/UX / Features), `## Next`, `## Later`, roadmap mirrors, `### Lurking (Unreproduceable)`, `## Done` | Stated priority: **Marketing = Bugs >= UI/UX > Features**. Bug entries carry repro pointers (`misc/bug-repros/*.mov`, `.log`, or `~/Desktop` paths). | +| `docs/investigations/-investigation.md` | Deep multi-round investigation chronicles for active bug classes | Existing: `delete-drift-`, `viewport-glitch-investigation.md`. Template in §5. | +| `docs/investigations/archives/-investigation.md` | Chronicles for closed/resolved bug classes | Existing: `callout-bottom-line-`, `callout-title-wrap-investigation.md`. | +| `docs/dev-guides/live-repro-guide.md` | Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. | +| `misc/before-you-release.md` | Pre-flight readiness checklist | Pairs with `how-to-release.md`; cross-ref `edmund-release-and-operate`. | +| `misc/how-to-release.md` | Release mechanics (CI tag path, local `release.sh`) | Same. | +| `CLAUDE.md` (root) | Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. | +| `LICENSES/` | Vendored license texts (currently `lucide.txt` for the Lucide icon SVGs) | Add one when vendoring third-party assets. | +| `Info.plist` | `CFBundleShortVersionString` + `CFBundleVersion` — the version of record | Must match the CHANGELOG section header at release (see `misc/before-you-release.md` §3). | + +Note: `misc/backlog.md` and `docs/ROADMAP.md` currently duplicate the +v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the +public plan; backlog is the working list. When they disagree, treat ROADMAP as +the versioned commitment and backlog as scratch — and mention the drift to the +maintainer rather than silently reconciling. + +## 2. Where does a new fact go — decision table + +Route the fact FIRST, then write. One home; cross-reference from elsewhere. + +| You learned / produced | Home | How | +| --- | --- | --- | +| Code quirk, edge case, workaround, non-obvious *why* | **Comment at the code site** | House rule (root `CLAUDE.md`): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." | +| Architectural gotcha that will bite the next agent | `ARCHITECTURE.md` §8 | Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. | +| New known issue / structural constraint | `ARCHITECTURE.md` §9 | It has an explicit placeholder: *"Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in `docs/`."* | +| Code debt / incomplete implementation | `ARCHITECTURE.md` §10 | Its footer says: track code-debt here, roadmap items in README/ROADMAP. | +| Changed invariant, new subsystem, new pipeline step | `ARCHITECTURE.md` §2–§7 (the relevant section) | Update in the same PR — header rule. | +| Multi-round investigation (2+ hypothesis cycles, live repro work) | New `docs/-investigation.md` | Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. | +| User-visible change (fix/feature/rename) | `CHANGELOG.md` under the next `## [x.y.z]` | Format in §4. Link the issue and any investigation doc. | +| New bug found (reproducible) | `misc/backlog.md` under `Bugs` | `- [ ] Bug: . See .` Drop repro assets (video/log) into `misc/bug-repros/`. | +| New bug found (unreproducible so far) | `misc/backlog.md` → `### Lurking (Unreproduceable)` | One line + "wait for screen record" style note. | +| Bug that is really code debt (design limitation) | `ARCHITECTURE.md` §9 | e.g. the image-on-wrapping-fragment constraint. | +| Feature idea, near-term (next few small releases) | `misc/backlog.md` (`Now`/`Next`/`Later`) | Sorted by priority + difficulty within category. | +| Feature idea, versioned/strategic | `docs/ROADMAP.md` under the right version | Refresh `Last updated`. | +| Repro method / debugging technique | `docs/dev-guides/live-repro-guide.md` | Method docs, not per-bug chronicles. | +| Release procedure change | `misc/how-to-release.md` / `misc/before-you-release.md` + `ARCHITECTURE.md` §13 | §13 owns the mechanism + failure modes; misc/ owns the operator checklist. | +| Agent workflow improvement | `ARCHITECTURE.md` §12 | Its footer invites this: "If you (the agent) improve this workflow... update this section." | +| Vendored third-party asset | `LICENSES/.txt` + a feature-map note in §6 | Follow the Lucide precedent. | +| Deep explanation of an existing subsystem | `docs/architecture/.md` | A fact's *statement* lives in `ARCHITECTURE.md`; its *explanation* lives in the deep doc; each links to the other. | + +**The same-PR rule is the load-bearing one.** Doc updates that ride the code +PR actually happen (see `cf10741`, `b600e12`, `c4a602b` in history); doc +updates deferred to "later" don't. + +## 3. House style + +Derived from reading `ARCHITECTURE.md` and the investigation docs. Match it. + +- **Dense, specific, evidence-first.** State the mechanism and the proof, not + vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps + and selection ranges quoted verbatim in investigation docs. +- **Bold lead-ins for gotcha bullets**, then the explanation: + `- **Stale release builds**: ...`. Scannable list, detail inline. +- **Backticks for every file, symbol, flag, and command**: + `` `recomposeDirty` ``, `` `+EditFlow` `` (the extension-file shorthand), + `` `-debug.reproScript` ``. +- **One-line repro pointers**, not embedded essays: "See + `misc/bug-repros/image-blank-after.mov`", "grep `~/.edmund/logs` for + `repairing content above origin`". +- **Honest status labels.** The docs say "unconfirmed live", "theory + + targeted repair, not a confirmed kill", "Verification limits (honest + gaps)", "the test documents intent; the leap only reproduces under live + layout". Never claim verification you didn't do. No oversell. +- **Section anchors as cross-refs**: "see §8", "ARCHITECTURE §13" — used + across ARCHITECTURE, CLAUDE.md, before-you-release.md. If you renumber + sections, grep the repo for `§` and fix every reference. +- **Address "you", the next agent/engineer**: "will bite you", "Context for + anyone who sees the bug again", "Next time it happens: ...". +- **Record what failed, not just what worked** — investigation docs keep the + overturned theories and the phantom fixes (stale-binary trap) because the + dead ends are the reusable knowledge. + +### Commit messages (from `git log --oneline -50`) + +Mixed but patterned: conventional prefixes dominate for fixes and docs — +`fix(scope): ...` (scopes seen: `editor`, `layout`, `scroll`, `undo`, +`release-workflow`, `changelog-to-html`), `docs: ...`, occasional +`refactor:`, `appcast: add Edmund X.Y.Z`, `release X.Y.Z`. Chores and README +work often use plain imperative subjects ("Update README", "Add assets for +README"). Branches: `fix/`, `docs/`, `chore/`. When in +doubt: `fix(scope):` for behavior changes, `docs:` for doc-only commits, +plain imperative for chores. Never auto-push, PR, or merge — only when asked. + +## 4. CHANGELOG format — machine-read, get it exact + +`.github/workflows/release.yml` extracts release notes with: + +``` +awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" +``` + +So the section header MUST be `## [x.y.z]` at line start, version matching +`CFBundleShortVersionString` exactly; the section ends at the next `## [`. +`scripts/changelog-to-html.py` converts the same section to HTML for +Sparkle's update dialog (it folds wrapped bullet lines into their `

    • ` — +wrapping bullets is safe). Full pipeline: `edmund-release-and-operate`. + +House format (verify against the file; current entries follow this): + +```markdown +## [0.1.4] — 2026-07-XX + +### Fixed +- ([docs](docs/-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN) + +--- +``` + +- Em dash between version and ISO date; `---` separator between versions. +- Subsections used so far: `### Added`, `### Changed`, `### Fixed` + (Keep a Changelog 1.1.0 vocabulary). +- Entries describe the user-visible effect, not the mechanism; mechanism + lives in the linked investigation doc / ARCHITECTURE. +- An optional free-text line under the header is fine (0.1.2 has one) — the + awk extraction includes it. + +## 5. The investigation-doc template + +Derived from `docs/investigations/delete-drift-investigation.md` (6 rounds) and +`docs/investigations/viewport-glitch-investigation.md`. Both open with why the doc exists +("Context for anyone who sees the bug again... records the trail end to +end") and name the fixing commits/branch up front. Chronicle structure: each +recurrence is a new `## Round N` appended to the same doc — symptom → +diagnosis → root cause → fix → verification, with limits stated. + +Skeleton (copy-paste): + +```markdown +# "" — investigation notes + +Context for anyone who sees this again. + +Fixed on branch `fix/`, commits: `` — , ... + +## Symptom + +`, `~/.edmund/logs/...`.> + +## How it was diagnosed + +1. + +## Root cause + + + +## The fix + + + +## Verification + + + +## If it ever recurs + + + +## Round 2: ← append on recurrence, same structure +``` + +After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a +pointer, add the CHANGELOG entry with a `([docs](docs/...))` link, and check +the corresponding `misc/backlog.md` box (or move it under `On-going bugs`). + +## 6. Maintenance duties + +Do these whenever you touch the relevant doc; they rot otherwise. + +- **ARCHITECTURE placeholders**: §9 and §10 end with italic *"Add new ones + here"* / *"track code-debt here"* lines — keep them last in their lists so + the invitation stays visible. +- **ROADMAP `Last updated:`** — bump the date on any edit. +- **Backlog hygiene**: check `- [x]` boxes when a fix ships (move to + `## Done` only if following the existing pattern — completed items live + there); keep repro pointers valid; don't reorder the maintainer's priority + sorting. +- **README's inline HTML comments** are the maintainer's own edit notes + (e.g. ``) — leave them unless acting on + them. +- **At release**: CHANGELOG section header ↔ `Info.plist` version ↔ appcast + `` must agree; the checklist is `misc/before-you-release.md`, the + mechanics `edmund-release-and-operate`. +- **Section renumbering** in ARCHITECTURE: grep the whole repo (docs, misc, + CLAUDE.md, skills) for `§` references before and after. +- **Never edit `test-files/todo.md`** — the maintainer owns it. + +## Provenance and maintenance + +Written 2026-07-05 against `main` at `fe8a1f5` (release 0.1.3). Sources, all +read directly: `docs/ARCHITECTURE.md` (header, §8–§13), +`README.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `misc/backlog.md`, +`docs/investigations/delete-drift-investigation.md`, `docs/investigations/viewport-glitch-investigation.md`, +`docs/dev-guides/live-repro-guide.md` (§1), `misc/before-you-release.md`, +`misc/how-to-release.md`, root `CLAUDE.md`, +`.github/workflows/release.yml` (awk extraction quoted verbatim), +`git log --oneline -50` (commit-style tally), directory listings of +`docs/` (`architecture/`, `investigations/` incl. `archives/`, `dev-guides/`), +`misc/`, `misc/bug-repros/`, `LICENSES/`. + +§1 map re-verified 2026-07-09 against the `docs/` reorg (investigation docs +split into `docs/investigations/` + `docs/investigations/archives/`; +`docs/live-repro-guide.md` moved to `docs/dev-guides/`). + +Maintain this skill when: a doc of record moves or splits (update the §1 +map), ARCHITECTURE sections are renumbered (fix every § reference here), +the CHANGELOG extraction in `release.yml` changes (§4 quotes it), or a new +investigation doc establishes a better template. Keep the one-home-per-fact +rule itself stable — it is the point of the skill. diff --git a/.claude/skills/edmund-external-positioning/SKILL.md b/.claude/skills/edmund-external-positioning/SKILL.md new file mode 100644 index 0000000..f51efd4 --- /dev/null +++ b/.claude/skills/edmund-external-positioning/SKILL.md @@ -0,0 +1,204 @@ +--- +name: edmund-external-positioning +description: > + Load when writing anything an outsider will read about Edmund — README edits, + blog posts, release notes, marketing copy, social posts, webpage text, Show HN + drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, + Nodes), deciding what may be publicly claimed vs. what is still unproven, + labeling a technique "novel", or planning ecosystem work (licenses, + attribution, notarization messaging, appcast, issue templates, discovery + listings). This skill is the overclaim firewall: what the positioning is, + what evidence backs each claim, and what must exist before a claim gets + stronger. +--- + +# Edmund external positioning — what we claim, what we can prove + +Governing rule: **nothing may be claimed publicly that an outsider cannot +reproduce from the repo + a release.** Unproven = "candidate", never "novel" +or "first". This skill exists to prevent overselling a beta. + +## When NOT to use this skill + +| You are doing | Use instead | +| --- | --- | +| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate | +| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing | +| Understanding the invariants / render pipeline itself | edmund-architecture-contract | +| Deciding whether a code change is allowed | edmund-change-control | +| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics | +| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology | +| Verifying behavior before shipping | edmund-validation-and-qa | + +## 1. The positioning (quote it exactly) + +Source of truth: `README.md`. As of 2026-07-05: + +- One-liner: **"Edmund is a minimal, file-based, native Markdown editor for + macOS with inline live preview."** + - README carries its own TODO comment on this line: *"Replace 'minimal' with + 'customizable' or 'lightweight' once more features are implemented"* — do + not do that replacement early; "minimal" is the honest word today. +- Goal statement: **"Our goal is to be the [CotEditor](https://coteditor.com) + of Markdown editors, i.e. elegant, powerful, configurable, and native inside + out."** +- Beta warning: **"⚠️ Edmund is currently in beta."** — this must stay visible + in the README and any landing page until v1.0.0 ships. +- Maintainer's blog post: ("more of + the motivation and design philosophy"). Cite it; do not paraphrase or invent + its content without fetching it. +- Ambition framing: **product-first**. "Beyond state of the art" means product + excellence — the TextKit 2 techniques are means, not ends. Never lead public + copy with internal mechanism names; lead with what the user gets. + +### The six claimed differentiators (README, verbatim, 2026-07-05) + +| # | Claim (verbatim) | +| --- | --- | +| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." | +| 2 | "File-based: Open `.md` files from anywhere. No vaults or dedicated folders required." | +| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." | +| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." | +| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" | +| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." | + +README also has a TODO comment after the list: *"Move 'Fast' and 'Extensible'? +Add 'integrations' section to Native after implementation"* — the list is +known-provisional; keep quotes synced to the file when you edit copy. + +## 2. Claims discipline + +Before strengthening any claim publicly, the evidence in the middle column +must be upgraded to the right column. Status as of 2026-07-05. + +| Claim | Current evidence | Required before strengthening | +| --- | --- | --- | +| Fast: "~1-2MB files with ease. No launch lag" | `Tests/EdmundTests/PerfHarnessTests.swift` (gated `MD_PERF=1`, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with `fullLayoutMaxLength = 100_000` regime (`EditorTextView.swift:80`) | A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (`MD_PERF=1 swift test`). No comparative "faster than X" claims without benchmarking X the same way. | +| Extensible: "Extensions system coming soon!" | Extensions API is `docs/ROADMAP.md` v1.0.0 — **not shipped**. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" | Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. | +| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (`ReadRenderOptions.allowRemoteImages = false`); inline HTML whitelisted via `HTMLRenderer.sanitizeInlineHTML` | Note: the "Block external images" Settings checkbox is a `misc/backlog.md` item, **not shipped**; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). | +| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". | +| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. | +| File-based: "No vaults" | Shipped by design | Safe. | +| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. | + +## 3. Novel vs. known — the honest inventory + +When writing a craft blog post or comparison, keep this ledger straight. +"Candidate" means blog-worthy pending proof; it is not "proven novel". + +### Known / prior art (never claim novelty here) + +- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is + the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience"). +- TextKit 2 viewport virtualization for Markdown: + [nodes-app/swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) + (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, + live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: + consult it **before inventing a new mechanism** and as a technique source + (drag-select autoscroll, overscroll). Its existence caps any "first TK2 + live-preview engine" claim at zero. +- The README's own Alternatives section credits ~15 editors. Public copy that + ignores them reads as either ignorant or dishonest. + +### Distinctive candidates (label as such; each needs proof before publishing) + +| Candidate | Why it might be blog-worthy | Proof needed first | +| --- | --- | --- | +| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples | +| Stroked-`CGPath` overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) | A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces | +| Bypassed-`didChangeText` heal + caret re-assertion (round-6 mechanism: TK2 leaves a `_fixSelectionAfterChange` queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) | Deep TK2 internals nobody has documented; the delete-drift chronicle (`docs/investigations/delete-drift-investigation.md`) already exists as raw material | The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) | +| Diff-based undo restore that preserves TK2 layout (snapshot restore *diffs* rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document | +| In-process ReproScript methodology (`-debug.reproScript`, keystroke replay without CGEvents/TCC; `docs/dev-guides/live-repro-guide.md`) | Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard | + +Reproducibility standard for any technical post: a reader with the repo and +the post must be able to reproduce every claim — frozen repro scripts, pinned +document fixtures, named macOS versions, measured numbers with the command +that produced them. If a claim can't meet that, cut it or mark it anecdotal. + +## 4. Ecosystem and license hygiene + +Verified against the repo, 2026-07-05: + +- **License: Apache 2.0** (`LICENSE`, README "License" section, and the 0.1.0 + changelog entry all agree). Say "Apache 2.0", never "MIT". +- **Lucide icons: vendored, ISC** (`LICENSES/lucide.txt`, © 2026 Lucide Icons + and Contributors; parts derived from Feather). Attribution duty: keep + `LICENSES/lucide.txt` shipping and credit Lucide where icons are discussed. +- **Why Lucide in both modes (SF Symbols constraint):** ARCHITECTURE §6 — + SF Symbols **cannot ship in exported PDFs** (license), so callout headers use + Lucide in both Read (inline SVG, `currentColor`-tinted) and Edit (rasterized + tinted `NSImage` overlay). App-chrome SF Symbols (toolbar/settings) are fine; + Edit-mode task checkboxes still use SF Symbols on-screen only. Don't + "simplify" copy or code in a way that breaks this split. +- **Dependencies to credit:** swift-markdown, SwiftMath, Sparkle (README + "Dependencies"). Acknowledgements section additionally credits + swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, + and others — preserve it when restructuring the README. +- **Not notarized (2026-07-05):** ad-hoc signed; users hit Gatekeeper + ("damaged app"). README's WARNING block gives the two workarounds + (System Settings → Privacy & Security → Open Anyway; or + `xattr -dr com.apple.quarantine /Applications/Edmund.app`, maybe `sudo`). + Keep those instructions accurate in every venue that mentions installing. + `misc/marketing/MARKETING.md` gates Show HN on fixing this (notarize, or + make the workaround idiot-proof). +- **GitHub issue templates exist:** `.github/ISSUE_TEMPLATE/bug_report.md`, + `feature_request.md`. Point users there, not at email. +- **`appcast.xml` is a public artifact** served raw from the repo (`SUFeedURL` + points at the raw GitHub URL). Anything committed to it is user-visible in + Sparkle's update dialog. Pipeline details: edmund-release-and-operate. + +## 5. Release-notes and public-writing style + +- **Pipeline:** `CHANGELOG.md` sections become both the GitHub release notes + (awk-extracted) and Sparkle's update-dialog HTML + (`scripts/changelog-to-html.py` → appcast ``). A CHANGELOG + entry IS public copy — write it that way. Mechanics: edmund-release-and-operate. +- **Actual house style** (read `CHANGELOG.md` 0.1.0–0.1.3 before writing): + Keep-a-Changelog headers (`### Added / Changed / Fixed`); one line per item, + sentence case, no trailing period enforced; links to issues (`[#156]`) and + investigation docs (`([docs](docs/investigations/delete-drift-investigation.md))`); + user-visible phrasing ("Redo now jumps to where changed text was instead of + caret") not internal jargon; occasional first-person maintainer notes with + personality ("trying to have Fable 5 fix all the big bugs while I still have + it with me"); 0.1.0 used bold **Feature** — one-line descriptions. Match this + voice: plain, specific, lightly informal, zero hype. +- **Screenshots/videos:** README embeds live in `docs/assets/` + (`v0.1.0_*.png`, `installation.png`, `v0.1.0_video.mp4`, `AppIcon/`). + Raw/source marketing assets live in `misc/marketing/`: `MARKETING.md` + (the plan), `reddit-post.md`, `demo-slide-v0.1.key`, `demo-src-files/`, + demo videos (`demo.mov`, `demo-video-v0.1-brown.mp4`), `_raw` screenshot + masters, `social-preview_figma.png`. New public screenshots: polished copy + → `docs/assets/`, raw master → `misc/marketing/`, versioned filenames. + +## 6. Marketing priority context (2026-07-05) + +From `misc/backlog.md` "Now": **"Priority: Marketing = Bugs >= UI/UX > +Features"** — marketing work is tied for top priority with bug fixes. +Open marketing items: screenshots/files and a webpage (reference: +kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart; +`misc/marketing/MARKETING.md` names GitHub stars (~69 at writing) as the goal +and metric, audience "developers who value craft", and holds Show HN in +reserve until first-run friction and a landing page are fixed. Its "craft +months" deep-dives are exactly the Section 3 candidates — which is why the +proof bar there matters. + +## Provenance and maintenance + +- Sources verified 2026-07-05 against: `README.md`, `docs/ROADMAP.md` + (last updated 2026-07-03), `misc/backlog.md`, `docs/ARCHITECTURE.md` + (§2, §6, §8, §13, §14), `CHANGELOG.md` (0.1.0–0.1.3), `LICENSE`, + `LICENSES/lucide.txt`, `.github/ISSUE_TEMPLATE/`, `misc/marketing/`, + `Tests/EdmundTests/PerfHarnessTests.swift`, + `Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift`, + `Sources/EdmundCore/TextView/EditorTextView.swift`. +- Volatile facts are date-stamped inline: version (0.1.3), beta status, + notarization status, shipped-vs-roadmap feature split, star count, README + wording. Re-verify each against the file before repeating it publicly. +- When README differentiators or the one-liner change, update the verbatim + quotes in §1 and re-run the §2 evidence check. +- If a §3 candidate ships as a published post, move it out of "candidate" and + link the post + its frozen repro. +- Cross-references: edmund-release-and-operate (release/appcast mechanics), + edmund-docs-and-writing (internal doc style), edmund-research-frontier + (novelty judgment for research direction), edmund-architecture-contract + (the invariants quoted here). diff --git a/.claude/skills/edmund-failure-archaeology/SKILL.md b/.claude/skills/edmund-failure-archaeology/SKILL.md new file mode 100644 index 0000000..779bca0 --- /dev/null +++ b/.claude/skills/edmund-failure-archaeology/SKILL.md @@ -0,0 +1,442 @@ +--- +name: edmund-failure-archaeology +description: > + The chronicle of every major bug investigation, dead end, rejected fix, and + revert in the Edmund Markdown editor. Load BEFORE re-investigating any + caret/selection symptom (drift, jump, desync), any viewport/scroll glitch + (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge + (clipped wrap, one-line collapse, blank space), or any release/update + failure (signing, appcast, Sparkle "improperly signed"). Load before + proposing a fix that might already have been tried and reverted, when a bug + report "looks familiar", when a test passes but the live app still misbehaves, + or when wondering why the code does something weird (a guard, a re-assert, a + deliberately-missing icon). Every entry: symptom, root cause, evidence + (commit hashes, docs), status, and what NOT to retry. +--- + +# Edmund failure archaeology + +Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives +symptom → root cause → evidence → status. Hashes are on `main` unless noted. +Dates are commit dates. Status vocabulary: **settled** (root-caused, fix +verified), **mitigated-unconfirmed** (fix shipped, never seen killing a live +occurrence), **open** (in `misc/backlog.md`), **reverted-pending-redo**. + +## When NOT to use this skill + +- Designing a change / asking "why is it built this way" → `edmund-architecture-contract`. +- Actively debugging a NEW symptom (method, not history) → `edmund-debugging-playbook`. +- Driving the live app to reproduce something → `edmund-live-repro-and-diagnostics` (and `docs/dev-guides/live-repro-guide.md`). +- TextKit 2 / AppKit API semantics → `textkit2-appkit-reference`. +- Cutting or fixing a release → `edmund-release-and-operate` (this file only records how 0.1.0 broke). +- Build environment, stale-link traps in depth → `edmund-build-and-env`. +- Deciding whether a change is safe to make at all → `edmund-change-control`. +- Test strategy / what the suite can and cannot catch → `edmund-validation-and-qa`. +- The ongoing caret-integrity program (forward-looking) → `edmund-caret-integrity-campaign`. +- Debug flags (`-debug.reproScript`, verbose tracing toggles) → `edmund-config-and-flags`. + +--- + +## 1. The delete-drift saga (rounds 1–6) — issue #156 + +The hardest bug in the project's history: pressing Delete moved the caret to a +different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04. +Full trail: `docs/investigations/delete-drift-investigation.md` (read it before touching +`+EditFlow` / `+SelectionTracking` / the heal). One symptom, FOUR distinct +root causes stacked on top of each other — each fix was real, and each round's +recurrence was a different mechanism underneath. + +**STATUS: settled through round 6** (shipped in 0.1.3, 2026-07-04). The class +— live-only caret/selection desync — remains the project's hardest problem; +new rounds are possible. Backlog still lists "Delete caret drift" under +On-going bugs as a class to watch, not a known unfixed defect. + +### Round 1 — stranded IME marked text (2026-06-26, `386604b` + docs `ef3d87e`) + +- **Symptom:** once it started, EVERY delete drifted; never at launch; cleared + by switching apps and back. Text stayed correct — caret-only desync. +- **Root cause:** every styling path bails on `hasMarkedText()` (correct during + live IME composition). A *stranded* composition (`hasMarkedText()` stuck true, + no live composition) made `didChangeText` bail forever → `rawSource`/`blocks` + froze while storage kept mutating → all caret math ran against a stale model. + Strander: the async active-block restyle in `+SelectionTracking` re-checked + `isUpdating` but not `hasMarkedText()`, so it could run `recomposeDirty` over + a live composition scheduled one turn earlier. +- **Fix:** (a) add the missing `!hasMarkedText()` guard to the async restyle; + (b) `becomeFirstResponder` recovery hook — `unmarkText()` + resync when the + invariant is broken on focus regain (made the user's accidental focus-switch + cure deterministic). +- **Why it came back:** the guard closed one strander; other marked-text + sources existed (round 2), and other desync mechanisms entirely (rounds 4–6). + +### Round 2 — marked text without "IME" (2026-06-27, `a1f3219`) + +- **Symptom:** recurred with no CJK/accent/emoji input. Focus-switch still cured it. +- **Root cause (by elimination, documented in the doc):** still stranded marked + text — from **automatic text completion / inline predictions**, which inject + provisional marked text on plain typing. +- **Fix:** `isAutomaticTextCompletionEnabled = false`, `inlinePredictionType = .no` + in `commonInit`, plus a permanent `Log.info` breadcrumb in the recovery hook. +- **Why it came back:** the next recurrence wasn't marked text at all. + +### Round 3 — no fix; built diagnostics instead (2026-06-28, `5dae387`, `3aaeb04`, PR #139) + +- **Symptom:** recurred on a build with rounds 1–2. NO `recovered stranded desync` + log line; a headless probe of the exact gesture showed the model was CORRECT. + Only appeared after minutes of editing in one window. +- **Conclusion:** the model/parse layer is sound; the drift is a live + NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing + it blind was declared the wrong move. +- **Shipped:** verbose editor tracing (Settings ▸ Advanced, `Log.trace`, + category `.edit`, per-event live-state prefix) + an always-on O(1) invariant + tripwire (`verifyEditorInvariants`, logs `error` on length mismatch). This + instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug + resists reproduction, ship diagnostics, not guesses. + +### Round 4 — drag-move deletes with NO `didChangeText` (2026-07-02, `9f99795`, PR #163) + +- **Symptom (from the round-3 trace):** drifting deletes showed + `shouldChangeText` → *nothing* → `selectionDidChange` mid-recompose with a + stale caret. Origin event: a drag-select, then `shouldChangeText OK repl=""`, + then LEN-MISMATCH forever — `didChangeText` never fired. +- **Root cause:** AppKit's drag-**move** gesture, when the drop lands past the + end of the document (or fumbles), performs the source deletion via + `shouldChangeText` → `replaceCharacters` and **never calls `didChangeText`**. + `rawSource` silently froze — and **autosave wrote the stale bytes**: this was + a data-corruption bug, not just a caret bug. +- **Fix:** `shouldChangeText` schedules a next-run-loop bypass check + (`RunLoop.main.perform`): a `pendingEdit` still unconsumed one pass later == + didChangeText was bypassed → run the same sync it would have, log + `healing storage edit that bypassed didChangeText` (release too). Exempt + while `isUpdating`/`isUndoRedoing`/`hasMarkedText()` (IME legitimately defers). +- **Why it came back:** the heal restored the *model* but rounds 5–6 found the + heal itself could move the caret. + +### Round 5 — the heal leaped the caret (stale selection) (2026-07-03, `422498f`, docs `c4a602b`, merged `222dd86`, PR #166) + +- **Symptom:** heal fired, invariant restored, bytes correct — but the caret + leaped to the END of the document at the heal moment. +- **Root cause:** when the bypassed deletion removes the *selected* text, AppKit + also skips its usual selection fix, so at heal time the selection still spans + deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes + AppKit re-resolve the invalid selection → clamps to document end. +- **Fix:** before syncing, the heal **collapses an out-of-bounds selection** to + the edit point. (Headless NSTextView clamps this itself — the test documents + intent; only live layout reproduces the leap.) +- **Why it came back:** this out-of-bounds clamp was a special case of the real + mechanism, found in round 6. + +### Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, `1b1420a`, branch `fix/wrapped-paragraph-caret-drift`, merged `218d922`, PR #169) + +- **Symptom:** typing mid wrapped paragraph, one backspace leaped the caret +43 + ("two viewport-lines down"); drift no longer continuous — one delete drifts, + the next ones don't. Model fine; a heal had fired 80 seconds EARLIER. +- **Root cause (named via a `traceSelectionOrigin` stack capture):** a normal + edit runs TextKit 2's `_fixSelectionAfterChangeInCharacterRange` synchronously + inside its own transaction. A didChangeText-bypassing mutation skips that too, + so the fixup **stays queued and fires at the NEXT `endEditing`** — the heal's + attribute-only restyle — where it maps the stale selection against post-edit + coordinates and drops the caret blocks away. Fires exactly once (state is + then synchronized), explaining "drifts once, then fine". Round 5's clamp was + the sub-case where the stale selection ran past the shrunk document end. +- **Fix:** the heal derives the correct caret from the pendingEdit hull and + sets it **both before AND after** `syncRawSourceFromDisplay()`. The + before-only version still leaped — **the queued fixer moves even a freshly + set, fully valid caret** during the sync's `endEditing`. The post-sync + re-assert is the load-bearing half; the pre-set keeps `cursorRaw`/active-block + styling correct. +- **The breakthrough repro** (first deterministic one in six rounds): + `ReproScript.swift` (DEBUG-only, `-debug.reproScript `) replays + keystrokes in-process through `window.sendEvent(_:)` — no TCC, works on an + invisible Space. `bypassdelete ` simulates the drag-move deletion + exactly; one bypass beforehand → the next delete always drifts. Typing alone + never drifts. See `edmund-live-repro-and-diagnostics`. + +### Do not retry (proven dead across the saga) + +- **Headless/unit tests for this class.** The test harness runs TextKit 2's + selection fixups synchronously, so the deferred-fixup state never forms. The + round-6 unit test **passes with and without the fix** — it is a contract + spec, not a regression guard. Only the ReproScript live repro discriminates. + Do not "add a test to catch it" and call the class covered. +- **CGEvent injection as the default live driver.** Round 6's session dropped + the events (per-session TCC), and the app's windows launch on an inactive + Space. Use ReproScript first. +- **DEBUG assertion in `didChangeText`'s marked-text guard** — rejected in + round 1: the invariant is legitimately broken during composition; it + false-fires on every IME keystroke. +- **"No explicit selection repair needed"** (round 4's claim) — wrong twice. + Any new heal-like path must handle selection explicitly, before and after. +- **`swift build` trusted after "Build complete!"** — round 6 hit a stale-link + relink failure TWICE; two "failed" fix iterations were phantoms running old + code. Verify with `strings` on a LONG literal (≤15-byte literals inline on + arm64 and never show), cure with `swift package clean`, never hand-delete + `edmd.build/`. Details: `edmund-build-and-env`. + +--- + +## 2. Undo/redo viewport drift — the costliest failure + +**STATUS: settled** (2026-07-02, `5bb2b40`, part of PR #164) — with one caveat: +`misc/backlog.md` "Lurking (Unreproduceable)" carries a later note "Undo/Redo +and Copy/Paste scrolling is failing again". No repro exists. Treat the +*mechanism* below as settled and any new report as a NEW investigation that +starts from this entry. + +- **Symptom:** undo scrolled too far down; redo centered on wherever the caret + sat before the undo; changed text never selected. +- **Root cause (two defects, found by code read before any experiment):** + 1. `restoreSnapshot` ran a **full `recompose`** — replacing the entire + storage discards every TextKit 2 layout fragment, resetting ALL geometry + to height estimates; the subsequent centering math measured estimates. + 2. `performUndo` recorded the redo snapshot with the caret *at undo + invocation time* (stale), and redo centered on it. +- **Fix:** `textDiff(old:new:)` single contiguous changed span → range-bounded + `recomposeReplacing` (layout outside the span stays real); the **changed + range** — never a stored caret — is selected and drives the viewport (hold if + visible, else center). +- **Load-bearing contract: never full-recompose on undo/redo.** Anyone + "simplifying" `restoreSnapshot` back to `recompose` reintroduces the bug. + Guarded by `TextDiffTests`, `UndoRedoSelectionTests`, `UndoRedoViewportTests`. +- **Prior art that treated symptoms without naming the estimate problem:** + `9aaa11b` (undo hold-or-center), `2778d6e`/`21cc284` (cursor-move lurch), + `84123e4` (pin scroll above viewport), `c49cd5c` (lazy viewport-first + styling). All sound, all workarounds; `5bb2b40` removed the manufactured + estimates at the source. + +--- + +## 3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02) + +Full trail: `docs/investigations/viewport-glitch-investigation.md`. Three reported symptoms, +one root cause: **every off-screen TextKit 2 frame is an estimate**; code that +discards layout, trusts off-screen y, or runs two scroll policies at once turns +estimate churn into visible jumps. Community-documented (Krzyżanowski / +STTextView, Apple forums) — even TextEdit exhibits it. + +- **Bug 1 (undo/redo):** entry 2 above. **STATUS: settled** (same caveat). +- **Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).** + Theory: TK2 assigns fragments **negative y** when estimates above the + viewport correct downward; scroller clamps at 0. Mitigations `217da5f` + (documents ≤100k UTF-16 kept **fully laid out** via a deferred + `scheduleFullLayoutSettle` — estimates never exist) and `8b4ecfe` + (`repairContentAboveOrigin`: first fragment `minY < -0.5` → re-lay + start→viewport-end inside `preservingViewportAnchor`; breadcrumb + `repairing content above origin`). + **STATUS: mitigated-unconfirmed.** The doc is explicit: Bug 2 was **never + reproduced live**; the repair had not been confirmed against a live + occurrence as of this writing (2026-07-05). If it recurs: grep + `~/.edmund/logs` for the breadcrumb — present means diagnosis confirmed but + repair raced/undersized; absent means different cause (scroller-only + estimate jumps, or `textContainerOrigin`). +- **Bug 3 (viewport oscillates during a steady drag-select).** Two scrollers + fighting: drag autoscroll pulling down vs the `scrollRangeToVisible` + override always revealing the selection **top** once the selection outgrew + the viewport. Fix `340fcbc`: reveal the **nearest** end. **STATUS: settled + by geometry/reasoning** — a live drag was never synthesized (that session + couldn't arm AppKit selection). Phase 1 of the same report ("can't select") + was NOT a bug: a whole-doc selection was active, so the drag was AppKit's + drag-move gesture — same family as delete-drift round 4. +- **Do not retry:** raising `fullLayoutMaxLength` without measuring + `ensureLayout` cost (full layout on large docs is the process-killing path + that motivated the lazy pipeline); running a full layout *inside* a caller's + `preservingViewportAnchor` (poisons its before/after measurement — the + tab-indent stability test caught a 366pt compensation; that's why the settle + is deferred). +- Backlog keeps "Inaccurate viewport estimates and things related" under + On-going bugs, plus a lurking "glitch when scrolling" — the estimate class + is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport + stabilization". + +--- + +## 4. Callout-title wrap / the image wedge (settled 2026-07-03, `aa45563` + `ae61644`, PR #165) + +Full trail: `docs/investigations/archives/callout-title-wrap-investigation.md` — including a 10-row +matrix of dead ends. + +- **Symptom/goal:** custom callout titles (`> [!type] Title`) should render as + real wrapping text with the type icon; any attempt to draw the icon clipped + the wrapped title to one line. +- **Root cause:** **drawing any IMAGE on a multi-line layout fragment wedges + that fragment to a single line** — an unexplained TextKit 2 reentrancy quirk. + Isolated exhaustively: fragment overlay, frame-relative draw, before/after + `super.draw`, raw `CGContext.draw`, editor-level `draw(_:)`, pre-rasterized + bitmap, transparent subview, layer-backed subview, CALayer `contents` — ALL + clip. Controls: no icon → wraps; positions computed but plain rect filled + instead of the image → wraps. Reading layout is fine; drawing a **shape** is + fine; drawing an **image** is not. +- **Fix:** the icon is a **stroked `CGPath`** — `SVGPath` parses the vendored + Lucide geometry, `FragmentOverlay` gained a path form, + `DecoratedTextLayoutFragment` strokes it in CG. Verified live: icon renders, + long titles wrap and re-wrap on resize. +- **Standing constraint:** any future overlay that can share a line with + wrapping text MUST use the path form, never the image form. Existing image + overlays (math, bullets, default callout header) survive only because they + sit on single-line fragments. +- **Do not retry:** any image-drawing mechanism from the matrix; bumping the + deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no + evidence, drops Sonoma incl. the dev machine). The whole-header-as-image + alternative is preserved on branch `fix/callout-title-image` + (tip `c7f5170`, unmerged): it sidesteps the wedge but has two unsolved + problems (~2× line height band above the title; a width-timing race). +- Still open nearby (backlog): callout icon baseline / crispness; the + callout-at-end-of-file extra colored line (live-path rendering bug). + +--- + +## 5. The 0.1.0 release failures (2026-06-26 → 2026-07-02) + +Reference: ARCHITECTURE §13. Five separate failures shipping and updating the +first releases. **STATUS: all settled**, with one time bomb (PAT expiry). + +1. **`sign_update -s` exits 1 for new keys** (`59565f5`, 2026-06-27, PR #135). + Sparkle deprecated `-s `; for keys generated after that change it + prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on + **stdin** — `echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - `. + Do not retry `-s`. +2. **Bundle not sealed → EVERY update failed "improperly signed"** + (`5e54b40`, 2026-06-29, issue #158). Sparkle re-validates the Apple code + signature at install (`SUUpdateValidator` → `SecStaticCodeCheckValidity`); + the build signed only the main binary, never sealed the bundle, so a valid + EdDSA signature didn't save it. Fix: `codesign --deep` the whole `.app` — + and because SwiftMath's resource bundle must sit at the `.app` root + (`Bundle.module` hardcodes `Bundle.main.bundleURL`) and codesign won't seal + a bundle with root items, **seal first, copy the SwiftMath bundle in + AFTER sealing**. The lone unsealed root item trips strict + `codesign --verify` but not Sparkle's non-strict check (verified against + that exact API). Do not "fix" the ordering or the failing strict verify. +3. **Appcast push to protected `main` rejected, `GH006`** (`e56a4dd`, + 2026-06-28, PR #140). `GITHUB_TOKEN` isn't admin; `enforce_admins: false` + means an admin PAT bypasses the required check. Fix: fine-grained admin PAT + in secret **`RELEASE_TOKEN`**, set on the **checkout step** (not the push + URL — `actions/checkout` persists an `extraheader` credential that + overrides inline-URL creds). **`RELEASE_TOKEN` expires 2027-06-27**; rotate + before then or releases fail at the appcast push. +4. **create-dmg quirks** (`098d8c0`, 2026-06-26, documented in §8): it's the + **npm** create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; + **exit code 2 for unsigned images is success**; space-in-filename + normalization. +5. **Release workflow YAML invalid** (`854f85d`, 2026-07-02, merged `232e6c8`, + PR #162). Literal multi-line bash strings inside a `run: |` block had + unindented lines — invalid in a YAML block scalar; the whole workflow file + failed to parse. Fix: build the strings with `printf` `\n` escapes; also + `$(...)` strips trailing newlines, so the separator newline lives in + `NEW_ITEM`'s format string, not `DESC_BLOCK`. + +--- + +## 6. Reverts and abandoned directions + +- **Selection tint** — `ee173f7` (2026-06-26): reverted an experimental + selection color back to **accent-derived (accent @ 30% alpha)**. + **STATUS: settled.** Don't re-hardcode a bespoke selection color. +- **`img.md-image { display:block }` in the export/Read HTML theme** — + `75d2824` (2026-06-25): reverted; it did NOT fix the image blank-space and + broke layout. The commit title itself records the verdict: **image + blank-space is a separate, STILL-OPEN bug** (backlog: "Attached image + padding… creates a large empty space below", + `misc/bug-repros/image-blank-after.mov`). Do not retry display:block for it. +- **Checkbox click-to-toggle + table borders** — `9991413` (2026-06-03): + pulled from a branch for separate passes. **STATUS: partially redone.** + Table work landed later (edit-mode table alignment shipped, per backlog + Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — + **reverted-pending-redo**. The original attempt lives on stale branch + `feature/checkbox-toggle` (merged history, `d6227e3`). +- **TextKit 2 migration regressions** — `b3a4b29` (2026-06-12): the TK1→TK2 + migration silently broke inline-math height, HR spacing, and scroll; fixed + with `RenderingRegressionTests` as the guard. Lesson: TK2 migrations + regress silently in geometry — extend that suite when touching layout. +- **Stale branches that look abandoned but are MERGED** (don't "rescue" them): + `feature/incremental-recompose` (tip `1222f79`, in main) and + `refactor/word-level-rendering` (merged via PR #8, `7eb6a21` — word-level + delimiter hiding became the shipped approach). The genuinely unmerged WIP + branches are `fix/callout-title-image` (entry 4) and dozens of old merged + topic branches never deleted. + +--- + +## 7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6 + +`fix/wrapped-paragraph-caret-drift` / merge `218d922` IS delete-drift round 6 +(entry 1): the branch name comes from the reporting symptom (backspace mid +wrapped paragraph), but the root cause was the queued TextKit 2 selection +fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was +incidental. **STATUS: settled** with `1b1420a`. If a caret drift is reported +"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a +preceding heal breadcrumb in `~/.edmund/logs` first. + +--- + +## 8. Smaller settled battles (one paragraph each; verified in git) + +- **Flaky math fit-width test — took TWO rounds** (`5421297` 2026-06-06 branch + `fix/flaky-math-test`, then `352bdc9` PR #65). First fix pinned the text + container width (tracking off) — the flake persisted ~1 in 3 runs. Real + cause: **shared theme-defaults state pollution** between tests; fixed with + isolated defaults. Lesson: a flake "fix" that doesn't name the shared state + isn't done. +- **Emoji rendered as missing-glyph boxes** (`0f5ffba`, 2026-06-06). + `EditorTextStorage.fixAttributes` is a deliberate no-op (so `.attachment` on + real characters survives) — which also disabled font substitution. Fix: + perform substitution manually (Apple Color Emoji per composed-character + sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the framework + `fixAttributes`; it strips the marker attachments. +- **Nested list hanging indent** (`8d2088f`, 2026-06-06, PR #64). + swift-markdown's list-item delimiter excludes leading indentation; the + visible spaces broke the hang. Fix: hide the leading whitespace in the + inactive branch; indentation comes entirely from the paragraph style. +- **Ordered-list deep nesting lost styling** (`b255903`, 2026-06-06). + swift-markdown parses ≥4-space indent as indented code; the rescue regex + only matched `[-*+]`. Extended to `\d{1,9}[.)]`. Any new list-ish syntax + must be added to the rescue parser too. +- **Window size persistence — three commits to get right** (`538ff6e` → + `d967bcf` → `678c5d6`, 2026-06-28, PR #144). Saving content-view size made + windows grow taller on reopen (titlebar double-counted); final form stores + the **full window frame** and restores via `setFrame`. +- **Toolbar right-click interception — three failed view-level attempts** + (`4eb604a`, `6723280`, then `f8472ca`, 2026-06-26). View `.menu`, + `rightMouseDown`, and a gesture recognizer all lost to the toolbar's + "Customize Toolbar…" menu. Working fix: `DocumentWindow` overrides + `NSWindow.sendEvent` — the documented funnel ahead of the toolbar — and + swallows secondary clicks on the view-mode button. Do not retry view-level + interception for anything the titlebar/toolbar claims. +- **Invisible CJK input** (`a3df387`): IME-composed text was invisible until + committed; fixed by keeping marked text visible. Related to (and predating) + the round-1 marked-text rules. + +--- + +## 9. Still OPEN — do not let this chronicle imply otherwise + +Per `misc/backlog.md` (cross-checked 2026-07-05): footnotes don't render +(edit or Read); math doesn't render in Read mode; math padding in edit mode; +**image blank-space below attached images** (see the `75d2824` revert); +callout-at-end-of-file extra colored line; max content width not applied to +Read mode; tables don't wrap/shrink at small content sizes; table-cell content +wraps out of the cell; "sometimes click to select / select+delete doesn't +work" (unreproduced); lurking scroll glitch from off-viewport height changes; +lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste +scrolling failing again" (see entry 2 caveat). Delete-caret-drift and +viewport-estimate classes stay on the watch list even though every known +mechanism is fixed. + +--- + +## Provenance and maintenance + +- Written 2026-07-05 by mining: `docs/investigations/delete-drift-investigation.md`, + `docs/investigations/viewport-glitch-investigation.md`, + `docs/investigations/archives/callout-title-wrap-investigation.md`, `docs/ARCHITECTURE.md` §13, + `CHANGELOG.md`, `misc/backlog.md`, `docs/ROADMAP.md`, and `git log --all` + (every hash above verified with `git show` on that date). +- **Code and git win over prose.** If this file disagrees with a commit or the + current source, trust the commit, then fix this file. +- **Update triggers:** a new delete-drift round (append to entry 1 — never a + new doc); any live confirmation or refutation of `repairContentAboveOrigin` + (flip entry 3 Bug 2 off mitigated-unconfirmed); `RELEASE_TOKEN` rotation + (entry 5); any revert (entry 6); closing a backlog bug named in entry 9. +- Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". + No oversell — this file's value is that its claims can be trusted blind. +- Sibling map lives in "When NOT to use" above; keep it in sync as the skill + library grows. diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md b/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md new file mode 100644 index 0000000..89d960d --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md @@ -0,0 +1,287 @@ +--- +name: edmund-live-repro-and-diagnostics +description: > + How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, + interpretation guides, and working scripts. Load when a bug involves LIVE + behavior (caret, IME, drag, viewport timing), when a unit test cannot + reproduce a report, when you need to read diagnostic traces, drive the + running app with scripted keystrokes, or measure pixels from a screenshot. + Contains the repro escalation ladder, the ReproScript driver, the CGEvent + fallback, and screencapture measurement, plus scripts/ helpers. Not the + symptom→mechanism table (edmund-debugging-playbook), not the campaign + (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env). +--- + +# Edmund live repro & diagnostics + +A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context +layer (deferred selection fixups, IME composition, drag sessions, event-loop +timing). **Headless tests cannot form the broken state** — the test harness runs +AppKit's deferred machinery synchronously, so a green unit test proves nothing +about this class. This skill is how you make such a bug cheap to observe, then +deterministic. Primary source doc: `docs/dev-guides/live-repro-guide.md`. + +Verified 2026-07-05. + +--- + +## 0. Safety preamble (do this every time) + +- **Check for the user's live instance first.** The user's daily-driver app has + the same binary name (`edmd`). Run `scripts/check-live-instance.sh`. **Never + blanket `pkill -x edmd`** — kill only your own PID, or use `pkill -f + EdmundDbg` (only your debug bundle matches). +- **Recreate the test document fresh before every run** — autosave mutates it; + run 2 against run 1's leftovers produces garbage offsets. +- **Verify the binary is fresh** before trusting a run (SwiftPM sometimes prints + `Build complete!` without relinking `edmd`) — strings/shasum method in + **edmund-build-and-env**. +- **Do not request macOS Computer Access.** Screen Recording + Accessibility are + already granted for this project; ReproScript (§3) needs neither. + +--- + +## 1. The escalation ladder + +Work down; stop at the first level that reproduces. Each is more faithful and +more expensive than the one above. + +| # | Technique | Faithful to | Cost | Use when | +|---|---|---|---|---| +| 1 | Plain unit test (`makeEditor()`) | model/parse/style logic | seconds | anything not event-timing-dependent | +| 2 | Windowed unit test (NSWindow + NSScrollView, real `deleteBackward(nil)`) | + layout, viewport, first responder | seconds | viewport/lazy-styling bugs (`LazyRenderingTests` setup) | +| 3 | **In-process ReproScript** (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — **the default for live bugs** | +| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll | +| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit | + +Two levels deserve emphasis: +- **Level 2 failing to repro is evidence, not defeat** — it tells you the bug + needs deferred/queued AppKit state, pointing you at level 3–4. +- **Level 3 exists because level 4 is unreliable** — background/agent sessions + often have no TCC grant and synthetic keyboard events get dropped silently. + In-process injection needs no permission. + +--- + +## 2. Step 0 — make the trace tell you the trigger + +Never script blind. The recipe is usually already in `~/.edmund/logs`, if +verbose diagnostics were on. Launch flags (file arg **must** be `argv[1]`): + +```bash +/Contents/MacOS/edmd FILE.md \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES +``` + +**Trace-field decoder** (each verbose line carries these; from +`EditorTextView+Diagnostics.swift`): + +| Field | Meaning | +|---|---| +| `sel` | current selection `{location,length}` | +| `active` | active (caret) block index | +| `marked` | is there marked/IME text | +| `up` | `isUpdating` — **`up=Y` = event arrived mid-recompose** (suspicious) | +| `undo` | undo-stack depth | +| `blocks` | block count | +| `storLen` / `rawLen` | storage length vs rawSource length | + +- **Healthy edit ordering:** `shouldChangeText` → `selectionDidChange` (up=N) → + `synced`. A transient `⚠︎LEN-MISMATCH` *between* those lines is normal + (storage moves before rawSource syncs). +- **Suspect:** a `selectionDidChange` with `up=Y` at a surprising position; a + **persisting** LEN-MISMATCH; a `shouldChangeText` with **no** + `synced`/`SKIPPED`/`DEFERRED` after it (a bypassed `didChangeText`); the + `healing storage edit that bypassed didChangeText` breadcrumb. +- **`traceSelectionOrigin`** logs a **call stack** for any selection change that + lands mid-recompose — this is what named `_fixSelectionAfterChange` in round 6. +- **Walk BACKWARDS from the first bad line, not forwards from the symptom.** The + round-6 drift was armed ~80 seconds and dozens of healthy edits before the + visible failure. The user-visible symptom is often the *second* half. + +If the log didn't capture the deciding fact, **add the log line first** (keep +good ones behind `Log.shouldTrace` and ship them) and reproduce again. Also +**reconstruct the document** — wrapped-paragraph geometry, block boundaries, and +block kinds all matter; repro against a lookalike, never `"hello world"`. + +`scripts/grep-trace.sh [YYYY-MM-DD]` surfaces the suspect patterns in one shot. + +--- + +## 3. The in-process ReproScript driver (default for live bugs) + +`Sources/edmd/App/ReproScript.swift`, **DEBUG builds only**. Replays a keystroke +script against the front document by synthesizing `NSEvent`s and pushing them +through `window.sendEvent(_:)` — the full authentic key route (keyDown → +`interpretKeyEvents` → `insertText:` / `deleteBackward:`). No Accessibility, no +visible window required (works on an inactive Space), real run-loop pacing. + +Launch: `scripts/launch-debug.sh FILE.md SCRIPT.repro` (assembles EdmundDbg.app, +guards the user's instance, direct-execs with all flags). Or by hand: + +```bash +build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \ + -settings.general.diagnosticLogging YES \ + -settings.advanced.verboseEditorDiagnostics YES \ + -debug.reproScript "$SCRIPT.repro" \ + -ApplePersistenceIgnoreState YES & +``` + +**Command surface** (one per line, `#` comments allowed): + +| Command | Effect | +|---|---| +| `sleep ` | wait before the next command | +| `caret ` | place caret before the first occurrence of `` | +| `type ` | one real key event per char, ~80 ms apart | +| `backspace ` | n real delete keystrokes, ~300 ms apart | +| `bypassdelete ` | simulate the drag-move source deletion: select range, `shouldChangeText` + storage mutation, **no `didChangeText`** | +| `assertcaret ` | log `repro assertcaret PASS/FAIL sel=… want=…` iff caret sits exactly before `` | +| `logsel` | log selection, rawSource length, doc count | + +Round-6 minimal repro (the worked example): the deciding output was **`logsel` +321 (broken) → 290 (fixed)**, every run, window not even visible. + +``` +sleep 2000 +bypassdelete Sizemore, +sleep 800 +logsel # broken: {321,…}; fixed: {290,…} +backspace 2 +logsel +``` + +**Design rules — keep them when extending:** +- **Address text by needle, never offset** — offsets go stale the moment a + script edits; needles survive (this is what makes soak scripts possible). +- **Real events over direct method calls** — `insertText("")` shortcuts skip + `deleteBackward`'s selection machinery, the exact place round 6 lived. +- **Simulate AppKit-internal paths by exact call sequence** — `bypassdelete` + replicates `shouldChangeText` → `replaceCharacters`, no `didChangeText` + *verbatim*, not an approximation. Pin any new internal path's real sequence + from a `traceSelectionOrigin` stack first, then replay it. +- **Asserts inside the app, results in the log** — the harness (you, or a shell + loop) only greps `PASS`/`FAIL`; the app is the oracle. +- New commands are ~10 lines each — extend `ReproScript.swift`, don't work around it. + +**Soak scripts** (§6): chain several trigger cycles at different positions with +ordinary editing between them, `assertcaret` after each predictable step, and +compare final `rawLen` across runs (byte-identical = deterministic). A soak green +across 4–5 cycles is far stronger than one clean repro — it catches bugs needing +*armed state* (round 6's queued fixup). + +``` +bypassdelete Sizemore, +assertcaret Strang +backspace 2 +type xy +bypassdelete widely +assertcaret used in various +logsel +``` + +--- + +## 4. CGEvent driver (mouse-only paths, TCC willing) + +For paths that must originate as HID events — real drag-select, drag-move, +autoscroll — keyboard replay can't cover them. A ~70-line `ui.swift` (compile +with `swiftc`) posting `CGEvent`s does: `bounds ` (window lookup via +`CGWindowListCopyWindowInfo`), `click x y`, `dragselect`, `dragmove` (mousedown + +**~400 ms hold** before moving, or AppKit never arms the text drag), `key`, +`type`. + +Caveats (all hit in practice): +- **TCC decides per session.** Background/agent sessions often can't post + keyboard events (dropped silently) or use System Events. Test with **one click + + log check**; if input doesn't land, fall back to §3 immediately — don't + iterate on driver variations. +- App windows are on an inactive Space until activated + (`kCGWindowIsOnscreen == false`); `osascript -e 'tell application ".app" + to activate'` (Apple Events, a separate TCC bucket) may work where System + Events is denied. +- Re-activate before every interaction batch; focus is lost between shell calls. + +--- + +## 5. Screencapture measurement + +**Visual judgments are measured, not eyeballed** — when the task says "balance +padding" or "align the icon", capture the window and measure pixels. + +`scripts/capture-window.sh out.png` finds the window id +(JXA → `CGWindowListCopyWindowInfo`) and runs `screencapture -x -o -l`. +Notes: +- Capture **by window id**, reliable even when not frontmost. +- **Crop by the detected window bounds** — the desktop wallpaper defeats + screencapture's brightness-based auto-crop. +- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for + the first/last colored row of a callout box). Report the pixel numbers, not an + impression. +- Window-server state can glitch (tiny windows, restoration) after many rapid + launch/kill cycles: `rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedState` + and relaunch. + +--- + +## 6. The loop, end to end + +1. Verbose trace from the occurrence → find the **first bad line**, walk + backwards, form a trigger hypothesis (§2). +2. Reconstruct the document; script the hypothesized trigger (§3). +3. **No repro?** Hypothesis wrong or fidelity too low — move down the ladder + (§1), or instrument and wait for the next hit. +4. **Repro in hand? Freeze it** (exact script + document), then let it falsify + fix candidates — round 6's first "fix by reasoning" failed in the repro within + a minute. +5. Fix verified → **soak** (§3) → full `swift test` → keep the script + new + diagnostics → update the relevant `docs/*-investigation.md`. + +**Meta-lesson from six rounds: time spent making the failure cheap to observe +beats time spent reasoning about the fix.** Every round that shipped on reasoning +alone came back; the round that shipped on a deterministic repro named the actual +mechanism. + +--- + +## scripts/ (in this skill dir) + +| Script | Purpose | Status | +|---|---|---| +| `check-live-instance.sh` | Report running `edmd`, exit 2 if any; never kills | logic verified; safe by construction | +| `grep-trace.sh [date]` | Surface suspect patterns in today's log | logic verified | +| `capture-window.sh ` | Screenshot a window by id + report bounds | **verify on first use** (JXA CGWindowList lookup not executed this session) | +| `launch-debug.sh [script.repro]` | Build + assemble EdmundDbg.app + direct-exec with flags | **verify on first use** (assumes arm64 debug triple; guards user instance) | + +All four pass `bash -n`. The two "verify on first use" scripts depend on live +system state (window server, build layout) that couldn't be exercised while +authoring; read the header comment before first run. + +--- + +## When NOT to use this skill + +- Deciding *which mechanism* a symptom implies → **edmund-debugging-playbook**. +- Running the full caret-integrity investigation → **edmund-caret-integrity-campaign**. +- Stale-binary detection / bundle internals → **edmund-build-and-env**. +- What counts as sufficient evidence to ship → **edmund-validation-and-qa**. +- The AppKit theory behind the fixup/marked-text mechanisms → **textkit2-appkit-reference**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05 against `docs/dev-guides/live-repro-guide.md`, +`Sources/edmd/App/ReproScript.swift`, and +`Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift`. + +```bash +grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u +grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift +grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift +grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +``` + +Re-verify the scripts against `docs/dev-guides/live-repro-guide.md` §4 if the debug-bundle +assembly recipe changes. diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh new file mode 100755 index 0000000..bc83000 --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/capture-window.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# capture-window.sh +# Screenshot a specific window by id (reliable even when not frontmost). +# +# Mechanism: osascript(JXA) enumerates on-screen windows via the ObjC bridge +# to CGWindowListCopyWindowInfo, finds the first whose kCGWindowName contains +# the substring, prints " ", then `screencapture -x -o +# -l` grabs it. We crop by the detected window bounds (the desktop +# wallpaper defeats screencapture's brightness-based auto-crop). +# +# VERIFY ON FIRST USE: JXA ObjC bridging + the CGWindowList key names are +# stable AppKit API, but this script has not been executed in this session. +# If the JXA lookup prints nothing, the window title didn't match or Screen +# Recording permission is missing (it is granted for this project per +# CLAUDE.md — do NOT request Computer Access to "fix" it). +set -euo pipefail + +needle="${1:?usage: capture-window.sh }" +out="${2:?usage: capture-window.sh }" + +read -r wid x y w h < <(osascript -l JavaScript <&2 + exit 1 +fi + +screencapture -x -o -l"$wid" "$out" +echo "Captured window $wid ($needle) -> $out bounds=${x},${y} ${w}x${h}" diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh new file mode 100755 index 0000000..73e442a --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/check-live-instance.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# check-live-instance.sh — report any running `edmd` processes, refuse to kill. +# +# The user's daily-driver Edmund shares the binary name `edmd`. NEVER blanket +# `pkill -x edmd`. Run this first; if it finds an instance you did not start, +# leave it alone and launch your own debug bundle instead (launch-debug.sh +# uses EdmundDbg so `pkill -f EdmundDbg` only ever hits yours). +# +# Exit: 0 = no edmd running; 2 = at least one edmd running (inspect, don't kill). +set -euo pipefail + +pids=$(pgrep -x edmd || true) +if [[ -z "$pids" ]]; then + echo "No running edmd instance." + exit 0 +fi + +echo "Found running edmd instance(s) — DO NOT pkill -x edmd:" +for pid in $pids; do + ps -o pid=,lstart=,command= -p "$pid" +done +exit 2 diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh new file mode 100755 index 0000000..eb84477 --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/grep-trace.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# grep-trace.sh [YYYY-MM-DD] — surface the suspect patterns in today's (or a +# given day's) Edmund diagnostic log. Read-only. +# +# Requires the app to have run with: +# -settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES +set -euo pipefail + +day="${1:-$(date +%F)}" +log="$HOME/.edmund/logs/edmund-${day}.log" + +if [[ ! -f "$log" ]]; then + echo "No log for ${day} at ${log}" >&2 + exit 1 +fi + +echo "== $log ==" +echo "--- bypassed-didChangeText heals ---" +grep -n 'healing storage edit that bypassed didChangeText' "$log" || echo "(none)" +echo "--- content-above-origin repairs ---" +grep -n 'repairing content above origin' "$log" || echo "(none)" +echo "--- persistent length mismatches ---" +grep -n 'LEN-MISMATCH' "$log" || echo "(none)" +echo "--- repro asserts ---" +grep -n 'repro assertcaret' "$log" || echo "(none)" +echo "--- FAILs ---" +grep -n 'FAIL' "$log" || echo "(none)" diff --git a/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh new file mode 100755 index 0000000..809761e --- /dev/null +++ b/.claude/skills/edmund-live-repro-and-diagnostics/scripts/launch-debug.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# launch-debug.sh [script.repro] +# Assemble/refresh build/EdmundDbg.app, then direct-exec it with diagnostic +# flags (and optionally replay a ReproScript). Kills only its OWN prior debug +# instance, never the user's daily-driver edmd. +# +# Steps (from docs/dev-guides/live-repro-guide.md §4): +# 1. swift build (debug). +# 2. Assemble EdmundDbg.app: Info.plist copy + debug edmd + Sparkle.framework +# (dyld aborts without the framework; a bare .build/debug/edmd never makes +# a window). +# 3. Refuse to run if a non-ours edmd is live (check-live-instance.sh). +# 4. Direct-exec the bundle binary (never `open -a`: LaunchServices can run a +# stale translocated copy). +# +# VERIFY ON FIRST USE: paths assume the standard arm64 debug layout +# `.build/arm64-apple-macosx/debug/`. If your host resolves a different triple, +# adjust BUILD_DIR. Binary-freshness check is a reminder, not enforced here — +# see edmund-build-and-env for the strings/shasum method. +set -euo pipefail + +doc="${1:?usage: launch-debug.sh [script.repro]}" +repro="${2:-}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" # repo root +cd "$ROOT" +BUILD_DIR=".build/arm64-apple-macosx/debug" +APP="build/EdmundDbg.app" + +# 0. Never step on the user's instance. +here="$(dirname "${BASH_SOURCE[0]}")" +if pgrep -x edmd >/dev/null 2>&1; then + # Allow only if it's our own EdmundDbg (safe to replace). + if ! pgrep -f EdmundDbg >/dev/null 2>&1; then + echo "A non-EdmundDbg edmd is running — refusing to launch. Inspect:" >&2 + bash "$here/check-live-instance.sh" || true + exit 2 + fi + pkill -f EdmundDbg || true +fi + +# 1. Build. +swift build + +# 2. Assemble the bundle. +mkdir -p "$APP/Contents/MacOS" +cp Info.plist "$APP/Contents/Info.plist" +cp "$BUILD_DIR/edmd" "$APP/Contents/MacOS/edmd" +if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then + rm -rf "$APP/Contents/MacOS/Sparkle.framework" + cp -R "$BUILD_DIR/Sparkle.framework" "$APP/Contents/MacOS/Sparkle.framework" +else + echo "WARNING: $BUILD_DIR/Sparkle.framework not found; dyld may abort." >&2 +fi + +# 3. Launch by direct exec, background, with diagnostics on. +args=( "$doc" + -settings.general.diagnosticLogging YES + -settings.advanced.verboseEditorDiagnostics YES + -ApplePersistenceIgnoreState YES ) +if [[ -n "$repro" ]]; then + args+=( -debug.reproScript "$repro" ) +fi + +echo "Launching $APP with: ${args[*]}" +"$APP/Contents/MacOS/edmd" "${args[@]}" & +echo "Launched PID $! — tail with: scripts/grep-trace.sh" diff --git a/.claude/skills/edmund-release-and-operate/SKILL.md b/.claude/skills/edmund-release-and-operate/SKILL.md new file mode 100644 index 0000000..0191985 --- /dev/null +++ b/.claude/skills/edmund-release-and-operate/SKILL.md @@ -0,0 +1,400 @@ +--- +name: edmund-release-and-operate +description: > + Load when cutting or debugging an Edmund release, or operating the shipped + app. Triggers: version bump (Info.plist CFBundleShortVersionString / + CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml + / release.sh / build-app.sh, appcast.xml or Sparkle update failures + ("improperly signed", update never offered), sign_update / EdDSA keys / + SPARKLE_ED_PRIVATE_KEY / RELEASE_TOKEN, create-dmg or DMG naming problems, + Gatekeeper "damaged" reports, launching the built app, reading + ~/.edmund/logs, crash reports (edmd-*.ips), or roadmap/priority questions. +--- + +# Edmund — release & operate + +Date-stamped 2026-07-05. Verified against `.github/workflows/release.yml`, +`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`, +`appcast.xml`, `Info.plist`, `CHANGELOG.md`, `docs/ARCHITECTURE.md` §8/§13, and +the Settings/CrashReporter sources. Where a doc and a script disagree, the +script is the truth; disagreements are flagged inline. + +**House rule: releases happen only when the maintainer explicitly asks.** +Never tag, push, create a release, or merge on your own initiative — see +`edmund-change-control`. Everything in §1–§4 below is a runbook for when the +maintainer says "cut a release", not a standing instruction. + +## When NOT to use this skill + +| You actually need | Go to | +|---|---| +| Build/test commands, stale-build cures, launch mechanics in depth | `edmund-build-and-env` | +| Editing invariants, render pipeline, TextKit 2 rules | `edmund-architecture-contract`, `textkit2-appkit-reference` | +| Debugging a bug in the app itself | `edmund-debugging-playbook`, `edmund-live-repro-and-diagnostics` | +| Past incidents and why the sharp edges below exist | `edmund-failure-archaeology` | +| Debug flags / launch arguments | `edmund-config-and-flags` | +| Branch/commit/PR etiquette, what needs maintainer sign-off | `edmund-change-control` | +| Pre-merge QA method | `edmund-validation-and-qa` | +| README/website/positioning copy | `edmund-docs-and-writing`, `edmund-external-positioning` | + +--- + +## 1. Release flow — CI path (the normal one) + +Ship via a tag; CI does the rest. In order: + +1. **Bump versions in `Info.plist`** — both keys: + - `CFBundleShortVersionString` — marketing version, e.g. `0.1.3` + - `CFBundleVersion` — build number, **monotonic integer** (0.1.3 = `4`) +2. **Add a `## [x.y.z]` section to `CHANGELOG.md`** — format is load-bearing, + see §2. The version MUST match Info.plist exactly. +3. **Merge to `main`** and push (via the normal PR flow). +4. **Tag and push the tag:** + ```sh + git tag vX.Y.Z && git push origin vX.Y.Z + ``` +5. CI (`.github/workflows/release.yml`, trigger `push: tags: 'v*'`, runner + `macos-14`, job `release` / "Build & publish") runs the steps below. +6. Afterwards, verify per §4 post-flight. + +### release.yml step anatomy (actual step names) + +| Step | What it does | Sharp edge | +|---|---|---| +| `actions/checkout@v5` | `fetch-depth: 0`, `token: ${{ secrets.RELEASE_TOKEN }}` | The PAT must be on **this** step — see §3.4 | +| `maxim-lobanov/setup-xcode@v1` | latest-stable Xcode | | +| **Cache .build** | SPM cache keyed on `Package.resolved` | | +| **Build app bundle** | `./scripts/build-app.sh` — release build, bundle assembly, Sparkle embed, **bundle sealing** | §3.2 | +| `actions/setup-node@v4` | pins Node 20 | create-dmg 8.x needs Node ≥ 20 | +| **Install create-dmg** | `npm install --global create-dmg` (sindresorhus/create-dmg, **not** Homebrew's) | §3.5 | +| **Create DMG** | reads VERSION from Info.plist, `create-dmg build/Edmund.app build/ \|\| true`, renames `"Edmund .dmg"` → `Edmund-.dmg`, fails loudly if no dmg | §3.5 | +| **Sign archive (EdDSA)** | finds `sign_update` in `.build`, key on **stdin** via `--ed-key-file -`, exports `ED_SIG` and `LENGTH` | §3.1 | +| **Create GitHub Release** | awk-extracts the CHANGELOG section → `gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest` | §2 | +| **Update appcast.xml** | builds the new `` (HTML `` via `scripts/changelog-to-html.py`), inserts it before ``, commits as `github-actions[bot]`, `git push origin HEAD:main` | §3.4 | + +### Local path (`scripts/release.sh`) + +Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update +appcast.xml **locally** → `gh release create`. Two differences: + +- **Signing**: with `SPARKLE_ED_PRIVATE_KEY` in the env it uses the stdin path + (CI-style); otherwise `sign_update` pulls the key from the **login keychain** + (put there by Sparkle's `generate_keys`) with no flag at all. +- **The appcast commit/push is left to you.** The script ends with the exact + commands: `git add appcast.xml && git commit -m 'Release ' && git push`. + +Prereqs for the local path: `gh auth status` authenticated, npm `create-dmg` +installed, `swift build` has run at least once (so `sign_update` exists under +`.build`). + +> **Stale doc**: `misc/how-to-release.md` still says the artifact is a **zip** +> ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG +> switch. The truth is DMG throughout — per `release.yml`, `release.sh`, and +> ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it. + +--- + +## 2. CHANGELOG format contract (release notes are machine-extracted) + +Both `release.yml` and `release.sh` extract the GitHub Release body with: + +```sh +awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md +``` + +So the section header **must** start at column 0 as `## [x.y.z]` — literally +`## [0.1.3] — 2026-07-04` in house style (em dash + ISO date after the +bracket is fine; the match only requires the `^## \[x.y.z\]` prefix). +Extraction runs until the next `^## [` line. If nothing matches, the release +body falls back to "See CHANGELOG for details." — a silent-ish failure, so get +the header right. (Version dots are unescaped in the regex; harmless in +practice, don't rely on it.) + +The Sparkle update-dialog notes come from the **same section** via +`scripts/changelog-to-html.py `, a deliberately tiny converter that +only understands Keep-a-Changelog shapes: + +- `### Added` / `### Changed` / `### Fixed` → `

      ` — **use `###`, not `##`**. + The 0.1.2 appcast item literally shows `

      ## Changed

      ` because the + section used `##` subheads at release time; the converter passed them + through as paragraphs. +- `- ` / `* ` bullets → `
      • `; indented continuation lines fold into the + previous bullet. +- `` `code` `` and `**bold**` are converted. **Markdown links are NOT** — + `[docs](docs/foo.md)` appears literally in the update dialog (see the 0.1.2 + item). Keep appcast-facing notes link-free or accept the raw brackets. +- Blank lines and `---` are skipped; anything else becomes a `

        `. +- Missing section → empty output → the `` is simply omitted. + +House format (verified from `CHANGELOG.md`): Keep a Changelog 1.1.0 + SemVer, +newest first, sections separated by `---`. + +--- + +## 3. The sharp edges (each one killed or nearly killed a real release) + +### 3.1 `sign_update -s` is FATAL — key goes on stdin + +Sparkle deprecated `-s `; for newly generated keys it prints a +deprecation warning and **exits 1** ("no longer supported"). This killed the +first 0.1.0 release. The only correct invocation with a key-in-hand: + +```sh +echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - +``` + +Both `release.yml` and `release.sh` do exactly this. Never "simplify" it back +to `-s`. Output format: `sparkle:edSignature="" length=""` — the +scripts grep those two attributes out for the appcast item. + +### 3.2 Bundle sealing (the "improperly signed" update failure) + +At install time Sparkle re-validates the update's **Apple** code signature +(`SUUpdateValidator`), independent of the EdDSA signature. A bundle that is +code-signed but not *sealed* (no `_CodeSignature/CodeResources`) fails that +check and every update dies with "The update is improperly signed and could +not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update +when the old script signed only the bare binary. + +`build-app.sh` therefore signs inside-out and in a very deliberate order: + +1. `codesign --force --deep --sign - Sparkle.framework` (nested XPC helpers + must be signed before macOS will launch them), +2. `codesign --force --deep --sign - --identifier "com.i7t5.edmd"` on the + whole `.app` **while its root holds only `Contents/`** — codesign refuses + to seal a bundle with extra items at the root, +3. copy the SwiftMath resource bundle to the `.app` root **after** sealing + (its generated `Bundle.module` looks at `Bundle.main.bundleURL`; without it + the app crashes on the first LaTeX render). + +Consequence: `codesign --verify` (CLI) and `--strict` **will complain** about +that one unsealed root item. That is expected and fine — Sparkle's actual +check is non-strict (`SecStaticCodeCheckValidityWithErrors` with +`kSecCSCheckAllArchitectures`) and tolerates it; verified end-to-end against +that API. Do not "fix" the verify warning by moving the SwiftMath bundle or +re-signing after the copy. + +### 3.3 Keypair discipline + +One EdDSA keypair, three places, all of which must agree: + +| Place | Used by | +|---|---| +| `Info.plist` `SUPublicEDKey` (`0XdLbbuO…`) | Every shipped app, to verify updates | +| Maintainer's **login keychain** | `release.sh` local signing (no flag) | +| GitHub secret **`SPARKLE_ED_PRIVATE_KEY`** | CI signing | + +If the signing key and `SUPublicEDKey` diverge, everything *looks* fine — the +DMG signs without error — but **every user's update fails signature +verification**. Sanity check when in doubt: +`sign_update --verify ` against the Info.plist public key. + +### 3.4 Appcast push to protected `main` — RELEASE_TOKEN + +The workflow's last step commits `appcast.xml` and pushes to `main`, which +requires the `test` status check. The default `GITHUB_TOKEN` / +`github-actions[bot]` is not an admin, so that push is rejected with +`GH006 … protected branch hook declined`. Branch protection has +`enforce_admins: false`, so an admin's push bypasses the check — hence the +fine-grained **admin PAT** in the `RELEASE_TOKEN` secret (Contents: +read/write), set as the `token:` on the **checkout step**, not on the push. +That placement matters: `actions/checkout` persists an +`http..extraheader` credential that overrides inline-URL credentials, +so rewriting the push URL would keep pushing with the bot token anyway. + +**`RELEASE_TOKEN` expires 2027-06-27.** Rotate it before then or every +release fails at the appcast push while the GitHub Release itself succeeds +(a confusing half-shipped state — see §4 post-flight). + +### 3.5 create-dmg quirks + +- It's the **npm** package `create-dmg` (sindresorhus), installed via + `npm install --global create-dmg`. Homebrew's `create-dmg` is a different + tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it). +- It exits **2** when it can't Developer-ID-sign/notarize the image (Edmund + ships ad-hoc) **but still produces the .dmg**. Both scripts run it with + `|| true` and then verify the file exists, failing loudly only if no dmg + was produced. Don't remove the `|| true`; don't trust the exit code. +- Output is named `"Edmund .dmg"` — with a **space**. Both scripts + rename to `Edmund-.dmg` (hyphen), which is the name the appcast + enclosure URL expects. If a rename is skipped, the release asset URL 404s + for every updater. + +--- + +## 4. Pre-flight and post-flight + +### Pre-flight (distilled from `misc/before-you-release.md` — read it too) + +- [ ] `swift test` green **on `main`**, not just the branch; `git status` clean. +- [ ] No debug flags / launch args left on (repro drivers, verbose tracing — + see `edmund-config-and-flags`, ARCHITECTURE §8). +- [ ] Visual sanity: build and screencapture the editor in **light and dark** + mode; click through everything the CHANGELOG claims ("fixed X" → actually + reproduce X and confirm). +- [ ] `CHANGELOG.md` has `## [x.y.z] — YYYY-MM-DD` for this release and the + version **matches Info.plist** (`CFBundleShortVersionString`); `###` + subheads, not `##` (§2). +- [ ] `CFBundleVersion` bumped (monotonic int). +- [ ] `RELEASE_TOKEN` not expired (**2027-06-27**). +- [ ] Local path only: `gh auth status` ok; keychain key matches + `SUPublicEDKey` (§3.3). + +### Post-flight + +- [ ] GitHub Release `vX.Y.Z` exists with the right notes and the + `Edmund-.dmg` asset (hyphenated name). +- [ ] `appcast.xml` on `main` got the new `` — with ``, + correct `sparkle:version` (= CFBundleVersion) and enclosure URL. +- [ ] Nothing to do for user prompts: Sparkle checks roughly daily; existing + users see the update within ~24 h. Don't panic if it isn't instant. + +If the Release exists but the appcast commit is missing, the release +half-shipped (usually §3.4). Fix the token, then add the `` manually or +re-run the job. + +--- + +## 5. Gatekeeper story (why users see "damaged") + +Edmund is **ad-hoc signed, not notarized** (no $99/yr Developer ID). First +launch of a downloaded copy trips Gatekeeper with the *"app is damaged"* +dialog. This is expected; the app is fine. The README documents both +workarounds (verified, README ~line 53): + +- `xattr -dr com.apple.quarantine /Applications/Edmund.app`, or +- right-click → Open. + +Known upgrade path (open/candidate, not scheduled): Developer ID certificate ++ notarization would remove the prompt entirely and also clean up the +non-strict-sealing compromise in §3.2. Don't promise it in user-facing text. + +--- + +## 6. Operating the app + +### Launching + +`open Edmund.app` **foregrounds a running instance instead of relaunching** — +you'll stare at old code. And never `pkill -x edmd` blindly: the maintainer's +own Edmund session may be running (the Mach-O is `edmd` for both). Check +first (`pgrep -x edmd`), kill only PIDs you started, or launch the binary +directly: `build/Edmund.app/Contents/MacOS/edmd file.md &`. Full launch / +stale-build / screencapture mechanics: `edmund-build-and-env`. + +### Logs — `~/.edmund/logs/edmund-YYYY-MM-DD.log` + +- One file per day, human-readable lines tagged `LEVEL [category]` + (categories: app, document, io, render, compose, selection, lazy, callout, + edit — grep by concern). +- Controlled by **Settings ▸ Advanced ▸ Diagnostics** ("Save diagnostic + logs"). The toggle **defaults OFF** (`AppSettings.diagnosticLogging` + defaults false) — i.e. opt-in in the shipped app, despite `Log.swift`'s + header comment calling it "always-on (opt-out)"; the code is the truth. + (The UserDefaults keys are named `settings.general.*` for legacy reasons; + the UI lives in Advanced.) +- Retention picker ("Clear logs after:") next to the toggle; a separate + "Verbose editor tracing" opt-in gates keystroke-level trace lines — leave + off except during repros (`edmund-live-repro-and-diagnostics`). +- Release builds write `info` and up; DEBUG builds also write `debug`. +- Logs may contain document text; they never leave the machine. + +### Crash reports — `~/Library/Logs/DiagnosticReports/edmd-*.ips` + +- macOS names crash reports after the **Mach-O executable**: look for + `edmd-.ips`, not "Edmund-…". +- **Uploading is opt-in and currently INERT.** The Settings toggle is + commented out in `Sources/edmd/Settings/AdvancedSettingsView.swift` ("dormant + until the receiving server exists"), and + `CrashReporter.reportingEndpoint` is a placeholder + (`https://REPLACE-ME.invalid/crash`). Nothing is ever sent in shipped + builds. Don't tell users crash reporting exists; don't uncomment the toggle + without a real server. Code: `Sources/EdmundCore/Diagnostics/CrashReporter.swift`. +- Reading works only because Edmund is **not sandboxed**; adopting App + Sandbox would force a MetricKit rewrite (noted in CrashReporter's header). +- **Triage of a user's `.ips`**: it's JSON — a one-line metadata header, then + the report body. Look at `exception` (type/signal), `faultingThread`, and + walk that thread's frames for images named `edmd` or `Sparkle`. Ad-hoc + builds ship no dSYM, so expect addresses rather than symbol names for app + frames; correlate with `~/.edmund/logs` from the same timestamp instead. + `.ips` files embed the user's home path and device model — treat as + mildly personal data. + +### Update mechanics (user side) + +- `SUFeedURL` = `https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml` + — the raw-GitHub URL of the checked-in appcast; committing to `main` *is* + publishing. +- `SUEnableAutomaticChecks` is true; no custom interval is set, so Sparkle + uses its default ~24 h cadence (plus a check on launch). +- Sparkle downloads the DMG enclosure, verifies EdDSA against `SUPublicEDKey`, + **mounts the DMG**, re-validates the Apple code signature (§3.2), installs. + +--- + +## 7. Versioning & appcast conventions + +| Thing | Convention | Current (2026-07-05) | +|---|---|---| +| Git tag | `vX.Y.Z` | `v0.1.3` pending its tag; last released 0.1.2 | +| `CFBundleShortVersionString` | SemVer marketing version | `0.1.3` | +| `CFBundleVersion` | monotonic integer, +1 per release | `4` | +| CHANGELOG | Keep a Changelog 1.1.0, `## [x.y.z] — YYYY-MM-DD`, `###` subheads, `---` separators | — | + +`appcast.xml` (checked into repo root): RSS 2.0 with the `sparkle:` namespace. +One `` (title/link/description/language) containing one `` per +release. Items are inserted **before ``**, so the file reads oldest +→ newest; Sparkle doesn't care about order — it picks by version. Per item: + +```xml + + Edmund 0.1.2 + Fri, 03 Jul 2026 19:03:59 +0000 + + + sparkle:shortVersionString="0.1.2" + sparkle:edSignature="…" + length="7608991" + type="application/x-apple-diskimage"/> + +``` + +`` is optional (omitted when the CHANGELOG section is missing). + +--- + +## 8. Roadmap context (for release-content judgment) + +- Edmund is in **beta** (0.1.x line, first public release 0.1.0 on + 2026-06-27). Small, frequent releases. +- **v0.2.0 goal: "Polished editing experience"** (`misc/backlog.md` § Now). +- **Priority ordering: Marketing = Bugs >= UI/UX > Features** — when deciding + what makes a release, bug fixes and polish beat new features. +- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork): + `docs/ROADMAP.md`; working backlog with per-bug detail: `misc/backlog.md`. + +--- + +## Provenance and maintenance + +Written 2026-07-05 from direct reads of: `.github/workflows/release.yml`, +`scripts/release.sh`, `scripts/build-app.sh`, `scripts/changelog-to-html.py`, +`appcast.xml`, `CHANGELOG.md`, `Info.plist`, `README.md`, +`docs/ARCHITECTURE.md` (§8, §13), `misc/how-to-release.md`, +`misc/before-you-release.md`, `docs/ROADMAP.md`, `misc/backlog.md`, +`Sources/EdmundCore/Diagnostics/CrashReporter.swift`, +`Sources/EdmundCore/Diagnostics/Log.swift`, +`Sources/edmd/Settings/AdvancedSettingsView.swift`, +`Sources/edmd/Settings/AppSettings.swift`. + +Known stale docs at time of writing: `misc/how-to-release.md` (zip vs DMG, +§1); `Log.swift` header ("always-on (opt-out)" vs the actual default-off +toggle, §6). Minor oddity, deliberate: `build-app.sh` signs with +`--identifier "com.i7t5.edmd"` while the bundle id is `com.i7t5.edmund`. + +Re-verify when any of these change: `release.yml` step names or secrets, +`build-app.sh` signing order, the CHANGELOG header format (the awk regex in +two places must match it), `SUFeedURL`, `RELEASE_TOKEN` rotation (hard +deadline 2027-06-27), notarization status, or the crash-report server going +live (which un-inerts §6's crash uploading and this skill's wording). diff --git a/.claude/skills/edmund-research-frontier/SKILL.md b/.claude/skills/edmund-research-frontier/SKILL.md new file mode 100644 index 0000000..a34f8f8 --- /dev/null +++ b/.claude/skills/edmund-research-frontier/SKILL.md @@ -0,0 +1,196 @@ +--- +name: edmund-research-frontier +description: > + Open problems where the Edmund Markdown editor could advance the state of the + art — product-first, everything labeled candidate/open, nothing proven. Load + when picking the next big problem, evaluating whether an ambitious idea is + worth starting, or answering "what would move this project beyond state of the + art". Each frontier: why current SOTA fails, Edmund's specific asset, the + first three concrete steps IN THIS REPO, and a falsifiable "you have a result + when…" milestone. Not for running an accepted investigation + (edmund-caret-integrity-campaign), the method of proof + (edmund-research-methodology), or shipping the change (edmund-change-control). +--- + +# Edmund research frontier + +Where Edmund could go past the state of the art. Ambition is **product-first**: +"the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are +**means, not ends** — a technique is worth pursuing when it makes the product +better, and it becomes publishable as a side effect. + +**Everything here is candidate / open. Nothing is proven.** Each item routes its +proof through **edmund-research-methodology** (hypothesis predicts numbers) and +its changes through **edmund-change-control**. Verified 2026-07-05 against the +repo; assets cited are real, outcomes are not. + +--- + +## Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16) + +**Why SOTA fails:** TK2 lays out only near the viewport; off-screen fragment +heights are **estimates** corrected as layout reaches them. This makes the +scroller jump and scroll-to-target miss in **every** TK2 app, including TextEdit +— a widely documented limitation. Above `fullLayoutMaxLength` (100k) Edmund +enters this regime. + +**Edmund's asset:** the mitigations already in `TextView/` — +`scheduleFullLayoutSettle`, `preservingViewportAnchor`, `repairContentAboveOrigin`, +`centerViewportOnCaret` re-measure, and the diff-based undo restore that avoids +resetting fragments to estimates; plus the `ScrollStabilityTests` / +`HeightStabilityTests` harnesses. (Note: `repairContentAboveOrigin` is +**mitigated-unconfirmed live** per `docs/investigations/viewport-glitch-investigation.md` — a +real result here would also *retire that honest gap*.) + +**First three steps in this repo:** +1. Build a large-doc fixture (`makeLargeMarkdown` in `TestHelpers.swift`) >100k + and a scripted scroll-accuracy metric (ReproScript `caret` + a `logsel`-style + position dump, or extend `PerfHarnessTests`). +2. Quantify the estimate-error distribution: for N scroll-to-target operations, + record predicted vs actual landing pixel offset. +3. Prototype persistent per-fragment height caching across the settle (or across + sessions) and re-measure the same distribution. + +**You have a result when:** scroll-to-target lands within a stated pixel budget +on a 1 MB document, measured by script, with **zero** `repairing content above +origin` events across a scroll soak — reproducibly. + +--- + +## Frontier 2 — Caret integrity by construction + +**Why SOTA fails:** every `NSTextView` consumer depends on `didChangeText` +pairing that **AppKit itself violates** (the drag-move bypass). The delete-drift +class is the symptom of building sync on a callback contract AppKit doesn't keep. + +**Edmund's asset:** the heal machinery, the `pendingEdit` model, six documented +rounds of mechanism knowledge, and the ReproScript + soak methodology. The +campaign skill runs *individual* rounds reactively; this frontier is the +**structural endgame** — eliminate the class. + +**First three steps:** +1. Inventory **every** storage-mutation entry point (grep `replaceCharacters`, + `setAttributes`, the edit-flow paths) and tabulate which currently rely on a + callback firing. +2. Design a sync layer keyed on a **storage-version counter** that reconciles + `rawSource` regardless of which callbacks fired (not a new guard per path). +3. Falsify it against **all** historical `.repro` scripts plus a new randomized + bypass-fuzzer script. + +**You have a result when:** all historical `.repro` scripts and a randomized +bypass soak stay green **with the callback-pairing assumption deleted from the +code**. **Candidate, big** — likely a multi-PR redesign; do not start without the +methodology skill's evidence bar in front of you. + +--- + +## Frontier 3 — The 10 MB class (performance headroom) + +**Why it matters:** README claims "~1–2 MB files"; native-with-no-Electron is the +differentiator, so headroom is a product claim, not vanity. + +**Edmund's asset:** viewport-based lazy styling, the idle drain, incremental +reparse (`pendingEdit` window), `PerfHarnessTests`. + +**First three steps:** +1. Extend `PerfHarnessTests` with 5/10 MB fixtures (`makeLargeMarkdown`). +2. Profile the block-parse and restyle hot paths (`Log.measure` single-line + durations are already in place). +3. Set explicit latency budgets for open, first-paint, and per-keystroke restyle + at 10 MB. + +**You have a result when:** open + steady-state typing latency on a 10 MB file +meets a stated budget, measured by the harness (not hand-timed). + +--- + +## Frontier 4 — A native extensions API + +**Why SOTA fails:** Obsidian/VS Code plugin ecosystems are Electron; there is no +strong precedent for a **native, safe, fast** extension surface for live-preview +Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, +primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as +"official extension" candidates. + +**Edmund's asset:** the custom-parser seam +(`Parsing/SyntaxHighlighter+CustomParsers.swift`), the `BlockKind`/`styleBlock` +architecture, and already-modular opt-in syntax (math, Obsidian syntax). + +**First three steps:** +1. Catalog which existing features could be **re-implemented as extensions** + (dogfood: callouts? highlight? wikilinks?) — this defines the real extension + points. +2. Define the minimal seam: block parser? inline parser? theme hook? Draw the + line at what the custom-parser architecture already supports. +3. Spike **one** official extension behind a flag and measure restyle cost vs the + built-in. + +**You have a result when:** one built-in syntax feature runs as an extension with +**no measurable restyle regression** against the built-in baseline. + +--- + +## Frontier 5 — Accessibility / RTL / localization as a differentiator + +**Why it matters:** native apps *can* excel where Electron editors are weak; +ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width +already ships as precedent that the pipeline can be locale-sensitive. + +**Edmund's asset:** the attribute-only pipeline (structure is in the string, not +in inserted characters), the existing locale-aware content-width path. + +**First three steps:** +1. VoiceOver audit of `EditorTextView`'s custom drawing — does the accessibility + tree expose headings/lists/callouts, given they're drawn as decorations? +2. Test RTL behavior of the attribute-only styling on a right-to-left document. +3. Scriptable a11y check (structure read-out) as a regression guard. + +**You have a result when:** a scripted VoiceOver audit reads document structure +(headings, list items, callouts) correctly on a mixed document. + +--- + +## How to start one + +1. **Predict numbers first** (edmund-research-methodology §2) — every milestone + above is a *number*, not a vibe. +2. Instrument to make the current failure/limit cheap to measure. +3. Prototype behind a flag; measure predicted vs observed. +4. Route changes through **edmund-change-control** (branch, tests, no auto-push). +5. When the first milestone lands, the item **graduates to `misc/backlog.md` or + `docs/ROADMAP.md`** and stops being a frontier. + +## What NOT to start (no current asset) + +- **iOS / iPadOS port** — explicitly **TBD** in ROADMAP; no shared UI layer today. +- **Collaborative / real-time editing** — zero repo support (no CRDT, no sync, + single `NSDocument` model). Would be a new product, not a frontier of this one. +- Anything that requires breaking an invariant (storage == rawSource; TextKit 2 + only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract). + +--- + +## When NOT to use this skill + +- Running an *accepted* investigation (a known bug) → **edmund-caret-integrity-campaign** / **edmund-debugging-playbook**. +- The method of turning a hunch into proof → **edmund-research-methodology**. +- Whether a public claim is allowed yet → **edmund-external-positioning**. +- Shipping the change → **edmund-change-control**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. Assets exist; **outcomes are unproven by definition** — +never quote a milestone here as achieved. + +```bash +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/ +ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift +grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md +grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift +``` + +When an item's milestone lands, move it to ROADMAP/backlog and delete it here — +a frontier list that keeps solved problems is lying. diff --git a/.claude/skills/edmund-research-methodology/SKILL.md b/.claude/skills/edmund-research-methodology/SKILL.md new file mode 100644 index 0000000..5384816 --- /dev/null +++ b/.claude/skills/edmund-research-methodology/SKILL.md @@ -0,0 +1,196 @@ +--- +name: edmund-research-methodology +description: > + The discipline that turns a hunch into an accepted result in the Edmund + Markdown editor — the evidence bar, hypothesis-predicts-numbers, the + first-principles analysis recipes, the idea lifecycle, and where good ideas + historically came from. Load when forming a hypothesis about a bug's + mechanism, evaluating whether an investigation's conclusion is trustworthy, + deciding if a fix is actually proven, or turning an idea into an accepted + change. This skill also absorbs the proof-and-analysis toolkit (prove it, + don't just install it). Not the caret-integrity campaign itself + (edmund-caret-integrity-campaign), not the repro tooling + (edmund-live-repro-and-diagnostics). +--- + +# Edmund research methodology + +How a hunch becomes something you can ship without it coming back. Every method +here is grounded in a real episode from this repo's history — the discipline was +paid for in the six delete-drift rounds and the viewport work. + +Verified 2026-07-05 against `docs/investigations/delete-drift-investigation.md`, +`docs/investigations/viewport-glitch-investigation.md`, and `docs/dev-guides/live-repro-guide.md`. + +--- + +## 1. The evidence bar + +A mechanism is **accepted** only when it clears both bars: + +1. **It explains ALL observations, including the negatives.** Not just "the caret + drifts" but *why headless tests pass, why it's intermittent, why it appears + minutes after the trigger.* A mechanism that explains the symptom but not the + negatives is incomplete — and incomplete mechanisms come back. +2. **It survives assigned adversarial refutation.** Before shipping, actively try + to **falsify** the supposed fix: run the frozen repro against it. Rounds 1–5 + of delete-drift shipped on reasoning and **all came back**; round 6's first + fix candidate "worked by reasoning" and **failed in the repro within a + minute** (`docs/investigations/delete-drift-investigation.md` round 6, "the iteration that + proved its shape"). + +Corollary: **headless-green is not the bar** for live-input bugs — the round-6 +regression test passes with and without the fix. See edmund-validation-and-qa +for the evidence hierarchy. + +--- + +## 2. Hypothesis predicts numbers BEFORE running + +State the expected observable **before** the experiment. An experiment without a +predicted number is a fishing trip. + +- **Worked example:** round-6 `logsel` predicted **321** on the broken build vs + **290** on the fixed build — a specific number, stated before the run, that the + repro then confirmed every time. +- **Determinism as a prediction:** a soak's final `rawLen` must be + **byte-identical** across runs; predict it, then check it. + +If you can't name the number your hypothesis predicts, you don't understand the +mechanism well enough to test it yet — go back to instrumentation (§3b). + +--- + +## 3. The analysis recipes (prove it, don't just install it) + +Each: when to use, the steps, and the episode that earned it. + +### 3a. Trace archaeology — walk BACKWARDS +**When:** any symptom with diagnostics on. **Steps:** find the first *bad* line, +then read *upward/earlier*, not forward from the symptom. **Episode:** the round-6 +drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of +healthy edits earlier. Source: `docs/dev-guides/live-repro-guide.md` §2. + +### 3b. Make the failure cheap to observe before reasoning about fixes +**When:** you're tempted to guess. **Steps:** add one breadcrumb (a call stack, a +state dump) behind `Log.shouldTrace` and ship it; reproduce; let the log name the +culprit. **Episode:** `traceSelectionOrigin` named `_fixSelectionAfterChange` in +a single run after five rounds of guessing. This is *the* meta-lesson: **time +spent making the failure cheap to observe beats time spent reasoning about the +fix.** + +### 3c. The fidelity ladder as an inference tool +**When:** a repro fails. **Steps:** a repro that fails at level N but succeeds at +N+1 **localizes** the mechanism to what N lacks. **Episode:** delete-drift not +reproducing in a windowed unit test (level 2) told the team it needed +deferred/queued AppKit state → pointed straight at level-3 in-process replay and +event-ordering hypotheses. Source: `docs/dev-guides/live-repro-guide.md` §1. + +### 3d. Invariant auditing +**When:** the behavior looks impossible. **Steps:** find which invariant +**transiently broke** — `storage == rawSource` during IME composition; +`didChangeText` pairing during a drag-move; grep the guard inventory +(`hasMarkedText`, bypass checks). Lock it with an equivalence/fuzz oracle: +`RecomposeEquivalenceTests` (`assertMatchesFullRecomposeOracle` — incremental +restyle must equal a full restyle) and `IncrementalParseFuzzTests` (random edits +keep window-reparse == full reparse). + +### 3e. Exact-sequence replication +**When:** simulating an AppKit-internal path. **Steps:** replay its **real call +sequence**, pinned from a stack trace, not an approximation of its *effect*. +**Episode:** `bypassdelete` reproduces `shouldChangeText`→`replaceCharacters` +with no `didChangeText` **verbatim** — an approximation would have hidden the bug. + +### 3f. Differential measurement over eyeballing +**When:** any visual or binary claim. **Steps:** screencapture **pixel** +measurement for visuals (edmund-live-repro-and-diagnostics §5); `shasum` +before/after for "did the binary actually change"; predicted-vs-observed tables +for numbers. **Episode:** the maintainer's rule — when told "balance the padding", +measure the top vs bottom pad in pixels, don't judge by eye. + +--- + +## 4. The idea lifecycle + +How an idea moves from hunch to settled (or to a documented dead end): + +``` +hunch + → cheap instrumentation / discriminating experiment (§3b, §3c) + → investigation-doc entry (docs/-investigation.md, round structure) + → frozen deterministic repro + → fix on its own fix/ branch, with test + soak (edmund-validation-and-qa) + → ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed) + → settled status in the chronicle (edmund-failure-archaeology) +``` + +**Retirement path for ideas that fail:** a documented dead end in the +investigation doc with a "do not retry" note and *why* (see the round chronicle +and the viewport doc's "Verification limits (honest gaps)" section — mitigations +that are real but **unconfirmed live** are labeled so, not oversold). + +**Where larger ideas are tracked:** `docs/ROADMAP.md` (versioned feature plan) +vs `misc/backlog.md` (near-term working list; priority order **Marketing = Bugs +>= UI/UX > Features**). A frontier idea graduates onto one of these when its +first milestone lands (edmund-research-frontier). + +--- + +## 5. Where good ideas historically came from + +Mine these seams first — they've paid out repeatedly: + +- **Making failures observable.** The diagnostics accumulated one breadcrumb at a + time; each named a mechanism the previous round guessed at. +- **Reading Apple's *actual* behavior, not the documented ideal.** Sparkle's + update validation is non-strict (`SecStaticCodeCheckValidityWithErrors`) — + discovered by testing the real API, which unblocked the whole update pipeline. + `NSWindow.sendEvent` as the pre-toolbar funnel solved the toolbar right-click + after `menu`/`rightMouseDown`/gestures all failed. +- **Prior art consulted before inventing.** `nodes-app/swift-markdown-engine` + (ARCHITECTURE §14) solves the same TK2 live-preview problems with different + trade-offs — a comparison point and technique source. MarkEdit/CotEditor as + product references (ROADMAP). +- **Field reports with logs + movs.** `misc/bug-repros/` holds the maintainer's + screen recordings and trace logs — the round-4 drag-move mechanism came + straight out of one such trace. + +--- + +## 6. Anti-patterns (each with its historical cost) + +| Anti-pattern | Cost paid | +|---|---| +| Fix at symptom time | Patches the wrong instant — symptom armed minutes earlier (round 6) | +| Trust headless green for a live-input bug | Round-6 test passes with *and* without the fix | +| Stack speculative fixes | Rounds 1–5 each "fixed" it; each came back | +| Skip document reconstruction | Geometry/block-kind-dependent bugs don't fire on `"hello world"` | +| Trust a possibly-stale binary | SwiftPM prints `Build complete!` without relinking → wrong conclusions | +| Declare victory without a soak | One clean repro misses armed-state / degradation bugs | + +--- + +## When NOT to use this skill + +- Actually running the caret investigation step by step → + **edmund-caret-integrity-campaign**. +- The repro drivers and trace decoding → **edmund-live-repro-and-diagnostics**. +- What evidence a change type requires + the test suite → **edmund-validation-and-qa**. +- The settled history you're checking against → **edmund-failure-archaeology**. +- Picking the next big problem → **edmund-research-frontier**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. + +```bash +grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md +grep -n '321\|290' docs/investigations/delete-drift-investigation.md # the predicted numbers (§2) +grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/ +ls misc/bug-repros/ # field-evidence seam (§5) +``` + +The methods are stable; the *episodes* they cite are the drift risk — if a new +round rewrites the delete-drift narrative, refresh the worked examples here. diff --git a/.claude/skills/edmund-validation-and-qa/SKILL.md b/.claude/skills/edmund-validation-and-qa/SKILL.md new file mode 100644 index 0000000..e53d743 --- /dev/null +++ b/.claude/skills/edmund-validation-and-qa/SKILL.md @@ -0,0 +1,208 @@ +--- +name: edmund-validation-and-qa +description: > + What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. + Load when writing or adjusting tests, deciding what proof a fix needs before + it can ship, judging whether a change is actually "verified", interpreting + the test suite, or adding a golden/regression check. Covers the evidence + hierarchy (headless is necessary but not sufficient for live-input bugs), + the test-suite map, the Swift Testing helpers, how to add a test, the golden + inventory, visual QA, and performance evidence. Not the gating rules + themselves (edmund-change-control) or how to run the live repro + (edmund-live-repro-and-diagnostics). +--- + +# Edmund validation & QA + +The discipline of proof. The central lesson: **a green headless test is +necessary but not sufficient** for the bug classes that cost the most time here. +Know which evidence each change actually requires. + +Framework: **Swift Testing** (`import Testing`, `@Test`, `#expect`, `#require`) +— NOT XCTest. Verified 2026-07-05: 810 `@Test` cases across `Tests/EdmundTests/`. + +--- + +## 1. The evidence hierarchy + +Ordered weakest → strongest. The rule at the bottom names which class each +change type **requires**. + +| Class | What it proves | Blind spot | +|---|---|---| +| (a) Headless unit test (`makeEditor()`) | model/parse/style/undo logic | **Cannot** exercise deferred AppKit machinery — runs it synchronously | +| (b) Windowed unit test (real NSWindow + NSScrollView, real `deleteBackward`) | + layout, viewport, first responder | still not real event-loop pacing / IME / drag | +| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run | +| (d) Soak script green across 4–5 cycles, byte-identical final `rawLen` | armed-state + determinism | slow | +| (e) Screencapture pixel measurement | anything that DRAWS | manual | + +**The trap that defines this skill:** the delete-drift **round-6 regression test +passes with AND without the fix** — the harness runs the queued selection fixup +synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing +bugs, class (a) is not evidence of a fix; you need (c)+(d). + +**Required evidence by change type** (gates enforced in edmund-change-control): + +| Change | Requires | +|---|---| +| Pure logic (parse/style/model) | (a) | +| Viewport/lazy-layout | (b), often (e) | +| Anything that draws | (a where testable) + **(e)** in light AND dark | +| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract **+ (c)** frozen repro **+ (d)** soak | +| Release | see edmund-release-and-operate | + +--- + +## 2. Test-suite anatomy + +Run: `swift test` (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 `@Test` +cases as of 2026-07-05). One suite: `swift test --filter `. **`swift test` +also runs automatically as a Stop hook** (`.claude/settings.json`) at the end of +any turn touching code, so failures surface before you commit. + +Map of `Tests/EdmundTests/` (what the files actually cover): + +- **Parsing:** `BlockParserTests`, `SyntaxHighlighterTests`, `IncrementalParseFuzzTests`, `PendingEditTests`, `EscapeRenderingTests`, `HTMLTagRenderingTests`, `EmojiRenderingTests`, `LineEndingTests`. +- **Rendering / styling:** `BlockStylingTests`, `CalloutRenderingTests`, `CalloutTests`, `CommentRenderingTests`, `NestedBlockStylingTests`, `InlineStylingTests`, `MathRenderingTests`, `ImageRenderingTests`, `CodeHighlighterTests`, `FootnoteTests`, `WikiLinkTests`, `TableAlignmentTests`, `RenderingRegressionTests`. +- **Editor behavior:** `EditorIndentationTests`, `ListContinuationTests`, `BlockquoteContinuationTests`, `BlockquoteDeletionTests`, `FormattingTests`, `ActiveBulletMarkerTests`, `NewListItemAlignmentTests`. +- **Edit-pipeline integrity (the costly class):** `BypassedEditSyncTests`, `MarkedTextDesyncTests`, `WrappedParagraphCaretTests`, `EditorDiagnosticsTests`, `UnmatchedDebugTests`, `InternationalInputTests`. +- **Viewport / layout / undo:** `LazyRenderingTests`, `ScrollStabilityTests`, `HeightStabilityTests`, `TypewriterCenteringTests`, `UndoRedoViewportTests`, `EditorUndoTests`, `RecomposeTests`, `RecomposeEquivalenceTests`. +- **Export / read mode:** `HTMLRendererTests`, `DocumentHTMLTests`, `ReadModeWebViewTests`, `HTMLThemeTests`, `EditorThemeTests`, `ViewModeTests`, `ContentWidthTests`. +- **Infra / harness:** `LogTests`, `CrashReporterTests`, `PerfHarnessTests`, `StatusBarPrefsTests`, `FileIntegrationTests`, `EditorDocumentTests`, `TestHelpers.swift`. `_RenderDump.swift` / `_RenderEdit.swift` are **local dev tools (gitignored intent)** that dump Read-mode HTML / edit output for `tmp/sample.md` — not part of the assertion suite. + +**Two invariant-guarding patterns worth copying:** +- **Recompose equivalence** (`RecomposeEquivalenceTests`, helper + `assertMatchesFullRecomposeOracle`): an incremental restyle must produce the + **same** result as a full recompose. This is the safety net for the lazy / + incremental styling paths. +- **Incremental-parse fuzz** (`IncrementalParseFuzzTests`): random edits must + keep the parser's window-reparse consistent with a full reparse. + +--- + +## 3. Test helpers (`TestHelpers.swift`) + +`@MainActor` helpers you build on (verified exports): + +| Helper | Use | +|---|---| +| `makeEditor()` | an `EditorTextView` on the real TK2 chain (mirrors `Document.makeWindowControllers`) | +| `ensureFullLayout(...)` | force layout so geometry is real, not estimated | +| `type(...)`, `paste(...)`, `pressEnter()`, `pressBackspace()` | drive edits | +| `activateBlock(...)` | move the caret / active block | +| `displayText(...)`, `attrs(...)`, `font(...)`, `fgColor(...)` | inspect styled output | +| `isHidden` / `isInvisible` / `isDimmed` | assert delimiter hiding | +| `blockDecoration(...)`, `textBlockDifference(...)` | inspect decorations / catch TK1-reverting table attrs | +| `expectedFullComposition(...)`, `drainAllStyling()`, `assertMatchesFullRecomposeOracle(...)` | equivalence-oracle checks | +| `makeLargeMarkdown(...)`, `sentence(...)` | build big fixtures for perf/viewport | + +`styleBlock(_:cursorPosition:)` is a method on `EditorTextView` +(`Rendering/EditorTextView+Rendering.swift`), called from tests — it renders one +block to an attributed string. + +--- + +## 4. How to add a test + +Skeleton (Swift Testing, `@MainActor` because the editor is main-thread): + +```swift +import Testing +import AppKit +@testable import EdmundCore + +@MainActor +@Test func deletingAtCalloutBottomKeepsInvariant() { + let editor = makeEditor() + editor.loadRawSource("> [!note]\n> body\n") + drainAllStyling() + // ... drive the edit ... + #expect(editor.rawSource == editor.string) // storage == rawSource invariant +} +``` + +Rules: +1. **Every bug fix ships with a test even if it can't discriminate a live-only + mechanism** — it still locks the headless contract so a *future* refactor + can't silently re-break the model half. For the live half, keep the frozen + `.repro` script alongside (edmund-live-repro-and-diagnostics). +2. Assert the **invariant** where you can (`rawSource == string`), not just the + surface symptom — that's what `BypassedEditSyncTests` / `MarkedTextDesyncTests` + do. +3. Name the test for the behavior/bug, put edit-pipeline repros next to their + siblings (the `*Desync*` / `*Bypassed*` / `*CaretTests` families). +4. New drawing behavior additionally needs a screencapture check (§6). + +--- + +## 5. Golden / certified inventory + +- **`RenderingRegressionTests`** + `RecomposeEquivalenceTests` are the closest + thing to golden checks — they pin styled output and incremental-vs-full + equivalence. +- **`test-files/*.md`** (`callout.md`, `decorations.md`, `math.md`, `menu.md`, + `test.md`) are the **user's manual test corpus** — hand-testing fodder. **Do + not rewrite them to fit an automated test**; build your own fixtures (helpers + in §3, or a scratch dir). +- **`misc/bug-repros/`** holds field evidence (`.mov` screen recordings + `.log` + traces) for open bugs — reference these when reproducing, don't delete them. + +--- + +## 6. Visual QA + +Anything that draws is verified by **screencapture pixel measurement**, in +**light AND dark mode** (per `misc/before-you-release.md`), never by eyeballing +headless layout. Method + scripts: **edmund-live-repro-and-diagnostics** §5. +Headless layout tests (`HeightStabilityTests`, `ScrollStabilityTests`) check +geometry numbers but **cannot** confirm the pixels are right. + +--- + +## 7. Flakiness & CI + +- A `fix/flaky-math-test` branch exists in history — math rendering has shown + timing flakiness; if a math test flakes, check that branch's approach before + inventing a new one (verify: `git log --oneline --all -- '*Math*'`). +- CI: `.github/workflows/ci.yml` on `macos-14`, latest-stable Xcode, SPM cache + keyed on `Package.resolved`, `concurrency: cancel-in-progress` (private-repo + macOS minutes bill 10×). CI runs the same `swift test`. + +--- + +## 8. Performance evidence + +- **`PerfHarnessTests`** measures the hot paths; `makeLargeMarkdown` builds big + fixtures. Use it (don't hand-time) for any perf claim. +- The README claim "handles ~1–2MB files" and `fullLayoutMaxLength = 100_000` + UTF-16 (`EditorTextView.swift:80`) mark the boundary between the **full-layout** + regime (≤100k, geometry is real) and the **estimate** regime (>100k, viewport + glitches possible). A perf/viewport claim must state which regime it was + measured in. + +--- + +## When NOT to use this skill + +- The gate/branch/commit rules → **edmund-change-control**. +- Running the live repro or measuring pixels → **edmund-live-repro-and-diagnostics**. +- The caret-integrity investigation end to end → **edmund-caret-integrity-campaign**. +- Why a mechanism works → **textkit2-appkit-reference** / **edmund-architecture-contract**. +- Release verification → **edmund-release-and-operate**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. + +```bash +grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases +grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest +grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3) +ls Tests/EdmundTests/ # suite map (§2) +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +``` + +If a helper in §3 no longer greps it was renamed; update §3 and any test +skeletons. Re-derive the suite map from `ls` if files are added/removed. diff --git a/.claude/skills/textkit2-appkit-reference/SKILL.md b/.claude/skills/textkit2-appkit-reference/SKILL.md new file mode 100644 index 0000000..8c6908a --- /dev/null +++ b/.claude/skills/textkit2-appkit-reference/SKILL.md @@ -0,0 +1,279 @@ +--- +name: textkit2-appkit-reference +description: > + Domain-theory pack for the AppKit text system as it applies to the Edmund + Markdown editor. Load when working on layout, selection, IME, undo, drag, or + eventing behavior; when TextKit 2 or NSTextView does something surprising; + or when terms like NSTextLayoutManager, layout fragment, marked text, queued + selection fixup, responder chain, or sendEvent appear and you lack AppKit + text-system background. Explains the mechanisms the invariants and gotchas + are built on. Not the invariants themselves (edmund-architecture-contract), + not a triage table (edmund-debugging-playbook), not the repro drivers + (edmund-live-repro-and-diagnostics). +--- + +# TextKit 2 / AppKit reference (as it applies to Edmund) + +The background a mid-level engineer or Sonnet-class model usually lacks. Each +concept: brief theory, then **where it bites in Edmund** with a verified file +pointer. This is not a textbook — it is only the parts that matter here. + +Facts checked against source 2026-07-05. Items labeled *(background)* are +general AppKit/TextKit behavior grounded in Apple's documentation, not directly +grep-able in this repo. + +--- + +## 1. The TextKit 2 object model *(background + repo)* + +TextKit 2 replaced the TextKit 1 `NSLayoutManager` stack. The players: + +- **`NSTextContentStorage`** — owns the backing string + attributes (the model). +- **`NSTextLayoutManager`** — lays text out (the TK2 analogue of the old layout manager). +- **`NSTextLayoutFragment`** — one laid-out chunk (≈ a paragraph); has a real + geometric frame **only once laid out**. +- **`NSTextElement` / `NSTextParagraph`** — the model-side elements fragments render. + +**Viewport-based layout** is the headline difference: TK2 lays out only the +content near the visible viewport, not the whole document. That is what makes +big documents fast — and it is the root of most viewport pain (§2). + +**Where it bites:** Edmund subclasses the fragment as `DecoratedTextLayoutFragment` +(`EditorTextView+TextKit2.swift`) to draw callout boxes, bars, and overlays. + +--- + +## 2. Height ESTIMATES — the master cause of viewport glitches + +A fragment that has not been laid out yet has an **estimated** height, not a +real one; the total document height is the sum of real + estimated fragment +heights. As layout reaches a fragment, its estimate is replaced by the true +value and everything below shifts. Consequences: the scroller thumb jumps, and +"scroll to offset Y" lands wrong because Y was computed from estimates. This is +a **widely documented TK2 limitation — even TextEdit shows it** *(background)*. + +**Where it bites / Edmund mitigations** (verify names by grep; all in `TextView/`): +- `fullLayoutMaxLength = 100_000` (`EditorTextView.swift:80`): documents ≤ 100k + UTF-16 units are kept **fully laid out** (no estimate regime) by a coalesced + next-run-loop settle. +- `scheduleFullLayoutSettle` / `preservingViewportAnchor`: the settle runs inside + an anchor block so corrections never shift what is on screen. +- `repairContentAboveOrigin` (`+LazyStyling.swift`, logs `repairing content + above origin`): fixes the case where an edit near the top strands the first + fragment at negative y (unreachable above the scroller top). +- `centerViewportOnCaret`: re-measures after its first scroll and corrects the + residual estimate error. + +**Rule:** never trust an off-screen fragment's y-coordinate without laying out +its span first. Deep write-up: `docs/investigations/viewport-glitch-investigation.md`. + +--- + +## 3. The TextKit 1 fallback trap + +An `NSTextView` can silently and **permanently** revert from TK2 to the legacy +TK1 stack. Two known triggers: **accessing `NSTextView.layoutManager`** (the +mere getter engages TK1), and **storing `NSTextBlock`/`NSTextTable` +attributes**. Once reverted, TK2 APIs still exist but do nothing useful, and the +whole editor misbehaves subtly. + +**Where it bites:** Edmund ships a **DEBUG tripwire** — an observer on +`NSTextView.willSwitchToNSLayoutManagerNotification` that asserts if the switch +happens (`EditorTextView.swift:273`+, message "TextKit 1 fallback triggered"). +Never add code that reads `layoutManager` or stores table attributes; draw +tables as decorations instead (`EditorTextView+TableRendering.swift`). + +--- + +## 4. Attribute-only mutation semantics + +Edmund renders by writing **attributes** onto the storage, never by inserting or +deleting characters (the storage == rawSource invariant). Two consequences from +the text system: + +- **`setAttributes` does not re-measure geometry.** After a restyle that changes + a block's height or indent, you must call `invalidateLayout(for:)` on its + range or the fragment keeps a **stale frame** (empty bands / clipped lines). + `recomposeDirty` and the idle drain already do this; new paths must too. +- **`NSTextAttachment` is only honored on the `U+FFFC` object-replacement + character** *(background)*. `rawSource` never contains `U+FFFC`, so attachments + can't be used — Edmund draws images/icons as **overlays** (§5) instead. + +--- + +## 5. The custom drawing model (fragments, decorations, overlays) + +`DecoratedTextLayoutFragment` draws two attribute families behind/over text: + +- **`.blockDecoration`** (paragraph-level): callout boxes, quote bars, table + borders, thematic-break rules, code backgrounds. Fragments **tile vertically**, + so a multi-line run renders as one continuous box. A box's `bottomPad` grows + the **last** fragment's frame (TK2 omits trailing paragraph spacing from the + fragment, so padding done otherwise would be dead space). +- **`.fragmentOverlay`** (character-level): an image **or** a stroked vector path + drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, + callout header icon, the custom-title callout icon (path). The anchor glyph is + **hidden** (≈0.01 pt font + clear color) and `.kern` reserves the drawing's + advance width. + +**The image-on-wrapping-fragment wedge:** drawing an **image** overlay on a +**multi-line (wrapping)** fragment re-triggers a layout pass that collapses the +fragment to one line. Drawing a **shape/path** does not. So the wrapping +callout title's icon is a stroked `CGPath` (parsed by `SVGPath` from vendored +Lucide geometry), never an image. Full saga: +`docs/investigations/archives/callout-title-wrap-investigation.md`. This constraint holds for **any new +overlay** that could share a line with wrapping text. + +**Hiding text** = `hiddenFont` (≈0.01 pt) + clear `foregroundColor`. This is how +delimiters (`**`, `` ` ``, `[!note]`) vanish without changing the string. + +--- + +## 6. The queued selection fixup (the round-6 delete-drift mechanism) + +When you mutate an `NSTextView`'s storage, AppKit queues a private step, +`-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:]`, that repairs +the selection against the new character coordinates. Normally it fires promptly. +But if an edit **bypasses the normal close-out** (see §7), the fixup stays +**queued** and fires at the **next `endEditing`** — *even an attribute-only +restyle* — where it maps the now-**stale** selection against post-edit +coordinates and **leaps the caret blocks away**. It will move even a freshly set, +valid caret. + +**Where it bites:** this is delete-drift round 6. The heal must set the caret +(from the pendingEdit hull) **before** the sync **and re-assert it after** +(`EditorTextView+EditFlow.swift`). Recognize a variant by: a suspicious +selection change arriving mid-recompose (`up=Y` in traces); +`traceSelectionOrigin` will log the call stack of whoever moved it. + +**Critical for testing:** a headless test harness runs this deferred fixup +**synchronously**, so this bug class **cannot reproduce in a unit test** — the +round-6 regression test passes with *and* without the fix. Only the live +in-process repro discriminates (edmund-live-repro-and-diagnostics). + +--- + +## 7. The AppKit edit pipeline contract (and where AppKit breaks it) + +Normal edit: `shouldChangeText(in:replacementString:)` → the view calls +`replaceCharacters` → `didChangeText()`. Edmund's `didChangeText` syncs +`rawSource` from storage and restyles the edited block(s). + +**AppKit does NOT always send `didChangeText`.** A drag-move of selected text +whose drop lands on **no valid target** (e.g. released past the end of the +document) deletes the dragged range via `shouldChangeText` → `replaceCharacters` +and **never calls `didChangeText`** — silently freezing `rawSource`/`blocks`, +after which every edit drifts and autosave writes stale content (delete-drift +round 4). Edmund heals this: `shouldChangeText` schedules a next-run-loop +**bypass check** (`scheduleBypassedEditSyncCheck`, `+EditFlow.swift`); an +unconsumed storage `pendingEdit` by then means the close-out never came, and the +editor runs the sync itself (breadcrumb: `healing storage edit that bypassed +didChangeText`). + +**Never build a sync path on the assumption that `didChangeText` follows every +edit.** + +**The authentic key route** *(background + repo)*: keyDown → +`interpretKeyEvents` → `insertText:` / `deleteBackward:`. This is why the repro +driver synthesizes real `NSEvent`s and pushes them through `window.sendEvent(_:)` +rather than calling `insertText` directly — shortcuts skip `deleteBackward`'s +selection machinery, which is exactly where round 6 lived. + +--- + +## 8. IME / marked text lifecycle + +While an input method is composing (e.g. CJK, accents), the view holds +**provisional "marked" text** in storage; `hasMarkedText()` is true. During this +window `storage == rawSource` is **transiently false**, and `didChangeText` +defers syncing until the composition commits. + +**The cascade:** any styling path that runs +`beginEditing`/`setAttributes`/`invalidateLayout` **mid-composition** can strand +the marked text in the input context. After that, `didChangeText` keeps bailing +on its own guard and the invariant stays broken — so **every later edit drifts +the caret** (the original delete-drift bug). Therefore **every storage-touching +styling path must guard `!hasMarkedText()`** — including async ones scheduled +*before* composition began (the caret-move restyle in `+SelectionTracking`). +`becomeFirstResponder` resyncs from storage as a catch-all. Full write-up: +`docs/investigations/delete-drift-investigation.md`. + +--- + +## 9. Responder chain & nil-target actions *(background + repo)* + +The **responder chain** is AppKit's search order for who handles an action. +Menu items and toolbar buttons with a **nil target** send their action up the +chain until something responds. Edmund's Format menu (`FormatMenu.swift`) is a +declarative command table whose items use nil targets and route to the focused +`EditorTextView`'s `@objc format…` actions — the same wiring as undo/redo. The +first responder is normally the focused `EditorTextView`. + +--- + +## 10. `NSWindow.sendEvent` — the pre-toolbar event funnel + +Every event a window receives passes through `sendEvent(_:)` **before** the +toolbar acts. This matters because with `NSToolbar.allowsUserCustomization = +true`, the toolbar claims **any secondary (right/control) click over the +toolbar** — including a custom item view — for its own "Customize Toolbar…" menu, +downstream of view-level handlers (`menu`, `rightMouseDown`, gesture +recognizers all lose). Edmund's fix for the view-mode button: intercept in +`DocumentWindow.sendEvent(_:)`, pop the menu when the click is inside the +button's bounds, and swallow it (`return`); other clicks fall through to +`super`. (Caveat: true fullscreen moves the toolbar to a separate window, so +this main-window hook wouldn't cover it.) + +--- + +## 11. Drag sessions *(background + repo)* + +- **Text drag-move arming:** AppKit only starts a text drag after a **mouse-down + hold (~400 ms)**; a CGEvent driver must hold before moving or the drag never + arms. +- **Drag-select autoscroll:** dragging past the viewport edge autoscrolls. +- **Reveal at nearest end:** a selection taller than the viewport must be + revealed at its **nearest** end (Edmund's `scrollRangeToVisible` override) — + always revealing the top fought the drag-select autoscroll and oscillated the + viewport mid-drag. + +--- + +## 12. swift-markdown walker model *(brief)* + +Edmund parses with `apple/swift-markdown` (CommonMark/GFM) and walks the +resulting `Document` with **two back-ends**: a `SpanCollector`-style walk that +produces editor attributes, and `HTMLRenderer` that produces HTML for Read mode. +One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark) +syntax — callouts, `==highlight==`, wikilinks, comments, footnotes, math — is +handled by `SyntaxHighlighter+CustomParsers.swift`. + +--- + +## When NOT to use this skill + +- The project's **rules/invariants** (what you must not do) → **edmund-architecture-contract**. +- **Which** symptom means which mechanism → **edmund-debugging-playbook**. +- **Reproducing** a live bug / reading traces → **edmund-live-repro-and-diagnostics**. +- The **history** of how these mechanisms were discovered → **edmund-failure-archaeology**. +- Running the caret-integrity campaign → **edmund-caret-integrity-campaign**. + +--- + +## Provenance and maintenance + +Verified 2026-07-05. Re-verify the load-bearing identifiers: + +```bash +grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift +grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift +grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/ +grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/ +grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/ +``` + +Items marked *(background)* are AppKit/TextKit behavioral facts documented by +Apple and in `docs/*-investigation.md`, not directly observable by grep. If any +Edmund mitigation name above no longer greps, it was renamed — update this file +and `docs/ARCHITECTURE.md` §5/§8 together. diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..d3b118d --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.command // empty' | { read -r cmd; if echo \"$cmd\" | grep -Eq '(^|&&|;|\\|)[[:space:]]*git[[:space:]]+(checkout[[:space:]]+-b|switch[[:space:]]+-c)\\b' || echo \"$cmd\" | grep -Eq '(^|&&|;|\\|)[[:space:]]*git[[:space:]]+branch[[:space:]]+[^-[:space:]][^[:space:]]*([[:space:]]|$)'; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Edmund prefers git worktrees over branch-switching for concurrent local work: git worktree add .worktrees/ (branch keeps its normal type/slug name). See CLAUDE.md Git practices / docs/ARCHITECTURE.md \\u00a712.\"}}'; else echo '{}'; fi; }" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "swift test 2>&1 | tail -5", + "timeout": 120, + "statusMessage": "Running swift test…" + } + ] + } + ] + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0eb3dd8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Something's literally wrong... +title: '' +labels: bug +assignees: '' + +--- + +**Description** +What's the bug? + +**Reproduction** +If description is not enough, how do you reproduce the bug? +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +If it's not obvious, what should've happened? + +**Screenshots or recordings** +If applicable, add screenshots or screen recordings of the bug or the expected behavior. + +**Device info:** + - Device: [e.g. MacBook Pro, M2 Pro, 16 GB] + - macOS version: [e.g. Sonoma 14.8.3] + +**Additional context** +Questions, concerns, comments, context, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f2f0991 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: How can we do better? +title: '' +labels: enhancement +assignees: '' + +--- + +**Problem** +What's wrong? + +**Solution** +What would you like to happen instead? + +**Alternative(s)** +What else would you like to happen instead? Why's the one above your favorite? + +**Additional context** +Context, screenshots / recordings, comments, etc. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..385e656 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported Versions + +Edmund is in beta (pre-1.0). Only the latest released version is supported +with security fixes. + +| Version | Supported | +| ------- | --------- | +| latest | ✅ | +| older | ❌ | + +## Reporting a Vulnerability + +Please report security vulnerabilities privately using GitHub's +[private vulnerability reporting](https://github.com/I7T5/Edmund/security/advisories/new) +(Security tab → "Report a vulnerability"). Do not open a public issue for +suspected vulnerabilities. + +Include as much detail as you can: affected version, reproduction steps, +impact, and any proof-of-concept. You should get an initial response within +7 days. + +Since Edmund is a local, offline-first macOS app (no server, no accounts, no +telemetry), the main risk areas are: + +- Malicious Markdown/HTML content leading to code execution, sandbox escape, + or unintended network access when opening a file +- Sparkle auto-update integrity (signature/verification bypass) +- Arbitrary file read/write outside the file the user opened + +If confirmed, a fix will be released and credited in the release notes +(unless you prefer to stay anonymous). + +## Disclosure + +Please give a reasonable amount of time to fix an issue before any public +disclosure. There is no bug bounty program. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d001e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +# Only the latest run per branch/PR matters; cancel older ones so private-repo +# macOS minutes (billed at 10x) aren't spent on superseded commits. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test + runs-on: macos-14 + steps: + - uses: actions/checkout@v5 + + # Package.swift declares swift-tools-version 6.0, which needs Xcode 16+. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Swift version + run: swift --version + + # Cache the SwiftPM build (deps + compiled objects) keyed on the resolved + # dependency graph, so green runs don't recompile SwiftMath etc. each time. + - name: Cache .build + uses: actions/cache@v5 + with: + path: .build + # v2: the repo was renamed md -> Edmund, which changed the checkout + # path and invalidated absolute paths baked into the cached module + # cache. Bump this token to discard any pre-rename .build cache. + key: spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }} + restore-keys: spm-v2-${{ runner.os }}- + + - name: Test + run: swift test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..89b5a76 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Build & publish + runs-on: macos-14 + permissions: + contents: write # create releases and commit appcast + steps: + - uses: actions/checkout@v5 + with: + # Fetch full history so we can push the appcast commit back. + fetch-depth: 0 + # Admin PAT so the appcast commit can be pushed to the protected + # `main` branch. The default GITHUB_TOKEN/bot is not an admin and is + # blocked by the required `test` status check. + token: ${{ secrets.RELEASE_TOKEN }} + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache .build + uses: actions/cache@v5 + with: + path: .build + key: spm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }} + restore-keys: spm-v2-${{ runner.os }}- + + - name: Build app bundle + run: ./scripts/build-app.sh + + # create-dmg 8.x requires Node >= 20; pin it so the runner's default node + # version can't break the release. + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install create-dmg + # sindresorhus/create-dmg is the npm package (NOT the homebrew + # create-dmg/create-dmg tool, which has an incompatible CLI). + run: npm install --global create-dmg + + - name: Create DMG + run: | + VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' Info.plist)" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + # create-dmg exits 2 when it can't Developer-ID-sign / notarize the + # image (we ship ad-hoc only) but still produces the .dmg — so don't + # let that non-zero status fail the job. + create-dmg build/Edmund.app build/ || true + # It names the output "Edmund .dmg"; normalize to a + # hyphenated, URL-friendly name and fail loudly if none was produced. + DMG_SRC="$(ls build/Edmund*.dmg 2>/dev/null | head -1)" + if [ -z "$DMG_SRC" ]; then + echo "create-dmg produced no .dmg" >&2 + exit 1 + fi + mv "$DMG_SRC" "build/Edmund-${VERSION}.dmg" + + - name: Sign archive (EdDSA) + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + SIGN_UPDATE="$(find .build -name sign_update -type f | head -1)" + # Pass the key on stdin via --ed-key-file -. The deprecated `-s ` + # is fatal for newly generated keys ("no longer supported"). + SIG_OUTPUT="$(echo "$SPARKLE_ED_PRIVATE_KEY" | "$SIGN_UPDATE" --ed-key-file - "build/Edmund-${VERSION}.dmg")" + echo "ED_SIG=$(echo "$SIG_OUTPUT" | grep -o 'sparkle:edSignature="[^"]*"')" >> "$GITHUB_ENV" + echo "LENGTH=$(echo "$SIG_OUTPUT" | grep -o 'length="[^"]*"' | head -1)" >> "$GITHUB_ENV" + + - name: Create GitHub Release + id: create_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" \ + CHANGELOG.md > release-notes.txt + if [ ! -s release-notes.txt ]; then + echo "See CHANGELOG for details." > release-notes.txt + fi + gh release create "v${VERSION}" "build/Edmund-${VERSION}.dmg" \ + --title "Edmund ${VERSION}" \ + --notes-file release-notes.txt \ + --latest + + - name: Update appcast.xml + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO="${{ github.repository }}" + ASSET_URL="https://github.com/${REPO}/releases/download/v${VERSION}/Edmund-${VERSION}.dmg" + PUB_DATE="$(date -u '+%a, %d %b %Y %H:%M:%S +0000')" + BUILD="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' Info.plist)" + + # Release notes for Sparkle's update dialog (scrollable pane), built + # from this version's CHANGELOG section. Omitted if the section is missing. + # Built with printf (not a literal multi-line string) so this step's + # `run: |` block scalar doesn't contain an unindented line, which + # breaks YAML parsing of the whole workflow file. + # $(...) strips trailing newlines, so DESC_BLOCK can't carry its own + # trailing separator — NEW_ITEM's printf adds it explicitly instead. + DESC_HTML="$(python3 scripts/changelog-to-html.py "$VERSION")" + DESC_BLOCK="" + if [ -n "$DESC_HTML" ]; then + DESC_BLOCK="$(printf ' ' "$DESC_HTML")" + fi + + # Built with printf (one logical YAML line) rather than a literal + # multi-line string: an unindented line inside this value would + # break this run block's YAML indentation (as DESC_BLOCK did above). + NEW_ITEM="$(printf ' \n Edmund %s\n %s\n%s\n \n ' "$VERSION" "$PUB_DATE" "$DESC_BLOCK" "$ASSET_URL" "$BUILD" "$VERSION" "$ED_SIG" "$LENGTH")" + + python3 - "$NEW_ITEM" <<'PYEOF' + import sys + new_item = sys.argv[1] + with open("appcast.xml", "r") as f: + content = f.read() + content = content.replace(" ", new_item + "\n ") + with open("appcast.xml", "w") as f: + f.write(content) + PYEOF + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add appcast.xml + git commit -m "appcast: add Edmund ${VERSION}" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c6208b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# AI +.claude/worktrees/ +.claude/settings.json +.claude/settings.local.json +.codex/config.toml + +# local tooling +.worktrees/ +Tests/EdmundTests/_RenderDump.swift +Tests/EdmundTests/_RenderEdit.swift + +# local files +/test-files/ +/misc/ +/tmp/ + +# dev stuff +.build/ +build/ +Packages/ +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata + +# Sparkle private key +sparkle_private_key.txt + +# system +.DS_Store +.netrc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6853440 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md — Edmund + +Native macOS Markdown editor, live preview (AppKit + TextKit 2, SPM, macOS 14+). + +**Before non-trivial work, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** — +it covers the build/test commands, the two hard invariants (storage == rawSource; +TextKit 2 only), the render pipeline, the feature map, and the gotchas. Keep that +doc updated as you learn things. + +## Environment +- Do **not** request Computer Access — Screen Recording and Accessibility are already granted. +- For frontend changes, verify with `screencapture` in the CLI (capture the window by id; see ARCHITECTURE §8). If it keeps failing, render the editor offscreen to a PNG. + +## Git practices +- Commit automatically and frequently. +- Branch off `main` for each fix (don't commit straight to `main`); one feature/fix per branch, small logical commits. +- For concurrent local work, use `git worktree add .worktrees/ ` instead of stashing or switching branches. Branch names keep the normal `type/slug` convention (e.g. `fix/foo`) — never rename a branch to `worktree-*`. `.worktrees/` is gitignored. This is separate from `.Codex/worktrees/`, which Codex's own EnterWorktree tool manages automatically; don't hand-edit that one. +- **Never auto-push, PR, or merge — only when I explicitly ask.** +- Never delete uncommitted changes. + +## Before committing (the checklist that works) +1. `swift test` — all green; add tests for new behavior / bug repros. **`swift test` runs automatically as a Stop hook** at the end of every turn that touches code, so failures surface before you commit. +2. Visual changes: build the app and `screencapture`-verify (or render offscreen to PNG). Don't trust headless layout alone for anything that draws. +3. Touch only what the task needs; match surrounding style; don't refactor unrelated code. + +See ARCHITECTURE §12 for the fuller rationale. + +## Comment quirks where they live +Document non-obvious behavior (edge cases, workarounds, the *why*) as a short +comment at the code itself — not in commits or this file. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d9dd8ee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,115 @@ +# Changelog + +All notable changes will be documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +GFM pass: closing the gaps between Edmund and the GFM spec in both edit and read mode. + +### Added +- Setext headings (`Title` underlined by `===`/`---`) render in edit mode +- Indented code blocks (4 spaces or a tab, after a blank line) render in edit mode +- HTML ``: dimmed in edit mode, hidden in read mode (previously showed as literal text in read mode) +- `` added to the rendered HTML whitelist (both modes) +- `` renders the image in both modes, at its declared size (one dimension alone scales proportionally); remote/local image policy applies as for markdown images +- Autolinks ([GFM extension](https://github.github.com/gfm/#autolinks-extension-)): bare `www.…`, `http(s)://…`, and email addresses become links in both modes, with CMD+click to follow +- Inline styling (bold, code, links, ==marks==, …) now renders inside table cells in edit mode; column widths align on the *styled* text, not the raw source +- Inline styling inside headings keeps the heading's font size (`# **bold** and `code``), for ATX and setext headings +- Raw HTML renders in read mode per GFM ([§4.6](https://github.github.com/gfm/#html-blocks)/[§6.10](https://github.github.com/gfm/#raw-html)) with the tagfilter extension ([§6.11](https://github.github.com/gfm/#disallowed-raw-html-extension-)) plus hardening: `on*` event-handler attributes and `javascript:`/`vbscript:` URLs stripped, and a `script-src 'none'` CSP on the page (JS was already disabled) +- HTML blocks (all seven GFM §4.6 start conditions) parse as blocks in edit mode and show as colored source +- Full GFM §6.10 inline tag grammar in edit mode: hyphenated tag names, single-quoted/unquoted attribute values, `>` inside quoted values, and PI/declaration/CDATA tokens +- Multi-backtick code spans in edit mode (`` ``a`b`` ``) style with their real delimiter length +- Loose vs tight lists in read mode: tight lists drop the `

        ` wrapper inside items per GFM §5.3 +- Link `title` attributes carry into read-mode/exported HTML + +### Changed +- Read mode no longer escapes unknown HTML — GFM passthrough (with tagfilter + hardening) replaces the escape-by-default whitelist +- A `---` line directly under a paragraph is now a setext h2 underline per GFM, no longer a thematic break — put a blank line between the paragraph and `---` to keep the rule +- `==highlight==` now follows GFM-style flanking: content can't begin or end with whitespace (`== spaced ==` stays literal) +- Setext heading content spans the whole preceding paragraph run (`Foo\nbar\n---` is one h2), matching GFM Example 51 +- Interior blank lines stay inside an indented code block (GFM Examples 82/87) + +### Fixed +- Tables whose delimiter row cell count differs from the header are no longer parsed as tables in edit mode (GFM Example 203) +- Backslash-escaped pipes (`\|`) are cell content, not column separators (GFM Example 200) +- The ATX heading closing sequence (`# foo ###`) hides like other delimiters instead of showing in the heading (GFM 4.2) +- A newline inserted at a display-math block boundary no longer leaves a stray centered line (separator newlines now reset when adjacent blocks restyle) + +## [0.1.4] - 2026-07-09 + +Various small fixes and improvement and new round of grind at the [delete caret drift](https://github.com/I7T5/Edmund/issues/156). I think it actually worked this time, but don't quote me on it. + +### Added +- `CMD+=`, `CMD+-`, and `CMD+0` to zoom in/out/reset. Also in View menu +- External images rendering in editor +- Block external images setting in Settings > Advanced + +### Changed +- Rename "Source Mode" to "Show Source in Editor" in app and button menu. Removed icon from button menu. +- Opening an existing file closes the last opened Untitled window with no edit history +- Move Automatic updates to Settings > General +- Apply Settings > Appearance > Max content width to read mode + +### Fixed +- Images have extra bottom padding when editor is not in full screen +- Images do not resize with max content width if the user changes the setting when the app is open +- Tables overflow handled by horizontal scroll +- Callouts have an extra line at the bottom when they are the last element of a file +- Footnotes rendering in edit mode and linking between inline marker and content in read mode +- Math environments `\begin{}...\end{}` padding offset in edit mode +- Math environments `\begin{}...\end{}` rendering in read mode +- Delete caret drift, round 7 ([docs](docs/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156) + +--- + +## [0.1.3] — 2026-07-04 + +### Fixed +- Delete caret drift *with reproduction* ([docs](docs/investigations/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156) + +--- + +## [0.1.2] — 2026-07-03 + +Polishing the editor and trying to have Fable 5 fix all the big bugs while I still have it with me. + +### Changed +- Redo now jumps to where changed text was instead of caret +- Removed old code for identity mapping, etc., using [ponytail](https://github.com/DietrichGebert/ponytail)-review + +### Fixed +- Updater [#158](https://github.com/I7T5/Edmund/issues/158) +- Icon display for callouts with custom titles ([docs](docs/investigations/archives/callout-title-wrap-investigation.md)) +- Undo/redo viewport glitches from TextKit 2 ([docs](docs/investigations/viewport-glitch-investigation.md)) +- Delete caret drift ([docs](docs/investigations/delete-drift-investigation.md)) [#156](https://github.com/I7T5/Edmund/issues/156) + +--- + +## [0.1.1] — 2026-06-29 + +### Added +- Thematic Break `---`/`***` in the Format menu +- Remember window size: new document windows reopen at the size of the last one. + +### Changed +- Max content width is now an absolute physical width (cm / in) with a max-width cap and a cm/in unit toggle. +- Typewriter Mode renamed to Typewriter Scroll + +### Fixed +- Typewriter Scroll no longer jumps the viewport when you click to reposition the caret — it re-centers only while typing. + +--- + +## [0.1.0] — 2026-06-27 + +First public release. + +- **Live WYSIWYG preview** — Typora/Obsidian style +- **GFM support** — bold, italic, strikethrough, tables, task lists, fenced code with syntax highlighting, blockquotes, alerts +- **Extended syntax** — ==highlights==, [[WikiLinks]], `[^footnotes]`, Obsidian-flavored callouts and comments +- **Math** — inline (`$…$`) and display (`$$…$$`) rendering via SwiftMath +- **Native macOS UI** — AppKit editor, SwiftUI settings panel, full Dark Mode support +- **Keyboard-first** — configurable shortcuts, no required mouse interaction +- **Auto-update** — Sparkle 2.x with EdDSA-signed appcast; checks on launch +- **Open source** — Apache 2.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9df2931 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md — Edmund + +Native macOS Markdown editor, live preview (AppKit + TextKit 2, SPM, macOS 14+). + +**Before non-trivial work, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** — +it covers the build/test commands, the two hard invariants (storage == rawSource; +TextKit 2 only), the render pipeline, the feature map, and the gotchas. Keep that +doc updated as you learn things. + +## Environment +- Do **not** request Computer Access — Screen Recording and Accessibility are already granted. +- For frontend changes, verify with `screencapture` in the CLI (capture the window by id; see ARCHITECTURE §8). If it keeps failing, render the editor offscreen to a PNG. + +## Git practices +- Commit automatically and frequently. +- Branch off `main` for each fix (don't commit straight to `main`); one feature/fix per branch, small logical commits. +- For concurrent local work, use `git worktree add .worktrees/ ` instead of stashing or switching branches. Branch names keep the normal `type/slug` convention (e.g. `fix/foo`) — never rename a branch to `worktree-*`. `.worktrees/` is gitignored. This is separate from `.claude/worktrees/`, which Claude Code's own EnterWorktree tool manages automatically; don't hand-edit that one. +- **Never auto-push, PR, or merge — only when I explicitly ask.** +- Never delete uncommitted changes. + +## Before committing (the checklist that works) +1. `swift test` — all green; add tests for new behavior / bug repros. **`swift test` runs automatically as a Stop hook** at the end of every turn that touches code, so failures surface before you commit. +2. Visual changes: build the app and `screencapture`-verify (or render offscreen to PNG). Don't trust headless layout alone for anything that draws. +3. Touch only what the task needs; match surrounding style; don't refactor unrelated code. + +See ARCHITECTURE §12 for the fuller rationale. + +## Comment quirks where they live +Document non-obvious behavior (edge cases, workarounds, the *why*) as a short +comment at the code itself — not in commits or this file. diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..5c0686b --- /dev/null +++ b/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleName + Edmund + CFBundleDisplayName + Edmund + CFBundleIdentifier + com.i7t5.edmund + CFBundleExecutable + edmd + CFBundleIconFile + AppIcon + NSAccentColorName + AccentColor + CFBundlePackageType + APPL + CFBundleVersion + 5 + CFBundleShortVersionString + 0.1.4 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + SUFeedURL + https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml + SUPublicEDKey + 0XdLbbuOm0NonqWeDJkQuFF0CGnhKmpAfq+RNKVwEDs= + SUEnableAutomaticChecks + + CFBundleDocumentTypes + + + CFBundleTypeName + Markdown Document + CFBundleTypeRole + Editor + LSItemContentTypes + + net.daringfireball.markdown + + CFBundleTypeExtensions + + md + markdown + mdown + mkd + + + + CFBundleTypeName + Plain Text + CFBundleTypeRole + Viewer + LSItemContentTypes + + public.plain-text + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63baad1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Yina Tang + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/lucide.txt b/LICENSES/lucide.txt new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/LICENSES/lucide.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4ece815 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "a31b21636c19c1fb2661a51b226a2aef490af95e85b9325c822a9c5af1d81190", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "7d9a5ce307528578dfa777d505496bd5f544ad94", + "version" : "0.7.3" + } + }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mgriebling/SwiftMath.git", + "state" : { + "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7", + "version" : "1.7.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6f6d8ec --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "Edmund", + platforms: [.macOS(.v14)], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.5.0"), + .package(url: "https://github.com/mgriebling/SwiftMath.git", from: "1.7.0"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), + ], + targets: [ + .target( + name: "EdmundCore", + dependencies: [ + .product(name: "Markdown", package: "swift-markdown"), + .product(name: "SwiftMath", package: "SwiftMath"), + ]), + // The user-facing app is "Edmund" (CFBundleName); the executable target — + // and so the Mach-O binary at Edmund.app/Contents/MacOS/edmd — is "edmd", + // an expansion of "Editor for Markdown". A quiet backronym for anyone who + // peeks inside the bundle or runs `swift run edmd`. + .executableTarget( + name: "edmd", + dependencies: ["EdmundCore", .product(name: "Sparkle", package: "Sparkle")]), + .testTarget( + name: "EdmundTests", + dependencies: ["EdmundCore"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..47dcc2c --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Edmund +![macOS Version Compatibility](https://img.shields.io/badge/platform-macOS%2014.0%2B-0064e1?style=flat-square&color=0064e1) +![GitHub License](https://img.shields.io/github/license/i7t5/edmund?style=flat-square&color=772678) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/i7t5/edmund/total?style=flat-square&color=ff6916) +![Tiny App Icon](docs/assets/AppIcon/AppIcon_16x16.png) + +Edmund is a minimal, file-based, native Markdown editor for macOS with inline live preview. + + +https://github.com/user-attachments/assets/5c9097c7-68d2-4423-b0f5-495979775f6d + +Whether as a companion alongside your Markdown knowledge base or as a standalone editor, +Edmund blends in with macOS and works seamlessly with your files wherever they are. + +Our goal is to be the [CotEditor](https://coteditor.com) of Markdown editors, +i.e. elegant, powerful, configurable, and native inside out. + +> ⚠️ Edmund is currently in beta. See the [roadmap](docs/ROADMAP.md) for what's coming next :D + + +## Differentiators + +- Live preview: Typora/Obsidian-style WYSIWYG. +- File-based: Open `.md` files from anywhere. No vaults or dedicated folders required. +- Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies. +- Fast: Handles ~1-2MB files with ease. No launch lag. +- Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon! +- Private: Offline by default. Optional blocking of external links and HTML sanitization. + + + +See [my blog post](https://i7t5.com/posts/2026-06-26-edmund/) for more of the motivation and design philosophy. + + +## Screenshots + +![Basic usage screenshot in light and dark mode](docs/assets/v0.1.0_basic.png) + +![GFM syntax screenshot in edit mode](docs/assets/v0.1.0_gfm-syntax.png) + +![Non-GFM and math syntax screenshot in edit mode](docs/assets/v0.1.0_more-syntax.png) + +![Basic usage in read mode and inspector](docs/assets/v0.1.0_read-mode.png) + + +## Installation + +Get `Edmund.dmg` from the [latest release](https://github.com/I7T5/Edmund/releases/latest), open it, and drag `Edmund.app` to `Applications`: + +Window for drag and drop to install + +> [!WARNING] +> If macOS reports that the app is `🚧DAMAGED🚧` when you're trying to open it for the first time, fear not. +> The app is not damaged. It's just not signed properly because I am not a $99/yr-certified Apple Developer. +> Good thing is there's an easy way to bypass the barrier. +> +> To open Edmund (or any other "damaged" app) for the first time, choose *one* of the following: +> - System Settings → Privacy & Security → scroll down → Open Anyway. Or, +> - Run the following line in Terminal: `xattr -dr com.apple.quarantine /Applications/Edmund.app` +> - You might also need to prepend the command with `sudo`. + +Edmund checks for updates automatically; you can also browse version history [here](https://github.com/I7T5/Edmund/releases). + +## Dependencies + +- [swift-markdown](https://github.com/swiftlang/swift-markdown) +- [SwiftMath](https://github.com/mgriebling/SwiftMath) +- [Sparkle](https://github.com/sparkle-project/Sparkle) + + +## Alternatives + +If Edmund's not your thing, some of the following might be: + +- Closed source + - Obsidian, cyberWriter, Notion + - Typora, Lettera (beta), LitSquare Ink MD +- Open source + - WYSIWYG: [MarkText](https://marktext.me), [Nodes](https://nodes-web.com), [Scratch](https://github.com/erictli/scratch) + - Split-screen: [MacDown](https://macdown.uranusjr.com), [MiaoYan](https://miaoyan.app) + - [MarkEdit](https://github.com/MarkEdit-app/MarkEdit) - TextEdit for Markdown + - I *love* this. If only I wasn't so dependent on rendered math... + - [editxr](https://github.com/pixdeo/editxr) - TUI + - More feature-rich: [FSNotes](https://fsnot.es), [Zettlr](https://www.zettlr.com), [Joplin](https://joplinapp.org), [Tangent](https://www.tangentnotes.com) + +The list is by no means exhaustive, and neither was it meant to be. I just wanted to give credit to the makers of these apps, esp. IMO the aesthetic open sourced ones. A comprehensive list may be found [here](https://github.com/mundimark/awesome-markdown-editors). + + +## Acknowledgements + +The following have greatly influenced the architecture and/or helped with design. I owe them many thanks: + +- [Swift Markdown Engine](https://github.com/nodes-app/swift-markdown-engine) / [Nodes](https://nodes-web.com) for the parser/token architecture and the TextKit 2 integration +- [Typora](https://typora.io) and Apple Notes for app menu organization +- [Tomorrow Light](https://github.com/chriskempson/tomorrow-theme) and [One Dark](https://github.com/atom/atom/tree/master/packages/one-dark-syntax) for code syntax highlighting +- [create-dmg](https://github.com/sindresorhus/create-dmg) for a Apple-looking `.dmg` +- [MarkEdit](https://github.com/MarkEdit-app/MarkEdit) for the readme organization +- [screenshot-studio](screenshot-studio.com) for the amazing screenshots editing experience +- [shields](shields.io) for the beautiful badges in this readme +- Claude, [caveman](https://github.com/JuliusBrussee/caveman), and [ponytail](https://github.com/DietrichGebert/ponytail) for the engineering. + + +## License + +[Apache License 2.0](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6903c6f --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`I7T5/Edmund` +- 原始仓库:https://github.com/I7T5/Edmund +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..265ae1d6d43ffa52fdc3c3ceaaf498410b924802 GIT binary patch literal 680447 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

        3=%R1=meCu3|M$x?ggi#}$z-QU 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ⅈ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

        HQsJTk{X# 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�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%0D1J?{ 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 z zW;@~dH$lNLxxG7W#)@)=A+dh5Et^DlxN;K37 z>pWc|BQ6bv94mS%3n84+2ci2!s(KS%dR81}s13zS>{NbgmYf981Iig+ z3r2h_EgT6cM5C>py;|$Ags3d7#t&k0cCg`PbBW4u30;+@#ovrbi&B%056gpZqj;0f z#gG=K`@_+C=?T}ewTJi1E?E%}x}#E5JDb73r(9`ia{%?;V+Ob^L6$OZv25%KKeN^r zwp4*m4eD&oR;KsO?pf-PDouTd?|SuXNqo$7)grTx*;wIic#FmTyO!B*7qKMvMtg&g*;gROfMHT=*ptaBJ!&aJ?d)%5oS{Dz(La5gA^ms6x;e7qA?ufUQVC)v~=-ySY^f&`0~hRF-Ic*^L?8z#406$mxT1%?%(N2m$lH z&U5C$%RrGZ{%m+~$iqI%d;^zWw##hocGjg`2P0zt_;mO(KekL;d_QNs)mMw@<}y=`h(wL6Csyq#);Ks`|V3`*H~ebsq& zFY>9joZ6eHpY_>hDQJ2*8i10R*MD&|%IJu?$&?F3j-;#&QZfy)Hx=pp0Qeb`reV5M z7hWlDArMhr@3gATK)PkZ5sqIiiH&mUHqv)L9!BOS$p3Iq#TC3@zvyY|9EBW@scUgP zF27=z&FV|$=fQ8Swr$=>(?9ol5S)GvMj{RvZ5@84MwFE4bb^#bU|F%9%-3Cy5RtSV2X403iwrNR^tVqC}*LNQXp31*HT;q&EqK9uWw=1xS!UC;?JQ-pu_x z@9`bq@%@1B7wmPewfDZRwbr@L^(k@cU#Nc=E}cjs0JPuUl;@$ZR9+Z=*ytIu9lzez zw$%hY7>o;vn2ym3L?ri$H+PCV}qDiN`n8Mc3tJgD!{oE8Y|B-F? z=%P|7{E^lq$^Kn6{@cU}>D;cV2OA-Z^3x8w2Ta~3Bj^7eLMbx|Nd~(%;9xF;XUnAt z&e6VE`QxwhNw4o!UXTx~YEg^~3Y-pnA;BrSBkRWU)QTr!Hc?|C^?^Mqx*_H!l0+GmyJqvX~5zSNBygx=q}5( z)AMo)VHv+YIt-vk`WBTkLg zgTbSsz~~10-#M$%f|n{YE-puHIW&x&w2Gk(!Ep}bjHDjgKMr?uud_Gk#%HuncDre0 z1->xtPoh5D&u+OF)f*DPy5ZJ=`lEZRFdf;8O7^}jh4%G}&sA-o$mYHF?#<@Ym*zAy z*mh>cmcs{p;~MEt0{VVQBhFE8GDE%E($|-j&1hcjX%<`LjEA?)5*Dxam77fc z_AI&bBeuUUWF|O{q42ln@E%muwsF$K9Va8wyCH}J*8N+pLY>>Z_vGEzwF~@pbfEiJ z{WBrsrkvk;YZd7>s`qb$?q~P<8ZzP!n)%-C>YrL1px!k2)QkVSFh8LcSbpCz;8D!p z3~A7G2=|8~`t*-U99?u*Caf zZM59W=1i`+Z)l_W&mKNvErs@F9P$mB1!DizElKl+2}ZI0x*=kn8QOdAn<~JQd-(ew zM?d;zKm3x|DEvJhBlvepcYsHq2K~$h9}?X$^mexjkXE68<)O}c?YY)3l)1utH>0P} z{Wr>=Ik*Kr7O^WYFM!>=55hlyx4?%1p&f0ghwx_+WM?=?uP~11msn1jRYm%6q>=3{ zV3iGT?S$n|Z6;CkmC??NkzD7k=XE%3;cPVO#kv=|m*AVnWdC^FBseh@tth-DRSO3k zyh@)Mv`%pJ)>)&eAd?cZj?==JkbSj5ceUwsn>vogTk>Cu*Pt;0=k7OywtM7#bI6Yd zKWNb4o{Mr8B`QhgWg!WkL(<;{KeXO~_uQycjYmL@t963s>z9W}lbYd41{LKCP!(iR zYK{7#kgY2=w4YHa43ayWGyJ3CTDgYKd{AKkX#1CG0y$YLQ$|6WAGv zwS)iUTs;6`=MmRCqBF9jpAJd02iu|Xf)$hfhsPEfOGVnyKhNx z?$D&TxWIB+sVu;De6;(y!5fkNEJ)>NxytbiI-BNE$X$B>g{E&M3EPJ$=X}B6U>{F{ zN?|55&!clPd+d`Ov+&?SA{?<=ep4ScA?r?hqmJ;<7uUuZj~wcTpXDhv_P>2%Jfd$? z9HcgIGd=4?NNk=rbkjgB@{le2d>Q`^zBZ%G32r#(_B9j0EIncp?6&PgW2MxQo%{RH z=eF(R3~b3FqsLVvL_q!}QMBh7rT)lY+<(B*N#~J&1Eyqe2SIRKUTvWD5sW0s!~F6S ztvB6S04T<=ov7JjO8IgThx%+WWO*0cvGjXdpKT#dK^oBPk!1o^w^4ZShX@6r$-x|?;=n7jN-7KRE{ywnT3cW;9SvhNY z^u_U$`HIf7tGrnof+f^#^oe_gxhiP; z9j&tB@hK#rdhAeoRy6x7bRdRO2CO7au=T5uBUcB{yRus6e#vLmRJ?0}|5Ayb&{8Ze zkQ_s((xZm8t!XM9ipX7NNzZkh&2TkK2o~c5tMXq!&zH_pvL0s5Ia6i~p#^0}&~z_h zS^Y@LYKmYMT2wkZLH1kTQF*!3T*<0(i+>13w>B)i=z z=;_?!Y2b~Y?(vLHbYmrKDT;fBeOk%FOd?Y;%8`ve{xQ92OfS*W+KDhKnYl8D38QhV zChh-9vM zj(9b5+FGggyR-)AJnwNH*8DX0kjG(C0nsL=hUpm3i3m)MNX*n?TcP8h=(ZaudOG>K zg2D{`IPc1jN)p~*HnEg-cdWlL%_K#gRr-4$mLKtlL6&o;3gjFd=Ugc}a>C}e@|RXO zCtyqD0&_Y*-kRw@?;6fN(T9szJuHQ-FjGt^G`v3dEFCdhYWwd)de16O#UcP@cWt# zn1i^J)DzdCmOABWn6DSrxRJ8l=V0Xg8s9bS&Z@zkLovG1beGpS{BotR187)Ae!n7un{O4ZV2W*Pwp<82Xhb%EBb1}@dLCCBG6W($ zOVyk5m91^V6JE~0K(!-nq^8Q7icep#{TFlhJp4MIlNE_E?@~2Z8Op&-Sl$0V8uB)# zFj_Jly`yVZW@Tq?uAukhcgXPfrv_Z!1j>=<>mYuiIWECly083)M-u;vIk_j5r%o8x zOXOyC^Ns;FyG+lG6kc^-olR=TBkps*)7l`ttWZzY%73B{>hr5JRYo*b`}O;x?$0-t zdFhSLC`P^V{6E#y;*{<`$NPG;s0dO1 z(Bk3BREX7QP-2Hsgl`OZ7VhDFVRCjdZQ zdC&k`gR>QJerESP;K^DLEaZ7FIpTD);=xAhdS0?=I;2RCCu3OxViNGw*tF;m; zYfDF~`=UNI!!rckw)-f7P= z*e*R9`O}^{5CfdWakCVmea{TqfSCKAo&*i1N}2_5Qn2{N9Olq!-E)9&^l#t0GOATa z+$lI?(z8he#DSyi2pd(IC!k?R`S)M?hxuIurn0RNr4x);=4Ctoln%QONQ7EK8m%e> zQ*TwFEbne78EQs%R59Y9PAJL?>_L75t+^X5CNyx6ynySg{4Y(&)yo?CL8d$p6>{w0 z{>+a*uX*3ncpZQJ?Wc#Hoy5a`7FOPQ_w5bs$)Q(&Z=L@k?=l8!mb!3b?d>r&#m(mE z-+x{B^VVVKWAG-W;uvWRKkmrfJZevBal?|QA zMEaptiabd1%Q760-J~^O=8F->vbf6!k0#9!-y7*4sxg>(N0?RMlV7yg=Z6^nFfSG(GJ zvTyNDuw&h}hxKYva16!t;)MfUblY34rURTkvM-CTy7k^2EOZ>d_qOP(_*nroGM6d3 zoM$<70R|k>^3mb^2Uip+Ic<489S=}J-x2`|s8b7{U1u0I z%3r#3Hb0rD>4F=Gy?>%<7dHde>_6Qa>(ul? zkd0*rU^e7I7;e$lZWox66p8%IE_`!cZcW|EuVPVzam1*Fe+hS`P%Kt1Ja`!ubH!Cr}uR4HPTL1C+ zdra@1QPCJNZ1@f6lRqq_6~(WLfrNuu4Z$;#NqSqOrjS3OfhT@dL>+WrkVUOr{C<4l z9~BF*F4u`_0n&3=A(k(!jL6d-DDanMtf^nS7i+Z5`^rx?JnB7hsR2knIQ3l)&~vl$ z=>oyA3tpGH6AIgQN}oW^7(3*&7kAHV5#VnmXgwGS@Acm#3`P#)|8xYjI8Qb);(%Y=%Pd-Xa2tc zpWXXkfQF2D1D%swz6u@N!M3Ba+a?^ zx9tK$EFs&D!byZhlH1vzV0)O}l4QtF@yqsKXD@jU7Jm4x+oz7Um`f<9sg`>!{W2^6i(ytBdnWStvHLo8$^zn z?=EJ-#IZIg8e>0q7U-jcLY-Z*(BIsOR&NZHV@S1R)$Al^ffCza&bCTfjn1Xj+f|@7 zp=cSk1E@@+V{Nd=QF()3agL1BEfr-ek#t$HeSj?6@~@?0mkHxqT^|oqBL8~g9$=Nz z>eG}ffQeyZZzqS7~v=My)T=MvRICQxwB0tTf-E3zu*0~es-(+Dx z#{3C?rC#JSyZqeMWNgz5+lbio`hMb5NY+h;+7Q0^`O%Mp^nl?cCwVh|pp%WL;8KVT z@UXVA9yKeBvxKp6M65NB8rIO>JagrKwwka0-3F8l_!cM^9;06sVt#c#hrc~~ZaBX4 z6VSXu8m)sf=D)3Kai~6J7&)7#fleMaxl-*ogyk59;IKtT65_e?*14+SN0aKtXLB8P zG^}c<^4KYi!j@hhfeo3hxGwO32qhHT?e7xn4QsxMD-LizH6ft*ek#HFL zw5y|>@VnxC2hG2+{ByJ(^-~ijam}nqYU`0SHmkqP$ti!C{>r`46?So#H2kP&GP5kE zW^9dE`Ej!0R{mP7fg$S1!)&-o>U{0m(6v`{e+oNF8oomQY5R+Hz}ug(yzLB!X4A8f zODdZFXV-MfJ=D-;RW0dAeZxrUk5LcsqZW*(-ge^2@g7xCiCRq??x59NH%_WC@!<0l zHap*Bmpd%uqH?Q(eWFuc_G5z%{t7t*90?lA$x?)M!`T|F|JYocHFZ!c#BN#mp|uU4}f#g>ym>wW!W-(>pye6J%(zLn-42`y5E4};sz?5|tvZIEqPQ?J_oX*lxzAWtNrJ>~tY>dGUC zrlti*5q8(1#$ho)quE_)b!#2}nDNH-ji^ApNf6BpNO;}ir>#l5O=WiZ?%es+owU1l zTlixSP%H9BOF3QoA?i>sqxG)vyecObJhzA`+;o`au65q57X4><>aaYB@g0C%$y8e( zvO3i8u%;_OzQt&tQ^gyJI2jB@@0bj0tKFw7ICB}RZfT?Mm3zFKsQp`MzF2*>qkF1b z9ej)%Qzjdv8(d$QPsvITo&N?nmD6kX&z7v!mSOO+RJ>{BXCoBCZuv%1M&ny#k!)rALQgSR;Q{!0p7rnvcNB}r zeX)Ddk)1ceMzK^9f z8@;YKlsf4#ZyarUJzu^EfS2@jPiYwj)1fm*XKLIxgN8!XsX~5nq?47u;KujSdW+ku zfq%dZ3NPk3G6%nh!H%dy(*(xFEM=+E@)y|-vi|D!q84O^P5ga$UmKjCZIbH{rgwYK zjf zwfTC-EJ@X7C0}Zj*4RTd$y9pHy(-g4vMXpX3QjoEr+i#~Kokqw9@CFN_5H=USw0iO zTFVPW?)XazsS2`bHc2Kfn)jgZ9gOH5-{bOyb6emjpLdYR<7_s`%>e=yf6FyT~Ve2`f^ zS@h9H8@XHGPj~a%v@jO7t|>~f#+@K~P+vO>(m>JsC&c@j$uq|vI`_23uwXo^Msc{9 zHNDdnj^Ynsy)bvPnk`m#gAdE{jk&s>y6o~T1!<5~VAl(no~kzOC1U<0AgT>;f3jRA z`n@H1bKdY4@M(;8cD*P=?h5`fzD(-Tv#^?$RNhkVC#T?TITUvy+N9`u@Rpg+nEdv< zy6k=J5CC(HsfD`pvI6N8((+o9xMdGCyjmY+vffHj`3ZWt9r9bES>fM?80yh)z~0+o zv$NVu3y@>p+zPC({nX9isUrK~%kW_DhlfMJ11Axq8^xq z!nf^bCHe`49t~M}+q)d)vFrUtik;kdOpAu>|(hXtj z+o^h+^WW5NsV07@Gg|LN>5<{i}ZnfS7nx+H^i zAhiqoWp>G}+1ZFaUpe)%)hjmwFg&}8`IO?VaZK8OqR4*EyTOBqJQwrc`$JB<7S)<# zd64tydw4GCRFz3!MKCzROD7@*y;12ZDs43hf;_Oo3wEz&30sp**GWTcZN^%nia}gq zW`)Te8~tGy4H@Wje6C)<;49zHKoZjCy!iz#0!EHeAOF!J>sGp}V*r z#<%)PYP+RIMT}*;j`RIg} z+*8U7>4i`@t-O2S%V#%h3aBOO;a6@It5p(usk;fYYk-ns++Z?SBQ2krAeVG)d7ca_tpPnQ{!nU=G!sOrvBxk* zj zvWuYQ7_^0t1&PEAcVQezJi&l+u(GM*GrPo2=fP?PTA}DFl$GWU3SV_)#orh91m(3T zw8frm+7%%l#_kL^t>Q3?IMHezB^oZRW<}(4Wg(~XPOtzEFs~r}?QE&P*f%^+g$M6X zwmtfbT!>n?@OH2fec!h7ScpMZuH)%{wBF&rwFCrGPz>3? z;xTv5t;FlVCZJQ3F!fPm?)FsPXZ=SXU`l@tKzcy`nc*G(D=oBt!T1EjaidiKlu`Ot z7X$g@@#hwJdoW%#>pS#!egEx3*eRvCP2)=p=yDCsHN{xV#0zsVQOSo@@o2-ei8ewz zVaVL8V9qSWY!+kwQA0bJ`>t-h;Fy?A8@TB&a+1fGqV-&#bA~~|PeV=ezo2DzuAvl2 zGs#V52~H*$#-qI1JFdPCb@z*p`SytR7H|w#yJ;u&Kufr_E2}cC3z_j zAi82Y9Sn`Nau@XVe6J;p$S6hrSSYh@Uv3JHa;L2=d~xHU04g^O-r;N$Nap$O+sf$C zXiB`uHv!|=S-u^3jKA|myjb&}_Jn$xlP9$_M5B6Po;7ixyI}RD_X@-WZOwz0t_y@5 zBn&@p-n5R&5%5{}DUn_nX6iVF8enrXfW~E;c-Z*I-&1Zyf7bW&95qsM;1?ChIdTr# z%Q7AFBhnf+WMhjWCW;+&DK_My_8CtTitKd{&~s%Y!4JaW_;u4cC$RUF!LRI8pnlT{ zs{=y}hmpk^54&(hE$Eu-Rr8I1J?WoQzv;SuySLp-QsVPncI}%MCQNgs#oHY8G(}j1 z-6D#%E-}Dj5q8fmxsSk7WCK&aS`6DYwWaxJ62z$NTYe=vJkzTv(e-!lJCC^!$t3k( zS4g5HExmbCB;=l6f0E+cOjq2MYiA&QwRXW51ab~pcWQ!0pdNqf&)Be|t>e70yJmxP z*ILhjFvwj+ZtE;*(MAf6=o?L_T--}`yyMj6q=h2ofCQ)eT4cWYv>55?EvdtUYF6%T zvof2L=2Y3kl&_$(P=w|vKw8={Ft zyEhIQdHHWC8dv`gE7K13U2N5b>Mb91pKQ-X%c_D6$^pMUMks@en0e{2HM)bg;CuhX ze3@4RwY#(Ns!5dPk)-0NcJ(v}MJQ)Ay)fV%bVka7w>j1wem?1^@i&T0=bt@4dfTkt zzk}ei><~VNk844Zfi>3L**AbD$YVQV1ZNKQF-gtT(6|4pVv#6YX1MqW^lV>wb(H%Z zZ$Px|flwcSYSq4mWEd}|@6W{Jy;=d}wR_X4S8tI4TQe6Zd(*Po^lsk>9{1f+R=PD) zIzdfwZQ?x#8SRYGN6fgjD#!5EdLb2(9^>!RNc*aNk+hdK=4`d1!1*tf#GCSh^M4fk zw*&J3A_$)t5oRV3$H^r%y&o-SlRZ7#>2X%Uu|uVU%Xu}V6Hc@em-XZ-+Dn2PvLB}e zw@H?I?8<)bi&n^$`N{9g+qTXDGDBe-z$1x*{U8)~W%v82Atq)942z^PM~V&nf+exL zf+SNNqBv%ktnNgNY3u_l?u=et$upV%WFE+E`974`?f|&6*d@Ma5ku@*0q3p z+3c_vamf45pAF1H%{pWdQSwep(DgtPRpwvVnpQ0#G+=0KbBi5!s-o85lDq?oW(BdI z?YKu!4VjjHcPCUIqm;^uWWOJs)W79k{EMxlbhgF}Rx&HK--cZFi@5nq3=g!u@t2BSSy3^LgRfGwtl4P7R zd_k?V%k)R97n6j2xqDD+vE^!cDgN(5zam$&YB(GH z<6b2@#SOz4OT2N^li2RY7uVgnxTyJyD>SF5Duz{Rm-6J%q=^9$|JKq$C zie;dW62)ZEY{m;vg!~SxIoqDai>~7#UY5kL!QtZ z@o)|gn?;NKXpk=nibcD;|Hz%o%Rgq1kl8J|AW9u#05^$2bUG*pS{@Aj*J28i7WH$} z++!u~(z*_u`bsJj-Df)K&{h{O7{(1&ZELqfvAu(s9^D_0fogo(i^v52lkiah5 z?tjg-p6V-tJXJRij%NTjcVBK2d>T~mkjT9pWGx{ga%CP%$Ot}R1lo(a@FNc6zrYjn zizCt7uQsUgF#>P#o|VoWrz9w24;j<`+NB|9g^(M4%GxcbbC)qvXk=9EH20Q8qcq<+O~@a;=}{O_h_tDdBbO(f=LH9@=#Y6D z)W#RJxcM*bBtXuNA_B&YiXrh&*dq^u^w7Q(eIn-?(62$US1)8eX-mS?Tu_WzQ`{xm zk#qd;v}Ty>y@w;-KSO3X^?$zf^@?vc(=gc6H6eb-HC;Z$t-X z>3a=p@Dr3c-(iedNwvzZ-qA=}x9jqj4MRJ+JO%fZ4S$$6jlJZy`EoA~`T@z7dWabU zRlQ6P5;p$;^ktwAAxaDii0{a~__nURrr^aUu&sU{ZtC82#Ao7*C!Zq3EpPq&5uGSLUpH#42xhRyxu5DgOtSIM1y>JDjL~eiEK4VrKloT$)4MCS z6LMw7IrMo@%%%5{eCrXq_QZegu9~6fpQF3YcP{xBfOjON-m>6~0(ay>zlLmD=uo#D zgtd_wy@H&%CNsc}?ASYPZE`k6vB9$?&t|_g|C866z3>=xs0bKLz6Z+PzESb>;J2^d zf)oqv!Vj$bM{m?`p@i)fyjS4qwfO6OZoN6LO_Lw9>{x}*UC{;~X_N_)#(N)w0?MT5 zi!MLsASAJS=Lq=)@s{utKGL|Kto_Bh#>`{mCJ}uE6jr))Z>?A>^6F}R$aeBx>F%7n z7c;{o88GFYRsAUroU+RkpG{^B0MZfs3}aRkpn|CEN?B z&sL3kg2k`6tW^K950G&JkgtUk3I2l2sTT^qt}bbL21hn_v92lS;GS_Cx~gW)+OAJ@ zq#nIA^w646w{r^2&*^QZ)Z~Yk*u7j0jLpOu1Q%)eTujR5zqu*(8?fdj8xbaw=wP>s z7YSlERs<7|5qDI?@{fUbOEtYNSe#}cMtyqLa+T_{2MRu^h~*L8cL|#T!e(jlJVBTu zMx$;w!aEvy$-8qjA5}gn2*4Ec0qBi$E9=KsV869oE%hyFl}=f}MWw2mV~**LWa9qv z#6)ycbnLhMnpUdP<1b$Mzt-9o9g2x8Bat>p@0!SP}6 zPb)Vaq-(XqlqKklmDgnVG~A$wp2^ELJ^3=%^kvN=Laz=!RIw9w{#yHA(&&}Rsrc)O zv*(RUujL4~M&%GC7{5rFvWewk2zYj>g>(A9rtkLs19W~$=by=>24EsN{3m3DSv&pF z_~(Mf#x?NN>=KcHD7dD_nE-CdKGQubF#f(1(!xQs2K3dYFL~RxIv*J$6%Ef;SuqZN z-CS(RlI|^|sUBh0Hy0Aw#F4g=Z8?MIB?7!Ck1M;3Vg+p11`;LWIxS0N}1$?$N-Fr&7w7B903j`RN7!;kQ>iU=q6 zbjZzxoWui*4nonq#@ob}9g1iaCg`6Q3$S9b!7Z+M*P8gyp^V`hHVb8|Te8+~r?SJ# zh;<*x`?c&ACCe9uV|bCblj7npger8LxiU5wJx6sd-YzpdR-ht|qFq*i~X zt^UCjYQ>ubW@?6QfPvh$d7R-6{bT*-y&tzJ^*_ix^I&qY{K}r z&^=y8*{Pu2S9^ED*5PLHGCgj=^BVjtykc%TZk z5D(r&F7H7ii5$AoX5Q&-2W(t88um|89ndlO&w|ybM{{KG1_AA+;@>N_&*Mk55=j#b zamG&0&<&KK!FVrQK0D-^`w~>JrHyjAd5YbtU*?X0Y2%)o~ zG=OVpNi3Eg4(2Y`7*iY5Dk`X%H4QjMpEH$W&VdL%6x^&@Tbz?99UQ*)tKUdcUyLl( zjBM%tMB)&!b4H@7^?*G>hr+oH$BU5z3*dOoaxpYd}oyR?!Zv=a7tOtJhK5F5>`17VTUZ5-v!){*8 z7HT$V?m6w2iVvs6*rTGBSOSFZTILL;MqKXmEXV zzBh*Vj*(Nc;|=9HPvXk$o_?Bom#)5EZEGd^ez2-?*0W^26*&>xTTS_dJRCmeO)fs= z1+AlJi0rLH51bGvkLXu^CTx$pT#&)kdt3QfuA7&dM9*aFt099v4Y}c(&XUs2{TQ>$ zEn}CL>S4OUIdYB44=@iuM0Ti#^J;Bs79@?cxjn?&(&<~p7Sqp@vGZSgY^|&@+loq& z-Q*sUY+sHHP**^NHgEljRKaci0ogOwwI9^g<)E}ZQXws~59@&hZ=M1D^evP&+~`(> zF#M7dDZClqP*5=O@U<<424F0C(i;+FQEmv=W-3x91p+LI~^RSCLR_M`dqVrhSE z)(1J6Nu^-wlPWCD=)=0cZ^&yb!X;Z#Ds`fZ2{0Jzq; z=uKs+{l(ce&6v&{G>=OAz)4W9ple&eP8`8k$%7n?(S{cG@V>N@t*HiUPZoX>wl<@) zyE*S8i|nNrntRx8D$yOR}j${&;I{p`OPxm`Q4to||>`)K6I<=~*SVL7L z2n?)xiidXWL_cPUL!o73`h?EiO5ssHC`bTY^^rTNbJ%^A)RDdYqI3Ea?yGX>r0SdxDLYe=8XDwni4%X?Dgr*k~W znlVa`%a>(b-LE|IzK!ZR;yx*$R+!hxrgbSAo{Zek6p^I&S^I9mCtX-FrNz57;nbTn z?~?7=WzOBmDbnj~Oo#Ue@fwhmI}gns|EkwTP$wRmv1EuI+Zg?!WW-OchFUJreJkQN;I*4Cx7X;}WHX2u8(v3%FzGx!l znfNZ7)j7|7G})E&Hrg4pf3nh^9|MQD5JybYgP~J1mC%N=CAInmzb43xx=}hM3BOb$ z`w3hgn6vaPcVZ}de$pZMP2(u|3{%+BWn_}JGn#VB)+@2%Cqwj&8))xF$)9kAA?5vv zBTLjL(uAFl+QJMw-o8Gyg_hE&^_*Ut;_6@Hy-fn#_+E#S>j#=DvJHNf3%(LxOKI)P zR5Tya_IgdJ6<&SFxwE2P!T7OHiAnpjF7k8xno~+Ra_KSo%25c?dCsXI5CPb>)D0RY zJyq_b`8z2^eWlF4D)8Lt={Yk^-?!Qmmosm0UH4?%1bn#wm9J@Bvw12_Xgm3B0XLws z82$dAV8?R#aqCs@~5iDiEnI)$txaIIY zx^EA~&eX8$r{aXRFNj(Wi}c!&9XskZbUy5W=u;K=;pX!GOYO5%-HFBNskL!%q`b^W z_DjOCxQ1~{7!vWRW_CrAp6s8l;O|A_hxO5pvN3K=~r@ryn?&LZa3;! z*x$Jn$%=CE99O)%TD;MOGzO_pOG+0>{jE#(COCYX>Ek)c~dG zI)!07U_56Q4iyX%jL}8EwYr&C1fNEWMwYC)THaJEyHClj>8?v^2vLoFVSNDyWG0>T z9)!L(RYLq*SCCoF3c27tG{IuSnv}`EAYLH^F+O^AzgLr1VWh4W>7lmwVkXJ0eC9=* z&NYEK`3OUst;%}t4 zGdrJPTLn<=ye5ptU=Q++pLQ1>dme?!VB=f`2@G8RS~PK{M$p!LVFdcD?5>sX&r&F)eXYMz(sZK=vOuVf*4RJ|QmE>auLNTP0w10KLJe z_#MLj@^ONyg`CiEXL8$=T5)cIU)7;sVj{MW;cD8~y!~ET*BK+hG?KLmE%+2C-U>^5 zMH(Lc=vvtlie8!h9K}T5oY=AcL*vo%vUGN84vw)d8#7TGMWpU~NbwBIQP#%`UQ0n0 zxZk&?wr1T!9~yg21!};QI8RvL(=ohQhn?eF4+v}h?)0}Q;Y$S%J!__8m3N3mK2=Sd zyPA_5-pgK-SiV&Mh|=EHMLdbSM;ya(enEK%g}%lO!rKZ>S7^lpz`c!1Yi;Bgz}=OC zIev(HaKmjYjL#&a>Eqb|18)gc#YFs6@zttTTX~SK7NQ+(wWO@G3gv!ZFf%}Loi
        M`t#@b)(vQ}oe<}`kY!1PeB&r#VgO9#hUlHGtA^4> zk^F??>vfWyS{PdK5iCKB(Czcf-7?kp(`({MBpz+B^H!L7n6T1aU?BG0#l*_pNl#pL zTkZIbg_W97jXXs3i>^o?&c-r0JB{Kjcn)ep*t~wzdw*Y3#LJscEr_7sN|;(OOKtWo z37KhWH*stdH?q0+o=2rOW#w%+yX|y1W@A01b1$^KPa4+Ed+Z%Mr0c!97qau|XsZJT zvkTBrDk@>VG?LD|DjPhsA|Uk}{4UWa@pR`Wv*I$~p45!c0r$v>_U{4Lv@y&#Ntp>U z)3sjKG{eUs!hGS=d+;N|ZQzCY-OJsXxHe{g)NT z=A-2ikRKm7(D`d1rF5R;o3mJ5qNSR_c-)%#Jp@Na^#8Rx3cUEsW>TqfBMFFf@_igy z0^}}qphr0Srwx7wZOy#>hG6jro*IVq;(_zpne^M|p$ujMaKm}CDF?eWVh zot*Q9yu4|1t!H5LFro$E27~v7OxjQtY$W*=Gn!iIxi&ntScO!Gn+UoKk8d1{w(OWM zmu{smS_PNqAQ#xyiFRnZb~ke3^;Ox{G0#n{b!5W#+JeFSljp(r%*>Xd($vhH8BLer zF7zX}jeMdZ#eC;O(!_jU&S?|9QTZT_9(x4488Z!J-cPZaKqD$NhCQrZV7yO8%O@AR zPZ>T$zx!O!(ceUyIqlb-DsLXdMgimtbdyZ(C8=#j4-~Nc79NA*G1U^?{$25q-HJqa zr@SCDk8$e*)ni9@!YEw0mOb{$+0wzS<0)hlWg{!t&&J&UwR>CNuxby<`LZk`_+>wl zv}zFZ4tWJ~WzXiVZxyX-HQZu-lU9eCT%z^$jn`Pol<Np(~{mUUeb>F}?js^PVQ0 zE=De}h~MLs#;HD)4veX6TXdjtRGx?0e6qUy*sUupYy?`>myI)(K%yzh zj?nu+#K4u>a+saKyN;M9>*FNA7n|!q=Ro}pBzop;yLx0+4W=efKiMa7+Bfrw& zj5|NPG2Z>xeEGK>wuN7$O|x@193_10R!&P?vo+sIMFt5@8=nai6!Ilv<<#?E;+e2| zh9E>Q+4IeSKj9dSP4bdX_`%2FG`;%I`}^KG?3|d9#;!?=UuELYo=iW#L+he|rpR3*vvOM} z<)gzl5#6>d{}+yq?dj z^V|SS_`yZHX}vni*uuS*XyLX#R)1P_+zpt#S`9D$cb^qmyx}hDCMew(S$jIx+Ldlr?WFxTBiDgFc6h_NvHK!1 z+E8Scvx+85G}o}ikno>jxGLxyMQmr}%o6B7+|DAER);9;KVFmH+D>>EZ!K$Xk>0Ug z97DQU*--IeX{(L*)-S(BYN=fdj%}rENaHZP#Ki&8?=p>&3S`ZqHdB>~NaBZA&r9QS zg_+Tf+u*9#6Pc~-xTe8V`&5es4y8)VFCR{U5t&!9+f7$$T0)eElSt_f?WK>rJtu@i zUb+UYvN?PO^Y^?<>9vQkbhD6P zl8(wVJp72d8V2S0!-;AVUr>9Gb3MLt<`RDJa=&3fTl8h!Ag&XWm%RD{k4$rsFrL2R zfuOzK2|d?E^Oo)cBb&>tp4rGLM$?o9*4XCFbgNYPP3)sPMVgf8K}4j4B0?wuq_;#tdWVqELlP2_K$2hd``-KB_paZ)Yu&Z(Km5hoGdrKX zXU{w{^Gw}YhmEdWh37frh~lAv!xOc`PTfYd*Zk;qSE%u;>V7R}#&sR8Dg<}{-{aWQ zemAEu{$`3?K~>4x`?9X`@hMDA=fw+Sqq4*6cl2f1O%ycp(iWgmj{QBpA!Cb(npM+C z$dVrqWVTGfr&=qD*3zmQpR<)eS7p2Q&8NxBE&^2&DfY9$^<@(L%&9iv$S^AjrkWK~ zL@nD{vc~XPzi!6d)44J-Y~AubucUu}8SMtcU~mZ$!*HFo^dw(lc-G$|NlnQu)9=nrDwEWmg~6xCW`%e8Ad~zELX)O6IZQ1$ix{+D zHdj|qV!IL>t(=tebad^5-RCXfX^7g!jj)uM=T$_FFYxgH+s`1)=!ncDwVQ4(M2Dx4 z#dNTkk5}|%g|-W#5-qAqJtPHdQR=@zv`JB4v><=9U;4Llrf!hnPobsll%;-)a)~)OM0+dKw7X5W@ zuknq4oq&)Bx=Sg^{GbnCAVCGSo=e$t{YP6(I@SsWsA&@$~_)(tcVg%^h|Y~omwd6)h;mIm&% z70rSS!#d*24xfY3J&DJSE&}CSd6SJ2tJtYGzel-_FS)^+*108|(Ru`nJL*!@w9w zS5+Gw%X<`@mYvz-*b)0aSNvHewR~rYPWT%I3n)0axVFiS5FC^|{YGPnCTxHE)Mfl4 zI=UU(11P!;4qeVj;eA%cw)MqdYyQ&RnhaAO89!h-dMi+%h(gO~8OnzzS^#g1RZ_Nz z{ThtrD3u!yOpT0PQq|L6UOCe=<19*gyY)TCPsTO6cRBld+#R&j{aZT+x$RiRfr%z< z6TQV_Y~itx@~)xg!W?J4`MS^2o`sJxtyMf)zV||veRN9(5t$h|Mzg!B!*QjacYg(; zmhen{D}0$G`}26vFSFRpo!ou~$$}U~pQTVt5V|zQD7V~ncvsD>BT=oX#Nm!_#RS*; zj#l*T7yQ&n(M}KC`Y*a>S_TliuHdW6-w2Zar_f)~s)=!q`IWaOM*T0+K-g7(EKe4;m_z{U%-O}5 z=`IZ0O|zryTb5-DHm*&}#xT5XuC955scjn4VaV6LVf<+TNDC|U6>1WQz@XCa-@C8V z&<_`M&ACszAH%vF^S5ARBe#b-3RULTYqub>^jM|wOloH(*2qrAy)8BemKnx%AL~Me zsa%&uyUl7386h&!vD5v}mcYB(MNXR6)?H_icoibv9d`MG)&# zch8;Fz&FS?R_sd`ze6%AXq<6SZ2iH_4b-{g;bAne_0ds+OSBK__~n$Y-8i zt$p07O|*rkGWLV?I+`5v(d&GI9yJ5Po_9D#mRAI_NS(%($~tjCGVCY z$Ru4}q28iYhR?H!HfV;f9&PzUI8be8g&j=-PC5%9VVSyZ!MZC$U#LwLi#AJ1=udhN zS0g-B>^E=0!?{`s;ifnNRaSi6BdCzr?h>))<4Qib$2wYPMMA~eZgXk?Bf^gZY4jxh zVRG=63`|S6HJ;)-EiqyArK=eu$TRRi<40@G$eq%o3c8)byGHoE8hBp9$wWlAWN!>M z?#TW!cO5%K=CagT8Zqc$VH#i;r6VbX^%?HGvL?A&%ppCX4t#DjE790CfJ#du2H9oK z4iU^~%bWArw})x9wT|s>qwiVWvW(#5W{wV(VU6FU#pw8P;HUHeW|TF6G%tU1 zEz%{W{AIWrq)T#0Pr}C(rf!9Dtv2Ue@3@fc25E}63w9C*CGbPJ-PPe~&Z|gRC8S3n5W`7w#8jb1vPsh8@BTH9D~T){#6q1i0b%> zxTORpUW&?iWtk&Z}sH-QH3y*fQF)twYnr?ar60z=wCVp!E8>nh2lT)I_e~1vR z;m0XO7KH`yIxF_8g+7b51mCL;sey?wkWkA{AWbOg(u58C)P>~Xg32GmuC}ZuO*mYy z4i2Zl{DarEv~QYn$4Goy(l_r|3jl5;*)&f*!W``?pIi8Fa4m)s`G1tAc;P)>&@_TW*S&aALuy$^KS zHa39_XA+d7JyH`&*0!J;x5nn`xcUq)XojobDa)m*W^ucg*A-EU_ zL-tY?!ft&1W(?J^PSVkkwkz!N!X_Yt)g6jk4;4Q-#}Yo=*nizMNEaBw6#b)3;x)V~ zFQ5Z^>W|oYy3F*ShM_b#kCJTDMzIH>q`UX9Evu+`oQ&49VTXw-a(;k0;g`PD=X?S) zT7hT_+|iUnSj%~44pk^$Yf!_JZWRS*bzWKONc?J$MDvQ&u0=t>gNN{eL2=n-{k$o) z00qrIepJ|{ z+_Hx-=3k;FR7pYJq{bnamm0WvagU2k&yNWd5=0upEllEFbo-*|d!Z{})No(r!c23` zr(Ge_Wp)ws|LT(v#{xk4qm36k%B%6sw`_!5_5E1PoU9zmroiOJ(r5S1Xc5OT$k8qw zFP7Zup)^Kwa||8zh%E@9r~+60_Xr$UvA)2pn>dP zPUN;*g+o+*Uvs#pGG;i)Od}N2hD@?!W-*&g*h_~V7i)xA)NnVQ>iFr3Kg+FGUSDUM zxQq9AtE}WC!gU+{GpS?zljCM&-V-P9<9Nw|$!t2B>F!`$ht;XExYHjx-^Hp4{`}*6 z=C3#FCe3u2ZBKC>l&ucmzNnuL_lq1OQgsvzLhUitbVty7PyLW}9h|0BSZMCSwx>(h z?8>;d!Ui_Ve@342Clm0U7*RraA$;Dfxh(jtR}_RXui$#S86}ck{I7MVoKac-W7*mi zO6}hP+#W_6-wJ_%4w>~EPbDAG*LdUK#21SgVi6(!z?av{pR}@LoG|IXW81U2b%e|) z%UbHMQXSmDwedSYSr%W=e?Ur}Wj*1=Bs>AW=^)l;@SX*B9KG+a6;J|XJx_RxOxUf0 zl+&8{Xtz0XWH)0|-qCae7!mlz-wxN423C$Lr8#pD!^36#{5sLUr(Zu;l+_x5ywJ1U zBck>sY)7~Xz@JI|$HC#z;5)4F3k@7;)KiuM4^qHy3?WeOVdi_`U@N`+u8hvXk8;H| zk*1sQxVDS6 z67+lD1YxXe)qLGq=2;5EbcL!WdPoezudpe_@m`spA$o)elW9ZxQ(iQEo2^-(>Fgw~ zNQ4Y;y3~yZZgXAGxlOVm@PH9cyP`0X(DOvsqPJ&P2@U-ZZe)FoEy>vdQe!0Pck!OZ z)P65dIrir$bu7~LIX+D1bhE4>k~AZ+w#`$tKvrnySlzCFZiD=mP_;R~O@VbJ?xF)A zI%>WayeciKuWJR|28>X{R*Iq5PE4mX`ks4f3$zcO)g1q#fEdWxqUEYt>DH;zeA8#k zSRFQ~lTJF`3;gO$V{Od@UrrvLgbv=dEA$AF4SnTLnY6bP3iQC&prIR1C?>$M9oIJ@ zJ8~U2Yh@bD`cejA`qEwocUezBQbL;-CuZmJm9nNDD8WB$riY%F57P#R5Jw{~&! z8Wrdj>odZ;`JOA1)0;=VdPC#$@>1qZ-+D)7_^{oL@k0dhNdRtE;%^Z5XS!!4N?tqU z)L7u*&I;*zZstK?qW0)Tkw7J78Tm(iKKmXHp^M!}tXs3e<(^5$sD&<2z2KSCsQ2;V zPcbxg#)|>*)dh?wl<}iXYEe^{q5}tW8VZY^m~g&Kh*ukd8)&MmnQLZ!c#^=Z0OV@{raZKB1E3l zsYN%a&cypq7Ng~~d8je5$9QcmJ2&(D=exi-sg1Y8?b&|XIam+wtB7V@+CNK864$O> zQO+}um?BI0vkCiAda7^zhACocEt7FqoLx^&37&eDPn{c35>DHCs)(B2;(0#@!e|k+ zB3j5dnH!ZW?1^5Ea#a|;e`r?!It<@UhUps?w@JjQs&fs>Cjp($+raOeZ zcVDWsvq!7;_)~gu6Iy4p^yp&!*Wtv@&=U(6I8imRc~Bi??cRi}kn{P58~2q4%jsR= zkc#A>W=~+A+=`n_69mo8k39y+P)$Y|GcSza#Lu6*+Gp<#kIN*n3v1)?G5%P73XUHd zXYw`g!-IvdL7f#wzkG22Kpx@5@}qEugc9{zy1%gBe-=i_OE{-;DSXSoi%4@!i{lDQ zuB{y%Ee%SqrILZCgs)DH|L~`^U6g9lI=gCTes#|-IW7JpAEKL|Mi=Xx3sJ`^YfMh) zPuv-@2op#(CZRMlvC>~fNKM?&p${M`w5!xEe5xz!d)zI* zfh;qNlDg>bN&Sfat)AHSg5HQ}TfzZK*ST@Hqe8Q87UsAMvl1uXcco+PB51*4!WwM= zn|=h?GoTi_QV4gbaraD9l{3FedZc@b>|xV24b|r}{fWtNL%p$lv{s4;K6_E@RP<;} zkRqxN=mz!8hu)Dq!Ar7f4Z;27@_V3-ep!6PejUpF!wN5I=271XS(q(^FdAx~`BfM- z8?;v2WXD!h;nfGY1kuMMBx9@v-Acr{yR5zFyMa0gx5K!%?FqQfLrBxRRd)Q_IzmcE zjHtp{s+yxKd^FWj`vy`cB|%5w__jSoWg$gs?4gP*-p`nT@JnTF*QhoVkyy@>R9LhA zMC9CE!7(7`JwuK3R+m{X?dimC(B9+6k(+kkvo>;fvm5V0uRK!$s&h8? z^#KaVnz$7&s)Y9-6FX+`cib&?#*y79mgj`(inC;(bG~_J#To&XUhy-1@aCo3rR)dm zeSCJ>tMwbWAVa?u-#pvXM9Cev(lMo#SKhoM=1s0YvZyZZBx@1NK!qWA%xd zErouzgmC>W=|eK$SC)h3%oN_%~*DJa$DOG1#uKvIAgYoHAyyo)V>m+_2UXaI7 zHTyZ@^4*IcfY$OWD?utUev0jDr>X4JYbz-d`k z;Kd)hVY{j!<}z?{Xi(#!(P(4l_DkY7x|~ufnRIDjLGwd8epYZOHgkPo}d(J11)jbUEA4VXN`NUJTN#DyYRfOtN{L9vL| zIT~FCM4TlRx45cW*4jc?wQqE}+YHw&&1iq)@ub}RIARgg`khI|Jl`TH#_Cgc(9Sa$C%;f ztfCUzrGmS|lJj7Hr=x~TO=)C*i!;h}7A6lwuQsfUnP5>Xc%15AAIpSstT{6{W*4qz$TUY!~KRjb_i%C3trp%=gf z5)d>$((k|v75{VxNtHO*PFx~KkZp(!pe01}wcaYd>O&!=vhbCcWrQuJs?K@RRHn-l zWLd6q9Z3R#-;|beMPdI4L32hZui?Ivd3F)x`?J;?gjPb?+!+We8 zm6y53l>nnYsmhswl+ip(!TWCr3ZP+X@Tc~sI%W9#2iGrBVjtj+W6UJspqzy3X0X%3 z+&x<3sTsx!O>mF`FQ$$Jchu!;>hL6>?6VSb^H6g}$O3wx%WkD!H}HU*ei*H6EO6dB zgJ?k}HSJA9OLpibvmyoIY+2!PByQp>?5EXXwnc!^+2FcIba6LDI7-0`eCn9c^%_BZ zT8~KiokMkrI0BtWGCR!>QlTPhIzR%)=5C&FR`KMPn$GbEAQ?MsPzYj@?ysgQS`Cbd z4;9*b!KhuB&{_wC#C*H;$tkfe;xvK{vtQW-n}kev-{Ujiw|+Y*BeYu7G|esL>@{A` z131s=kMnKq;U+~%LxJoSqfWSs+M9F}19sDK8S#;_@;>m=PP;g3#|!lMi~UwK|haIiDQ z8~_-vkCA?9CIeCN2*|iOz32wxNz}4AW&T!@kLvDfz{qe~sLp6M#8bi~jT6PW@I;E%IlExOW?H(S zenRqZO=qelTpTge&S7$d@m9$;_`YXgn!cnc;6=&Ev!x5;1-brtxz*I?he#V_{o)zW z?EmXVYPwjpx>#n6c;)#NciUSNZtXbhmpQDNmEJdNZlSqYzuBWJ?8$BQPv+Zs$0vL@ zdh+-q^xqgM7hZlsB=Kl|z@SF3tvfaYQg4|pXhW}k0otmV-RcAcUYfn`d5ZJg4s0De zF%4dVr;Yi6sAj7TzO_f;=+#3Yx3aw$>y_BLcU?!O{|#NgCI0a5j7CklZTzO}CD-Gl zZrPA?tMG!E<{Yq*r+?CBm_A4qQQMb{QqM?Io><6tO|QVN4pp$OhTC)XsMN+>TuP+B zZ0HI|7oLm%@{?Y(tLbFdGw@#;hc=@Pv(sxS7H zC3nR8b8hKMERQ$~2C5DzM4Cbc?Ga&Ie!N*jR^R7j%JEm+oG$x?{kX7!OTT2;|3`)f zU9#>7rRf@9vT#)BJ7aWIk+%YVQ?kFEm288C|12vhNK(n5e6eXVsbC1l8A8glbmr`i zzX$rVAzg(ysoyn3ibkUoJIkfdJBa`628dSyxB;U(v+Q?AcbIQpiU-_wm2wKl1azb3 zbV13JTJWvPHcQ8LpYDDd`s^8QB!+j3-1OcDbY6k;2|M+;s16~dq9 zlB-^ua+Lil=vnkXTZ^zE2BDKygG-adEUJRRN!g_vgpgBFJvF!9YC*jGy%<4;fxmWI zH4@6)Oc3AW<4nF@j`>PlSlsTnq5Zo^*GXP*qetrBk;s1~;2U5|-8J#{mw;Gc+9R&} z>Z;ss(DCs0(IA?(NIL72$5_;;q>$O@xtyTjwODLb9?9cKo)uQ7`wt3ED|2qq6p`yJudtUuvFE0g=zr5&#bVg|34 z6Y>YQgHEzt3_^dVq7uT*JY^D$w@bzYoFGmj<3<+{v)fg5OP<5Hdq9K&{k60T9$Z;? zy|W5Hj7!ruFw7C=0x&!vZy0usH7OZdyp;bi-RZYF6)Ou)c>|LEjh46UBTiX+zj}UY zDOui;CU`c}<1xPFrt(7vL?QCZ+WEhXL%%5M^zZog?wp*O z`M%4#?-&?*D3+ekmBA^d)azTRP_+hSEVP9Vq1hBbWZ0}_UPYs4;eG<_$sDTanY_`D z-tpOaTfc~@_dl&-YY=-&E;IA5#+pUfy_k{kuDE`ut881JM69sHXBW2%->Uz>@>u z2qAP!OC5wQyQ3@8E}mqrTqX6jrAsy?F!*#e4-sVPdRE3m_%3PVf57Fl_2U3U&0gUPX(dI7Y65H-6JpGR@o%PRI8FJy0ZY^536U_{N z#SHJlUE5LAf~Oqg^I~QK~Tjpzt%-Ib3cc^*S zlep=tH$KA|yaJ6l&~nqFMaRIIje^wh=`(i9T{@z-qpw0g8yiX5d;Fj4cR5s$$jfUr zO>}f9}m*yJA&@rAFY+;@e}%;A=_R+Gd>X`FiBX%b#mnjr(PrBgpi z-VOPE$Tj{G6ggB*w)Iq>Q~VH#zhGA<$ZS0R#WmX1RzGBMV)7R#%G(LxRh+|_oPvp1 zDHE~cA{qd^dP=$!avis)P6v4TPtUkn3sb0e>i%>CSg(|XZ_~^nm(z|{WGErEzN{q$ zaJ1zb)Kco4yDpyySx>_HV}K*>*LY~`2eyIv z%ii$j#9&AK=8zLT)#-f>;Yq$f@edv1I=_xm$GF-)>yp9X?OJ<0Wtx1zd?Nfrti~7_ zj&3J})-ZEH_?efBR-SQB;aQreC7QobWc?~IU?~3lr?%+in5g3K-`2=$HN7hai);dge^yhRURIXvTFKS~J z%i;|VGCml4q-@hoR9h^p+j0Jg5 zY?ICQPqLF2Hq?Gh)|OEXZb$>K+2Y*Z&=Rx#eA?blwBR=YVR^X`)uT~Q+TJCs)YWiz zP!^(c`M(lc6%n4~kMP@N2uw=}5q(Lkf2iZ>!7^qy{i08S1N}5`@!*MT4*rGi_<5-7 z`a8nxJq<{a+4QRg>y^l~%DHT=wvnFTcKFjDz)^(v(DdmWav6LJFh}E(7sz;_9ewg!&jn1ga=`E@Up`&Rxp;=c-?!t_|Grv4yZw0pNn6yHAgpGO@L z+-*DSFdxC;%TQQnyH3=9D=-r#rI?v2{aEfaZRdLOd8LM*JK&=4P+|1-x6t^ilA{Q$ zVjacoovnG_$gB+YS4tO`H?R3h&g|CVSyzAmZ3(y%G^%zp-99|@50+%aoI9B7KEd=X z=$u-TnOMzy&YsL2{}7zYMAjDjtZeVBiNSsyjm8v7`b_a@NN+cJ-N2U!EH}d>BY%w4 zYYBi61Ou-vA;PT}v5{@fhPhXj5o+qfc3I~#Ikv1}r6-yPuWdRNF%{};iYGe2=WfB9 z>7*{jAZ|sAXt5vj9BL=y*1kN^0rq3%|> z;8Gtd|8JJ}adEx*Da@mE+$v@ZAc_~ecfw09AlkYX`FZ!|b^ZApQR_`Mr`ZLGS9k~9 zPnA&m7^6{X9ogvU$cX+f?2V{Ju{&cd!l%7xvCH<5rebs+OTD-F{pz-04)tEb%L!^?`tlxxbps@P1ilC;BK~w#FNT!7Y@!=r*EG-pT zSh@7P%`7|wg?5>6JPM4mn=Y1(lW^JTv~ z9+?UBZ^6F&E;N;DCe$^KIuhzc>E57=y)mka6_0-P}OR-`N-}VOc&lOwV4Oo6u5 zsIW_rkndIeA(-G2rN(s3OvVN}{>Q0@2BmaXWJeSuL_xF3a8~b3R3Y6e-oUi&s;jf zJ5pD2VJ9i6=8rjrg6!Nrjb3}b+MLAJqspM-}s#R zigu84!QH9CH(eBl_iRY`7YuwpAw#wjdJpP3omD(l#(SX#K?mjf={8h+qn6tE z@EMwSx=X(Dtyrqba&`T2nzFzM+LY*LB*}YbcVr-1#)5=~Wq4)-PI{&dL==qPWX$}Oh++1YU3ntM7>zVh(Ivw7XzZzoNG)qZ9| zO%PzUHmMFHYtahTG0bS8{_ELVT{xqDxA|o5hK?qAxZ4`ddXs6&>F?G_WtObmiTL@} zK0iDVQrPk?(QakM)Ga34t=pKW%}X_aZB}}kF7IBCc|EEyIZ#Z0r5~wkR(_akY{lfs z*7qeCoS7hKDxTLQtYnHxwvj^Lv^;g8He;sWyF0e-fKbIcj{(P``0 z!8fleB249hpyrt?!mTBn2$zQF|Lg?OnS#Zr%Hf9cCmM87oer2us9*3s?(Nf*@{b2J zE>%Y|13Q5)Z)qSdjf13@K(MKISl{8S)>?H1ty;Evl+nP8vx|+35b~@0{?q5P+~#%1 z7C)Ka9htelTFK1{2>AI`FxL_vUV>XP2=8~!C5^7p?vItX2BM$WW+uUR2?=73<-Li)W6WC^pw_Bj+a#ty zK%`+oct|o#%z=p~o}j)R+c>UYe}&%^J)d%YW+&>T5lpdcU$vJ zO~Wji_U!H1MN08B-HF8D0^&fm$D*BQt(K19bK$yO^O#)<=F#qP>pcdOHQ#Is{Thu( zxzFTtcwc5!4@azP$>ixsx}Rr8N~XIMchw=LkCOg|;fBp1SrjdM*D(r28xS+LoR)qd@QKsf@hr(zGswqr-$8fW?y`IAW$e&_9zO>G491tD%^tH7ZD(q zr)=3$gJ(QZ04W_Y=W$TjW$q8!4>|UHnBfB@%QZKMduwunN_2%!{apfr<_vwC{eVq9 zNH5>3NzV)En;ZRn^mhE4rs1!YJmO}HyNwr$lVEa|G+yk#_q4I!kRT}$X`-Tkww1Wl z6&?5uUGT|dyF7y8gN(f9Ro zum9E@nkC*+hFe~1h%w(BgSNB}AsA=nqtJsnFmeu$iMkqm(qO$xIgB^QciZ`OOOo`7fX?eLIE|r>QLFh` z)7Sku*Xv33iN)(D+LIorD2!lOhd$CxzM3<0QcP|Rjym^es?ip9;RjVu*> zmJZu#Y;1>Vj1M<9@jtfN?23ZN=QEs=p^vF$DgR9r9J1K3F0 z&4z#zCr0OzEJGC*9cQI_v1{ty^!K(}5w6U3DH6>Jqokja!imaW!7G%>3EDd_C||-9MVjF{HP6`{_$yj(O@`L{TJj(rbka zLRZF{u51mw-_l#Ra8sAHPkMxYozt5-e=8o8HCO&jNo%Lvqi91<)(@yVz3?w$K{R^O zJoiUl>sP^8v9gprM^6C(Q=)nKEv4ToTdQrUdL|oLP0ibR*5DaRPB2gOIs(lubR9}w zQ1dMr5!in;)$Xz-#-(&pQ9Z5=FYUOqqA}ypkUgf&i=7U8> zRSIChq^)~q^~crv3=U`FWv(K%10hLt-+f*_a~D_GX|}m;V<1elm7jU@KGzi)6xd~t zB!rPQ=yf#JP^K>ag6e!@F{1HiNu zZ4HU&kMd0r2&P_T91+$({XjW#N5R)7^J`=)|KU7HUCudS2-v?!w0ma^k1qt&3~Y(d z8*8mqQk5RY6seTyMEI_Hy3paG&(+K-@R3%2=R4qodx4>2ug8Fa-jIUDXV?#vm@1ZR zC`=Zaf$pu#xGZpFAKkSl81pGhrG5>hRK2m&RS9&}P?xPIM?zWX=y~WTIL-W>vPFn? zt(iVEF;j^8^VGmMk#FJaf>4V2LKQBraLNoFoO#MPV^4fbfHd-5vyoT z3pj{5PPXTKS=di`CG?b3nBzLG76cO8Yo8NJ*WGB_NOSMUICphxlPh?B)W*OgPZM>8 zm{IeFhCpOex>>>lqcVCaw6!Vw$!<%UZ2!2xg-+&`k>BEpnp0PcoCNWW$v9Gb)4?KyKR zOl_y)`_|8!397lE$t4oCr6^A5FY)(W%@()jx{bbI*H2q`uJ7<xZTvz zdV=xZYLdib=-P4x`{f0oLLg=n%$O{_%n4~X78SX5^i{^x`7vStNMxow3mn<^E)2y}BRGfzG8XKT8Zh45&^E z%mF5Fp5Si!Oh{N_o$+GE!nwVbCkYgD?|mmWf_z%fyKk#5{Z>(FmY@?2%&3h|q$s6H zA)QChOIgHf(}$4KPDbl9FUoXeRT;lZ_0jIq^!xN~IAI_= zFLfk}pyGM3(9tDe(Y1PX9daI8^G15zLWOTP(?Nh_M5U!_R~Qf zxqr(MH`H?zE(SJ^c`mfPn%AwZqxMMOTUB~rtJCzObw#xT-L!U;_(LQahmviG6VY+w zhWI)zqVAGof-Y{8nXZfF-JRHnm6UKruxE8~{K=wE$ou1I^q%}@fzLlR#(7To9B6bf zwz?`UFC7@9kcR+1@^p~f5|%bI<$D1+nDDyokW3x%C&aA8fQzZ#7Ih|}$9GMM_Wmy+ zm;i!ipLQW^*5bKF>{6XUE5Bz=IoM-$zV*ET3#k(6F3zNx9#E;@B7|=YogWZ7wfxYi zB}>^5)CPx_su%YWF!AJ=o%dQ}q{u-9`EBtMQ(!a z7K86NN!1UDKc9db-I$Wb@l*Vg2yOj24a!F^q%VKG3LO~RzASZNvZ)>(-QXaLJ9UA^F z0qQRJk|ap#uRk1lZ=Ze<H+F#xDdkiNEQobuBvhxAh5u|<%w*j#T} zt~WP@fNc!tkUOPZk3GDioSq^B;w4TXy^?1lXFdX-TMb*geaHN@ls?pcQ6nV^cxiFg zx)vDUF;JuYNl7I+Z1Q?CK3KJv<9i>cgpN957MWuROl}A(xF^1aW)`V*4%mNiU(`Q# zOr=v>tT$<(YikhK?OVE<{71H3LUZF~z_%LR-{8wJw# zyMMtgD95vwLZlicr%-kM3$RIZHZ6R{b*96v0h@ZxQ_0+S+TshXQOf7;ntoJ{#(2%E zKLUk!i~!#RM|KjBTk*k1P6HFG*Wd128`p>&Tc^Zhk~0yqn4(dW+x_V0a#?I|^FI;* zR~eKH2}OStfg&zV681c010mCmB@3^bd$v1hD@K4d3K#d62)kt`clJYVk-hyyqp1uJIX=c1qO{O6CLM zWU8t<@`moH;LMkhmi7T%xikNZs5kLT0{z0iE2o^M(#lM8t+8^-)XZ@~5vQ86vZQiK zO9hw83U_j4s~j_PE6v=cGBa~QG8a_99d|PqT)_oJML|$@kk{WlpZ9tGh5J6A``qW8 z>-ruubye}2&p#wazq`&lln;E_Aum}hwySn#!|HVc18YP! zM5}_B@IKMPC1Hwk&%Nhfut&E!S1?o`Lwr@rd7VG82%if?l^-W=#C;atX)ZDNL=D~@ zp*=fRb2}vCpOpHRkEUgv!VfNrsE*^+?!!OwtYHK4hV3tUGF(65Bjc!LTwyd|uQiY~ zeP5|rGZz&3A*e=gsT6Q)*p6ozh=%iUC3>Y68%C>Uhmk1rf6A(nPM|UEir*T zxg_0%XOF6bkq|B9PU-YvUcry|`^1eiuiC3}0b?>4#nP69urbi)jEH2PC!9 z@3^Fx1_+Afn*T)^m35=q_&=VHs-lN1wt5?`#yv@2`>md|C8E#vaHoCkaTLpqE+K~A znEre1p26bMznk20=a%^8+7Goe6(8E6iPK#=<$0ooHx;vzc->QOK@CL7D4-nvH?7D- z(`Pgq-Big3E>{Pk&hBVFjiI&{Vv76WBWsP&1hudz*VC{$ts|3?nSm{e|0I!*U`?X^HRDeZl@OY0V#m5TnB6f$2wPpz=ihj`Q|bHJkrJKhePe zYa8ay+|y+5TGfzD1^k6|2>SX?()EVGt9jpvZz|uJ&2^s5glryW`gXrcO?kPy0=4ie znw751|M?8*2mMp$t*Rwn-l{~BT~iJr^#(e=r9YcTD+PW^RthT1w^6m48;722Pv1a` zuWuFbWBGkHBUE@iX8rnxUh8H5irUO-6U`w8O*v zPwug=A2!?Jv$ki)_y;<_iLTRqjTp5zy;8d&$|=x;oO5MnrY=3x(S8BpcilH>NbSMn z6uNfU(AEL_R&1mIRHY&l6^4iMvgx*P;j=4J7_kHn<1jv8!6I#fY;st*b-6 z4ffa(#?+hd=p9Ki3;*e)BMBRYwQSUAw&*}?@jcV^zO1|-%JmSZ+i3V{ecofG@syD% zw<%LB?0N&us%^x|?yaWx>x8&F4t2rGGyTk+wsxmpSH2#;sr<>Lw`JLIEdd;WYt6I8 zJNI?Ca3O}Mkh=~TZtp9Lk5qdzgooeky-xv&DMb67R*Vr}BV<~1@>a6N_h72!%lyyy z&b1K$KV--XFkIDk%g$`-(6Dt$EWv+6rOQb9CaUJ3$^}^(t>KEpL5c)aKcnqOJ!eX* zgbMYJ2V9vifv~Wr6^;6`MhE`3DN6aCgPAs^UN{f)8W%1A*TCBKv@h}*_AD8@yKAG< zg~Y4N<;1WHLd`Ru+^IovuKF_&9r**xh57;ree@*~?2{`)WzuU9C3;G~qCOxpk8YHrZd zn4}~hxG?OCYy5U`-!2CgSwc!O>2&uI67yQ`T~5n>pQ~iO==RDNEyS~W7|Qeq4aVYne!J7rOWPdgl|JdKEkwL|4YP~(l36iifmujgX5HwbAs5gTDW$}?OkX^e_6 zofJQVe<^f_xgM6axth%40~&@c&GJcWy0f~v)*MU=WWtR)@i0vV7r=6xA>u9wONSfyxwMrK`ze^ zUP8@xoPR+wkcxKGAlhrKK7}|}G)IWWq0eyVagXyEe_m>0MbQ7x=z;SrR+6WgcT^v_ z-fFrrtAhBQ%|m{)JN@C15y2@;ehduV;dyA4vXe%#8}6OWuUI}IOE%F7Ib8ZiG&tv+ z6>lfLlitcR1jZkS_g8KoaR}jN#S-GH4Lay_e=eO8~(=Gt%{9eCS>Us1WK7cs12cl@f9F0;O+!E%Gf?q)I z@q0n=|2KDJu(d!|!eyMZ&qW|sn;_8dZ|dNTJ!|S4&#OFmrybVTCp#O-K9R3S_1nYq zjK*sb#z}FZ24v;JjhH%*I}VdJSL8x#(rwd2)(1IzB9I@hE03p&jLOs~>QgQ)>8iNE ziUM#Kg4HM;)TSl{l`8@h!P?Qu?rU~clBl-IQ;$ER(|p|{i(TM}&TEh)vrn$lKaUp% z6F42+lfgU=+OJF$!HfH#8{=6^et^Q*&s=Y0$z`hBD;`dLhT&q@BG1TQyRJb( zx>D^H%-s;_ZAR%?kyoEwlBrjdrrPC7^qF6}-udvPqR&pe6<1yYJtdCtr6Q^e8Qcp` z_iiot^Y%C9qurRqfxAgKL{OPFx^+F>?>OQ&MON3ebgCL7troMoF_wE(j>N{M-c%GL z9sW;sWxQ_Q6%m5GuGmmSOG2rb@3~i1HMD+RyLU@??|2dfy|FS^`zsLDlowfQpr3;0 zmY7yAM(^qlF4}oYeh(8J{8LbOPvdLyvf_8BtD|&(>?fxV(n8{-#nr*^`_17-0SWcQ zGE!%yAj`MKWKB>rA%d-wzE=@XsKqCL0LNjHb=xWM%HDSLXbSKelx5@u5x-tk^M$l7 ze53vfkfgNucEw_l6T8nO6ka7;y_URI3DNAF#G#I@IS$G;V__d+a~!9Jl_b@D!!9vT zD${T2Rq~B`D1v#NNl>_l%FKY%B<%oohI2TvBTJ42l#wFU%gYA-QJo+5 z+(yL_Gw_yawbUqq*@zISP&5k33OW7DK}AN=qskpOiMlv_j_-EJrpg@~ zDx3mmO0qFr(~Z{H(DR~#ftVPzyf+%p&JbRU3$8{~6Rux!Lhdm=$lHHu^L0D^N)@vf zOY}I|XQ$D-Yqs{+?D%_s^j9d>)!=`F?G)wDN^Ih)KZ90?FWhIs2$afL9J!wHeTPoW z+WeMWK(!s-#0L@t%5t3P$36b@4(htvR=<03v0l2sVf0IopwWRI1JL#1-81D~>n0c+ zlJC?Iw9lL5#;QJnYztC)*;5tpo1qElermIQjz{qmWpvxd)95};3CKj)ykpF)&RF)2 zUUM@C^++3h`Kr4lT>fhIHqBVk8`(wry8R%{4e#bI5dsl|u$=} zO7o5CYvH+|uKG!h6X7JgDyl^i7CwQ>w?8&U*f*Q~B>iVX*-5N3~V7yhbE-H6(G-7qMyUURkX3xFG4{DHi(8h%|n z(btWFf;faqpS4lzp}QslE%+YNF>YE ziuv-Y_0m`~Z5TXdaiU9viHn<`NU#H(#;nuCq1yC{Mt`VS{$lF&pX9eC3+j={b z+wz4I>|ge*+PVceG~gT@8I~zc)vR(GTY%IpP+cXWx|8-eP1Eg~GUKOMsvt&u?dk6z z6mH#0opkrSH%q#3I524=Bf>aL7zUtr+V2SwY`s|aiceDWAw}L7j(yrZ7I^dLcy{02 z`InWwBa@Y+rds=|(Lba3Mv%+@*hSqDC2XDuj40HrOv}AVykPPb=vcM3+3)p)UTIor zo#Z<}<(#nfAKhce?iAlrCM&7z$T)8c>Z!aq|1x``((*noGTJOWsLRQOb)a6Fs_o{X z=STQ4cUd3(0%R5&K3Ah>oQi3_(JUU?^_0^LeC?4XK1px>FF;h|LC5U&EpI{ilND03 zVs3U3y55}lbSLX?NYV{pWEx^3b#vtu;EkOZI0$gOP+_e5=z0CGe;BrH|44q)sO|v1 zZ2}L^3=WNGY4pkUcy%_#5lC?+O}W7hpqP-wY^{GnJhUv)>+Ey3h}ZdN2a1D_nLW8U zMb$__GZ=eqhw2-q_S0+i8FwkQ`?@ygbR%UJ7IjtPcCEJY8qvb&EfUnb#DGXp9X_w{ ztl7$-f(Uku$ZfzKgHYBNKYd;;mN@XISowrqi{z3?p0X|J7J_7L{z~b-mZ7jalKQ%py%GYxwX3 zcRhcfOWt*f$?03%IpXG5myqPC31<5g;EHpc~X%8-EtWK4z z>2t+<+?4|MK4<8Z?4ga~r44RdgP*SCrs`d^pMa-Yq3b7PT*X=Qnj`BUKHy{WRnQly zek)T~^vyP9h#8lO2Uy6O1W*{Qi3OFu3C;eX>g-XkS%9AZ7_hs@bhe1zv_z*~QosxN zjr6b50~$Kjl1m%u!>6qX1F>bfcCW~MH5Dr7#dSt)zojiP_moC1T`*+@9 z%H`wHhR_b>hF|Qw%PQ_EMGAAY(kN9W3FCeDPG*RVJqN;u=PJH=}^ z%36M=Whh?;E4yPQo-+gC)0Fp_1*>uHB1+Ukw)Isi1+NPG*k!zy^8{=maKHXw}FFSJ;N zyb+37`v>GIPHP!V6KRgH;yFzL*O5mpCrWAYDnR?q7KM|6b#%hYhKn?GyX)4ZWCZ1s9z_^bc09w4@-gFW2(4?MPo712zp%!-fa_+F z5RZvlp9aE1dNy0@xylICnCF@#5lf3Gci>GaDryB4DAzE>)`k?M>8Y#1B~Y=lv(BxB zhxvZ%RjY}pSJ63|ba1NpX<~#h)>t)4MSdbKP+oE%*yj7nkh{#Hmi$w39IdF>vFtod zoqcaO=bmOg^hO$#`1TORC130iSk`jDuq5&AW|uJkfe_}`7d@a^d){ef%8hbOoMnp_ zIMQx8wd$9)B^kKr`S=L&2150Vzznx&V36OEwo7ihVznC0s;0?0u(G&Q$zyq2Hhf~^ zkZD-f|88C6o-Q0^NOy7@4L8Rafq70*MZo^=Zz7vk45!rtkjW;sA(1m(@tCEuUGGQU zE+j2GdqB(#r04F@+kTo5%{o$vndaQkM!kf!^{&s;A!)%pAL{&`e?v;~sKldILN(hE zdCP|<)IFhlq^DwucC``PlJs0mI=f+|ac2>kHP+2N={Yluqixqa!z22z-s`U>#RHaw z44b6S%FDDDVC_`pjg2_)B*V-wV6WKt*}DR`(kpf}h>+`oejgE)G0tGvmaGqMAer9?t*X`yeV>Lit%?l$( zig4@cycHQvuy(rv-Ypl`mnhxuCE*xv9diU}e=29%jL0tYoe3>#5w%T)902hci0?)~ zDC&stkw9L73eAFatg+mQ5q!-l$Ml!Ckc!B1VL<~_u^P*&4YVs8S$!$Q3{2J4`9z|U zIPV{Cd*x$a6^2|Fp2Rni&+V;TrR5=bNOg1{hbeH*|X~O-MoWNvh(GYt+wd-iQRFgd64)8SRZS>`w)q}Z!+U& z%^3-3s%(QX^8~bXsVyjP*amwE(h z>#B*16m1MndWS{MYSZa`ZwESdY8c*80sjs5_6O4%*&6s(Bgjs4aq!m+Gx%%gz`las zxeGl2_h5Jasa_iafvT6IvB;MHPRZUozJ;n$%dq%zRS$Jczo!q<_yvqDR&iJ37s`=(Tl)e@)NGbsoB?@3sk}QoSdlrw|M2@QT(I!;SaTEI2 z7;@XU(iYeGRLg{pLQC=+`e&O9pyc!-z%y^nQ`x>v~PYuQH)yA z%L^Ath9r$})4vZ4GA~PC);)d#${B=zaZ_H*Ann)h&#!K3s#~IWPO_eAW7pVaoz;&= zc3H1E@7GX6in7j^*cBYTXmT?LBtHgE)Brs$wMiWi40?gu+&b%oAFkQ)QwX2qjbI^`K|f8c$2HqqqBNg-IvLspnt&@KJ)PNzdBcFNuV;rt88y+M) z-4;NO;QSNN&igduo%(#* zK^!nJ+Svp_rm~VOlr!;esAI?B*q_4sNlT0nX0Woe<40lcP2C)upUS8LnT&z0A&=2F zB@x$fU*k9WH8!_L!7#ReLC{L?wHBe1pNlsF0MB7|b#764q@Ae)`AA(VsGbS-X8hi| zH~{@gD2a+QZoB0hGM0Cxn9~{gHqeUa7A~;e>J@IzEy?Z zD5|;e%tSZGU|rqH@|*YyRO5B~zATGz!;5!%9W>HwU!QUkwf^k{k5PPc;6^@cV#ah@ z-}4S=Rf|Ta6=PVKUlsItnEDlExp5%a1omzY)d+FMqe3Lwu(>+{Q%~a&*+} zhdOEtm>c`c+$r&kjh^nOU6VX>n0-oz>19uiw=STy%CwGZpYN?1sb|bw;KcN7plLt-IMcYy4K1x# zr;sUO+DS&(iM}|)gLYIj74<_H^QE#;`~#c-tJ8$nhrx|oU1DqsS$*ze_SCUPjmO1t zd}4I)s@+>p9no>$=`$)$yQA8|Fr(ct6i3U!$s}$3XJL-$!~;{pmRN0d`JePK5b_DJCH%_z5z{k4$=1~opk-f(%jd!- zq0Q9Obg$CE*zgrKZ-#1{hDxDUbH%1oE~ymKcL95|Ua-!6xDw|}M?hMsTH5EcVoOSy z=_pk}zgA`)g>w5(=pmT>4PWj0*TouJpuY)EYLHy`b|wF-Ov1?v)hcMYvAtv=pKg;| zFUT|;aX$szsf~#-Fa$@L{^Cje(r%lxLiODkxr56t%_05OxgAIF_;`KH^W#ORb*EsH zIB)q+LYa@g_28WUk3qbu&sgq3Xy+lYZkZNB6;}GIjq;Nh;v-;ag`~mJg){>bm<=oA&t!aCw`gqGPC-+-VcuE5;-ePS6L{i^X z(Uy11WC8T&)`M3S2OEIh2%k$O(7pd2?Ndhkh#F3osyac9){eYGeRcXG4BnEsnH?6B zzu&e`9)T!HA-P5}X8@Rc$c6Ux4Z6H+&5_4Atr%c{ETFLZPH3X~sb$gn--}SB}dFlJUL)-k*Z#H2GyUZ zX)o#D)Q%=OEuTMKzxzuLKb6*58pm%_Cy~=T*|BG3F)Pn{8=T~;g0bDa)r@MUXSRMy zB#;+h>I#pxGUVP-Qs|i{-D}vVNe%Ad|E2pw0_@pkN_e?pz25pPa8`pH)#r|9n0>@m z`I0=2;WZ@vmxEp&N>URJa%)(3`Hc{W>T2{O`Zmv@5oyZ+3DT zOgJd|QOH_CN4n;E2NX_-NsWCw?FzVKWIfkb8Ktw4<`%Z5P9aJa-l8dOx~R?Nbw!%> z&1UAey$D^-!~go5`7xv$pZl;cA7QRTACe~xy?y~3&g95EhdVS3CHe91@F|7ndoW22 zm31O%yeE&h+miIS<3gaI&=mH?rNmH2b2;?czS#O1^T2z;M@je|c9cj^Bb#x20tL6;6e)*i6 zw8eU)@jY7!sd}`gk*Ais;q!FOZ43F-Vlyx$@foA-y7pu#VMNrjhElmc6Sd&&LNym1=hC~)>Dkwdh?KOS$bz*llV?ZMPFgWOGL@NJXPcBnYc-_ zu}d#xj|%hL7v_r%=KdmEbUQ<)Umi+#cH30_xd0_CM|4-0sNvvO%06J>D!7#qaalDa zt5{ye=&c(cCNRmbR!EcRSLxOOf}(qREF1mAALk%$0P6uj>(Mc8**a+LEp>_=e=S1j z*|=5Ncg6IjET@@~Z`F?(*LPvGL{?bjVYj|b*3p$qSE0PmAhDebFGY1+R;&qfm@C~_ zy+*^W3l-}$6f7kgoDIbfLRAz#m zG=5DXy!?Y$^W8S!Vl%n?k2#L%a$|L(lb?R%zLP{V_lFIQ&P}_%@O97?Lnj5gQV>4tbJr%yW*=XCBfX|wmkSK_lgxjya!s(Pkzo7c0%t2 zlf7$Po+`$lQN1L|q#X?7aJR}eVWye+$@Sd6III)%7*%!VYD9m$io#h<^ z@Uoimp8|GwSj^qNI&mw{j8Q5wR`_;I0xHu&I91FPRms%qBZU7Pu+iZZbjN$v+6Szb zsH2fGg~g;*P}5wEd#7=I5Z0;%X~qM0W89&&9YVk5rdcsNR2-`H(XO(XYmjtr!M=nN zCRPli71RkSsL!IdI?VP3|3LDq-1GH6T^YbJE6?LJX_mVmB`Ai{ek4^p**%R32yUfz zj1~erqvfI&%4thW84EZewJel*dM=>L`=`g1b&@(hJn-p>*07ryf&JTSzg09`P2%%k z-6ssXj1}_&{u92|d2os!OXF4>=~4tH*}S&5=C_`>s{H_b8>fT$DAwU+QnUoSAj5rXJla+O0>lcA1B@4eJDTCPIKPbw^I<)!p2pRv#pNA#qQ)#*PUF zVVXIAJsbHX;9!4c<3lTIkS2xF5Y~_yox#dQucu3d$Q3tWn=`GM7T-91O+1v7*Lh5F zaQ+=OCq02dSmM5l*AYVJH~DvQ6)%2@96#~Xu*cKo=TxibKVXmTSmLzRv6!OjUc2|UVDc+3^fzAO%bO!2c}NTId^n`-2PNOEs6e^*NY zcQE#6qiS%-DVMvuPJ#3oga-(j^aogHSz|VNLtaug*MhkdslLpKiQ4mHjl&7;`_MUO zs(@dJjV_K{Ga%efz#3>w&YF_-(;;G&;F0-$hkZJ)8>+zB5exBkGE6o}L9O?=ey{A> zm_*2L<-D}$;d@B`tq~tXVBf%KE${4o7KZn`ocU_MdJp&Mjaz=-zI*<8sv<;2h2wLH z3gd<{)W73$1fQKV7H8!xIjQiVDK=%s6w;QU%e6Ravas&I|0YM`%MbNN3hU@|b<5?< z=K~riL%&t+`cYf{@=(Wx=lJ&VugUAl?Co<;`a5=Ao34+r2;(u9^j3SeKju&D0Y3R* zEuA{|De2RWkCu-^yKP#8W_UHjjP}JmJipP+1~8oO3#&YZm~QmXhYuKrOI|gHej``5 zO|>7|{h4q3$>ST`(&Tia1K!fkhxZI5YpYYnM)Pr-hj^V*Y=wzn-I5ui_j)IhJF zSV>_A9bV1}-tT)YPfKY15^o|r-GSL)F=!Ck_iKyD|3NN=8q?-1)GxZa-7ibN zC@nOa3;KhVl4@$21hUU<`N)jpRY9xDfzndz5zCeHCw3gtanx5Vsk{0Y7ZzP!2}P%* z`aHIvED*5`8TPekp#gh%g^Ik{-;wU)jYY1&$d_rm-5fsm{t2Pjy8oxY48m96ls1BE zz%S^cM7k$=Fzwq_r}uy&Cqs{XLrBd)Ot_N`FKe$~LMZ+Ztf5t<7OX4+P;yp>SjFCz zH0BigJlZum*HC5$UYYhlHr}^;ZxjG&&w(ohX7|?hO(2Y*6k-q4p%q$xtCF5Z3D?tuy3Qv_Z_BT|Qfzw1>>c+2 zUhftb!;#t(*qB+=94S#eARV6}+>JE~K(Ysla}ajNpi3@*JIvd}+}Ydqb#$M7_E7;K zQCAQDpuVbPV_n}vTgOHdM~}2qoNE;so8cA*-)fK2je<*Aw9c~6TR@lgAA+N%Y~KaO zf{I(-p^!drvF;aJ?%LPONBM^6oLpbhys4f~#}KK=tIzmEoIUFuxNK5?1^BgMX*Z>1$!kxWit)~!c&({V zaGEb|2nag@zW3bylBPCPDsY{-RFvokUJt#o%_Uk>BYtDZUuXC)T)$XS68f*+jF&08 z++3N7x|{!MRC}>BdmxFIKop)X<_2+dYzEDTjjy4lKVT#0huK^7ql5Q2xwmXK+(C>0 zMYn1$-`|86+iZb<2e$|xHII#f{gtZcKGwdhI{880R=$ZJuwWdiuI&5(ePIRgZ_OIk zr8WI|P{zpUQu<`|oOVib{ZY4d?hk2-A~2&6%?|oVTgFJ$NpB;5P3sa6)Vpu4}Nd zud?CUHTS%h=Neta?N(PuVNmCj*lFP@dpU=G@Q=t}u6)Bbyd5-z?EIYdaGAH&i5~P* zNp8t(6s-a><_+XWLLT1mRrTtc#5XxnC=#8g>^CH>V%5w3qCFLQQ%M5ChW03{^b*ZdI@)O zz}&$Dn1Wx6t1~pW3Jxh$mA}JKCM!xaHL2F()|IvWYdV=0c|Yfr3X_o9v;N#=qB|EN z766xxbU;+w&^260Kyc>TKNFiHcf*a5g~9I{m~X0u)W^s>2GDI^HuH@L>WKzm)EaV} zN|G&11y7G9oRNTvOf7KX{W(_WaI?Cl|1~?l{{X-3fD5Dlw?i0O7XMo~ zuW(mqZB5WN8|%2re$$K0p3AJAwVGdUM0Op=M6BD@p1xZcLcNesUv5qz&ADmp z8N2m<(t2qWLQ9WB84xz2X7<>h_{3@bu*+Ke5mSQXt-2d&`d##E`QbxtlC4l#yApSG z6tlOy``s;7mBX&pXn2um*+;0Z|yPhPVS2wXhrRxXrYNGOGY-xgLalzpG;%o$WG4p6Cb zMU7o5b9OnueCrgdul2r)#F4k~CN|1ptozv!eM?s>nco;_B0IIJmet(#9P7f4t++TG z)FY8LlIlNSwI3MFhQ3<~2U~>Hu+;RCMi*%Adqe&G0)raHbbGi5zSvJgASpQ>VN3 zW~~TPo7`9i`mOefBs3!)6-{2N0+{Szb!Y&+CGJiE{7$<``xm)@LB+!6GPBY@wpO~; zK!?JK|NTq(ujn``b0VCymWw-B!r#cq8KicA48x!q0YScyr3UH+aGsG0IKn%c72hf9 zKV-;mzoVf9(;Po`v~d*a{j#UrN%-D5Q}m@eYS2Ywr%w~8KPc^5Ed@O7ceut{0kIOk z``*+n)?e>TQ~VnZ?@O<~V-v}*hbFiLdcP640X<-|pB08c(ARKsMA&BHlr17ye=0jlvDA z4}H+ph?wmmOlLWsO0nJ+AnppUr9S-Sy#`Xzbw6OV>{(e|imX&v7lH5m!XZIm?td3+ z1kc}E4oUp2TK24K3wBNiNj7dOl@te}xSmGE@#Vjo1}P_da5b z3U>X3oCm&&>malj9xy)x7*Q?Ee%0h!Y|75GMYbGTJyljj4$(H#N9d1rK5HBjhuo?c z;}1>sj7s$|Ld!@y-1O4=@km+lSAZAw-jFs114w3HJ90b%{<>}5h>w`faE(Zq_01@x z^{$*>p5JiqagTM2$%{5i*M2xZ&5cOQ2I1$Sm#y#gkpQL*B&Rx5EBCD_Vw*IFls6+g z#g!kKuLvT}qc@CJEt4A4!#V)L76KnC4QMFTi3n(I>HLt(+YA_&sQGz72D%sF?YFdn zx3?yOGG-mOdWU3nMYakyw>zZE-8?rgYPzo78ImEkxHraGDAUjTr2BA_D`F(b@m!=_ z_g0>R*^Ny!UYo&*2lqmLWXoCA!!u@d&ZjA}SimzVVgB%nBep6;78FWyR}Vz{?TCO2 zNE9$AW4_7CeI-eA14a8+jr;^==?UtjR$jV{SsJ#)Z0^VY!@QLFkbPW#I<`XY2?Ne3 zJL5qIS6ZK|v9d%ZzR2`AQzFbi=Z?L2)z+F_Zwje28X;6iWsDi&Rsn$ow>$u7o@}Mq zw9gtGp`3d}>=b_c@1^MaMmLqPESwC3n(T^Z{)HM&u?+%OBX8o&ajV5n(~_CLe~WC4 zS$qgIlV&t5o^wht(JRP#Sq)? zK4C3VX1_yjIoooVgx=n7%d!P&$ema*BWOVJC?qu+IXqPW1kVxQ4#Xaea-4*O>sJ|2 zUF}Imxj6}s48nod^Wv1sQNr~71s6TqteU<7OaDzppEbUiA48FEvxF77Iik5^F1#fj zv%qv0aUb_av(3~T%E=_7TMmSWu}k97I8Nb=%fu?)Y%tQ(C4E4i*rQ}`nQhuDyS9b5 zo*zAzaTvGhZ!q23Nu@Y;B)Oj9VA?(VovI;Qq8 zan+dg>VDvD!@ST|Q|RVl)z{foI%RNIX1?z*H;fuXynlgmRW%8YcS(w?#0-1R&FbWQ zLkv08RJDQG0p}ZCH!REbk{OGC)P98!n3H26BA`CGiIN2?pn>d>HgH~ zxd2r*x|`EuMJV2+T)_3if%8{K`hhLWKHKyviZx`-{rD_VHCgTt-7<3S%991?|Eq5~ zZ)c{U;}m_247P!(H1c(_2H)&E(03SI72}ebS{__rAU%2hNM+Io<(+A&EOrz9OK!5x!R7qkk(Ic9}PL$QDnm>+Qp?B0jVo!jFlfd|b0 zj&K@?C7x~Fle@NJ>X>5=G?YZdNA7#k{EYohb)RT>592A{>w!v?WbD_3s+;v;od~mF z&kSl(=$?dlateY}rQ-rvR1@ahWyFD#JgSvt(uuI;@oyY{n%zXRU9|=?{ipIFK$x@> zn=#nP7LYKTJ$)A*q|9vYmOlXZc*b0KGJtI&51b5B1k|)9C?LrZ9rNEQO35}lF5vX^^-!^qJUEqYrY$*a8()!N@6 z38I(EuN)RsqPgu3Qqro&0K~D3aL&Q0{U$hnwqI%|%gWyEJitGijt^=^M9i8mnNgfE zt)pVkGi~*4DxYr)qwJI;roQwG7FNC_m)*e(EN}Yr4laE>y>XIG|4-b7fnnDE{$e!+ z?Wp2?I#V>~e!xkC1c>a=$aXihH#`|po=OGe$eOQpAA!zNG9woak>BFMYzKVfjIz%) zuHak_ep!Cb+)n>XDXDi)G-Alyk`+?>MZFjO>B=ZRIA`AY68oDlqEFeLr2;CCy+g}n z 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 `i=M3sbLoSE; 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 z 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

        Na)%lSv=~P+3%Y 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.ips`. Our Mach-O + /// executable is `edmd` (see `main.swift`), so that's the filename prefix. + public static let processPrefix = "edmd" + + /// Where macOS writes this user's crash reports. + public static var diagnosticReportsDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/DiagnosticReports", isDirectory: true) + } + + /// Pure and testable: the `.ips` crash reports in `directory` that belong to + /// our process and haven't been sent yet, sorted by name (oldest-ish first) + /// for deterministic order. + public static func pendingReports(in directory: URL, + processPrefix: String = processPrefix, + alreadySent: Set) -> [URL] { + let fm = FileManager.default + guard let urls = try? fm.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) else { return [] } + return urls.filter { url in + url.pathExtension == "ips" + && url.lastPathComponent.hasPrefix(processPrefix) + && !alreadySent.contains(url.lastPathComponent) + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + /// Scan the real DiagnosticReports directory and POST any crash reports not in + /// `alreadySent`. Fire-and-forget — returns immediately and never blocks the + /// caller. `onSent` is invoked on the main actor with each filename that + /// uploaded successfully, so the caller can record it and avoid resending. + public static func uploadPendingReports(alreadySent: Set, + onSent: @escaping @MainActor (String) -> Void) { + let pending = pendingReports(in: diagnosticReportsDirectory, alreadySent: alreadySent) + guard !pending.isEmpty else { return } + for url in pending { upload(url, onSent: onSent) } + } + + private static func upload(_ url: URL, + onSent: @escaping @MainActor (String) -> Void) { + guard let data = try? Data(contentsOf: url) else { return } + let name = url.lastPathComponent + var request = URLRequest(url: reportingEndpoint) + request.httpMethod = "POST" + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.setValue(name, forHTTPHeaderField: "X-Crash-Report-Name") + let task = URLSession.shared.uploadTask(with: request, from: data) { _, response, error in + guard error == nil, + let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { return } + Task { @MainActor in onSent(name) } + } + task.resume() + } +} diff --git a/Sources/EdmundCore/Diagnostics/Log.swift b/Sources/EdmundCore/Diagnostics/Log.swift new file mode 100644 index 0000000..c621e79 --- /dev/null +++ b/Sources/EdmundCore/Diagnostics/Log.swift @@ -0,0 +1,261 @@ +import Foundation + +// MARK: - Diagnostic logging +// +// A small always-on (opt-out) file logger that writes human-readable lines to +// `~/.edmund/logs/edmund-YYYY-MM-DD.log` (one file per day) so problems can be +// diagnosed after the fact. Logs stay on the user's Mac and may contain document +// text — that's fine because they never leave the device. +// +// Design: +// - Three semantic levels (`debug`/`info`/`error`). A single compile-time +// threshold decides what ships: release writes `info` and up; DEBUG builds also +// write `debug`. The user never picks a level — only on/off (see Settings). +// - Writes happen on a private serial queue, so logging never blocks the caller; +// timestamps are captured at the call site, so async writes stay in order. +// - `measure` times a closure and emits a single duration line — for operations +// (load, full recompose) whose cost can't be read off a pair of events. + +public enum Log { + + public enum Level: Int, Comparable, Sendable { + case debug = 0, info = 1, error = 2 + public static func < (lhs: Level, rhs: Level) -> Bool { lhs.rawValue < rhs.rawValue } + var tag: String { + switch self { + case .debug: return "DEBUG" + case .info: return "INFO" + case .error: return "ERROR" + } + } + } + + /// Subsystem tag on each line — mirrors the architecture's areas so logs can + /// be grepped by concern. + public enum Category: String, Sendable { + case app, document, io, render, compose, selection, lazy, callout, edit + } + + /// What gets written in this build. The user opts the whole facility out; + /// they do not choose a level. + #if DEBUG + static let minLevel: Level = .debug + #else + static let minLevel: Level = .info + #endif + + // MARK: Configuration (driven by the app from Settings) + + /// Point the logger at a directory, enable/disable it, and set a retention + /// window (`nil` = keep forever). Enabling prunes anything past `retention`. + public static func configure(enabled: Bool, directory: URL, retention: TimeInterval?) { + LogStore.shared.configure(enabled: enabled, directory: directory, retention: retention) + } + + /// Verbose editor tracing: a separate opt-in (off by default) gating the + /// high-volume per-edit / per-caret-move `trace` lines. Kept distinct from the + /// on/off of the whole logger so a normal user's logs aren't flooded with + /// keystroke-level detail; turned on only when reproducing an editor bug. + public static func setVerbose(_ verbose: Bool) { + LogStore.shared.setVerbose(verbose) + } + + // MARK: Emit + + public static func debug(_ message: @autoclosure () -> String, category: Category = .app) { + guard shouldLog(.debug) else { return } + LogStore.shared.write(level: .debug, category: category, message: message(), date: Date()) + } + + public static func info(_ message: @autoclosure () -> String, category: Category = .app) { + guard shouldLog(.info) else { return } + LogStore.shared.write(level: .info, category: category, message: message(), date: Date()) + } + + public static func error(_ message: @autoclosure () -> String, category: Category = .app) { + guard shouldLog(.error) else { return } + LogStore.shared.write(level: .error, category: category, message: message(), date: Date()) + } + + /// High-volume editor trace (edit pipeline, caret moves). Written at `info` + /// level but ONLY when verbose editor tracing is enabled — so it's free and + /// silent in normal use, and complete when a bug is being reproduced. Use for + /// the intricate live-NSTextView / TextKit 2 paths that can't be inspected + /// headlessly. The message is an autoclosure: zero cost when verbose is off. + public static func trace(_ message: @autoclosure () -> String, category: Category = .edit) { + guard shouldTrace else { return } + LogStore.shared.write(level: .info, category: category, message: message(), date: Date()) + } + + /// True when verbose editor tracing should be written (logging on AND verbose + /// on). Lets callers skip building expensive trace context. + public static var shouldTrace: Bool { + LogStore.shared.isEnabled && LogStore.shared.isVerbose + } + + /// Runs `body`, and if logging is active emits one line with how long it took. + /// Zero overhead (just runs `body`) when the level is filtered out or logging + /// is off. + @discardableResult + public static func measure(_ label: @autoclosure () -> String, + category: Category = .app, + level: Level = .info, + _ body: () throws -> T) rethrows -> T { + guard shouldLog(level) else { return try body() } + let start = DispatchTime.now() + let result = try body() + let ms = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000 + LogStore.shared.write(level: level, category: category, + message: "\(label()) — \(String(format: "%.1f", ms)) ms", date: Date()) + return result + } + + /// Logs the structure of a block array at `debug` level: each block's kind + /// and character count, with no document text. Example output: + /// Structure (4): heading(2)·18c, paragraph·234c, codeBlock(swift)·456c, callout·120c + public static func blockStructure(_ blocks: [Block], category: Category = .compose) { + guard shouldLog(.debug) else { return } + let parts = blocks.map { b -> String in + let c = b.range.length + switch b.kind { + case .paragraph: return "paragraph·\(c)c" + case .heading(let level): return "heading(\(level))·\(c)c" + case .quoteRun(let isCallout): return "\(isCallout ? "callout" : "quote")·\(c)c" + case .fence: return "fence·\(c)c" + case .indentedCode: return "indentedCode·\(c)c" + case .mathDisplay: return "math·\(c)c" + case .table: return "table·\(c)c" + case .listItem: return "listItem·\(c)c" + case .thematicBreak: return "hr·\(c)c" + case .htmlBlock: return "htmlBlock·\(c)c" + case .blank: return "blank·\(c)c" + } + } + LogStore.shared.write(level: .debug, category: category, + message: "Structure (\(blocks.count)): \(parts.joined(separator: ", "))", + date: Date()) + } + + /// Blocks until queued writes have hit disk. For tests. + public static func flush() { LogStore.shared.flush() } + + private static func shouldLog(_ level: Level) -> Bool { + level >= minLevel && LogStore.shared.isEnabled + } +} + +// MARK: - Backing store + +/// Holds the logger's mutable state. Configuration is guarded by a lock; all file +/// I/O (and the non-`Sendable` `DateFormatter`s) is confined to one serial queue. +private final class LogStore: @unchecked Sendable { + static let shared = LogStore() + + private let lock = NSLock() + private let queue = DispatchQueue(label: "com.i7t5.edmund.log") + + // Lock-guarded configuration. + private var _enabled = false + private var _verbose = false + private var directory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".edmund/logs", isDirectory: true) + + // Queue-confined state. + private var handle: FileHandle? + private var handleDay: String? + private let dayFormatter = LogStore.makeFormatter("yyyy-MM-dd") + private let timeFormatter = LogStore.makeFormatter("yyyy-MM-dd HH:mm:ss.SSS") + + var isEnabled: Bool { lock.withLock { _enabled } } + var isVerbose: Bool { lock.withLock { _verbose } } + + func configure(enabled: Bool, directory: URL, retention: TimeInterval?) { + lock.withLock { + _enabled = enabled + self.directory = directory + } + queue.async { [weak self] in + self?.closeHandle() // directory may have changed + if enabled, let retention { self?.prune(retention: retention) } + } + } + + func setVerbose(_ verbose: Bool) { + lock.withLock { _verbose = verbose } + } + + func write(level: Log.Level, category: Log.Category, message: String, date: Date) { + let dir = lock.withLock { directory } + queue.async { [weak self] in + guard let self else { return } + let line = "\(self.timeFormatter.string(from: date)) [\(level.tag)] [\(category.rawValue)] \(message)" + self.append(line, in: dir, date: date) + } + } + + func flush() { queue.sync {} } + + // MARK: File I/O (queue only) + + private func append(_ line: String, in dir: URL, date: Date) { + let day = dayFormatter.string(from: date) + if handleDay != day { closeHandle() } + if handle == nil { + guard let h = openHandle(dir: dir, day: day) else { return } + handle = h + handleDay = day + } + guard let data = (line + "\n").data(using: .utf8) else { return } + do { + try handle?.write(contentsOf: data) + } catch { + // The file may have been moved or deleted out from under us; reopen once. + closeHandle() + if let h = openHandle(dir: dir, day: day) { + handle = h + handleDay = day + try? h.write(contentsOf: data) + } + } + } + + private func openHandle(dir: URL, day: String) -> FileHandle? { + let fm = FileManager.default + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("edmund-\(day).log") + if !fm.fileExists(atPath: url.path) { + fm.createFile(atPath: url.path, contents: nil) + } + guard let h = try? FileHandle(forWritingTo: url) else { return nil } + try? h.seekToEnd() + return h + } + + private func closeHandle() { + try? handle?.close() + handle = nil + handleDay = nil + } + + private func prune(retention: TimeInterval) { + let fm = FileManager.default + let dir = lock.withLock { directory } + guard let urls = try? fm.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } + let cutoff = Date().addingTimeInterval(-retention) + for url in urls where url.lastPathComponent.hasPrefix("edmund-") && url.pathExtension == "log" { + let modified = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + if let modified, modified < cutoff { + try? fm.removeItem(at: url) + } + } + } + + private static func makeFormatter(_ format: String) -> DateFormatter { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = format + return f + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift b/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift new file mode 100644 index 0000000..4412ec1 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+BlockquoteContinuation.swift @@ -0,0 +1,59 @@ +import AppKit + +// MARK: - Block-quote / Callout Continuation on Enter +// +// Pressing Return inside a block quote (including a callout) repeats the quote +// prefix on the next line — `> ` — so the quote/callout keeps going, the same +// way list items auto-continue. Pressing Return on an *empty* quote line removes +// the prefix and breaks out of the quote. + +extension EditorTextView { + + /// Leading indent + one or more `>` levels (each with an optional single + /// space), at the start of a line. Captures the *full* nesting depth so a + /// nested callout/quote line `> > …` continues as `> > `, not `> `. + private static let blockquotePrefixRegex = try! NSRegularExpression( + pattern: #"^[ \t]*(?:>[ \t]?)+"# + ) + + /// Continues a block quote / callout on Return. Returns true if it handled + /// the newline. + func handleBlockquoteNewline(at location: Int) -> Bool { + let ns = rawSource as NSString + guard location <= ns.length else { return false } + + // The line containing the cursor (without its trailing newline). + let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) + var lineEnd = lineRange.upperBound + if lineEnd > lineRange.location, ns.character(at: lineEnd - 1) == 0x0A { lineEnd -= 1 } + let lineStart = lineRange.location + let line = ns.substring(with: NSRange(location: lineStart, length: lineEnd - lineStart)) + let lineNS = line as NSString + + guard let m = Self.blockquotePrefixRegex.firstMatch( + in: line, range: NSRange(location: 0, length: lineNS.length)) else { return false } + + let prefix = lineNS.substring(with: m.range) + let hasContent = m.range.length < lineNS.length + + if hasContent { + // Continue the quote/callout at the same depth: newline + same prefix. + insertText("\n" + prefix, replacementRange: NSRange(location: location, length: 0)) + } else { + // Empty quote line → step out one nesting level (drop the last `>`). + // At the top level this empties the line, breaking out of the quote. + insertText(Self.reduceQuotePrefix(prefix), + replacementRange: NSRange(location: lineStart, length: lineEnd - lineStart)) + } + return true + } + + /// Drops the deepest `>` level from a quote prefix: `> > ` → `> `, + /// ` > ` → ` ` (indent kept), `> ` → `` (broken out). + static func reduceQuotePrefix(_ prefix: String) -> String { + let ns = prefix as NSString + let lastGT = ns.range(of: ">", options: .backwards) + guard lastGT.location != NSNotFound else { return "" } + return ns.substring(to: lastGT.location) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift b/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift new file mode 100644 index 0000000..1e9b613 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+FormattingCommands.swift @@ -0,0 +1,503 @@ +import AppKit + +// MARK: - Format-menu actions +// +// Public @objc action methods targeted by the Format menu (nil-target items +// route through the responder chain to the focused editor — the same wiring as +// undo/redo). Each delegates to a primitive/helper in +FormattingCore. +// +// ## Invertibility +// +// Most format commands are toggles: applying twice returns to the original text +// and cursor position. Exceptions: +// • Checklist (⌘L) — NOT invertible. Re-applying cycles [ ] ↔ [x] instead of +// removing the checklist marker. +// • Footnote — NOT invertible. Inserts a new [^n] each time. +// • Table — NOT a toggle. Always inserts a fresh placeholder table. +// +// ## Caret repositioning +// +// After wrap-on (no selection or wrap-off leaves content selected): +// • Symmetric inline styles (Bold, Italic, …): caret placed on the first char +// of the wrapped content — so the next keystroke edits inside the delimiters. +// • Link/Image (no selection): caret lands inside the `()` so the URL can be +// typed immediately. +// • Link/Wikilink/Image (selection or word expansion): caret inside `()` or +// after content. +// • Footnote: caret at the end-of-file definition line, ready to type the note. +// • Math Block / Code Block: caret on the opening-fence line (language / math +// content). +// • Table: caret on the first header cell. +// +// ## List replacement +// +// Applying any list format (Bulleted / Numbered / Checklist) to a line that +// already carries a different list marker strips the old marker first (via +// stripListPrefix), so lists replace rather than nest: +// `- [ ] task` → ⌥⌘B → `- task` (not `- - [ ] task`) +// `1. item` → ⌘L → `- [ ] item` +// +// ## Whitespace stripping +// +// Inline wraps (Bold, Italic, Code, etc.) exclude leading/trailing spaces from +// the delimiters: selecting " word " and pressing Cmd+B gives " **word** ", not +// "** word **". Toggle-off detection also operates on the trimmed range, so +// selecting " **word** " and pressing Cmd+B correctly unwraps. + +extension EditorTextView { + + // MARK: - Inline font styles + // All toggle (applying twice restores original). + // + // Caret-with-no-selection behaviour: a caret INSIDE a word acts on the whole + // word (`anyth|ing` + Cmd+B → `**anything**`); pressing again unwraps it. When + // the caret is not in a word (blank line, between punctuation), empty delimiters + // are inserted with the caret centred (`**|**`). + // + // Bold and Italic additionally use markdown's `*`-nesting semantics at a caret, + // so the two compose: `**w**` + Cmd+I → `***w***`, and `***w***` + Cmd+B → + // `*w*`. See toggleStarEmphasis. The other styles use the generic word wrap + // (expandToWord), since they don't nest with each other. + + @objc public func formatBold(_ sender: Any?) { toggleStarEmphasis(stars: 2) } + @objc public func formatItalic(_ sender: Any?) { toggleStarEmphasis(stars: 1) } + @objc public func formatUnderline(_ sender: Any?) { toggleInlineWrap(open: "", close: "", expandToWord: true) } + @objc public func formatStrikethrough(_ sender: Any?) { toggleInlineWrap(open: "~~", close: "~~", expandToWord: true) } + @objc public func formatHighlight(_ sender: Any?) { toggleInlineWrap(open: "==", close: "==", expandToWord: true) } + @objc public func formatCode(_ sender: Any?) { toggleInlineWrap(open: "`", close: "`", expandToWord: true) } + @objc public func formatInlineMath(_ sender: Any?) { toggleInlineWrap(open: "$", close: "$", expandToWord: true) } + @objc public func formatKeyboard(_ sender: Any?) { toggleInlineWrap(open: "", close: "", expandToWord: true) } + @objc public func formatComment(_ sender: Any?) { toggleInlineWrap(open: "%%", close: "%%", expandToWord: true) } + + // MARK: - Inline links + // Link / Image: caret in `()` so URL can be typed next. + // Wikilink: expands to the current word at caret (expandToWord: true). + // Footnote: NOT invertible — inserts [^n] marker and EOF definition. + + @objc public func formatWikilink(_ sender: Any?) { toggleInlineWrap(open: "[[", close: "]]", expandToWord: true) } + @objc public func formatLink(_ sender: Any?) { insertLink() } + @objc public func formatImage(_ sender: Any?) { insertImage() } + @objc public func formatFootnote(_ sender: Any?) { insertFootnote() } + + // MARK: - Block-level commands + + @objc public func formatBulletedList(_ sender: Any?) { toggleLinePrefix("- ") } + @objc public func formatNumberedList(_ sender: Any?) { toggleNumberedList() } + @objc public func formatChecklist(_ sender: Any?) { toggleChecklist() } + @objc public func formatBlockQuote(_ sender: Any?) { toggleLinePrefix("> ") } + @objc public func formatThematicBreak(_ sender: Any?) { insertThematicBreak() } + @objc public func formatCodeBlock(_ sender: Any?) { insertCodeBlock() } + @objc public func formatMathBlock(_ sender: Any?) { insertMathBlock() } + @objc public func formatTable(_ sender: Any?) { insertTable() } + + /// Heading level read from the menu item's `tag` (1–6). + /// Heading H1–H6: strips any existing `#…` prefix and applies the new level. + /// Re-applying the same level clears the heading. Applies per selected line. + @objc public func formatHeading(_ sender: Any?) { + applyHeadingLevel((sender as? NSMenuItem)?.tag ?? 1) + } + + /// Callout type read from the menu item's `representedObject` (pre-cased: + /// uppercase for GitHub alerts, lowercase for Obsidian callouts). + @objc public func formatCallout(_ sender: Any?) { + guard let type = (sender as? NSMenuItem)?.representedObject as? String else { return } + applyCalloutType(type) + } + + // MARK: - Menu validation + // Formatting actions are disabled in Reading mode (the editor is read-only). + + public override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if let action = menuItem.action, Self.formattingActions.contains(action) { + return viewMode != .reading + } + return super.validateMenuItem(menuItem) + } + + static let formattingActions: Set = [ + #selector(formatBold(_:)), #selector(formatItalic(_:)), #selector(formatUnderline(_:)), + #selector(formatStrikethrough(_:)), #selector(formatHighlight(_:)), #selector(formatCode(_:)), + #selector(formatInlineMath(_:)), #selector(formatKeyboard(_:)), #selector(formatComment(_:)), + #selector(formatWikilink(_:)), #selector(formatLink(_:)), #selector(formatImage(_:)), + #selector(formatFootnote(_:)), #selector(formatBulletedList(_:)), #selector(formatNumberedList(_:)), + #selector(formatChecklist(_:)), #selector(formatBlockQuote(_:)), #selector(formatThematicBreak(_:)), + #selector(formatCodeBlock(_:)), #selector(formatMathBlock(_:)), #selector(formatTable(_:)), + #selector(formatHeading(_:)), #selector(formatCallout(_:)), + ] + + // MARK: - Heading + + func applyHeadingLevel(_ level: Int) { + // All selected lines get the same heading level applied or cleared. + // "Same level" is determined by majority: if every non-empty line already + // has exactly `level` hashes, they are all cleared (toggle-off). + transformSelectedLines { lines in + let nonEmpty = lines.filter { !$0.isEmpty } + let allAtLevel = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.leadingHashCount($0) == level } + return lines.map { line in + guard !line.isEmpty else { return line } + let stripped = self.stripLeadingHashes(line) + return allAtLevel ? stripped : String(repeating: "#", count: level) + " " + stripped + } + } + } + + // MARK: - Lists / quote + + /// Prepend `prefix` to every line, or strip it when every non-empty line is + /// already that exact type (toggle-off). + /// + /// List replacement: for bullet prefixes (`"- "` etc.), any existing list + /// marker (checklist, numbered, other bullet) is stripped before the new one + /// is applied, so list types replace each other rather than stacking. Block + /// quotes (`"> "`) are not list markers; they stack normally. + /// + /// Toggle-off check: only fires when every non-empty line is EXACTLY this + /// bullet type — not checklists (which also start with `"- "` but are a + /// different type) or numbered lists. + func toggleLinePrefix(_ prefix: String) { + let isList = (prefix == "- " || prefix == "* " || prefix == "+ ") + transformSelectedLines { lines in + let nonEmpty = lines.filter { !$0.isEmpty } + let stripAll: Bool + if isList { + // Toggle-off only for exact bullets, not checklists or numbered. + stripAll = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.isBulletLine($0) && $0.hasPrefix(prefix) } + } else { + stripAll = !nonEmpty.isEmpty && nonEmpty.allSatisfy { $0.hasPrefix(prefix) } + } + if stripAll { + return lines.map { $0.hasPrefix(prefix) ? String($0.dropFirst(prefix.count)) : $0 } + } + if lines == [""] { return [prefix] } + return lines.map { line -> String in + guard !line.isEmpty else { return line } + // Strip existing list marker before adding new one (replacement, not nesting). + return isList ? prefix + self.stripListPrefix(line) : prefix + line + } + } + } + + /// Numbered list: prepend `1.`, `2.`, … per line (toggle-off strips the + /// prefix). If the line immediately before the selection ends with `N. `, + /// numbering continues from N+1 rather than restarting at 1. + /// Replacement: strips any existing list marker before numbering. + func toggleNumberedList() { + let ns = rawSource as NSString + let ctx = selectedLineContext() + + var start = 1 + if ctx.range.location > 0 { + let prev = ns.lineRange(for: NSRange(location: ctx.range.location - 1, length: 0)) + var prevLine = ns.substring(with: prev) + if prevLine.hasSuffix("\n") { prevLine.removeLast() } + if let n = leadingListNumber(prevLine) { start = n + 1 } + } + + transformSelectedLines { lines in + let nonEmpty = lines.filter { !$0.isEmpty } + let allNumbered = !nonEmpty.isEmpty && nonEmpty.allSatisfy { self.leadingListNumber($0) != nil } + if allNumbered { + return lines.map { self.stripListPrefix($0) } + } + if lines == [""] { return ["\(start). "] } + var n = start + return lines.map { line -> String in + guard !line.isEmpty else { return line } + defer { n += 1 } + return "\(n). " + self.stripListPrefix(line) + } + } + } + + /// Checklist (NOT invertible): + /// • Checklist line: toggles the mark `[ ]` ↔ `[x]`. + /// • Any other line (plain, bullet, numbered): strips the existing list + /// marker and prepends `- [ ] ` (replacement, not nesting). + func toggleChecklist() { + transformSelectedLines { lines in + lines.map { line in + if self.isChecklistLine(line) { + let ns = line as NSString + let mark = ns.character(at: 3) + let newMark = (mark == 0x20) ? "x" : " " // ' ' ↔ 'x' + return "- [" + newMark + "] " + ns.substring(from: 6) + } + // Strip existing list marker before adding checklist prefix. + return "- [ ] " + self.stripListPrefix(line) + } + } + } + + // MARK: - Link / Image / Footnote + + /// Link (⌘K): toggle. + /// • With selection: wraps as `[selection]()`, caret in `()`. If the selection + /// is already `[text](dest)`, unwraps to the text. + /// • No selection: if caret is inside an existing `[text](url)`, unwraps it. + /// Otherwise expands to the current word, producing `[word]()`, caret in `()`. + /// Fallback (no word): inserts `[]()`, caret in `()`. + private func insertLink() { + let ns = rawSource as NSString + let sel = selectedRange() + + if sel.length > 0 { + let text = ns.substring(with: sel) + if let inner = unwrapLink(text) { + applyFormattingEdit(rawRange: sel, replacement: inner, + select: NSRange(location: sel.location, length: (inner as NSString).length)) + return + } + let replacement = "[" + text + "]()" + let caret = sel.location + 1 + (text as NSString).length + 2 // inside () + applyFormattingEdit(rawRange: sel, replacement: replacement, + select: NSRange(location: caret, length: 0)) + return + } + + // Caret: check if inside an existing link → unwrap. + if let linkRange = linkRangeAroundCaret() { + let linkText = ns.substring(with: linkRange) + if let inner = unwrapLink(linkText) { + applyFormattingEdit(rawRange: linkRange, replacement: inner, + select: NSRange(location: linkRange.location, length: (inner as NSString).length)) + return + } + } + + // Expand to current word. + if let word = currentWordRange() { + let wordText = ns.substring(with: word) + let replacement = "[" + wordText + "]()" + let caret = word.location + 1 + (wordText as NSString).length + 2 // inside () + applyFormattingEdit(rawRange: word, replacement: replacement, + select: NSRange(location: caret, length: 0)) + return + } + + // No word: insert empty link, caret inside (). + applyFormattingEdit(rawRange: NSRange(location: sel.location, length: 0), + replacement: "[]()", + select: NSRange(location: sel.location + 3, length: 0)) + } + + /// Image: same shape as Link, prefixed with `!`. + /// Caret ends up inside the `()` so the URL/path can be typed. + private func insertImage() { + let ns = rawSource as NSString + let sel = selectedRange() + + if sel.length > 0 { + let text = ns.substring(with: sel) + if let inner = unwrapImage(text) { + applyFormattingEdit(rawRange: sel, replacement: inner, + select: NSRange(location: sel.location, length: (inner as NSString).length)) + return + } + let replacement = "![" + text + "]()" + let caret = sel.location + 2 + (text as NSString).length + 2 // inside () + applyFormattingEdit(rawRange: sel, replacement: replacement, + select: NSRange(location: caret, length: 0)) + return + } + + if let word = currentWordRange() { + let wordText = ns.substring(with: word) + let replacement = "![" + wordText + "]()" + let caret = word.location + 2 + (wordText as NSString).length + 2 // inside () + applyFormattingEdit(rawRange: word, replacement: replacement, + select: NSRange(location: caret, length: 0)) + return + } + + applyFormattingEdit(rawRange: NSRange(location: sel.location, length: 0), + replacement: "![]()", + select: NSRange(location: sel.location + 4, length: 0)) + } + + /// Footnote (NOT invertible): inserts `[^n]` after the selection / end of + /// current word / caret, then appends `[^n]: ` at the end of the document. + /// Caret lands at the EOF definition so the note body can be typed immediately. + /// `n` is the next unused number (max existing [^k] + 1, starting at 1). + private func insertFootnote() { + let ns = rawSource as NSString + let sel = selectedRange() + let n = nextFootnoteNumber() + + // Insertion point: after selection, or after the current word, or at caret. + let markerPos: Int + if sel.length > 0 { + markerPos = sel.upperBound + } else if let word = currentWordRange() { + markerPos = word.upperBound + } else { + markerPos = sel.location + } + + var newRaw = ns.replacingCharacters(in: NSRange(location: markerPos, length: 0), + with: "[^\(n)]") + let body = newRaw as NSString + // A blank line (not just a single \n) before the definition so it parses + // as its own paragraph rather than a lazy-continuation line of the + // reference's paragraph — CommonMark (and Read mode's HTMLRenderer, which + // parses the whole document) needs that separation to recognize it as a + // footnote definition rather than fused body text. + var trailingNewlines = 0 + var i = body.length - 1 + while i >= 0 && body.character(at: i) == 0x0A { trailingNewlines += 1; i -= 1 } + let separator = body.length == 0 ? "" : String(repeating: "\n", count: max(0, 2 - trailingNewlines)) + newRaw += separator + "[^\(n)]: " + let caret = (newRaw as NSString).length + applyWholeDocumentEdit(newRawSource: newRaw, select: NSRange(location: caret, length: 0)) + } + + // MARK: - Block insert + + /// Code block: wraps selected lines in ` ``` `…` ``` ` fences (toggle-off removes + /// them). Caret lands on the opening-fence line so a language tag can be typed. + private func insertCodeBlock() { + let ctx = selectedLineContext() + if ctx.lines.count >= 2, ctx.lines.first!.hasPrefix("```"), ctx.lines.last! == "```" { + // Toggle off: unwrap the fenced content. + let inner = ctx.lines.dropFirst().dropLast().joined(separator: "\n") + var replacement = inner + if ctx.trailingNewline { replacement += "\n" } + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, + select: NSRange(location: ctx.range.location, length: 0)) + return + } + let content = ctx.lines.joined(separator: "\n") + var replacement = "```\n" + content + "\n```" + if ctx.trailingNewline { replacement += "\n" } + // Caret after the opening "```" so a language tag can be typed. + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, + select: NSRange(location: ctx.range.location + 3, length: 0)) + } + + /// Math block: wraps selected lines in `$$`…`$$` fences (toggle-off removes + /// them). Each `$$` occupies its own line (block math format). + /// Caret lands on the first content line between the fences. + private func insertMathBlock() { + let ctx = selectedLineContext() + if ctx.lines.count >= 2, ctx.lines.first! == "$$", ctx.lines.last! == "$$" { + let inner = ctx.lines.dropFirst().dropLast().joined(separator: "\n") + var replacement = inner + if ctx.trailingNewline { replacement += "\n" } + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, + select: NSRange(location: ctx.range.location, length: 0)) + return + } + let content = ctx.lines.joined(separator: "\n") + var replacement = "$$\n" + content + "\n$$" + if ctx.trailingNewline { replacement += "\n" } + // Caret on the first content line (after the opening "$$\n"). + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, + select: NSRange(location: ctx.range.location + 3, length: 0)) + } + + /// Table: inserts a 3×2 placeholder table (3 columns, 2 data rows) after the + /// current line. Not a toggle. Dividers are padded to match header widths (8 chars). + /// Caret lands on the first header cell. + private func insertTable() { + let ns = rawSource as NSString + let sel = selectedRange() + let line = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) + let lineEndsWithNewline = line.upperBound > line.location && ns.character(at: line.upperBound - 1) == 0x0A + let lineIsBlank = ns.substring(with: line).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + // 3 columns; dividers padded to "Header N" length (8 dashes). + let table = """ + | Header 1 | Header 2 | Header 3 | + | -------- | -------- | -------- | + | Cell 1 | Cell 2 | Cell 3 | + | Cell 4 | Cell 5 | Cell 6 | + + """ + let insertPos: Int + let replacement: String + let lead: Int + if lineIsBlank { + insertPos = line.location + replacement = table + lead = 0 + } else if lineEndsWithNewline { + insertPos = line.upperBound + replacement = table + lead = 0 + } else { + insertPos = ns.length + replacement = "\n" + table + lead = 1 + } + let caret = insertPos + lead + 2 // past leading newline (if any) + "| " + applyFormattingEdit(rawRange: NSRange(location: insertPos, length: 0), + replacement: replacement, + select: NSRange(location: caret, length: 0)) + } + + // MARK: - Thematic break + + /// Thematic break (horizontal rule): inserts `---` on its own line. + /// Toggle-off: if the selected line is exactly `---`, removes it. + /// + /// Insertion rules: + /// • Empty line — replace the line with `---\n` (the caret is already on + /// an isolated line, so no separator is needed). + /// • Non-empty line — insert `\n---\n` after the line end; prepend an + /// extra `\n` when the line has no trailing newline (last line, no EOF + /// newline) so the `---` isn't parsed as a setext heading underline. + /// + /// Caret lands after `---\n` so the next paragraph can be typed immediately. + private func insertThematicBreak() { + let ns = rawSource as NSString + let ctx = selectedLineContext() + + if ctx.lines == ["---"] { + applyFormattingEdit(rawRange: ctx.range, replacement: "", + select: NSRange(location: ctx.range.location, length: 0)) + return + } + + let sel = selectedRange() + let line = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) + + // Empty line: replace just the line content with "---\n". + if ctx.lines == [""] { + let replacement = "---\n" + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, + select: NSRange(location: ctx.range.location + 3, length: 0)) + return + } + + // Non-empty line: insert after the line end. + let hasTrailingNewline = line.upperBound > line.location + && ns.character(at: line.upperBound - 1) == 0x0A + let text = hasTrailingNewline ? "\n---\n" : "\n\n---\n" + let insertAt = line.upperBound + let caret = insertAt + (text as NSString).length + applyFormattingEdit(rawRange: NSRange(location: insertAt, length: 0), + replacement: text, + select: NSRange(location: min(caret, (rawSource as NSString).length), length: 0)) + } + + // MARK: - Callout / alert + + /// Wrap the selected lines in a callout (`> [!TYPE]` header + `> ` body), or + /// strip it when the lines are already that callout. `type` is pre-cased + /// (uppercase for GitHub, lowercase for Obsidian). + func applyCalloutType(_ type: String) { + transformSelectedLines { lines in + let header = "> [!\(type)]" + if let first = lines.first, + first.trimmingCharacters(in: .whitespaces).lowercased() == "> [!\(type.lowercased())]" { + return lines.dropFirst().map { + if $0.hasPrefix("> ") { return String($0.dropFirst(2)) } + if $0.hasPrefix(">") { return String($0.dropFirst(1)) } + return $0 + } + } + let body = lines.map { $0.isEmpty ? ">" : "> " + $0 } + return [header] + body + } + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift b/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift new file mode 100644 index 0000000..6eefb16 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+FormattingCore.swift @@ -0,0 +1,527 @@ +import AppKit + +// MARK: - Formatting primitives & helpers +// +// The Format-menu commands (EditorTextView+FormattingCommands) all funnel +// through the two edit primitives here. They follow the same template as the +// Tab-indent path (EditorTextView+Indentation): push a single undo snapshot, +// rebuild `rawSource`, re-parse blocks, and restyle only the affected span via +// `recomposeReplacing` — preserving the hard invariant that the text storage +// always equals `rawSource` (rendering is attribute-only). + +extension EditorTextView { + + // MARK: - Edit primitives + + /// Replace one contiguous `rawRange` (in current rawSource coordinates) with + /// `replacement` as a single undoable step, restyle the affected block span + /// in place, and set `select` (a caret when `length == 0`). + func applyFormattingEdit(rawRange: NSRange, replacement: String, select: NSRange) { + guard !blocks.isEmpty else { return } + let ns = rawSource as NSString + let loc = min(max(0, rawRange.location), ns.length) + let clamped = NSRange(location: loc, length: min(rawRange.length, ns.length - loc)) + + guard let startBlock = blockIndexForRawOffset(clamped.location), + let endBlock = blockIndexForRawOffset(clamped.upperBound) else { return } + + // Pre-edit storage span covering exactly the affected blocks, so layout + // (and the viewport) above/below the edit stays put. + let oldSpan = NSRange( + location: blocks[startBlock].range.location, + length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location) + + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: selectedRange().location)) + redoStack.removeAll() + lastEditType = .other + lastEditBlockIndex = nil + + rawSource = ns.replacingCharacters(in: clamped, with: replacement) + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource, previous: blocks) + + // The replaced region grew/shrank by `delta`; the new block-aligned span + // is the old span plus that delta. Its text is the storage replacement. + let delta = (replacement as NSString).length - clamped.length + let newSpan = NSRange(location: oldSpan.location, length: max(0, oldSpan.length + delta)) + let newRaw = rawSource as NSString + let safeSpan = NSRange(location: min(newSpan.location, newRaw.length), + length: min(newSpan.length, newRaw.length - min(newSpan.location, newRaw.length))) + let newText = newRaw.substring(with: safeSpan) + + let lastPos = safeSpan.length > 0 ? safeSpan.upperBound - 1 : safeSpan.location + let newStart = blockIndexForRawOffset(safeSpan.location) ?? 0 + let newEnd = blockIndexForRawOffset(lastPos) ?? newStart + let dirty = IndexSet(integersIn: newStart...min(newEnd, blocks.count - 1)) + + let sel = NSRange(location: min(select.location, newRaw.length), + length: min(select.length, newRaw.length - min(select.location, newRaw.length))) + stabilizingViewport { + recomposeReplacing(oldRange: oldSpan, with: newText, dirty: dirty, + cursorInRaw: sel.location, + selectionInRaw: sel.length > 0 ? sel : nil) + } + document?.updateChangeCount(.changeDone) + } + + /// Replace the whole document as one undoable step (for non-contiguous edits + /// like footnotes: an inline marker plus an end-of-file definition). + func applyWholeDocumentEdit(newRawSource: String, select: NSRange) { + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: selectedRange().location)) + redoStack.removeAll() + lastEditType = .other + lastEditBlockIndex = nil + + rawSource = newRawSource + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource, previous: blocks) + + let len = (rawSource as NSString).length + let loc = min(select.location, len) + let sel = NSRange(location: loc, length: min(select.length, len - loc)) + recompose(cursorInRaw: sel.location, selectionInRaw: sel.length > 0 ? sel : nil) + document?.updateChangeCount(.changeDone) + } + + // MARK: - Line-range helpers + + /// The full lines covered by the current selection (or caret), their + /// contents split on `\n`, and whether the range ends in a trailing newline. + func selectedLineContext() -> (range: NSRange, lines: [String], trailingNewline: Bool) { + let ns = rawSource as NSString + let sel = selectedRange() + let startLine = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) + let lastChar = sel.length > 0 ? max(sel.location, sel.upperBound - 1) : sel.location + let endLine = ns.lineRange(for: NSRange(location: min(lastChar, ns.length), length: 0)) + let range = NSRange(location: startLine.location, + length: endLine.upperBound - startLine.location) + let text = ns.substring(with: range) + let trailing = text.hasSuffix("\n") + var lines = text.components(separatedBy: "\n") + if trailing { lines.removeLast() } + return (range, lines, trailing) + } + + /// Apply a per-line `transform` over the selected line range. + /// + /// Caret repositioning: the caret tracks the first line, shifted by however + /// many characters that line's prefix changed by (e.g. adding "- " moves the + /// caret two positions right). With a multi-line selection the whole new + /// content is re-selected (excluding any trailing newline). + func transformSelectedLines(_ transform: ([String]) -> [String]) { + let sel = selectedRange() + let ctx = selectedLineContext() + let newLines = transform(ctx.lines) + var replacement = newLines.joined(separator: "\n") + if ctx.trailingNewline { replacement += "\n" } + + let select: NSRange + if sel.length > 0 { + let len = (replacement as NSString).length - (ctx.trailingNewline ? 1 : 0) + select = NSRange(location: ctx.range.location, length: max(0, len)) + } else { + let oldFirst = (ctx.lines.first.map { ($0 as NSString).length }) ?? 0 + let newFirst = (newLines.first.map { ($0 as NSString).length }) ?? 0 + let caretInLine = sel.location - ctx.range.location + let newCaretInLine = min(max(0, caretInLine + (newFirst - oldFirst)), newFirst) + select = NSRange(location: ctx.range.location + newCaretInLine, length: 0) + } + applyFormattingEdit(rawRange: ctx.range, replacement: replacement, select: select) + } + + // MARK: - Inline wrap (toggle) + + /// Wrap the selection (or caret) in `open`…`close`, or unwrap when already wrapped. + /// + /// ## Whitespace stripping + /// Leading and trailing spaces are excluded from the delimiters, so selecting + /// `" word "` and pressing Cmd+B yields `" **word** "`, not `"** word **"`. + /// Toggle-off detection also uses the trimmed range. + /// + /// ## Toggle-off detection (selection path) + /// Three checks, tried in order: + /// 1. Delimiter pair sits immediately around the trimmed selection. An isolation + /// guard prevents a false match when the selection content (`"word"` after Cmd+B) + /// is inside a LONGER delimiter run: e.g. `*` at position 1 of `**word**` is not + /// an italic delimiter — it is the inner character of the bold `**`. Without the + /// guard, Cmd+B → Cmd+I would toggle italic OFF instead of adding it, producing + /// `*word*` rather than `***word***`. + /// 2. The trimmed selection itself starts and ends with the delimiter strings + /// (user selected `**word**` and pressed Cmd+B). + /// + /// ## Toggle-off detection (caret path) + /// Three checks, tried in order: + /// 1. Empty delimiters straddle the caret → remove them. + /// 2. The current word is wrapped by `open`/`close` (nearest-neighbour search) → + /// unwrap. No isolation guard here, so peeling one layer from `***word***` works: + /// Cmd+B finds `**` at word.location-2 and correctly removes it. + /// 3. Fallback: insert empty `open+close` with the caret centred. When + /// `expandToWord` is true, the current word is wrapped instead. + func toggleInlineWrap(open: String, close: String, expandToWord: Bool = false) { + let ns = rawSource as NSString + let sel = selectedRange() + let openLen = (open as NSString).length + let closeLen = (close as NSString).length + + if sel.length > 0 { + let selText = ns.substring(with: sel) + + // Whitespace stripping: exclude leading/trailing spaces from the wrap + // so " word " → " **word** " instead of "** word **". + let leading = selText.prefix(while: { $0 == " " || $0 == "\t" }).count + let trailing = selText.reversed().prefix(while: { $0 == " " || $0 == "\t" }).count + let hasContent = leading + trailing < sel.length + let effLead = hasContent ? leading : 0 + let effTrail = hasContent ? trailing : 0 + let trimmedSel = NSRange(location: sel.location + effLead, + length: sel.length - effLead - effTrail) + let trimmedText = ns.substring(with: trimmedSel) + + // Check 1: delimiters sit immediately around the trimmed selection. + // The isolation guard rejects matches where the found delimiter is part of + // a longer run: e.g. the `*` at offset 1 of `**word**` is the inner char + // of `**`, not a standalone `*`. Without the guard, selecting the bare + // content of a bold word and pressing Cmd+I would fire here and unwrap + // instead of compounding to `***word***`. + let before = trimmedSel.location - openLen + if before >= 0, trimmedSel.upperBound + closeLen <= ns.length, + ns.substring(with: NSRange(location: before, length: openLen)) == open, + ns.substring(with: NSRange(location: trimmedSel.upperBound, length: closeLen)) == close, + delimiterIsIsolated(open: open, close: close, + openAt: before, closeAt: trimmedSel.upperBound, in: ns) { + let full = NSRange(location: before, length: openLen + trimmedSel.length + closeLen) + applyFormattingEdit(rawRange: full, replacement: trimmedText, + select: NSRange(location: before, length: trimmedSel.length)) + return + } + + // Check 2: the trimmed selection itself IS the wrapped text. + let trimmedLen = (trimmedText as NSString).length + if trimmedLen >= openLen + closeLen, + trimmedText.hasPrefix(open), trimmedText.hasSuffix(close) { + let innerLen = trimmedLen - openLen - closeLen + let inner = (trimmedText as NSString).substring(with: NSRange(location: openLen, length: innerLen)) + applyFormattingEdit(rawRange: trimmedSel, replacement: inner, + select: NSRange(location: trimmedSel.location, length: innerLen)) + return + } + + // Wrap on — apply only to the non-whitespace content, leaving leading/ + // trailing spaces outside the delimiters. + let leadStr = String(selText.prefix(effLead)) + let trailStr = String(selText.suffix(effTrail)) + let replacement = leadStr + open + trimmedText + close + trailStr + // Caret: inside the new delimiters, on the first char of the content. + applyFormattingEdit(rawRange: sel, replacement: replacement, + select: NSRange(location: sel.location + effLead + openLen, + length: trimmedSel.length)) + return + } + + let caret = sel.location + // Caret check 1: empty delimiters straddle the caret → remove. + // E.g. `**|**` → pressing Cmd+B again removes the pair. + if caret - openLen >= 0, caret + closeLen <= ns.length, + ns.substring(with: NSRange(location: caret - openLen, length: openLen)) == open, + ns.substring(with: NSRange(location: caret, length: closeLen)) == close { + applyFormattingEdit(rawRange: NSRange(location: caret - openLen, length: openLen + closeLen), + replacement: "", + select: NSRange(location: caret - openLen, length: 0)) + return + } + // Caret check 2: nearest word is wrapped → unwrap (nearest-neighbour, no + // isolation guard so peeling one layer from `***word***` works correctly). + // E.g. caret in `***word***` + Cmd+B: finds `**` at word.location-2 and peels + // it, giving `*word*`. The same caret + Cmd+I finds `*` at word.location-1 + // and peels that, giving `**word**`. + if let word = currentWordRange() { + let before = word.location - openLen + if before >= 0, word.upperBound + closeLen <= ns.length, + ns.substring(with: NSRange(location: before, length: openLen)) == open, + ns.substring(with: NSRange(location: word.upperBound, length: closeLen)) == close { + let inner = ns.substring(with: word) + let full = NSRange(location: before, length: openLen + word.length + closeLen) + // Caret lands where it was but shifted left by openLen (delimiters removed). + applyFormattingEdit(rawRange: full, replacement: inner, + select: NSRange(location: caret - openLen, length: 0)) + return + } + // Caret check 2b: no wrapping found — wrap the whole word under the caret. + // Used by the symmetric inline styles (Bold, Italic, …) and Wikilink so + // that `anyth|ing` + Cmd+B → `**anyth|ing**`. The caret keeps its position + // within the word (shifted right by the opening delimiter), so the word + // text isn't disturbed and a second press re-detects + unwraps it. + if expandToWord { + let wordText = ns.substring(with: word) + let replacement = open + wordText + close + applyFormattingEdit(rawRange: word, replacement: replacement, + select: NSRange(location: caret + openLen, length: 0)) + return + } + } + // Fallback: insert empty delimiters; caret centred between them. + applyFormattingEdit(rawRange: NSRange(location: caret, length: 0), + replacement: open + close, + select: NSRange(location: caret + openLen, length: 0)) + } + + /// Toggle `*`-based emphasis using markdown's nesting semantics, where the run + /// of `*` around a span encodes both styles (1 = italic, 2 = bold, 3 = both). + /// `stars` is 2 for Bold, 1 for Italic. This lets the two compose at a caret: + /// + /// plain → Cmd+B → `**w**` (bold on) + /// `**w**` → Cmd+I → `***w***` (italic added — compound) + /// `***w***` → Cmd+B → `*w*` (bold removed) + /// `***w***` → Cmd+I → `**w**` (italic removed) + /// + /// With a selection it defers to `toggleInlineWrap`, which already compounds + /// (selecting the inner text adds a layer) and peels (selecting a `**…**` span + /// strips it). With a bare caret it reads the symmetric run of `*` surrounding + /// the current word and adds/removes exactly `stars` of them; when the caret is + /// not in a word it inserts/removes empty delimiters like `toggleInlineWrap`. + func toggleStarEmphasis(stars: Int) { + let delim = String(repeating: "*", count: stars) + let sel = selectedRange() + if sel.length > 0 { + toggleInlineWrap(open: delim, close: delim) + return + } + + let ns = rawSource as NSString + let caret = sel.location + + // Empty delimiters straddle the caret → remove them. + if caret - stars >= 0, caret + stars <= ns.length, + ns.substring(with: NSRange(location: caret - stars, length: stars)) == delim, + ns.substring(with: NSRange(location: caret, length: stars)) == delim { + applyFormattingEdit(rawRange: NSRange(location: caret - stars, length: stars * 2), + replacement: "", select: NSRange(location: caret - stars, length: 0)) + return + } + + guard let word = currentWordRange() else { + // No word under the caret: insert empty delimiters, caret centred. + applyFormattingEdit(rawRange: NSRange(location: caret, length: 0), + replacement: delim + delim, + select: NSRange(location: caret + stars, length: 0)) + return + } + + // The symmetric run of `*` immediately surrounding the word. + var leftRun = 0 + while word.location - leftRun - 1 >= 0, + ns.character(at: word.location - leftRun - 1) == 0x2A { leftRun += 1 } + var rightRun = 0 + while word.upperBound + rightRun < ns.length, + ns.character(at: word.upperBound + rightRun) == 0x2A { rightRun += 1 } + let run = min(leftRun, rightRun) + + // Bold present iff ≥2 stars; italic present iff an odd star count (1 or 3). + let present = (stars == 2) ? (run >= 2) : (run % 2 == 1) + let newRun = present ? run - stars : run + stars + + let wordText = ns.substring(with: word) + let starsStr = String(repeating: "*", count: newRun) + let full = NSRange(location: word.location - run, length: run + word.length + run) + // Caret keeps its position within the word, shifted by the star-count change. + let newCaret = caret + (newRun - run) + applyFormattingEdit(rawRange: full, replacement: starsStr + wordText + starsStr, + select: NSRange(location: newCaret, length: 0)) + } + + /// Returns false when the delimiter at `openAt`/`closeAt` is part of a longer + /// run of the same character — indicating it is an inner char of a wider delimiter, + /// not a standalone one of the type we matched. + /// + /// Example: `*` at position 1 of `**word**` has `*` at position 0 to its left, + /// so it is NOT an isolated italic `*`; it is the inner character of the bold `**`. + /// + /// This guard is applied ONLY to the selection-path Check 1. The caret word-check + /// does NOT use it, so that pressing Cmd+B with the caret inside `***word***` + /// correctly finds and peels the `**` at word.location-2. + private func delimiterIsIsolated(open: String, close: String, + openAt: Int, closeAt: Int, in ns: NSString) -> Bool { + let openFirst = (open as NSString).character(at: 0) + let closeLen = (close as NSString).length + let closeLast = (close as NSString).character(at: closeLen - 1) + if openAt > 0, ns.character(at: openAt - 1) == openFirst { return false } + let afterClose = closeAt + closeLen + if afterClose < ns.length, ns.character(at: afterClose) == closeLast { return false } + return true + } + + /// The maximal run of alphanumerics around the caret, or nil when the caret + /// is not adjacent to a word character. + func currentWordRange() -> NSRange? { + let ns = rawSource as NSString + let caret = selectedRange().location + func isWord(_ at: Int) -> Bool { + guard let scalar = ns.substring(with: NSRange(location: at, length: 1)).unicodeScalars.first + else { return false } + return CharacterSet.alphanumerics.contains(scalar) + } + var start = caret + while start > 0, isWord(start - 1) { start -= 1 } + var end = caret + while end < ns.length, isWord(end) { end += 1 } + return end > start ? NSRange(location: start, length: end - start) : nil + } + + // MARK: - Markdown line helpers + + /// Number of leading `#` (1–6) when the line is an ATX heading (`#`s then a + /// space); 0 otherwise. + func leadingHashCount(_ line: String) -> Int { + let ns = line as NSString + var i = 0 + while i < ns.length, i < 6, ns.character(at: i) == 0x23 { i += 1 } // '#' + if i > 0, i < ns.length, ns.character(at: i) == 0x20 { return i } + return 0 + } + + func stripLeadingHashes(_ line: String) -> String { + let n = leadingHashCount(line) + guard n > 0 else { return line } + let ns = line as NSString + var j = n + while j < ns.length, ns.character(at: j) == 0x20 { j += 1 } + return ns.substring(from: j) + } + + /// The leading list number when the line is `N. `; nil otherwise. + func leadingListNumber(_ line: String) -> Int? { + let ns = line as NSString + var i = 0 + while i < ns.length, ns.character(at: i) >= 0x30, ns.character(at: i) <= 0x39 { i += 1 } + guard i > 0, i + 1 < ns.length, + ns.character(at: i) == 0x2E, ns.character(at: i + 1) == 0x20 else { return nil } + return Int(ns.substring(to: i)) + } + + func stripLeadingNumber(_ line: String) -> String { + guard leadingListNumber(line) != nil else { return line } + let ns = line as NSString + var i = 0 + while ns.character(at: i) != 0x2E { i += 1 } + return ns.substring(from: i + 2) // skip ". " + } + + /// The next unused footnote number (max existing `[^n]` + 1, starting at 1). + func nextFootnoteNumber() -> Int { + guard let re = try? NSRegularExpression(pattern: #"\[\^(\d+)\]"#) else { return 1 } + let ns = rawSource as NSString + var maxN = 0 + re.enumerateMatches(in: rawSource, range: NSRange(location: 0, length: ns.length)) { m, _, _ in + guard let m, m.numberOfRanges > 1, let n = Int(ns.substring(with: m.range(at: 1))) else { return } + maxN = max(maxN, n) + } + return maxN + 1 + } + + // MARK: - List-type helpers + + /// True when `line` is a checklist item (`- [ ] ` or `- [x] `). + func isChecklistLine(_ line: String) -> Bool { + let ns = line as NSString + return ns.length >= 6 + && ns.substring(to: 3) == "- [" + && ns.substring(with: NSRange(location: 4, length: 2)) == "] " + } + + /// True when `line` is a plain bullet (`- `, `* `, `+ `) but NOT a checklist. + func isBulletLine(_ line: String) -> Bool { + guard line.count >= 2 else { return false } + let start = String(line.prefix(2)) + return (start == "- " || start == "* " || start == "+ ") && !isChecklistLine(line) + } + + /// Strips any leading list marker (checklist, bullet, numbered) from `line`, + /// leaving just the content. Returns `line` unchanged if none is detected. + /// + /// Used by all three list commands to implement "replace" semantics: applying + /// any list type first strips the current marker so lists replace each other + /// instead of nesting (e.g. `- [ ] task` → Cmd+B → `- task`, not `- - [ ] task`). + func stripListPrefix(_ line: String) -> String { + if isChecklistLine(line) { return String(line.dropFirst(6)) } + if isBulletLine(line) { return String(line.dropFirst(2)) } + if leadingListNumber(line) != nil { return stripLeadingNumber(line) } + return line + } + + // MARK: - Link detection + + /// The range of the `[text](url)` link that contains the caret, or nil. + /// Handles carets in both the `[text]` and `(url)` parts. + func linkRangeAroundCaret() -> NSRange? { + let ns = rawSource as NSString + let caret = selectedRange().location + guard ns.length > 0, caret <= ns.length else { return nil } + + // Path A: caret is in [text]. Scan backward for '[', bail on ']'/newline. + var i = caret + while i > 0 { + i -= 1 + let c = ns.character(at: i) + if c == 0x5B { break } + if c == 0x5D || c == 0x0A { i = -1; break } + } + if i >= 0, i < ns.length, ns.character(at: i) == 0x5B { + if let r = linkRange(ns: ns, from: i, mustContain: caret) { return r } + } + + // Path B: caret is in (url). Scan backward for '(', then locate '[' before ']'. + var p = caret + while p > 0 { + p -= 1 + let c = ns.character(at: p) + if c == 0x28 { break } // '(' + if c == 0x0A { p = -1; break } + } + if p >= 0, ns.character(at: p) == 0x28, + p > 0, ns.character(at: p - 1) == 0x5D { // '(' preceded by ']' + var q = p - 2 + while q >= 0 { + let c = ns.character(at: q) + if c == 0x5B { break } + if c == 0x5D || c == 0x0A { q = -1; break } + q -= 1 + } + if q >= 0, ns.character(at: q) == 0x5B { + if let r = linkRange(ns: ns, from: q, mustContain: caret) { return r } + } + } + return nil + } + + private func linkRange(ns: NSString, from openBracket: Int, mustContain caret: Int) -> NSRange? { + var j = openBracket + 1 + while j < ns.length, ns.character(at: j) != 0x5D, ns.character(at: j) != 0x0A { j += 1 } + guard j < ns.length, ns.character(at: j) == 0x5D else { return nil } + guard j + 1 < ns.length, ns.character(at: j + 1) == 0x28 else { return nil } + var k = j + 2 + while k < ns.length, ns.character(at: k) != 0x29, ns.character(at: k) != 0x0A { k += 1 } + guard k < ns.length, ns.character(at: k) == 0x29 else { return nil } + let r = NSRange(location: openBracket, length: k - openBracket + 1) + return (caret >= r.location && caret <= r.upperBound) ? r : nil + } + + /// Returns the link text when `s` is exactly `[text](dest)`, else nil. + func unwrapLink(_ s: String) -> String? { + captureFirst(s, pattern: #"^\[([^\]]*)\]\([^)]*\)$"#) + } + + /// Returns the alt text when `s` is exactly `![alt](dest)`, else nil. + func unwrapImage(_ s: String) -> String? { + captureFirst(s, pattern: #"^!\[([^\]]*)\]\([^)]*\)$"#) + } + + private func captureFirst(_ s: String, pattern: String) -> String? { + guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } + let ns = s as NSString + guard let m = re.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)), + m.numberOfRanges > 1 else { return nil } + return ns.substring(with: m.range(at: 1)) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift b/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift new file mode 100644 index 0000000..93bf192 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+Indentation.swift @@ -0,0 +1,224 @@ +import AppKit + +// MARK: - Tab / Shift-Tab List Indentation +// +// Tab / Shift-Tab change the nesting of list items by adding or removing one +// indent unit of leading whitespace. They apply to every list block the +// selection touches (so a whole sub-list can be indented at once) and only kick +// in on list lines — elsewhere Tab inserts a literal tab as usual. + +extension EditorTextView { + + private static let listLineRegex = try! NSRegularExpression(pattern: #"^\s*(?:[-*+]|\d+\.)\s"#) + static let indentUnit = " " // 2 spaces + + /// Returns true if the line looks like a markdown list item + /// (optionally indented): `- `, `* `, `+ `, `1. `, etc. + func isListLine(_ line: String) -> Bool { + let range = NSRange(location: 0, length: (line as NSString).length) + return Self.listLineRegex.firstMatch(in: line, range: range) != nil + } + + // MARK: - Key Overrides + + public override func insertTab(_ sender: Any?) { + guard let (startBlock, endBlock) = affectedListBlockRange() else { + super.insertTab(sender) + return + } + indentListBlocks(from: startBlock, to: endBlock) + } + + public override func insertBacktab(_ sender: Any?) { + guard let (startBlock, endBlock) = affectedListBlockRange() else { + return + } + dedentListBlocks(from: startBlock, to: endBlock) + } + + // MARK: - Block Range Detection + + /// Returns the inclusive range of block indices covered by the current + /// selection, but only if every covered block is a list line. + private func affectedListBlockRange() -> (Int, Int)? { + let sel = selectedRange() + let rawStart = sel.location + let rawEnd = sel.location + sel.length + + guard let startIdx = blockIndexForRawOffset(rawStart), + var endIdx = blockIndexForRawOffset(rawEnd) else { + return nil + } + + // If the selection end lands exactly on the first character of a + // block, that block isn't meaningfully selected — exclude it. + if sel.length > 0 && endIdx > startIdx && endIdx < blocks.count + && rawEnd == blocks[endIdx].range.location { + endIdx -= 1 + } + + for i in startIdx...endIdx { + guard i < blocks.count, isListLine(blocks[i].content) else { + return nil + } + } + + return (startIdx, endIdx) + } + + // MARK: - Indent (Tab) + + private func indentListBlocks(from startBlock: Int, to endBlock: Int) { + let sel = selectedRange() + let rawStart = sel.location + let rawEnd = sel.location + sel.length + let indentLen = (Self.indentUnit as NSString).length + + // The pre-edit storage span covering exactly the affected blocks; only + // this is replaced so layout above/below — and the viewport — is kept. + let oldRange = NSRange( + location: blocks[startBlock].range.location, + length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location) + + // Record undo + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart)) + redoStack.removeAll() + lastEditType = .other + lastEditBlockIndex = nil + + // Build new rawSource + var parts: [String] = [] + for (i, block) in blocks.enumerated() { + if i >= startBlock && i <= endBlock { + parts.append(Self.indentUnit + block.content) + } else { + parts.append(block.content) + } + } + let newText = parts[startBlock...endBlock].joined(separator: blockSeparator) + let oldIndentUnit = listIndentUnit + rawSource = parts.joined(separator: blockSeparator) + rebuildListIndentState() + rebuildLinkDefState() + + // Cursor in startBlock shifts by 1 indent; rawEnd in endBlock + // shifts by (endBlock - startBlock + 1) indents (one per block). + let newRawStart = rawStart + indentLen + let newRawEnd = rawEnd + indentLen * (endBlock - startBlock + 1) + + blocks = BlockParser.parse(rawSource, previous: blocks) + + let selInRaw = sel.length > 0 + ? NSRange(location: newRawStart, length: newRawEnd - newRawStart) : nil + stabilizingViewport { + recomposeReplacing(oldRange: oldRange, with: newText, + dirty: indentDirtySet(startBlock, endBlock, + unitChanged: listIndentUnit != oldIndentUnit), + cursorInRaw: newRawStart, selectionInRaw: selInRaw) + } + // The indented blocks changed depth: they may now belong to a + // different ordered run (or start a new one), and the old depth's + // remaining siblings lost a member — both need renumbering. + renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1), + depthChanged: Set(startBlock...endBlock)) + document?.updateChangeCount(.changeDone) + } + + /// Blocks to restyle for an indent/dedent: the directly-edited span, plus — + /// when the document-global list indent unit moved — every list block, + /// whose rendered indentation is derived from that unit. + private func indentDirtySet(_ startBlock: Int, _ endBlock: Int, + unitChanged: Bool) -> IndexSet { + var dirty = IndexSet(integersIn: startBlock...min(endBlock, blocks.count - 1)) + if unitChanged { + for (i, block) in blocks.enumerated() where block.kind == .listItem { + dirty.insert(i) + } + } + return dirty + } + + // MARK: - Dedent (Shift-Tab) + + private func dedentListBlocks(from startBlock: Int, to endBlock: Int) { + let sel = selectedRange() + let rawStart = sel.location + let rawEnd = sel.location + sel.length + let maxRemove = Self.indentUnit.count + + // Compute how many leading whitespace characters to strip from each block. + var removed: [Int] = Array(repeating: 0, count: blocks.count) + for i in startBlock...endBlock { + let content = blocks[i].content + if content.hasPrefix("\t") { + removed[i] = 1 + } else { + let leading = content.prefix(while: { $0 == " " }).count + removed[i] = min(leading, maxRemove) + } + } + + let totalRemoved = removed[startBlock...endBlock].reduce(0, +) + guard totalRemoved > 0 else { return } + + // The pre-edit storage span covering exactly the affected blocks; only + // this is replaced so layout above/below — and the viewport — is kept. + let oldRange = NSRange( + location: blocks[startBlock].range.location, + length: blocks[endBlock].range.upperBound - blocks[startBlock].range.location) + + // Record undo + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: rawStart)) + redoStack.removeAll() + lastEditType = .other + lastEditBlockIndex = nil + + // Build new rawSource + var parts: [String] = [] + for (i, block) in blocks.enumerated() { + if i >= startBlock && i <= endBlock { + parts.append(String(block.content.dropFirst(removed[i]))) + } else { + parts.append(block.content) + } + } + let newText = parts[startBlock...endBlock].joined(separator: blockSeparator) + let oldIndentUnit = listIndentUnit + rawSource = parts.joined(separator: blockSeparator) + rebuildListIndentState() + rebuildLinkDefState() + + // Adjust rawStart (in startBlock). No blocks before startBlock + // were modified, so its start position is unchanged. + let startOff = rawStart - blocks[startBlock].range.location + let newRawStart = blocks[startBlock].range.location + + max(0, startOff - removed[startBlock]) + + // Adjust rawEnd (in endBlock). Every indented block before + // endBlock shifted its start position left. + var cumulativeBefore = 0 + for i in startBlock.. 0 + ? NSRange(location: newRawStart, length: max(0, newRawEnd - newRawStart)) : nil + stabilizingViewport { + recomposeReplacing(oldRange: oldRange, with: newText, + dirty: indentDirtySet(startBlock, endBlock, + unitChanged: listIndentUnit != oldIndentUnit), + cursorInRaw: newRawStart, selectionInRaw: selInRaw) + } + // The dedented blocks changed depth: they may now belong to a + // different ordered run (or merge into an existing one), and the + // old depth's remaining siblings lost a member — both need renumbering. + renumberOrderedListRunsIfNeeded(touching: startBlock..<(endBlock + 1), + depthChanged: Set(startBlock...endBlock)) + document?.updateChangeCount(.changeDone) + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift b/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift new file mode 100644 index 0000000..dd22f81 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+ListContinuation.swift @@ -0,0 +1,102 @@ +import AppKit + +// MARK: - List Continuation on Enter +// +// Pressing Return inside a list item starts the next item automatically: it +// repeats the same indent and a fresh marker (the next number for ordered +// lists, an empty checkbox for task lists, the same bullet otherwise). Pressing +// Return on an *empty* item instead removes the marker and breaks out of the +// list, matching the behavior of most note editors. + +extension EditorTextView { + + /// Regex that captures a list marker prefix: + /// Group 1 = leading whitespace, Group 2 = marker (e.g. "- ", "* ", "1. ", "- [ ] ", "- [x] ") + private static let listMarkerRegex = try! NSRegularExpression( + pattern: #"^(\s*)([-*+]\s+(?:\[[ xX]\]\s+)?|\d+\.\s+)"# + ) + + /// If the cursor is on a list line, returns (leadingWhitespace, marker, hasContent). + /// `marker` is the bullet/number portion (e.g. "- ", "1. ", "- [ ] "). + private func parseListMarker(_ line: String) -> (indent: String, marker: String, hasContent: Bool)? { + let ns = line as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = Self.listMarkerRegex.firstMatch(in: line, range: range) else { + return nil + } + let indent = ns.substring(with: match.range(at: 1)) + let marker = ns.substring(with: match.range(at: 2)) + let prefixLen = match.range.length + let hasContent = prefixLen < ns.length + return (indent, marker, hasContent) + } + + /// Builds the next marker for list continuation. + /// - Ordered lists: increments the number (e.g. "1. " → "2. ") + /// - Checkbox items: resets to unchecked (e.g. "- [x] " → "- [ ] ") + /// - Plain bullets: returns the same marker + private func nextMarker(for marker: String) -> String { + // Ordered: "1. " → "2. " + if let dotRange = marker.range(of: #"^\d+\."#, options: .regularExpression) { + let numStr = String(marker[dotRange].dropLast()) // drop the "." + if let num = Int(numStr) { + return "\(num + 1)." + String(marker[dotRange.upperBound...]) + } + } + // Checkbox: replace [x] with [ ] + if let cbRange = marker.range(of: "[x]", options: .caseInsensitive) { + var next = marker + next.replaceSubrange(cbRange, with: "[ ]") + return next + } + return marker + } + + // MARK: - Override + + public override func insertNewline(_ sender: Any?) { + let sel = selectedRange() + guard sel.length == 0 else { + super.insertNewline(sender) + return + } + if handleListNewline(sel) { return } + if handleBlockquoteNewline(at: sel.location) { return } + super.insertNewline(sender) + } + + /// List continuation. Returns true if it handled the newline. + private func handleListNewline(_ sel: NSRange) -> Bool { + guard let blockIdx = blockIndexForRawOffset(sel.location), + blockIdx < blocks.count else { return false } + + let block = blocks[blockIdx] + guard let (indent, marker, hasContent) = parseListMarker(block.content) else { + return false + } + + if hasContent { + // Content present → insert newline + next marker. + // If splitting mid-line and the next char is a space, consume it + // so we don't get a double space after the marker. + let next = indent + nextMarker(for: marker) + var replaceRange = sel + let nsRaw = rawSource as NSString + if sel.location < nsRaw.length && nsRaw.character(at: sel.location) == 0x20 { + replaceRange.length += 1 + } + insertText("\n" + next, replacementRange: replaceRange) + } else if !indent.isEmpty { + // Indented empty list line → un-indent one level + let maxRemove = Self.indentUnit.count + let leading = indent.prefix(while: { $0 == " " }).count + let remove = indent.hasPrefix("\t") ? 1 : min(leading, maxRemove) + let dedented = String(block.content.dropFirst(remove)) + insertText(dedented, replacementRange: block.range) + } else { + // Root-level empty list line → remove the marker entirely + insertText("", replacementRange: block.range) + } + return true + } +} diff --git a/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift b/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift new file mode 100644 index 0000000..6ee58c2 --- /dev/null +++ b/Sources/EdmundCore/Editing/EditorTextView+ListRenumbering.swift @@ -0,0 +1,177 @@ +import AppKit + +// MARK: - Ordered List Renumbering +// +// Keeps a contiguous run of ordered ("1. ", "1) ") list items sequential +// after an edit changes its item count (insert mid-list, delete an item, +// paste). Scoped to the nesting depth the edit touched — sibling depths and +// unrelated lists elsewhere in the document are never rewritten. Called once +// per edit from `syncRawSourceFromDisplay` (EditorTextView+EditFlow.swift), +// after `blocks` has been reparsed to the post-edit state. + +extension EditorTextView { + + private static let orderedMarkerRegex = try! NSRegularExpression( + pattern: #"^([ \t]*)(\d+)([.)])[ ]"# + ) + + /// Leading indent, number, delimiter, and the digits' block-local NSRange + /// when `content` is an ordered list item line; nil otherwise. + private func orderedMarker(_ content: String) -> (indent: String, number: Int, digits: NSRange, delim: Character)? { + let ns = content as NSString + guard let m = Self.orderedMarkerRegex.firstMatch(in: content, range: NSRange(location: 0, length: ns.length)) else { + return nil + } + let indent = ns.substring(with: m.range(at: 1)) + guard let number = Int(ns.substring(with: m.range(at: 2))) else { return nil } + let delim = ns.substring(with: m.range(at: 3)).first ?? "." + return (indent, number, m.range(at: 2), delim) + } + + /// Reuses the render-time indent→depth mapping so renumbering agrees with + /// what's actually drawn. + private func depthOf(_ block: Block) -> Int { + let indent = block.content.prefix(while: { $0 == " " || $0 == "\t" }) + return listDepth(leadingWhitespace: String(indent)) + } + + /// Entry point: renumbers every distinct contiguous ordered run touched + /// by the edit — including two disjoint runs at the same depth (e.g. an + /// edit that splits one list into two, or an indent/dedent that moves + /// items between an old list and a new/existing one at a different + /// depth). `touchedBlocks`' block count is assumed unchanged by any + /// rewrite this makes (only digit widths inside existing lines change), + /// so indices computed up front stay valid across the whole call. + /// + /// `depthChanged` — block indices whose nesting depth this specific edit + /// just altered (Tab/Shift-Tab; empty for every other caller, which never + /// change depth) — lets a run tell "merging into an existing list" apart + /// from "forming a brand-new one": a run made up entirely of just-moved + /// items starts at 1 instead of inheriting whatever number its first + /// member happened to have at its old depth. + func renumberOrderedListRunsIfNeeded(touching touchedBlocks: Range, depthChanged: Set = []) { + guard !blocks.isEmpty else { return } + let lo = max(0, touchedBlocks.lowerBound - 1) + let hi = min(blocks.count, touchedBlocks.upperBound + 1) + guard lo < hi else { return } + + var processed = IndexSet() + for idx in lo.. ClosedRange { + func sameOrDeeper(_ idx: Int) -> Bool { + guard blocks[idx].kind == .listItem else { return false } + let d = depthOf(blocks[idx]) + if d < depth { return false } + if d == depth { return orderedMarker(blocks[idx].content) != nil } + return true // deeper: nested child, part of the span + } + var lo = seedIndex + while lo > 0 { + if sameOrDeeper(lo - 1) { lo -= 1; continue } + if blocks[lo - 1].kind == .blank, lo >= 2, sameOrDeeper(lo - 2) { lo -= 2; continue } + break + } + var hi = seedIndex + while hi < blocks.count - 1 { + if sameOrDeeper(hi + 1) { hi += 1; continue } + if blocks[hi + 1].kind == .blank, hi + 2 < blocks.count, sameOrDeeper(hi + 2) { hi += 2; continue } + break + } + return lo...hi + } + + /// Renumbers the contiguous ordered run spanning `bounds`; `sequence` — + /// precomputed by the caller — is the same-depth ordered subset of + /// `bounds` that actually gets numbered. No-op (no storage mutation) + /// when the run is already sequential. + /// + /// Start number: when at least one sequence member predates this edit + /// (isn't in `depthChanged`), this run is continuing/merging into + /// something that already existed, so it preserves whatever number its + /// first member already had — same as the plain-edit case, where + /// `depthChanged` is always empty. Only when EVERY member just arrived + /// together (a brand-new run, nothing pre-existing to continue) does it + /// start at 1 instead of inheriting a number from wherever its first + /// member used to live. + private func renumberOrderedListRun(bounds: ClosedRange, sequence: [Int], depthChanged: Set) { + guard let first = sequence.first else { return } + let isBrandNew = sequence.allSatisfy { depthChanged.contains($0) } + let start: Int + if isBrandNew { + start = 1 + } else if let firstNumber = orderedMarker(blocks[first].content)?.number { + start = firstNumber + } else { + return + } + + var rewrites: [(idx: Int, digits: NSRange, newNumber: String)] = [] + for (i, idx) in sequence.enumerated() { + guard let marker = orderedMarker(blocks[idx].content) else { continue } + let expected = start + i + if marker.number != expected { + rewrites.append((idx, marker.digits, String(expected))) + } + } + guard !rewrites.isEmpty else { return } + + let oldSpan = NSRange( + location: blocks[bounds.lowerBound].range.location, + length: blocks[bounds.upperBound].range.upperBound - blocks[bounds.lowerBound].range.location) + + let rewriteByIndex = Dictionary(uniqueKeysWithValues: rewrites.map { ($0.idx, $0) }) + var netDelta = 0 + let newText = bounds.map { idx -> String in + guard let r = rewriteByIndex[idx] else { return blocks[idx].content } + let content = blocks[idx].content as NSString + let newLine = content.replacingCharacters(in: r.digits, with: r.newNumber) + return newLine + }.joined(separator: "\n") + + let caretBefore = selectedRange().location + for idx in bounds { + guard let r = rewriteByIndex[idx] else { continue } + let digitsStart = blocks[idx].range.location + r.digits.location + if digitsStart < caretBefore { + netDelta += (r.newNumber as NSString).length - r.digits.length + } + } + let caretAfter = max(0, caretBefore + netDelta) + + rawSource = (rawSource as NSString).replacingCharacters(in: oldSpan, with: newText) + blocks = BlockParser.parse(rawSource, previous: blocks) + + // `recomposeReplacing` wipes the whole replaced span to base + // attributes before restyling only the dirty blocks — every block in + // `bounds` sits inside that span, not just the ones whose digits + // changed, so all of them must be marked dirty or the untouched ones + // are left showing unstyled (undimmed) markers. + let dirty = IndexSet(bounds) + recomposeReplacing(oldRange: oldSpan, with: newText, dirty: dirty, cursorInRaw: caretAfter) + } +} diff --git a/Sources/EdmundCore/Export/DocumentHTML.swift b/Sources/EdmundCore/Export/DocumentHTML.swift new file mode 100644 index 0000000..c946ef5 --- /dev/null +++ b/Sources/EdmundCore/Export/DocumentHTML.swift @@ -0,0 +1,252 @@ +import AppKit +import SwiftMath + +// MARK: - DocumentHTML +// +// Assembles the full, self-contained HTML document for Read mode and PDF export: +// the `HTMLRenderer` body, the `HTMLTheme` stylesheet, and a second pass that +// fills the renderer's placeholder elements with inlined assets (SwiftMath +// glyphs and local images) as data URIs. Callout/checkbox icons are inline +// Lucide SVGs emitted by `HTMLRenderer` (no asset pass needed). Inlining keeps +// the document self-contained — the webview needs no file/network access. +// Raw HTML in the markdown passes through per GFM, filtered by +// `HTMLRenderer.filterRawHTML` (tagfilter + hardening); the page also carries a +// `script-src 'none'` CSP meta as defense-in-depth (§G, ARCHITECTURE §10). +@MainActor +enum DocumentHTML { + + /// Builds a complete `…` document for `markdown`. `baseURL` is + /// the document's directory, used to resolve relative image paths for inlining. + static func full(markdown: String, + theme: EditorTheme, + callouts: [String: CalloutStyle], + dark: Bool, + baseURL: URL? = nil, + options: ReadRenderOptions = .default) -> String { + var body = HTMLRenderer.render(markdown: markdown, options: options) + body = fillMath(body, theme: theme, dark: dark) + body = fillImages(body, baseURL: baseURL, options: options) + let css = HTMLTheme.css(theme, callouts: callouts, dark: dark, + maxContentWidthPoints: options.maxContentWidthPoints) + return """ + + + + + +

        + """ + } + + // MARK: Math (SwiftMath → PNG data URI) + + private static let inlineMathPattern = "" + private static let displayMathPattern = "
        " + + private static func fillMath(_ html: String, theme: EditorTheme, dark: Bool) -> String { + let color = NSColor(hex: dark ? "#e6e6e6" : "#1a1a1a") ?? .textColor + var out = replaceMatches(html, pattern: displayMathPattern) { groups in + let tex = unescapeAttr(groups[1]) + guard let r = mathImage(latex: tex, display: true, + fontSize: theme.fontSize, color: color), + let data = pngData(r.image, scale: 2) else { + return "
        \(HTMLRenderer.escape(tex))
        " + } + let uri = "data:image/png;base64,\(data.base64EncodedString())" + return "
        \"\(HTMLRenderer.attr(tex))\"
        " + } + out = replaceMatches(out, pattern: inlineMathPattern) { groups in + let tex = unescapeAttr(groups[1]) + guard let r = mathImage(latex: tex, display: false, + fontSize: theme.fontSize, color: color), + let data = pngData(r.image, scale: 2) else { + return "\(HTMLRenderer.escape(tex))" + } + let uri = "data:image/png;base64,\(data.base64EncodedString())" + // Drop the image so its baseline (descent above its bottom) lands on + // the text baseline — same alignment the editor computes. + return "\"\(HTMLRenderer.attr(tex))\"" + } + return out + } + + /// Renders LaTeX with SwiftMath to an image + baseline descent. Standalone + /// (no `EditorTextView`) mirror of `EditorTextView.mathOverlay`'s core. + private static func mathImage(latex: String, display: Bool, + fontSize: CGFloat, color: NSColor) + -> (image: NSImage, descent: CGFloat)? { + let mode: MTMathUILabelMode = display ? .display : .text + let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode) + let insetPad: CGFloat = 2 + math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0) + let (error, image) = math.asImage() + guard error == nil, let image else { return nil } + + let label = MTMathUILabel() + label.latex = latex + label.fontSize = fontSize + label.labelMode = mode + label.layout() + let asc = label.displayList?.ascent ?? 0 + let desc = label.displayList?.descent ?? 0 + let clamped = max(asc + desc, fontSize / 2) + let descent = (asc + desc - clamped) / 2 + desc + insetPad + return (image, descent) + } + + // MARK: Images (local → inlined data URI; remote → off by default) + + // Groups 3/4 are optional declared dimensions from an HTML `` tag + // (captured with their leading space so they re-emit verbatim). + private static let imagePattern = + "\"([^\"]*)\"(" + + /// Resolves each `md-image` placeholder: local/relative paths are read and + /// inlined as a data URI (self-contained, no file access needed at render + /// time); a `data:` source passes through; remote `https` sources load only + /// when `options.allowRemoteImages` is set. Anything that can't be shown + /// gets a visible icon + reason (`ImageLoadFailure`, shared with Edit + /// mode's inline preview) instead of silently showing nothing. + private static func fillImages(_ html: String, baseURL: URL?, + options: ReadRenderOptions) -> String { + var cache: [String: String] = [:] // resolved path → data URI + return replaceMatches(html, pattern: imagePattern) { groups in + let src = unescapeAttr(groups[1]) + let alt = groups[2] // already attribute-escaped by the renderer + let dims = groups[3] + groups[4] // optional ` width="N" height="N"` + + if src.isEmpty { return blockedImagePlaceholder(reason:.notFound) } + let lower = src.lowercased() + if lower.hasPrefix("data:") { + return "\"\(alt)\"\(dims)" + } + if lower.hasPrefix("http://") { + return blockedImagePlaceholder(reason:.httpUnsupported) + } + if lower.hasPrefix("https://") { + guard options.allowRemoteImages else { + return blockedImagePlaceholder(reason:.blockedBySetting) + } + return "\"\(alt)\"\(dims)" + } + // Local: resolve against the document directory, read, inline. + guard let fileURL = resolveLocalImage(src, baseURL: baseURL) else { + return blockedImagePlaceholder(reason:.notFound) + } + if let cached = cache[fileURL.path] { + return "\"\(alt)\"\(dims)" + } + // `resolveLocalImage`'s absolute/`~` branches don't check existence + // (only the relative-path branch does), so a missing file and an + // undecodable one would otherwise fail `imageDataURI` identically — + // check existence first so the two get distinct, accurate messages. + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return blockedImagePlaceholder(reason:.notFound) + } + guard let uri = imageDataURI(fileURL) else { + return blockedImagePlaceholder(reason:.notAnImage) + } + cache[fileURL.path] = uri + return "\"\(alt)\"\(dims)" + } + } + + /// A visible stand-in for an image that can't be shown: an icon plus a + /// short reason, instead of just empty space. + private static func blockedImagePlaceholder(reason: ImageLoadFailure) -> String { + let icon = LucideIcons.inlineSVG("image-off") ?? "" + return "\(icon)\(reason.label)" + } + + /// Resolves a local image `path` to a file URL: absolute / `~` / `file:` + /// load directly; a relative path resolves against the document's directory. + private static func resolveLocalImage(_ path: String, baseURL: URL?) -> URL? { + if let url = URL(string: path), url.scheme == "file" { return url } + // A markdown image destination may be percent-encoded (e.g. `%20`). + let decoded = path.removingPercentEncoding ?? path + if decoded.hasPrefix("/") { return URL(fileURLWithPath: decoded) } + if decoded.hasPrefix("~") { return URL(fileURLWithPath: (decoded as NSString).expandingTildeInPath) } + guard let baseURL else { return nil } + let resolved = baseURL.appendingPathComponent(decoded) + return FileManager.default.fileExists(atPath: resolved.path) ? resolved : nil + } + + /// Reads an image file and returns a `data:` URI, with the MIME type guessed + /// from the file extension (covers the common web image formats). Decodes + /// the bytes first (discarding the result) so a file that merely has an + /// image extension but isn't actually image data is caught here — as + /// "Not an image" — rather than silently inlining garbage the browser + /// then fails to render with no explanation. + private static func imageDataURI(_ url: URL) -> String? { + guard let data = try? Data(contentsOf: url), NSImage(data: data) != nil else { return nil } + let mime: String + switch url.pathExtension.lowercased() { + case "png": mime = "image/png" + case "jpg", "jpeg": mime = "image/jpeg" + case "gif": mime = "image/gif" + case "svg": mime = "image/svg+xml" + case "webp": mime = "image/webp" + case "bmp": mime = "image/bmp" + case "tiff", "tif": mime = "image/tiff" + default: mime = "application/octet-stream" + } + return "data:\(mime);base64,\(data.base64EncodedString())" + } + + // MARK: Bitmap / escaping helpers + + /// Rasterizes an `NSImage` to PNG `Data` at `scale`× its point size. + private static func pngData(_ image: NSImage, scale: CGFloat) -> Data? { + let size = image.size + guard size.width > 0, size.height > 0, + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int((size.width * scale).rounded()), + pixelsHigh: Int((size.height * scale).rounded()), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) else { return nil } + rep.size = size + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + image.draw(in: NSRect(origin: .zero, size: size)) + NSGraphicsContext.restoreGraphicsState() + return rep.representation(using: .png, properties: [:]) + } + + /// Reverses the HTML-attribute escaping done by `HTMLRenderer.attr` so the + /// raw LaTeX/symbol can be recovered from a placeholder attribute. + private static func unescapeAttr(_ s: String) -> String { + s.replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "&", with: "&") // last, by convention + } + + /// Finds every match of `pattern` and replaces it with `transform(groups)`, + /// where `groups[0]` is the whole match. Replaces back-to-front so ranges + /// stay valid. + private static func replaceMatches(_ html: String, pattern: String, + _ transform: ([String]) -> String) -> String { + guard let regex = try? NSRegularExpression( + pattern: pattern, options: [.dotMatchesLineSeparators]) else { return html } + let ns = html as NSString + let result = NSMutableString(string: html) + let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length)) + for m in matches.reversed() { + var groups: [String] = [] + for i in 0.. String { + v == v.rounded() ? String(Int(v)) : String(format: "%.1f", v) + } +} diff --git a/Sources/EdmundCore/Export/HTMLRenderer.swift b/Sources/EdmundCore/Export/HTMLRenderer.swift new file mode 100644 index 0000000..c21e793 --- /dev/null +++ b/Sources/EdmundCore/Export/HTMLRenderer.swift @@ -0,0 +1,768 @@ +import Foundation +import Markdown + +// MARK: - HTMLRenderer +// +// Renders the *same* swift-markdown `Document` the editor parses into an HTML +// body string. This is the Read-mode / Print-export counterpart to +// `SpanCollector` (which produces editor attribute spans): one parser, one set +// of element semantics, two back-ends. It mirrors SpanCollector's element +// coverage so Read mode shows exactly what Edit mode highlights. +// +// The renderer is intentionally **pure** — AST → string, no AppKit. Assets that +// need AppKit (callout icons, math glyphs) are emitted as placeholder elements +// that `DocumentHTML` fills in a second pass, so this type stays unit-testable +// with plain string assertions. +// +// Non-GFM inline constructs (==highlight==, $math$, [[wikilink]], %%comment%%) +// are detected by reusing the exact regex passes in +// `SyntaxHighlighter+CustomParsers` — no second source of truth. +struct HTMLRenderer: MarkupVisitor { + typealias Result = String + + /// Private URL scheme for `[[wikilink]]` hrefs. The read view's navigation + /// policy intercepts this scheme and routes the (percent-decoded) target + /// through the app's document graph instead of navigating the webview. + static let wikiScheme = "x-edmund-wiki" + + /// Private URL scheme for relative/internal regular markdown links + /// (`[text](other.md)`). Routed like wikilinks; external links (http/https/ + /// mailto) and in-page `#fragment` anchors keep their real hrefs. + static let linkScheme = "x-edmund-link" + + /// The markdown this instance is rendering. Held so block-level constructs + /// (callouts) can recover their *raw* source text by range, the way the + /// editor's styling layer does. + private let source: String + private let sourceLines: [String] + private let options: ReadRenderOptions + + /// Footnote definitions collected while walking the document (see + /// `visitParagraph`), rendered as a section at the bottom of the page + /// instead of in place. Order is document order of the *definitions*. + private var footnotes: [(id: String, bodyHTML: String)] = [] + + /// Tightness of each list currently being walked (stack: nested lists). + /// See `isTight(_:)`. + private var listIsTight: [Bool] = [] + + private init(source: String, options: ReadRenderOptions) { + self.source = source + self.sourceLines = source.components(separatedBy: "\n") + self.options = options + } + + /// Parses `markdown` and returns the rendered HTML body (no ``/`` + /// wrapper — `DocumentHTML` adds that). + static func render(markdown: String, options: ReadRenderOptions = .default) -> String { + var r = HTMLRenderer(source: markdown, options: options) + let doc = Document(parsing: markdown, options: [.disableSmartOpts]) + let body = r.visit(doc) + return body + r.renderFootnotesSection() + } + + /// `[^id]: body` definitions render at the bottom of the page as a `
        ` + + /// ordered list, each entry linking back to its in-text reference — the + /// Obsidian-style footnote layout (see misc/backlog.md's Markdown Footnotes + /// entry). Not rendered at all if the document had no footnote definitions. + private func renderFootnotesSection() -> String { + guard !footnotes.isEmpty else { return "" } + var out = "
          " + for (id, bodyHTML) in footnotes { + let safeID = Self.attr(id) + out += "
        1. \(bodyHTML) " + + "
        2. " + } + out += "
        " + return out + } + + /// Top-level block iteration. When `preserveBlankLines` is on, a *run* of + /// blank source lines between two blocks emits one `.blank-line` spacer for + /// every blank line beyond the first — i.e. standard Markdown keeps a single + /// blank line as the normal block separator and only renders the 2nd, 3rd, … + /// blank lines as extra vertical space. + /// + /// REFERENCE (future "rigorous" Read mode): to mimic Edit mode's layout + /// exactly, emit a spacer for EVERY blank line (`spacers = blanks`, not + /// `blanks - 1`). That preserves the author's spacing literally but fights + /// the HTML/CSS box model (blocks already carry their own margins), so it's + /// parked until Read mode commits to a styled-source rather than a rendered- + /// document model. See the discussion in the handoff notes. + /// + /// QUIRK: a block's `range.upperBound.line` is NOT reliably its last content + /// line — cmark folds trailing blank lines into some block ranges (lists in + /// particular), so a list followed by a blank line then a paragraph reports + /// the list ending on the blank line. We therefore clamp each block's end + /// back to its last non-blank source line; the blank run between blocks A and + /// B is then `B.firstLine - clamp(A.end) - 1`. + mutating func visitDocument(_ document: Document) -> String { + guard options.preserveBlankLines else { return renderChildren(of: document) } + var out = "" + var prevEndLine: Int? + for child in document.children { + if let prevEndLine, let range = child.range { + let blanks = range.lowerBound.line - prevEndLine - 1 + if blanks > 1 { + out += String(repeating: "
        ", count: blanks - 1) + } + } + out += visit(child) + if let range = child.range { + prevEndLine = lastContentLine(atOrBefore: range.upperBound.line) + } + } + return out + } + + /// The last source line at or before `line` (1-indexed) that has non-blank + /// content. Used to undo cmark folding trailing blank lines into a block. + private func lastContentLine(atOrBefore line: Int) -> Int { + var l = min(line, sourceLines.count) + while l >= 1, sourceLines[l - 1].trimmingCharacters(in: .whitespaces).isEmpty { + l -= 1 + } + return l + } + + // MARK: Default / children + + mutating func defaultVisit(_ markup: Markup) -> String { + renderChildren(of: markup) + } + + private mutating func renderChildren(of markup: Markup) -> String { + var out = "" + for child in markup.children { out += visit(child) } + return out + } + + // MARK: Block-level + + mutating func visitParagraph(_ paragraph: Paragraph) -> String { + // A paragraph that is wholly `$$…$$` is a display-math block. Reuse the + // editor's detector so Read mode and Edit mode agree on what's math. + // Detect on the *raw* source, not `plainText`: swift-markdown has already + // applied Markdown backslash-unescaping to a Text node's `.string` + // (`\\`→`\`, `\$`→`$`), which corrupts LaTeX row separators and commands + // — so a `\begin{aligned}…\\…\end{aligned}` block would be mangled. The + // editor's styling reads from raw source by range for the same reason. + let raw = sourceText(paragraph) ?? Self.plainText(of: paragraph) + var dm: [SyntaxHighlighter.Span] = [] + SyntaxHighlighter.parseDisplayMath(raw, into: &dm) + if let span = dm.first(where: { if case .math(true) = $0.kind { return true }; return false }) { + let tex = (raw as NSString).substring(with: span.contentRange) + return "
        " + } + + // A `[^id]: body` paragraph (the marker starts the paragraph's first + // Text child) is a footnote definition, not visible content — collect it + // for the bottom-of-page footnotes section instead of rendering in place. + let children = Array(paragraph.children) + if let first = children.first as? Text, + let (id, markerLength) = Self.footnoteDefinitionMarker(in: first.string) { + var bodyHTML = Self.renderInline(String(first.string.dropFirst(markerLength))) + for child in children.dropFirst() { bodyHTML += visit(child) } + footnotes.append((id: id, bodyHTML: bodyHTML)) + return "" + } + return "

        \(renderChildren(of: paragraph))

        " + } + + mutating func visitHeading(_ heading: Heading) -> String { + let level = min(max(heading.level, 1), 6) + return "\(renderChildren(of: heading))" + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String { + // Per-token syntax coloring reuses the editor's `CodeHighlighter`, so + // Edit mode and Read mode color the same tokens identically (the actual + // colors live in CSS, from the shared `CodeSyntaxPalette` via HTMLTheme). + let lang = codeBlock.language.map { " class=\"language-\(Self.attr($0))\"" } ?? "" + // QUIRK: U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are valid + // Unicode line-ending characters that appear in macOS-pasted text (e.g. + // from Notes or Safari). In HTML they are NOT newline characters — inside + // a
         block they render as spaces or nothing, concatenating lines that
        +        // should appear on separate rows. Normalize to plain U+000A before escaping.
        +        let raw = codeBlock.code
        +            .replacingOccurrences(of: "\u{2028}", with: "\n")
        +            .replacingOccurrences(of: "\u{2029}", with: "\n")
        +        // swift-markdown includes a trailing newline on the block's code.
        +        let code = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw
        +        return "
        \(Self.highlightCode(code, language: codeBlock.language))
        " + } + + /// CSS class for a code token kind (consumed by `HTMLTheme`'s `.tok-*` rules). + private static func tokenClass(_ type: CodeHighlighter.TokenType) -> String { + switch type { + case .keyword: return "tok-keyword" + case .type: return "tok-type" + case .string: return "tok-string" + case .number: return "tok-number" + case .comment: return "tok-comment" + case .function: return "tok-function" + } + } + + /// Escapes `code` and wraps each `CodeHighlighter` token in a colored + /// ``. Gaps between tokens stay plain (escaped) text and + /// inherit the plain `pre code` color, mirroring the editor's "plain first, + /// tokens paint over" model. + static func highlightCode(_ code: String, language: String?) -> String { + let tokens = CodeHighlighter.tokenize(code, language: language) + guard !tokens.isEmpty else { return escape(code) } + let ns = code as NSString + var out = "" + var cursor = 0 + for token in tokens { + let r = token.range + guard r.location >= cursor, r.upperBound <= ns.length else { continue } + if r.location > cursor { + out += escape(ns.substring(with: NSRange(location: cursor, length: r.location - cursor))) + } + out += "\(escape(ns.substring(with: r)))" + cursor = r.upperBound + } + if cursor < ns.length { + out += escape(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor))) + } + return out + } + + mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> String { "
        " } + + mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String { + // Detect a GFM callout (`> [!type] …`) on the first line, the same way + // the editor does (Callout.parseMarker over the de-quoted first line). + if let inner = deQuoted(blockQuote) { + let firstLine = String(inner.prefix(while: { $0 != "\n" })) + if let marker = Callout.parseMarker(firstLine), + let style = Callout.style(for: marker.type) { + return renderCallout(marker: marker, style: style, + firstLine: firstLine, blockQuote: blockQuote) + } + } + return "
        \(renderChildren(of: blockQuote))
        " + } + + /// GFM §5.3: a list is LOOSE iff any two adjacent blocks inside it — between + /// items, or between blocks within one item — are separated by a blank source + /// line. swift-markdown doesn't expose cmark's tight flag; recover it from + /// source-line gaps, clamping each block's end past cmark's folded trailing + /// blanks (same trick as visitDocument). + private func isTight(_ list: Markup) -> Bool { + var prevEnd: Int? = nil + for item in list.children { + guard let r = item.range else { continue } + if let p = prevEnd, r.lowerBound.line - p > 1 { return false } + var innerPrev: Int? = nil + for block in item.children { + guard let br = block.range else { continue } + if let ip = innerPrev, br.lowerBound.line - ip > 1 { return false } + innerPrev = lastContentLine(atOrBefore: br.upperBound.line) + } + prevEnd = lastContentLine(atOrBefore: r.upperBound.line) + } + return true + } + + mutating func visitUnorderedList(_ list: UnorderedList) -> String { + listIsTight.append(isTight(list)) + defer { listIsTight.removeLast() } + return "
          \(renderChildren(of: list))
        " + } + + mutating func visitOrderedList(_ list: OrderedList) -> String { + listIsTight.append(isTight(list)) + defer { listIsTight.removeLast() } + let start = list.startIndex == 1 ? "" : " start=\"\(list.startIndex)\"" + return "\(renderChildren(of: list))" + } + + mutating func visitListItem(_ listItem: ListItem) -> String { + if let checkbox = listItem.checkbox { + let checked = checkbox == .checked + // Composed Lucide SVG (not an SF Symbol, which can't ship in exported + // PDFs) mirroring the editor's look; CSS supplies the accent/dim color. + let mark = "" + + "\(LucideIcons.checkboxSVG(checked: checked))" + let checkedClass = checked ? " task--checked" : "" + return "
      • \(mark)\(renderListItemContents(listItem))
      • " + } + return "
      • \(renderListItemContents(listItem))
      • " + } + + /// Item contents; in a tight list, each direct Paragraph child loses its + ///

        wrapper (visit-then-strip, so visitParagraph's math/footnote + /// special cases still run). + private mutating func renderListItemContents(_ item: ListItem) -> String { + guard listIsTight.last == true else { return renderChildren(of: item) } + var out = "" + for child in item.children { + var html = visit(child) + if child is Paragraph, html.hasPrefix("

        "), html.hasSuffix("

        ") { + html = String(html.dropFirst(3).dropLast(4)) + } + out += html + } + return out + } + + mutating func visitTable(_ table: Table) -> String { + let aligns = table.columnAlignments + func cellStyle(_ col: Int) -> String { + guard col < aligns.count, let a = aligns[col] else { return "" } + switch a { + case .left: return " style=\"text-align:left\"" + case .center: return " style=\"text-align:center\"" + case .right: return " style=\"text-align:right\"" + } + } + var html = "
        " + for (col, cell) in table.head.cells.enumerated() { + html += "\(renderChildren(of: cell))" + } + html += "" + for row in table.body.rows { + html += "" + for (col, cell) in row.cells.enumerated() { + html += "\(renderChildren(of: cell))" + } + html += "" + } + html += "
        " + return html + } + + // MARK: Inline + + mutating func visitText(_ text: Text) -> String { + Self.renderInline(text.string, rawSource: sourceText(text)) + } + mutating func visitEmphasis(_ emphasis: Emphasis) -> String { "\(renderChildren(of: emphasis))" } + mutating func visitStrong(_ strong: Strong) -> String { "\(renderChildren(of: strong))" } + mutating func visitStrikethrough(_ s: Strikethrough) -> String { "\(renderChildren(of: s))" } + mutating func visitInlineCode(_ code: InlineCode) -> String { "\(Self.escape(code.code))" } + mutating func visitLineBreak(_ lineBreak: LineBreak) -> String { "
        \n" } + mutating func visitSoftBreak(_ softBreak: SoftBreak) -> String { "\n" } + + mutating func visitLink(_ link: Link) -> String { + let dest = link.destination ?? "" + let inner = renderChildren(of: link) + let title = link.title.map { " title=\"\(Self.attr($0))\"" } ?? "" + // In-page `#fragment` anchors and external links (http/https/mailto, or + // any explicit scheme) keep their real href — the nav policy lets the + // anchor scroll and hands external schemes to the browser. A relative / + // internal destination is wrapped in the private link scheme so it routes + // through the app's document graph reliably. + if dest.hasPrefix("#") || Self.hasExternalScheme(dest) { + return "\(inner)" + } + let encoded = dest.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? dest + return "\(inner)" + } + + /// Whether a link destination carries an explicit URL scheme (`http:`, + /// `mailto:`, `file:`, …) and so should be treated as external/absolute + /// rather than a relative path into the document's directory. + private static func hasExternalScheme(_ dest: String) -> Bool { + guard let colon = dest.firstIndex(of: ":") else { return false } + let scheme = dest[dest.startIndex.. String { + // Emit a placeholder carrying the raw source; `DocumentHTML` resolves and + // inlines it in a second pass (it needs the document directory + the + // remote-image policy, which the pure renderer doesn't have). No `src` + // here ⇒ if the asset pass can't resolve it, the alt text shows. + let alt = Self.attr(Self.plainText(of: image)) + let src = Self.attr(image.source ?? "") + return "\"\(alt)\"" + } + + // Inline HTML (§6.10): full GFM raw-HTML passthrough, filtered through + // tagfilter (§6.11) + hardening (§G — see ARCHITECTURE §10). Block HTML + // gets the same filter (below). + mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String { + Self.sanitizeInlineHTML(inlineHTML.rawHTML) + } + mutating func visitHTMLBlock(_ html: HTMLBlock) -> String { + // A block-level `` is invisible, like in a browser. + let trimmed = html.rawHTML.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("") { return "" } + if isSingleTag(trimmed, named: "img"), let img = Self.imgPlaceholder(trimmed) { + return "

        \(img)

        " + } + // GFM passthrough (§4.6): tagfilter + hardening, then rewrite interior + // `` tags to asset-pass placeholders (same baseURL-nil reason + // as inline; also the remote-image-policy enforcement point). No

        + // wrapper, no escaping. filterRawHTML runs FIRST: the placeholders carry + // only class/data-src/alt/width/height attrs, which the hardening + // regexes can't touch. + return Self.rewriteImgs(in: Self.filterRawHTML(html.rawHTML)) + } + + /// Full §6.10 open-tag grammar for `img` (quoted values may contain `>`). + private static let imgTagRegex = try! NSRegularExpression( + pattern: #"`]+|'[^']*'|"[^"]*"))?)*\s*/?>"#, + options: [.caseInsensitive]) + + /// Replaces every `` that has a usable src with the md-image + /// placeholder; src-less imgs pass through untouched (they simply won't load). + static func rewriteImgs(in html: String) -> String { + let ns = html as NSString + let out = NSMutableString(string: html) + for m in imgTagRegex.matches(in: html, range: NSRange(location: 0, length: ns.length)).reversed() { + if let placeholder = imgPlaceholder(ns.substring(with: m.range)) { + out.replaceCharacters(in: m.range, with: placeholder) + } + } + return out as String + } + + /// True when `raw` is exactly one `` tag (no trailing content — + /// the anchored tag regex must consume the whole string). + private func isSingleTag(_ raw: String, named name: String) -> Bool { + let ns = raw as NSString + guard let m = Self.inlineTagRegex.firstMatch( + in: raw, range: NSRange(location: 0, length: ns.length)) else { return false } + return ns.substring(with: m.range(at: 1)).isEmpty + && ns.substring(with: m.range(at: 2)).lowercased() == name + } + + private static let inlineTagRegex = + try! NSRegularExpression(pattern: #"^<(/?)([A-Za-z][A-Za-z0-9]*)[^>]*>$"#) + + // MARK: - GFM tagfilter (§6.11) + hardening + + /// Tagfilter (§6.11): the leading `<` of the nine disallowed tag names + /// (open or closing, case-insensitive) becomes `<`. Lookahead only — + /// nothing else is consumed. + private static let tagfilterRegex = try! NSRegularExpression( + pattern: #"<(?=/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)(?:[\s/>]|$))"#, + options: [.caseInsensitive]) + + /// Hardening (beyond spec): strip `on*` event-handler attributes. + /// ponytail: plain-text regex, not an HTML parser — a literal ` onclick="x"` + /// in text between tags is also stripped; harmless for a hardening pass. + private static let eventAttrRegex = try! NSRegularExpression( + pattern: #"\son[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)"#, + options: [.caseInsensitive]) + + /// Hardening: neutralize javascript:/vbscript: schemes in URL-carrying + /// attributes (the scheme is deleted; the rest becomes a harmless relative URL). + private static let scriptURLRegex = try! NSRegularExpression( + pattern: #"(\s(?:href|src|action|formaction|xlink:href|data)\s*=\s*["']?\s*)(?:javascript|vbscript)\s*:"#, + options: [.caseInsensitive]) + + /// GFM raw-HTML output filter: tagfilter + Edmund's hardening. Defense-in-depth + /// on top of the read webview's JS-off + CSP script-src 'none' + baseURL nil. + static func filterRawHTML(_ raw: String) -> String { + func sub(_ s: String, _ rx: NSRegularExpression, _ template: String) -> String { + rx.stringByReplacingMatches(in: s, range: NSRange(location: 0, length: (s as NSString).length), + withTemplate: template) + } + var out = sub(raw, tagfilterRegex, "<") + out = sub(out, eventAttrRegex, "") + out = sub(out, scriptURLRegex, "$1") + return out + } + + /// GFM raw-HTML passthrough (§6.10) with tagfilter (§6.11) + hardening. + /// Comments stay invisible. A lone `` becomes the asset-pass + /// placeholder — REQUIRED, not just policy: the page loads with `baseURL: nil`, + /// so a raw relative `` could never resolve; the placeholder routes + /// it through DocumentHTML.fillImages (data-URI inlining + remote-image policy, + /// declared width/height carried through). Everything else passes through + /// filtered. Whitelisted formatting tags now keep their (hardened) attributes. + static func sanitizeInlineHTML(_ raw: String) -> String { + if raw.hasPrefix(" + case processing // 3: + case declaration // 4: + case cdata // 5: + case blockTag // 6: one of the 62 block tag names — ends BEFORE a blank line + case completeTag // 7: a complete lone tag — ends BEFORE a blank line; can't interrupt a paragraph + } + + // Tag set pinned to CommonMark 0.29 / GFM (script|pre|style — no textarea, + // which later CommonMark added); deliberate, documented in ARCHITECTURE §10. + private static let htmlType1Regex = try! NSRegularExpression( + pattern: #"^ {0,3}<(?:script|pre|style)(?:[ \t>]|$)"#, options: [.caseInsensitive]) + + private static let htmlType6Regex = try! NSRegularExpression( + pattern: #"^ {0,3}|$)"#, + options: [.caseInsensitive]) + + /// One COMPLETE open tag (full §6.10 attribute grammar — quoted values may + /// contain `>`) or closing tag, alone on the line. Check order 1→7 means a + /// normal `") || l.contains("

        ") || l.contains("") + case .comment: return line.contains("-->") + case .processing: return line.contains("?>") + case .declaration: return line.contains(">") + case .cdata: return line.contains("]]>") + case .blockTag, .completeTag: return false + } + } + + /// Returns true for a thematic break: 3+ of the same `-`/`*`/`_` character + /// and nothing else but spaces. + private static func isThematicBreakLine(_ line: String) -> Bool { + let stripped = line.filter { $0 != " " && $0 != "\t" } + guard stripped.count >= 3, let first = stripped.first, + first == "-" || first == "*" || first == "_" else { return false } + return stripped.allSatisfy { $0 == first } + } + + /// Returns true if the first line of a quote run opens a callout + /// (`> [!type]`, known or unknown type). + private static func quoteRunOpensCallout(_ firstLine: String) -> Bool { + let trimmed = firstLine.drop(while: { $0 == " " }) + guard trimmed.first == ">" else { return false } + return Callout.parseMarker(String(trimmed.dropFirst())) != nil + } + + /// If the line (after optional leading whitespace) starts with `$$`, returns + /// whether a second `$$` also appears on the same line (a one-line `$$…$$` + /// block). Returns nil when the line is not a display-math opener. + private static func displayMathClosedOnSameLine(_ line: String) -> Bool? { + let trimmed = line.drop(while: { $0 == " " }) + guard trimmed.hasPrefix("$$") else { return nil } + return trimmed.dropFirst(2).contains("$$") + } + + /// Returns true if the line is a block-quote line (optional leading spaces + /// then `>`). + private static func isBlockquoteLine(_ line: String) -> Bool { + return line.drop(while: { $0 == " " }).first == ">" + } + + /// Extent of a plain block quote starting at line `i`, honoring CommonMark + /// lazy continuation. Gathers the run of non-blank candidate lines (a blank + /// always ends a quote), parses them with swift-markdown, and truncates the + /// quote to the first `BlockQuote` node's line span — so the exact rules + /// (heading/list/fence/thematic-break interrupt; an empty `>` closes the + /// paragraph; a bare paragraph line continues it) come straight from the + /// CommonMark parser, matching read mode. Returns the quote content and the + /// index of the line after it, or nil when the candidate's first child + /// isn't a BlockQuote (caller falls back to the strict `>`-run). + private static func mergePlainQuote(_ buf: inout LineBuffer, at i: Int) + -> (content: String, next: Int)? { + var candidate: [String] = [] + var j = i + while let line = buf.line(at: j), !isBlankLine(line) { + candidate.append(line) + j += 1 + } + let doc = Document(parsing: candidate.joined(separator: "\n"), + options: [.disableSmartOpts]) + guard doc.child(at: 0) is BlockQuote else { return nil } + // The candidate has no blank lines, so its top-level blocks are + // contiguous: the quote spans lines 1 ..< (second child's start line). + // swift-markdown line numbers are 1-based within the candidate, and + // candidate line 1 == buffer line `i`. + let quoteLineCount: Int + if doc.childCount > 1, let nextStart = doc.child(at: 1)?.range?.lowerBound.line { + quoteLineCount = min(max(nextStart - 1, 1), candidate.count) + } else { + quoteLineCount = candidate.count + } + let content = candidate[0.. Bool { + return line.contains("|") + } + + /// Returns true if the line is a table separator (e.g., "| --- | --- |"). + private static func isTableSeparator(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|") && trimmed.contains("---") else { return false } + return trimmed.allSatisfy { "|:- \t".contains($0) } + } + + /// Returns fence info (character and count) if the line is an opening code fence. + private static func codeFenceInfo(_ line: String) -> (char: Character, count: Int)? { + let trimmed = line.drop(while: { $0 == " " }) + let leadingSpaces = line.count - trimmed.count + guard leadingSpaces <= 3 else { return nil } + guard let first = trimmed.first, (first == "`" || first == "~") else { return nil } + let count = trimmed.prefix(while: { $0 == first }).count + guard count >= 3 else { return nil } + if first == "`" { + let afterFence = trimmed.dropFirst(count) + if afterFence.contains("`") { return nil } + } + return (first, count) + } + + /// Returns true if the line is a valid closing fence for the given char/count. + private static func isClosingFence(_ line: String, char: Character, count: Int) -> Bool { + let trimmed = line.drop(while: { $0 == " " }) + let leadingSpaces = line.count - trimmed.count + guard leadingSpaces <= 3 else { return false } + guard let first = trimmed.first, first == char else { return false } + let fenceCount = trimmed.prefix(while: { $0 == char }).count + guard fenceCount >= count else { return false } + let after = trimmed.dropFirst(fenceCount) + return after.allSatisfy { $0 == " " || $0 == "\t" } + } +} diff --git a/Sources/EdmundCore/Parsing/CodeHighlighter.swift b/Sources/EdmundCore/Parsing/CodeHighlighter.swift new file mode 100644 index 0000000..d965035 --- /dev/null +++ b/Sources/EdmundCore/Parsing/CodeHighlighter.swift @@ -0,0 +1,135 @@ +import Foundation + +// MARK: - Code Highlighter +// +// A small, language-agnostic tokenizer for fenced code blocks. It is not a full +// parser — it recognizes the lexical tokens that carry most of the visual +// signal (comments, strings, numbers, keywords, types, function calls) across +// the common C-family / script languages. The block's info string picks the +// comment style (`#` vs `//`), everything else is shared. + +enum CodeHighlighter { + + enum TokenType: Equatable { + case keyword, type, string, number, comment, function + } + + struct Token: Equatable { + let range: NSRange + let type: TokenType + } + + /// Languages whose line comments start with `#` rather than `//`. + private static let hashCommentLanguages: Set = [ + "python", "py", "ruby", "rb", "sh", "bash", "shell", "zsh", "fish", + "yaml", "yml", "toml", "ini", "perl", "pl", "r", "makefile", "make", + "dockerfile", "docker", "elixir", "ex", "nim", "julia", "jl", "tcl", + ] + + /// A union of frequent keywords across popular languages. Over-inclusive is + /// fine: a keyword that doesn't apply to the current language simply won't + /// appear in its code. + private static let keywords: Set = [ + // declarations / control flow (shared across many languages) + "func", "function", "fn", "def", "let", "var", "val", "const", "static", + "final", "class", "struct", "enum", "interface", "protocol", "trait", + "impl", "extends", "implements", "namespace", "package", "module", "mod", + "import", "export", "from", "use", "using", "include", "require", + "public", "private", "protected", "internal", "fileprivate", "open", + "if", "else", "elif", "for", "while", "do", "switch", "case", "default", + "break", "continue", "return", "yield", "goto", "match", "when", "where", + "try", "catch", "except", "finally", "throw", "throws", "raise", "rescue", + "guard", "defer", "async", "await", "go", "chan", "select", "with", "as", + "is", "in", "of", "new", "delete", "typeof", "instanceof", "sizeof", + "void", "int", "long", "short", "char", "float", "double", "bool", + "boolean", "string", "unsigned", "signed", "auto", "typedef", "template", + "virtual", "override", "abstract", "extension", "init", "self", "this", + "super", "nil", "null", "none", "undefined", "true", "false", "and", + "or", "not", "lambda", "pass", "global", "nonlocal", "mut", "pub", "dyn", + "type", "object", "end", "begin", "then", "elsif", "unless", "until", + ] + + static func tokenize(_ code: String, language: String?) -> [Token] { + let ns = code as NSString + let n = ns.length + guard n > 0 else { return [] } + + let lang = language? + .trimmingCharacters(in: .whitespaces).lowercased() ?? "" + let hashComments = hashCommentLanguages.contains(lang) + + func isIdentStart(_ c: unichar) -> Bool { + (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 95 + } + func isIdentChar(_ c: unichar) -> Bool { isIdentStart(c) || (c >= 48 && c <= 57) } + func isDigit(_ c: unichar) -> Bool { c >= 48 && c <= 57 } + + var tokens: [Token] = [] + var i = 0 + while i < n { + let c = ns.character(at: i) + + // Line comment: `//` always, `#` for hash-comment languages. + if (c == 0x2F && i + 1 < n && ns.character(at: i + 1) == 0x2F) + || (c == 0x23 && hashComments) { + let start = i + while i < n && ns.character(at: i) != 0x0A { i += 1 } + tokens.append(Token(range: NSRange(location: start, length: i - start), type: .comment)) + continue + } + // Block comment `/* … */`. + if c == 0x2F && i + 1 < n && ns.character(at: i + 1) == 0x2A { + let start = i; i += 2 + while i + 1 < n && !(ns.character(at: i) == 0x2A && ns.character(at: i + 1) == 0x2F) { i += 1 } + i = min(n, i + 2) + tokens.append(Token(range: NSRange(location: start, length: i - start), type: .comment)) + continue + } + // String: "…", '…', `…` with backslash escapes; stops at end of line. + if c == 0x22 || c == 0x27 || c == 0x60 { + let quote = c, start = i; i += 1 + while i < n { + let d = ns.character(at: i) + if d == 0x5C { i += 2; continue } // escape + if d == quote { i += 1; break } + if d == 0x0A { break } + i += 1 + } + tokens.append(Token(range: NSRange(location: start, length: min(i, n) - start), type: .string)) + continue + } + // Number (incl. trailing hex/exponent/dot chars). + if isDigit(c) { + let start = i; i += 1 + while i < n { + let d = ns.character(at: i) + if isIdentChar(d) || d == 0x2E { i += 1 } else { break } + } + tokens.append(Token(range: NSRange(location: start, length: i - start), type: .number)) + continue + } + // Identifier → keyword / type / function call / plain. + if isIdentStart(c) { + let start = i; i += 1 + while i < n && isIdentChar(ns.character(at: i)) { i += 1 } + let word = ns.substring(with: NSRange(location: start, length: i - start)) + let range = NSRange(location: start, length: i - start) + if keywords.contains(word) { + tokens.append(Token(range: range, type: .keyword)) + } else if let first = word.unicodeScalars.first, first.properties.isUppercase { + tokens.append(Token(range: range, type: .type)) + } else { + // Function call: identifier immediately followed by `(`. + var j = i + while j < n && ns.character(at: j) == 0x20 { j += 1 } + if j < n && ns.character(at: j) == 0x28 { + tokens.append(Token(range: range, type: .function)) + } + } + continue + } + i += 1 + } + return tokens + } +} diff --git a/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift b/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift new file mode 100644 index 0000000..d5faeef --- /dev/null +++ b/Sources/EdmundCore/Parsing/CodeSyntaxPalette.swift @@ -0,0 +1,47 @@ +import Foundation + +// MARK: - Code Syntax Palette +// +// The single source of truth for fenced-code-block colors: the Tomorrow palette +// (light) and One Dark (dark), as plain hex strings with no AppKit dependency. +// Both consumers derive from these: +// - the editor (`EditorTextView+CodeHighlighting`) builds `NSColor`s for the +// TextKit attribute run, +// - Read mode / PDF export (`HTMLTheme`) emits CSS `color` rules, +// so Edit mode and Read mode color identical tokens identically. + +enum CodeSyntaxPalette { + + /// The hex color for a token kind (`nil` = plain, un-tokenized code text) in + /// the given appearance. Only foregrounds are themed; the block keeps its + /// background, so each palette is paired with the appearance it's legible on. + static func hex(_ type: CodeHighlighter.TokenType?, dark: Bool) -> String { + dark ? oneDark(type) : tomorrow(type) + } + + /// Tomorrow (light). + private static func tomorrow(_ type: CodeHighlighter.TokenType?) -> String { + switch type { + case nil: return "#4d4d4c" + case .keyword: return "#8959a8" + case .type: return "#c18401" + case .string: return "#718c00" + case .number: return "#f5871f" + case .comment: return "#8e908c" + case .function: return "#4271ae" + } + } + + /// One Dark. + private static func oneDark(_ type: CodeHighlighter.TokenType?) -> String { + switch type { + case nil: return "#abb2bf" + case .keyword: return "#c678dd" + case .type: return "#e5c07b" + case .string: return "#98c379" + case .number: return "#d19a66" + case .comment: return "#5c6370" + case .function: return "#61afef" + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift new file mode 100644 index 0000000..aafed5e --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift @@ -0,0 +1,727 @@ +import Foundation +import Markdown + +// MARK: - Custom Parsers +// +// Regex / scan-based passes for inline constructs that swift-markdown does not +// model. Each appends to the span list built by the AST walker (see parse()): +// +// - parseHighlight ==text== +// - parseDisplayMath $$\u{2026}$$ (block pre-merged by BlockParser) +// - parseMath $\u{2026}$ (Pandoc-style disambiguation) +// - parseLineBreak trailing backslash hard break +// - parseIndentedListItem 4+ space list items swift-markdown treats as code + +extension SyntaxHighlighter { + + private static let footnoteDefRegex = + try! NSRegularExpression(pattern: #"^\[\^([^\]\s]+)\]:"#) + private static let footnoteRefRegex = + try! NSRegularExpression(pattern: #"\[\^([^\]\s]+)\]"#) + + /// Parses footnotes (not supported by swift-markdown): + /// - `[^id]:` at the start of a block → a `.footnoteDefinition` marker. + /// - `[^id]` elsewhere → a `.footnoteReference`. + static func parseFootnotes(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + let whole = NSRange(location: 0, length: ns.length) + + // Definition marker at the very start of the block: `[^id]:`. + if let m = footnoteDefRegex.firstMatch(in: text, range: whole) { + let marker = m.range(at: 0) // includes the trailing ":" + spans.append(Span( + kind: .footnoteDefinition(id: ns.substring(with: m.range(at: 1))), + fullRange: marker, + contentRange: m.range(at: 1), + delimiterRanges: [marker])) + } + + // References `[^id]` anywhere — except the definition marker (followed by + // ":") and anything overlapping a code span or the definition above. + for m in footnoteRefRegex.matches(in: text, range: whole) { + let full = m.range(at: 0) + if full.upperBound < ns.length && ns.character(at: full.upperBound) == 0x3A { continue } + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock, .footnoteDefinition: break + default: return false + } + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + } + guard !overlaps else { continue } + spans.append(Span( + kind: .footnoteReference(id: ns.substring(with: m.range(at: 1))), + fullRange: full, + contentRange: m.range(at: 1), // the id + delimiterRanges: [NSRange(location: full.location, length: 2), // "[^" + NSRange(location: full.upperBound - 1, length: 1)])) // "]" + } + } + + private static let commentRegex = + try! NSRegularExpression(pattern: "%%([\\s\\S]*?)%%", options: []) + + /// Parses Obsidian-style `%%comment%%` spans (not supported by + /// swift-markdown). Matches across newlines within a block; skips `%%` + /// inside code spans / code blocks. + static func parseComments(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + for m in commentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let full = m.range(at: 0) + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock: break + default: return false + } + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + } + guard !overlaps else { continue } + spans.append(Span( + kind: .comment, + fullRange: full, + contentRange: m.range(at: 1), + delimiterRanges: [NSRange(location: full.location, length: 2), + NSRange(location: full.upperBound - 2, length: 2)])) + } + } + + private static let htmlCommentRegex = + try! NSRegularExpression(pattern: "") + + /// Parses HTML `` spans into the same `.comment` kind as + /// `%%…%%` (dimmed in edit mode, hidden in reading view; inner spans are + /// dropped by the opaque-range pass). Skips comments inside code / math. + static func parseHTMLComments(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + for m in htmlCommentRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let full = m.range(at: 0) + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock, .math: break + default: return false + } + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + } + guard !overlaps else { continue } + spans.append(Span( + kind: .comment, + fullRange: full, + contentRange: NSRange(location: full.location + 4, length: full.length - 7), + delimiterRanges: [NSRange(location: full.location, length: 4), + NSRange(location: full.upperBound - 3, length: 3)])) + } + } + + private static let wikiLinkRegex = + try! NSRegularExpression(pattern: #"\[\[([^\[\]\n]+?)\]\]"#) + + /// Parses Obsidian-style `[[target]]`, `[[target#heading]]`, and + /// `[[target|alias]]` internal links. The span's `contentRange` is the + /// visible display text (the alias when present, else the target); the + /// `[[`, an optional `target|`, and the `]]` are delimiter ranges hidden + /// when rendered. Skips `[[` inside code spans / code blocks. + static func parseWikiLinks(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + for m in wikiLinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let full = m.range(at: 0) + let inner = m.range(at: 1) + let innerNS = ns.substring(with: inner) as NSString + guard innerNS.length > 0 else { continue } + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock: break + default: return false + } + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + } + guard !overlaps else { continue } + + // Split target | alias on the first "|". + let pipe = innerNS.range(of: "|") + let targetRel = pipe.location == NSNotFound + ? NSRange(location: 0, length: innerNS.length) + : NSRange(location: 0, length: pipe.location) + var displayRel = pipe.location == NSNotFound + ? targetRel + : NSRange(location: pipe.upperBound, length: innerNS.length - pipe.upperBound) + if displayRel.length == 0 { displayRel = targetRel } // "[[Note|]]" → show target + + let target = innerNS.substring(with: targetRel).trimmingCharacters(in: .whitespaces) + guard !target.isEmpty || pipe.location != NSNotFound else { continue } + + let content = NSRange(location: inner.location + displayRel.location, length: displayRel.length) + let leading = NSRange(location: full.location, length: content.location - full.location) + let trailing = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound) + spans.append(Span( + kind: .wikilink(target: target), + fullRange: full, + contentRange: content, + delimiterRanges: [leading, trailing])) + } + } + + /// Parses ==highlight== spans using regex (not supported by swift-markdown). + /// GFM-style flanking: the content must not begin or end with whitespace + /// (`== spaced ==` stays literal), matching how cmark treats `**`/`~~`. + static func parseHighlight(_ text: String, into spans: inout [Span]) { + let nsText = text as NSString + guard let regex = try? NSRegularExpression(pattern: "==(?!\\s)(.+?)(?= full.upperBound + } + guard !overlaps else { continue } + let openDelim = NSRange(location: full.location, length: 2) + let closeDelim = NSRange(location: full.upperBound - 2, length: 2) + spans.append(Span( + kind: .highlight, + fullRange: full, + contentRange: content, + delimiterRanges: [openDelim, closeDelim] + )) + } + } + + /// Scans for `$$…$$` display math runs. A run can own its whole block + /// (`BlockParser` merges a multi-line `$$ … $$` into one block, so content + /// may span newlines) or sit inline within a prose line (`text $$x$$ more`). + /// + /// Tightness (space/tab, NOT newline) guards against prose false positives + /// like "pay $$5 and $$6": a `$$` delimiter must abut non-space on the inner + /// side — mirrors the Pandoc rule in `parseMath`. Newlines are allowed so a + /// block-merged `$$\n … \n$$` still matches. Runs before `parseMath`, which + /// skips ranges inside a `.math(display: true)` span. + static func parseDisplayMath(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + let n = ns.length + let dollar: unichar = 0x24, backslash: unichar = 0x5C + + // Same-line whitespace only; newlines are legal inside a display block. + func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 } + + var i = 0 + while i < n { + let c = ns.character(at: i) + if c == backslash { i += 2; continue } // skip escaped char + // Opening `$$`, abutting a non-space on its inner side. + guard c == dollar, i + 1 < n, ns.character(at: i + 1) == dollar else { i += 1; continue } + let afterOpen = i + 2 + guard afterOpen < n, !isSpace(ns.character(at: afterOpen)) else { i += 1; continue } + + // Find the closing `$$`, abutting a non-space on its inner side. + var j = afterOpen + var closeLoc = -1 + while j + 1 < n { + let cj = ns.character(at: j) + if cj == backslash { j += 2; continue } + if cj == dollar && ns.character(at: j + 1) == dollar { + if !isSpace(ns.character(at: j - 1)) { closeLoc = j; break } + j += 2; continue // `$$` preceded by space isn't a valid close + } + j += 1 + } + guard closeLoc > afterOpen else { i += 2; continue } // no close / empty content + + spans.append(Span( + kind: .math(display: true), + fullRange: NSRange(location: i, length: closeLoc + 2 - i), + contentRange: NSRange(location: afterOpen, length: closeLoc - afterOpen), + delimiterRanges: [NSRange(location: i, length: 2), + NSRange(location: closeLoc, length: 2)] + )) + i = closeLoc + 2 + } + } + + /// Scans for inline `$…$` math. Uses Pandoc-style disambiguation so prose + /// like "it cost $5 to $10" is left alone: + /// - the opening `$` is immediately followed by a non-space, non-`$` char, + /// - the closing `$` is immediately preceded by a non-space char and is + /// not followed by a digit, + /// - `\$` is a literal escape, `$$` is skipped (display math, later phase), + /// - inline math never spans a newline. + static func parseMath(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + let n = ns.length + let dollar: unichar = 0x24, backslash: unichar = 0x5C, newline: unichar = 0x0A + + func isSpace(_ c: unichar) -> Bool { c == 0x20 || c == 0x09 } + func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 } + + var i = 0 + while i < n { + let c = ns.character(at: i) + if c == backslash { i += 2; continue } // skip escaped char + if c != dollar { i += 1; continue } + // Skip display `$$` (handled per-block in a later phase). + if i + 1 < n && ns.character(at: i + 1) == dollar { i += 2; continue } + // Opening `$`: must be followed by a non-space, non-`$` character. + guard i + 1 < n else { break } + let next = ns.character(at: i + 1) + if isSpace(next) || next == dollar || next == newline { i += 1; continue } + + // Find the closing `$`. + var j = i + 1 + var close = -1 + while j < n { + let cj = ns.character(at: j) + if cj == backslash { j += 2; continue } + if cj == newline { break } // inline math stays on one line + if cj == dollar { + let prev = ns.character(at: j - 1) + let isDouble = j + 1 < n && ns.character(at: j + 1) == dollar + let nextIsDigit = j + 1 < n && isDigit(ns.character(at: j + 1)) + if !isDouble && !isSpace(prev) && !nextIsDigit { close = j; break } + } + j += 1 + } + + guard close > i + 1 else { i += 1; continue } + + let full = NSRange(location: i, length: close - i + 1) + // Don't match inside code spans or a display-math block. + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock, .math(display: true): + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + default: + return false + } + } + if !overlaps { + spans.append(Span( + kind: .math(display: false), + fullRange: full, + contentRange: NSRange(location: i + 1, length: close - i - 1), + delimiterRanges: [NSRange(location: i, length: 1), + NSRange(location: close, length: 1)] + )) + } + i = close + 1 + } + } + + /// The set of ASCII-punctuation characters CommonMark allows a backslash to + /// escape (§2.4). A `\` before any other character is a literal backslash. + private static let escapableChars: Set = { + let punct = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + return Set((punct as NSString).description.utf16) + }() + + /// Parses CommonMark backslash escapes: a `\` followed by an escapable + /// punctuation char. The backslash becomes the span's hidden/dimmed + /// delimiter; the escaped char renders literally (swift-markdown already + /// strips the escape from the AST text, so no inline span double-styles it). + /// Skips escapes inside code / math / a trailing-`\` line break so those keep + /// their raw source (e.g. `\,` inside `$…$` stays a LaTeX command). + static func parseEscapes(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + let n = ns.length + let backslash: unichar = 0x5C + var i = 0 + while i < n - 1 { + guard ns.character(at: i) == backslash, + escapableChars.contains(ns.character(at: i + 1)) else { i += 1; continue } + let full = NSRange(location: i, length: 2) + let overlaps = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock, .math, .lineBreak: + return existing.fullRange.location <= full.location + && existing.fullRange.upperBound >= full.upperBound + default: + return false + } + } + if !overlaps { + spans.append(Span( + kind: .escape, + fullRange: full, + contentRange: NSRange(location: i + 1, length: 1), + delimiterRanges: [NSRange(location: i, length: 1)])) + } + // Consume both chars so `\\` is one escape (and the 2nd `\` can't + // start another escape or be read as a trailing line break). + i += 2 + } + } + + /// Whitelisted HTML formatting tags rendered (not just colored). The inner + /// content keeps its own markdown styling. Built from `htmlFormatTags` so the + /// Edit and Read whitelists share one source of truth. + /// Known ceiling: the open tag's attr swallow `(?:\s[^>]*)?` breaks on a `>` + /// inside a quoted attribute of a whitelist pair open tag — the pair then + /// falls back to two colored `.htmlTag` tokens (acceptable). + private static let htmlPairRegex: NSRegularExpression = { + let names = htmlFormatTags.sorted().joined(separator: "|") + return try! NSRegularExpression( + pattern: "<(\(names))(?:\\s[^>]*)?>(.*?)", + options: [.caseInsensitive, .dotMatchesLineSeparators]) + }() + + /// Any single inline HTML tag per GFM §6.10: an open tag (group 1 = name, + /// full attribute grammar — names may contain hyphens, attribute values may + /// be double-quoted, single-quoted, or unquoted, and quoted values may + /// contain `>`), or a closing tag (group 2 = name; no attributes allowed). + private static let htmlTagRegex = try! NSRegularExpression(pattern: + #"<(?:([A-Za-z][A-Za-z0-9-]*)(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^\s"'=<>`]+|'[^']*'|"[^"]*"))?)*\s*/?|/([A-Za-z][A-Za-z0-9-]*)\s*)>"#) + + /// §6.10 processing instructions ``, declarations ``, and + /// CDATA `` — shown as dimmed source. HTML comments are handled + /// (more laxly than spec — interior `--` allowed, deliberate divergence) by + /// parseHTMLComments, which runs first. + private static let htmlOtherRegex = try! NSRegularExpression( + pattern: #"<\?[\s\S]*?\?>|]*>|"#) + + // `` attribute extractors — double-, single-, and unquoted values + // (§6.10). Exactly one of groups 1–3 participates per match. Shared with the + // read-mode renderer so both back-ends accept the same tags. + static let imgSrcRegex = try! NSRegularExpression( + pattern: #"\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive]) + static let imgAltRegex = try! NSRegularExpression( + pattern: #"\salt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#, options: [.caseInsensitive]) + static let imgWidthRegex = try! NSRegularExpression( + pattern: #"\swidth\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive]) + static let imgHeightRegex = try! NSRegularExpression( + pattern: #"\sheight\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))"#, options: [.caseInsensitive]) + + /// The matched value range: whichever of groups 1–3 participated. + static func attrValueRange(_ m: NSTextCheckingResult) -> NSRange { + for i in 1...3 where m.range(at: i).location != NSNotFound { return m.range(at: i) } + return m.range(at: 0) + } + + /// Parses inline HTML tags. Two tiers: + /// - a whitelisted pair (``, ``, ``, ``, ``) + /// becomes a `.htmlFormat` span whose tags hide and whose content takes a + /// rendered attribute; + /// - any other recognized tag becomes a `.htmlTag` span shown as colored + /// source (the open/close tags of a pair are not re-emitted). + /// Skips tags inside code / math, and a `\<`-escaped `<` (escapes run first). + static func parseHTMLTags(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + let whole = NSRange(location: 0, length: ns.length) + + // True if `r` sits inside a code/math span, or its `<` is an escaped `\<`. + func guarded(_ r: NSRange) -> Bool { + for span in spans { + switch span.kind { + case .code, .codeBlock, .math: + if span.fullRange.location <= r.location + && span.fullRange.upperBound >= r.upperBound { return true } + case .escape: + // The escape covers `\` + the escaped char; reject if it + // covers this tag's opening `<`. + if span.fullRange.location <= r.location + && span.fullRange.upperBound > r.location { return true } + default: + break + } + } + return false + } + + // Pass 1: whitelist pairs render. Remember each pair's tag ranges so the + // generic pass doesn't re-emit them (inner tags are still colored). + var pairTagRanges: [NSRange] = [] + for m in htmlPairRegex.matches(in: text, range: whole) { + let full = m.range(at: 0) + guard !guarded(full) else { continue } + let name = ns.substring(with: m.range(at: 1)).lowercased() + let content = m.range(at: 2) + let openTag = NSRange(location: full.location, length: content.location - full.location) + let closeTag = NSRange(location: content.upperBound, length: full.upperBound - content.upperBound) + spans.append(Span(kind: .htmlFormat(tag: name), fullRange: full, + contentRange: content, delimiterRanges: [openTag, closeTag])) + pairTagRanges.append(openTag) + pairTagRanges.append(closeTag) + } + + // Pass 2: any other recognized tag → colored source. + for m in htmlTagRegex.matches(in: text, range: whole) { + let full = m.range(at: 0) + guard !guarded(full) else { continue } + if pairTagRanges.contains(where: { + $0.location <= full.location && $0.upperBound >= full.upperBound + }) { continue } + // Group 1 = open-tag name, group 2 = closing-tag name. + let nameR = m.range(at: 1).location != NSNotFound ? m.range(at: 1) : m.range(at: 2) + + // `` renders as an inline image (like `![](…)`), + // optionally at declared pixel dimensions. Without a src the tag + // stays colored source. + if ns.substring(with: nameR).lowercased() == "img", + let srcM = imgSrcRegex.firstMatch(in: text, range: full) { + func intAttr(_ regex: NSRegularExpression) -> Int? { + regex.firstMatch(in: text, range: full) + .map { ns.substring(with: attrValueRange($0)) }.flatMap(Int.init) + } + spans.append(Span( + kind: .image(destination: ns.substring(with: attrValueRange(srcM)), + width: intAttr(imgWidthRegex), + height: intAttr(imgHeightRegex)), + fullRange: full, + contentRange: attrValueRange(srcM), + delimiterRanges: [])) + continue + } + let pre = NSRange(location: full.location, length: nameR.location - full.location) + let post = NSRange(location: nameR.upperBound, length: full.upperBound - nameR.upperBound) + spans.append(Span(kind: .htmlTag, fullRange: full, contentRange: nameR, + delimiterRanges: [pre, post])) + } + + // Pass 3: PI / declaration / CDATA → dimmed source. Zero-length content + + // full-range delimiter ⇒ the whole token dims (like a comment); tokens + // inside a real are dropped by the opaque-range pass. + for m in htmlOtherRegex.matches(in: text, range: whole) { + let full = m.range(at: 0) + guard !guarded(full) else { continue } + spans.append(Span(kind: .htmlTag, fullRange: full, + contentRange: NSRange(location: full.location, length: 0), + delimiterRanges: [full])) + } + } + + // GFM autolinks extension. Group 1 is the allowed preceding character + // (start of text, whitespace, or `*`/`_`/`~`/`(`); group 2 the candidate: + // a scheme/www URL run or an email. Trailing punctuation, unbalanced `)`, + // and `&entity;` suffixes are trimmed in code afterwards, then the domain + // is validated (≥1 dot, no `_` in the last two labels). + private static let autolinkRegex = try! NSRegularExpression( + pattern: #"(^|[\s*_~(])((?:https?://|www\.)[^\s<]+|[A-Za-z0-9._+-]+@[A-Za-z0-9._-]+)"#, + options: [.caseInsensitive]) + + /// Parses bare `www.…`/`http(s)://…`/`user@host` autolinks per the GFM + /// autolinks extension (swift-markdown doesn't attach cmark's). Emits + /// `.link` spans with no delimiters (the whole match is content). Skips + /// candidates inside code, math, comments, wikilinks, real links/images, + /// and HTML tags. Must run after every other pass. + static func parseAutolinks(_ text: String, into spans: inout [Span]) { + let ns = text as NSString + + func isTrimPunct(_ c: unichar) -> Bool { + // ? ! . , : * _ ~ ' " + switch c { + case 0x3F, 0x21, 0x2E, 0x2C, 0x3A, 0x2A, 0x5F, 0x7E, 0x27, 0x22: return true + default: return false + } + } + func isAlnum(_ c: unichar) -> Bool { + (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || (c >= 0x30 && c <= 0x39) + } + + // GFM trailing trim: strip punctuation, a `)` only while the match's + // parens are unbalanced, and a trailing `&entity;`. + func trimmedEnd(from start: Int, to initialEnd: Int) -> Int { + var end = initialEnd + loop: while end > start { + let c = ns.character(at: end - 1) + if isTrimPunct(c) { end -= 1; continue } + if c == 0x29 { // ")" + var opens = 0, closes = 0 + for i in start.. opens { end -= 1; continue } + break + } + if c == 0x3B { // ";" — strip a `&word;` entity-like suffix + var i = end - 2 + while i >= start, isAlnum(ns.character(at: i)) { i -= 1 } + if i >= start, ns.character(at: i) == 0x26, i < end - 2 { // "&" + 1+ alnum + ";" + end = i + continue loop + } + break + } + break + } + return end + } + + /// GFM valid domain: `.`-separated labels of alphanumerics/`-`/`_`, + /// at least two labels, no `_` in the last two. + func isValidDomain(_ domain: Substring) -> Bool { + let labels = domain.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + for label in labels { + guard !label.isEmpty, + label.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) + else { return false } + } + return !labels.suffix(2).contains { $0.contains("_") } + } + + for m in autolinkRegex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let candidate = m.range(at: 2) + let end = trimmedEnd(from: candidate.location, to: candidate.upperBound) + guard end > candidate.location else { continue } + let full = NSRange(location: candidate.location, length: end - candidate.location) + let match = ns.substring(with: full) + + let destination: String + if match.range(of: "^https?://", options: [.regularExpression, .caseInsensitive]) != nil { + let afterScheme = match[match.range(of: "://")!.upperBound...] + guard isValidDomain(afterScheme.prefix { + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." + }) else { continue } + destination = match + } else if match.lowercased().hasPrefix("www.") { + guard isValidDomain(match.prefix(while: { + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." + })) else { continue } + destination = "http://" + match + } else { + // Email: needs text before the `@`, a valid domain after it, + // and the last character can't be `-` or `_`. + guard let at = match.firstIndex(of: "@"), at != match.startIndex, + isValidDomain(match[match.index(after: at)...]), + match.last != "-", match.last != "_" + else { continue } + destination = "mailto:" + match + } + + let overlapsExisting = spans.contains { existing in + switch existing.kind { + case .code, .codeBlock, .math, .comment, .wikilink, + .link, .image, .htmlTag, .htmlFormat: + return existing.fullRange.location < full.upperBound + && existing.fullRange.upperBound > full.location + default: + return false + } + } + guard !overlapsExisting else { continue } + + spans.append(Span( + kind: .link(destination: destination), + fullRange: full, + contentRange: full, + delimiterRanges: [])) + } + } + + /// Parses trailing `\` as a line break indicator. + static func parseLineBreak(_ text: String, into spans: inout [Span]) { + let nsText = text as NSString + let len = nsText.length + guard len > 0 else { return } + // Must not contain \n (only applies to single-line blocks) + guard !text.contains("\n") else { return } + let lastChar = nsText.character(at: len - 1) + guard lastChar == 0x5C else { return } // backslash + // Not an escaped backslash (\\) + if len >= 2 && nsText.character(at: len - 2) == 0x5C { return } + let range = NSRange(location: len - 1, length: 1) + spans.append(Span( + kind: .lineBreak, + fullRange: range, + contentRange: NSRange(location: len - 1, length: 0), + delimiterRanges: [range] + )) + } + + /// Detects list items with deep indentation (4+ spaces or tabs) that + /// swift-markdown parses as indented code instead of list items. Group 2 is + /// the marker — an unordered bullet (`-`/`*`/`+`) or an ordered number + /// (`1.`/`1)`), so nested ordered lists are rescued too. + static let indentedListRegex = try! NSRegularExpression( + pattern: #"^([\t ]*\t[\t ]*|[ ]{4,})([-*+]|\d{1,9}[.)])\s"# + ) + + /// Matches a GFM task-list checkbox at the start of list-item content: + /// "[ ] ", "[x] ", or "[X] ". Capture group 1 is the state character. + static let checkboxRegex = try! NSRegularExpression( + pattern: #"^\[([ xX])\]\s"# + ) + + static func parseIndentedListItem(_ text: String, into spans: inout [Span]) { + // Only single-line blocks (no \n) + guard !text.contains("\n") else { return } + let nsText = text as NSString + let match = indentedListRegex.firstMatch( + in: text, range: NSRange(location: 0, length: nsText.length) + ) + guard let match = match else { return } + // Don't duplicate if swift-markdown already found a listItem + let alreadyHasListItem = spans.contains { + if case .listItem = $0.kind { return true } + return false + } + guard !alreadyHasListItem else { return } + + let full = NSRange(location: 0, length: nsText.length) + let markerEnd = match.range(at: 0).upperBound // end of " - " + + // An ordered marker (1./1)) starts with a digit; a bullet (-/*/+) doesn't. + let marker = nsText.substring(with: match.range(at: 2)) + let ordered = marker.first?.isNumber ?? false + + // Detect a GFM task-list checkbox following the marker ("[ ] "/"[x] "). + // swift-markdown skips these on deeply-indented lines (it treats the + // whole line as code), so we parse the checkbox ourselves — otherwise + // task items nested beyond level 2 render without a circle. Only the + // unordered `- [ ]` form is supported. + var checkbox: Span.Kind.CheckboxState? = nil + var delimEnd = markerEnd + if !ordered { + let afterMarker = nsText.substring(from: markerEnd) as NSString + if let cb = checkboxRegex.firstMatch( + in: afterMarker as String, + range: NSRange(location: 0, length: afterMarker.length) + ) { + let stateChar = afterMarker.substring(with: cb.range(at: 1)) + checkbox = (stateChar == "x" || stateChar == "X") ? .checked : .unchecked + delimEnd = markerEnd + cb.range(at: 0).length + } + } + + let delim = NSRange(location: 0, length: delimEnd) + let content = NSRange(location: delimEnd, length: nsText.length - delimEnd) + + // Remove any codeBlock span swift-markdown created for this indented line + spans.removeAll { span in + if case .codeBlock = span.kind { return true } + return false + } + + spans.append(Span( + kind: .listItem(ordered: ordered, checkbox: checkbox), + fullRange: full, + contentRange: content, + delimiterRanges: [delim] + )) + + // Re-parse the content for inline formatting (bold, italic, code, etc.) + // since swift-markdown treated the whole line as code and skipped them. + let contentStr = nsText.substring(with: content) + let inlineSpans = parse(contentStr) + for s in inlineSpans { + // Skip any listItem spans from the recursive parse + if case .listItem = s.kind { continue } + // Offset ranges by the content start position + let offsetFull = NSRange(location: s.fullRange.location + content.location, + length: s.fullRange.length) + let offsetContent = NSRange(location: s.contentRange.location + content.location, + length: s.contentRange.length) + let offsetDelims = s.delimiterRanges.map { + NSRange(location: $0.location + content.location, length: $0.length) + } + spans.append(Span(kind: s.kind, fullRange: offsetFull, + contentRange: offsetContent, + delimiterRanges: offsetDelims)) + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift new file mode 100644 index 0000000..c68642b --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+Walker.swift @@ -0,0 +1,549 @@ +import Foundation +import Markdown + +// MARK: - AST Walker +// +// SpanCollector walks the swift-markdown AST and records a Span for each inline +// construct it recognizes (headings, emphasis, code, links, tables, lists, …), +// translating swift-markdown SourceRanges into NSRanges over the original text. +// Constructs swift-markdown does not model (==highlight==, $math$, indented +// list items) are handled by the regex passes in +CustomParsers. + +extension SyntaxHighlighter { + + struct SpanCollector: MarkupWalker { + let source: String + private let lines: [String] + var spans: [Span] = [] + + /// Track nesting depth so we can detect bold-inside-italic (= boldItalic) + /// and avoid emitting duplicate spans. `insideEmphasis`/`insideStrong` + /// are internal (not private) so the inline visitors in + /// SyntaxHighlighter+WalkerInline can read and set them. + var insideEmphasis = false + var insideStrong = false + private var insideOrderedList = false + /// >0 while walking inside a *plain* block quote. Nested block-level + /// constructs (code blocks, headings, lists, tables, rules, nested + /// quotes) are left literal there — only inline emphasis renders. + /// Callouts are not walked at all (their bodies are rendered + /// recursively by the styling layer), so this never counts them. + private var plainQuoteDepth = 0 + + init(source: String) { + self.source = source + self.lines = source.components(separatedBy: "\n") + } + + // MARK: - Source offset conversion + + /// Converts a SourceLocation (1-indexed line, 1-indexed UTF-8 column) + /// to a UTF-16 offset suitable for NSRange. + func utf16Offset(for loc: SourceLocation) -> Int { + var utf8Offset = 0 + for i in 0..<(loc.line - 1) { + if i < lines.count { + utf8Offset += lines[i].utf8.count + 1 + } + } + utf8Offset += loc.column - 1 + + let utf8View = source.utf8 + let targetIdx = utf8View.index(utf8View.startIndex, + offsetBy: min(utf8Offset, utf8View.count)) + return source.utf16.distance( + from: source.utf16.startIndex, + to: String.Index(targetIdx, within: source.utf16) ?? source.utf16.endIndex + ) + } + + func nsRange(for range: SourceRange) -> NSRange { + let start = utf16Offset(for: range.lowerBound) + let end = utf16Offset(for: range.upperBound) + return NSRange(location: start, length: max(0, end - start)) + } + + /// Computes delimiter ranges by subtracting direct child ranges from parent. + func delimiterRanges(parent: NSRange, children: some Sequence) -> [NSRange] { + // Collect child ranges (only direct children with source ranges) + var childRanges: [NSRange] = [] + for child in children { + if let cr = child.range { + childRanges.append(nsRange(for: cr)) + } + } + guard !childRanges.isEmpty else { return [] } + + var delims: [NSRange] = [] + let firstChild = childRanges[0] + if firstChild.location > parent.location { + delims.append(NSRange(location: parent.location, + length: firstChild.location - parent.location)) + } + let lastChild = childRanges[childRanges.count - 1] + if lastChild.upperBound < parent.upperBound { + delims.append(NSRange(location: lastChild.upperBound, + length: parent.upperBound - lastChild.upperBound)) + } + return delims + } + + /// Trims delimiter ranges to the expected width for emphasis types. + /// When cmark includes unmatched delimiter characters in the emphasis + /// node's source range (e.g. `**here*` → italic with opening `**`), + /// this trims them so only the real delimiter chars are styled. + /// Returns adjusted (fullRange, delimiterRanges). + func trimEmphasisDelimiters( + expectedWidth: Int, full: NSRange, delims: [NSRange] + ) -> (NSRange, [NSRange]) { + guard delims.count == 2 else { return (full, delims) } + var trimmedDelims = delims + var trimmedFull = full + + // Opening delimiter: keep only the last `expectedWidth` chars + if delims[0].length > expectedWidth { + let excess = delims[0].length - expectedWidth + trimmedDelims[0] = NSRange(location: delims[0].location + excess, + length: expectedWidth) + trimmedFull = NSRange(location: trimmedFull.location + excess, + length: trimmedFull.length - excess) + } + + // Closing delimiter: keep only the first `expectedWidth` chars + if delims[1].length > expectedWidth { + let excess = delims[1].length - expectedWidth + trimmedDelims[1] = NSRange(location: delims[1].location, + length: expectedWidth) + trimmedFull = NSRange(location: trimmedFull.location, + length: trimmedFull.length - excess) + } + + return (trimmedFull, trimmedDelims) + } + + /// Compute content range from full range and delimiter ranges. + func contentRange(full: NSRange, delims: [NSRange]) -> NSRange { + var start = full.location + var end = full.upperBound + if let first = delims.first, first.location == full.location { + start = first.upperBound + } + if let last = delims.last, last.upperBound == full.upperBound { + end = last.location + } + return NSRange(location: start, length: max(0, end - start)) + } + + // MARK: - Visitors + + mutating func visitHeading(_ heading: Heading) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + guard let range = heading.range else { return } + let full = nsRange(for: range) + let text = (source as NSString).substring(with: full) + + // Setext heading (`Title\n===`, or `Foo\nbar\n===` per GFM Example + // 51 — content can span multiple lines): no `#` prefix. Everything + // up to the *last* line is the content; that last line is the + // underline delimiter (hidden when rendered, dimmed when active). + // The `\n`s stay untouched so the line structure survives. + if !text.drop(while: { $0 == " " }).hasPrefix("#") { + let nl = (text as NSString).range(of: "\n", options: .backwards) + guard nl.location != NSNotFound else { return } // setext is 2+ lines + spans.append(Span( + kind: .heading(heading.level), + fullRange: full, + contentRange: NSRange(location: full.location, length: nl.location), + delimiterRanges: [NSRange(location: full.location + nl.upperBound, + length: full.length - nl.upperBound)] + )) + descendInto(heading) // inline children style at heading size + return + } + + let delimLen = heading.level + 1 + // cmark already recognizes and trims a valid optional closing + // sequence (GFM 4.2, e.g. `# foo ###`) out of `heading.range` — + // it can even trim `full` down to just the opening `#` run when + // the heading is otherwise empty (`## ##`), shorter than + // `delimLen`. Clamp so an empty-content heading doesn't push + // `cStart` past what `full` actually covers. + let openDelimLen = min(delimLen, full.length) + let cStart = full.location + openDelimLen + let cLen = max(0, full.length - openDelimLen) + var delimiterRanges = [NSRange(location: full.location, length: openDelimLen)] + + // Whatever raw text follows `full` to the end of this + // single-line block is exactly what cmark trimmed as the + // closing sequence (its required separating whitespace, the + // `#` run, and any trailing whitespace) — hide it too. + let nsSource = source as NSString + let lineEnd = nsSource.length + if full.upperBound < lineEnd { + delimiterRanges.append(NSRange(location: full.upperBound, length: lineEnd - full.upperBound)) + } + + spans.append(Span( + kind: .heading(heading.level), + fullRange: full, + contentRange: NSRange(location: cStart, length: cLen), + delimiterRanges: delimiterRanges + )) + // The heading span is appended first, so inner spans read the + // heading font as their context and keep its size. + descendInto(heading) + } + + // MARK: - Code Blocks + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + guard let range = codeBlock.range else { return } + let full = nsRange(for: range) + guard full.length > 0 else { return } + + let nsSource = source as NSString + let blockText = nsSource.substring(with: full) as NSString + + // Indented code block: no fence, so no delimiters — every + // character (indentation included) is content. swift-markdown's + // node range starts *after* the first line's 4-space indent, so + // expand back over the leading whitespace or those characters + // would keep the body font and misalign the first line. + let opener = (blockText as String).drop(while: { $0 == " " }) + if !(opener.hasPrefix("```") || opener.hasPrefix("~~~")) { + var start = full.location + while start > 0 { + let c = nsSource.character(at: start - 1) + guard c == 0x20 || c == 0x09 else { break } + start -= 1 + } + let expanded = NSRange(location: start, length: full.upperBound - start) + spans.append(Span( + kind: .codeBlock(language: nil), + fullRange: expanded, + contentRange: expanded, + delimiterRanges: [] + )) + return + } + + var delims: [NSRange] = [] + var cStart = full.location + var cEnd = full.upperBound + + let firstNL = blockText.range(of: "\n") + if firstNL.location != NSNotFound { + // Opening fence line (including newline) + let openLen = firstNL.location + 1 + delims.append(NSRange(location: full.location, length: openLen)) + cStart = full.location + openLen + + // Look for closing fence line + let lastNL = blockText.range(of: "\n", options: .backwards) + if lastNL.location != NSNotFound && lastNL.location != firstNL.location { + let lastLineStart = lastNL.location + 1 + if lastLineStart < blockText.length { + let lastLine = blockText.substring(from: lastLineStart) + .trimmingCharacters(in: .whitespaces) + if lastLine.hasPrefix("```") || lastLine.hasPrefix("~~~") { + let closeStart = full.location + lastNL.location + delims.append(NSRange(location: closeStart, + length: full.upperBound - closeStart)) + cEnd = closeStart + } + } + } + } else { + // Single line (shouldn't normally happen with fenced code blocks) + delims.append(full) + cStart = full.upperBound + } + + let content = NSRange(location: cStart, length: max(0, cEnd - cStart)) + spans.append(Span( + kind: .codeBlock(language: codeBlock.language), + fullRange: full, + contentRange: content, + delimiterRanges: delims + )) + } + + // MARK: - Block Quotes + + mutating func visitBlockQuote(_ blockQuote: BlockQuote) { + guard let range = blockQuote.range else { + descendInto(blockQuote) + return + } + let full = nsRange(for: range) + let nsSource = source as NSString + let opensCallout = Self.quoteOpensCallout(firstLineOf: full, in: nsSource) + + // A callout nested inside a plain quote is fully literal: emit no + // span and don't descend (matches showing `> [!note]` raw inside an + // outer quote — callouts render via their own recursive splice, + // which isn't set up to stack with an enclosing quote's bar). A + // nested *plain* quote is the one exception to "nested blocks stay + // literal": it gets its own span (below) so its marker hides and it + // draws its own bar, stacked with its ancestors'. + if plainQuoteDepth > 0 && opensCallout { return } + + // `plainQuoteDepth` doubles as this quote's nesting depth (0 = + // outermost) — captured before incrementing for our own descent. + let depth = plainQuoteDepth + + // Scan each line within the blockquote for its OWN "> " marker. + // swift-markdown's nested-BlockQuote `range` only skips ancestor + // markers on the *first* line (its start position lands right on + // this quote's own `>`) — every subsequent line is the raw source + // verbatim, ancestor markers and all. So each later line must peel + // exactly `depth` ancestor markers before this quote's own can be + // at hand. + // + // A no-marker line is CommonMark "lazy continuation". Which quote + // it belongs to depends on depth: at the OUTERMOST quote (depth 0) + // it continues *this* quote's paragraph — BlockParser already + // merged it into the block, so keep it in the span (the bar extends + // over it) with no marker to hide. Deeper in (depth > 0), a line + // that runs out of ancestor markers, or lacks this quote's own, + // belongs to a shallower ancestor's span — clip `fullRange` before + // it. + var delims: [NSRange] = [] + var cursor = full.location + var clippedEnd = full.upperBound + var isFirstLine = true + while cursor < clippedEnd { + let remaining = NSRange(location: cursor, length: clippedEnd - cursor) + let nlRange = nsSource.range(of: "\n", options: [], range: remaining) + let lineEnd = nlRange.location != NSNotFound ? nlRange.location : clippedEnd + + var p = cursor + var ranOut = false + for _ in 0..<(isFirstLine ? 0 : depth) { + guard let after = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) else { + ranOut = true + break + } + p = after + } + if ranOut { + // Ran out of ancestor markers: line belongs to a shallower + // ancestor (nested lazy continuation). Clip here. + clippedEnd = cursor + break + } + if let markerEnd = Self.peelOneMarker(nsSource, from: p, lineEnd: lineEnd) { + delims.append(NSRange(location: p, length: markerEnd - p)) + } else if depth == 0 { + // Lazy continuation of the outermost quote: keep it in the + // span, no marker to hide, and keep scanning. + } else { + // Nested quote missing its own marker: belongs to a + // shallower ancestor. Clip here. + clippedEnd = cursor + break + } + + cursor = nlRange.location != NSNotFound ? nlRange.location + 1 : clippedEnd + isFirstLine = false + } + + let clippedFull = NSRange(location: full.location, length: clippedEnd - full.location) + let content = contentRange(full: clippedFull, delims: delims) + + spans.append(Span( + kind: .blockquote(depth: depth), + fullRange: clippedFull, + contentRange: content, + delimiterRanges: delims + )) + + // A callout's body is rendered recursively by the styling layer + // (which strips the `>` prefixes and re-parses), so don't descend — + // doing so would emit nested spans over `>`-prefixed source ranges. + // A plain quote descends, but with a depth guard so only inline + // content and further nested plain quotes render (other nested + // blocks stay literal). + if opensCallout { return } + plainQuoteDepth += 1 + descendInto(blockQuote) + plainQuoteDepth -= 1 + } + + /// Peels one `>` marker (optional leading spaces, `>`, optional single + /// trailing space) starting at `from`, returning the position right + /// after it — or `nil` if `[from, lineEnd)` doesn't start with `>` + /// (after skipping spaces). Used both to skip `depth` ancestor + /// markers and to locate this quote's own marker on a line (see + /// `visitBlockQuote`). + private static func peelOneMarker(_ source: NSString, from: Int, lineEnd: Int) -> Int? { + var q = from + while q < lineEnd, source.character(at: q) == 0x20 { q += 1 } + guard q < lineEnd, source.character(at: q) == 0x3E else { return nil } + q += 1 + if q < lineEnd, source.character(at: q) == 0x20 { q += 1 } + return q + } + + /// Whether the first line of a block quote (its source `range`) opens a + /// callout — `> [!type]` (known or unknown type). Mirrors + /// `BlockParser.quoteRunOpensCallout` over an NSRange. + private static func quoteOpensCallout(firstLineOf range: NSRange, in source: NSString) -> Bool { + let bound = NSRange(location: range.location, length: range.length) + let nl = source.range(of: "\n", options: [], range: bound) + let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location + let line = source.substring(with: NSRange(location: range.location, + length: lineEnd - range.location)) + let trimmed = line.drop(while: { $0 == " " }) + guard trimmed.first == ">" else { return false } + return Callout.parseMarker(String(trimmed.dropFirst())) != nil + } + + // MARK: - Tables + + mutating func visitTable(_ table: Table) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + guard let range = table.range else { + descendInto(table) + return + } + let full = nsRange(for: range) + + // Compute gaps between child rows (head/body) as delimiters + // (this captures the separator row between head and body) + var childRanges: [NSRange] = [] + for child in table.children { + if let cr = child.range { + childRanges.append(nsRange(for: cr)) + } + } + + var delims: [NSRange] = [] + if let first = childRanges.first, first.location > full.location { + delims.append(NSRange(location: full.location, + length: first.location - full.location)) + } + for i in 0..<(childRanges.count - 1) { + let gapStart = childRanges[i].upperBound + let gapEnd = childRanges[i + 1].location + if gapEnd > gapStart { + delims.append(NSRange(location: gapStart, + length: gapEnd - gapStart)) + } + } + if let last = childRanges.last, last.upperBound < full.upperBound { + delims.append(NSRange(location: last.upperBound, + length: full.upperBound - last.upperBound)) + } + + spans.append(Span( + kind: .table, + fullRange: full, + contentRange: full, + delimiterRanges: delims + )) + } + + // MARK: - Lists + + mutating func visitOrderedList(_ orderedList: OrderedList) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + insideOrderedList = true + descendInto(orderedList) + insideOrderedList = false + } + + mutating func visitListItem(_ listItem: ListItem) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + guard let range = listItem.range else { + descendInto(listItem) + return + } + let full = nsRange(for: range) + var delims = delimiterRanges(parent: full, children: listItem.children) + // An empty list item (just "- " / "1. " / "- [ ] " with no content, + // e.g. a marker freshly created by pressing Return) has no child + // nodes, so `delimiterRanges` finds no marker and the whole item is + // treated as content. That collapses the marker's width to zero and + // pushes the freshly-typed marker a full slot too deep. Synthesize + // the marker delimiter from the leading text so the content begins + // after it, matching a non-empty item. + if delims.isEmpty, let markerLen = Self.leadingListMarkerLength(in: source, range: full) { + delims = [NSRange(location: full.location, length: markerLen)] + } + let content = contentRange(full: full, delims: delims) + + // swift-markdown flags an item as a task list item via `checkbox`, + // but it reports the STATE by scanning the whole line for `[x]` — so + // an unchecked `- [ ]` whose body merely contains `[x]` (e.g. in a + // code span) is wrongly reported as checked. Take only the "is this a + // task item" signal from swift-markdown and read the actual state + // from the leading `[ ]`/`[x]` marker ourselves. + let checkbox: Span.Kind.CheckboxState? + if listItem.checkbox != nil { + let markerLen = max(0, content.location - full.location) + let marker = (source as NSString).substring( + with: NSRange(location: full.location, length: markerLen)) + checkbox = Self.leadingCheckboxState(inMarker: marker) + ?? (listItem.checkbox == .checked ? .checked : .unchecked) + } else { + checkbox = nil + } + + spans.append(Span( + kind: .listItem(ordered: insideOrderedList, checkbox: checkbox), + fullRange: full, + contentRange: content, + delimiterRanges: delims + )) + descendInto(listItem) + } + + /// Matches a list item's leading marker (optional indentation + + /// `-`/`*`/`+` or `N.`, plus an optional `[ ]`/`[x]` checkbox), used to + /// recover the marker range for an empty item that has no child nodes. + private static let listMarkerRegex = try! NSRegularExpression( + pattern: #"^[ \t]*(?:[-*+][ \t]+(?:\[[ xX]\][ \t]*)?|\d+\.[ \t]+)"#) + + /// Length (UTF-16) of the leading list marker within `range` of `source`, + /// or nil if the text there doesn't begin with a marker. + private static func leadingListMarkerLength(in source: String, range: NSRange) -> Int? { + let line = (source as NSString).substring(with: range) + let m = listMarkerRegex.firstMatch( + in: line, range: NSRange(location: 0, length: (line as NSString).length)) + guard let m, m.range.location == 0, m.range.length > 0 else { return nil } + return m.range.length + } + + /// Reads a task-list checkbox state from the item's leading marker text + /// (e.g. `"- [ ] "` → unchecked, `"1. [x] "` → checked) by inspecting the + /// character inside the first `[...]`. Returns nil if no bracket is found. + private static func leadingCheckboxState(inMarker marker: String) + -> Span.Kind.CheckboxState? { + let ns = marker as NSString + let open = ns.range(of: "[") + guard open.location != NSNotFound, open.upperBound < ns.length else { return nil } + switch ns.substring(with: NSRange(location: open.upperBound, length: 1)) { + case "x", "X": return .checked + case " ": return .unchecked + default: return nil + } + } + + // MARK: - Thematic Break + + mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) { + if plainQuoteDepth > 0 { return } // literal inside a plain quote + guard let range = thematicBreak.range else { return } + let full = nsRange(for: range) + + spans.append(Span( + kind: .thematicBreak, + fullRange: full, + contentRange: full, + delimiterRanges: [full] + )) + } + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift new file mode 100644 index 0000000..9b01c1c --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter+WalkerInline.swift @@ -0,0 +1,202 @@ +import Foundation +import Markdown + +// Inline-construct visitors for SpanCollector: emphasis/strong (including the +// `***boldItalic***` nesting both cmark orderings produce), inline code, +// strikethrough, links, and images. The struct, its stored state, and the +// shared source-offset/delimiter helpers live in SyntaxHighlighter+Walker; +// block-level visitors (headings, code blocks, quotes, tables, lists) stay there +// too. +extension SyntaxHighlighter.SpanCollector { + + mutating func visitEmphasis(_ emphasis: Emphasis) { + guard let range = emphasis.range else { + descendInto(emphasis) + return + } + let full = nsRange(for: range) + + if insideStrong { + // Already inside Strong — parent will have emitted boldItalic + // or we're a nested emphasis. Just descend. + descendInto(emphasis) + return + } + + // Check for ***...***: Emphasis wrapping a single Strong child + // with the same source range. + if emphasis.childCount == 1, + let strong = emphasis.children.first(where: { $0 is Strong }) as? Strong, + let strongRange = strong.range { + let strongNS = nsRange(for: strongRange) + if strongNS == full { + // This is boldItalic. Compute delimiters from the Strong's children + // (the text nodes inside), not from the Emphasis's children (the Strong). + let rawDelims = delimiterRanges(parent: full, children: strong.children) + let (trimmedFull, delims) = trimEmphasisDelimiters( + expectedWidth: 3, full: full, delims: rawDelims) + let content = contentRange(full: trimmedFull, delims: delims) + spans.append(SyntaxHighlighter.Span( + kind: .boldItalic, + fullRange: trimmedFull, + contentRange: content, + delimiterRanges: delims + )) + // Don't descend — we've handled the whole subtree + return + } + } + + // Regular italic + let rawDelims = delimiterRanges(parent: full, children: emphasis.children) + let (trimmedFull, delims) = trimEmphasisDelimiters( + expectedWidth: 1, full: full, delims: rawDelims) + let content = contentRange(full: trimmedFull, delims: delims) + spans.append(SyntaxHighlighter.Span( + kind: .italic, + fullRange: trimmedFull, + contentRange: content, + delimiterRanges: delims + )) + + insideEmphasis = true + descendInto(emphasis) + insideEmphasis = false + } + + mutating func visitStrong(_ strong: Strong) { + guard let range = strong.range else { + descendInto(strong) + return + } + let full = nsRange(for: range) + + if insideEmphasis { + // Already inside Emphasis — parent will have emitted boldItalic + // or we're nested. Just descend. + descendInto(strong) + return + } + + // Check for ***...***: Strong wrapping a single Emphasis child + // with the same source range. (cmark can produce either nesting order.) + if strong.childCount == 1, + let emph = strong.children.first(where: { $0 is Emphasis }) as? Emphasis, + let emphRange = emph.range { + let emphNS = nsRange(for: emphRange) + if emphNS == full { + let rawDelims = delimiterRanges(parent: full, children: emph.children) + let (trimmedFull, delims) = trimEmphasisDelimiters( + expectedWidth: 3, full: full, delims: rawDelims) + let content = contentRange(full: trimmedFull, delims: delims) + spans.append(SyntaxHighlighter.Span( + kind: .boldItalic, + fullRange: trimmedFull, + contentRange: content, + delimiterRanges: delims + )) + return + } + } + + // Regular bold + let rawDelims = delimiterRanges(parent: full, children: strong.children) + let (trimmedFull, delims) = trimEmphasisDelimiters( + expectedWidth: 2, full: full, delims: rawDelims) + let content = contentRange(full: trimmedFull, delims: delims) + spans.append(SyntaxHighlighter.Span( + kind: .bold, + fullRange: trimmedFull, + contentRange: content, + delimiterRanges: delims + )) + + insideStrong = true + descendInto(strong) + insideStrong = false + } + + mutating func visitInlineCode(_ code: InlineCode) { + guard let range = code.range else { return } + let full = nsRange(for: range) + guard full.length >= 2 else { return } + + // GFM §6.3: the delimiters are equal-length backtick runs of ANY length. + // Measure the actual runs in the raw source (the AST doesn't carry them). + let ns = source as NSString + let backtick: unichar = 0x60 + var open = 0 + while full.location + open < full.upperBound, + ns.character(at: full.location + open) == backtick { open += 1 } + var close = 0 + while full.upperBound - 1 - close > full.location + open - 1, + ns.character(at: full.upperBound - 1 - close) == backtick { close += 1 } + // cmark guarantees matching runs; clamp defensively so a surprise can't + // produce inverted ranges. + let d = max(1, min(min(open, close), full.length / 2)) + + // NOTE: the §6.3 one-space strip (`` ` `` → "`") is a render rule; in + // edit mode the padding spaces are source and stay in contentRange. + spans.append(SyntaxHighlighter.Span( + kind: .code, + fullRange: full, + contentRange: NSRange(location: full.location + d, length: max(0, full.length - 2 * d)), + delimiterRanges: [NSRange(location: full.location, length: d), + NSRange(location: full.upperBound - d, length: d)] + )) + } + + mutating func visitStrikethrough(_ strikethrough: Strikethrough) { + guard let range = strikethrough.range else { + descendInto(strikethrough) + return + } + let full = nsRange(for: range) + let delims = delimiterRanges(parent: full, children: strikethrough.children) + let content = contentRange(full: full, delims: delims) + + spans.append(SyntaxHighlighter.Span( + kind: .strikethrough, + fullRange: full, + contentRange: content, + delimiterRanges: delims + )) + descendInto(strikethrough) + } + + mutating func visitLink(_ link: Link) { + guard let range = link.range else { + descendInto(link) + return + } + let full = nsRange(for: range) + let delims = delimiterRanges(parent: full, children: link.children) + let content = contentRange(full: full, delims: delims) + + spans.append(SyntaxHighlighter.Span( + kind: .link(destination: link.destination ?? ""), + fullRange: full, + contentRange: content, + delimiterRanges: delims + )) + descendInto(link) + } + + mutating func visitImage(_ image: Image) { + guard let range = image.range else { + descendInto(image) + return + } + let full = nsRange(for: range) + let delims = delimiterRanges(parent: full, children: image.children) + let content = contentRange(full: full, delims: delims) + + spans.append(SyntaxHighlighter.Span( + kind: .image(destination: image.source ?? "", width: nil, height: nil), + fullRange: full, + contentRange: content, + delimiterRanges: delims + )) + descendInto(image) + } +} diff --git a/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift b/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift new file mode 100644 index 0000000..ff1e38b --- /dev/null +++ b/Sources/EdmundCore/Parsing/SyntaxHighlighter.swift @@ -0,0 +1,202 @@ +import Foundation +import Markdown + +/// Parses raw markdown using Apple's swift-markdown (cmark-gfm) and returns +/// spans identifying inline formatting with their delimiter and content ranges. +/// +/// This ensures the active block's syntax highlighting is consistent with the +/// rendered (non-active) blocks, including mismatched-delimiter edge cases +/// like `**hi*` (treated as literal `*` + italic `hi`). +/// +/// This file holds the public model (`Span`/`Kind`) and the `parse` entry +/// point. The heavy lifting lives in two siblings: +/// - SyntaxHighlighter+Walker.swift — the swift-markdown AST walker +/// - SyntaxHighlighter+CustomParsers.swift — regex passes for constructs the +/// AST doesn't model (==highlight==, $math$, indented list items) +public enum SyntaxHighlighter { + + /// Inline HTML element names that *render* their formatting rather than show + /// as colored source. Single source of truth shared by the editor's + /// `parseHTMLTags` (Edit mode) and `HTMLRenderer.sanitizeInlineHTML` (Read + /// mode), so the two back-ends can't drift on which tags are allowed. + public static let htmlFormatTags: Set = ["u", "kbd", "mark", "sub", "sup", "small"] + + // MARK: - Model + + public struct Span: Sendable { + public let kind: Kind + public let fullRange: NSRange + public let contentRange: NSRange + public let delimiterRanges: [NSRange] + + public enum Kind: Equatable, Sendable { + case bold + case italic + case boldItalic + case code + case codeBlock(language: String?) + case strikethrough + case highlight + case heading(Int) + case link(destination: String) + /// A markdown `![alt](src)` image, or an HTML `` tag + /// (which may carry declared pixel dimensions). + case image(destination: String, width: Int?, height: Int?) + /// `depth` is the nesting level (0 = outermost, not itself inside + /// another plain quote). A `> > text` emits two spans, depth 0 and + /// depth 1, so each level's own marker hides and draws its own bar. + case blockquote(depth: Int) + case listItem(ordered: Bool, checkbox: CheckboxState? = nil) + case table + case thematicBreak + case lineBreak + case math(display: Bool) + /// An inline `[^id]` footnote reference. + case footnoteReference(id: String) + /// A `[^id]:` footnote definition marker at the start of a block. + case footnoteDefinition(id: String) + /// An Obsidian-style `%%comment%%` (hidden in reading view). + case comment + /// An Obsidian-style `[[target]]` internal link. `target` is the raw + /// `path#heading` portion (before any `|alias`); the visible display + /// text is the span's contentRange. + case wikilink(target: String) + /// A CommonMark backslash escape `\X`. The backslash is the delimiter + /// (hidden when inactive, dimmed when active); the escaped character + /// `X` renders literally as its content. + case escape + /// A single inline HTML tag (`` or ``) shown only as + /// colored source: the `<`/`>`/`/` dim and the tag name colors red + /// (like math). Used for unknown / unpaired tags. `contentRange` is + /// the tag name. + case htmlTag + /// A whitelisted HTML formatting tag pair (``, etc.). When the + /// caret is outside, the open/close tags hide and the corresponding + /// attribute is applied to the inner `contentRange`; inside, the raw + /// tags show colored. `tag` is the lowercased element name; the two + /// delimiterRanges are the open and close tags. + case htmlFormat(tag: String) + + public enum CheckboxState: Equatable, Sendable { + case checked, unchecked + } + } + } + + // MARK: - Parsing + + /// Returns all inline syntax spans found in `text`, ordered by position. + /// + /// `linkDefinitions` (the document's collected `[label]: url` lines, from + /// `LinkDefinitionState.defsText`) is appended after the block so + /// swift-markdown can resolve GFM reference links whose definition lives in + /// another block; spans landing in the appended region are dropped. Empty + /// (the common case) means no append and no cost. + public static func parse(_ text: String, linkDefinitions: String = "") -> [Span] { + guard !text.isEmpty else { return [] } + + // Walk the AST over the block plus any appended reference definitions, + // then keep only spans within the original block. Custom parsers below + // still run on `text`, so their offsets need no adjustment. + let textLen = (text as NSString).length + let parseText = linkDefinitions.isEmpty ? text : text + "\n\n" + linkDefinitions + let doc = Document(parsing: parseText, options: [.disableSmartOpts]) + var walker = SpanCollector(source: parseText) + walker.visit(doc) + if !linkDefinitions.isEmpty { + walker.spans.removeAll { $0.fullRange.upperBound > textLen } + } + + // ==highlight== is not supported by swift-markdown; parse with regex. + parseHighlight(text, into: &walker.spans) + + // $$…$$ display math (the block is pre-merged by BlockParser), then + // $…$ inline math. + parseDisplayMath(text, into: &walker.spans) + parseMath(text, into: &walker.spans) + + // Trailing backslash line break (single-line blocks only). + parseLineBreak(text, into: &walker.spans) + + // Deeply indented list items (4+ spaces) that swift-markdown treats as code. + parseIndentedListItem(text, into: &walker.spans) + + // [^id] footnote references and [^id]: definition markers. + parseFootnotes(text, into: &walker.spans) + + // %%comments%% and [[wikilinks]]. Both are opaque: their inner text is + // a raw note / link target, not markdown — drop any span fully inside + // one so the content isn't re-styled. + parseComments(text, into: &walker.spans) + parseWikiLinks(text, into: &walker.spans) + + // CommonMark backslash escapes (`\*`, `\$`, …). Runs after math/line-break + // so it can defer to them; before HTML tags so `\<` defers to the escape. + parseEscapes(text, into: &walker.spans) + + // HTML `` share the `.comment` kind (and its opaque + // treatment below). Before parseHTMLTags so a tag inside a comment + // belongs to the comment, not the tag pass. + parseHTMLComments(text, into: &walker.spans) + + // Inline HTML tags: whitelist pairs render (``); any other tag is + // colored source. Runs after escapes so an escaped `\<` isn't seen as a tag. + parseHTMLTags(text, into: &walker.spans) + + // Bare www./http(s)/email autolinks (GFM extension). Last, so every + // guard (code, math, real links, HTML tags, …) is already in place. + parseAutolinks(text, into: &walker.spans) + + let opaqueRanges: [NSRange] = walker.spans.compactMap { span in + switch span.kind { + case .comment, .wikilink: return span.fullRange + default: return nil + } + } + if !opaqueRanges.isEmpty { + walker.spans.removeAll { span in + switch span.kind { + case .comment, .wikilink: return false + default: break + } + return opaqueRanges.contains { + $0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound + } + } + } + + // A callout's body is rendered recursively by the styling layer (it + // strips the `>` prefixes and re-parses the inner markdown), so drop any + // other span the custom parsers placed inside a callout — keeping it + // would double-style the body. Plain block quotes are unaffected: their + // inline spans are intentionally kept. + let calloutRanges: [NSRange] = walker.spans.compactMap { span in + guard case .blockquote(_) = span.kind, + isCalloutFirstLine(of: span.fullRange, in: text) else { return nil } + return span.fullRange + } + if !calloutRanges.isEmpty { + walker.spans.removeAll { span in + if case .blockquote(_) = span.kind { return false } + return calloutRanges.contains { + $0.location <= span.fullRange.location && $0.upperBound >= span.fullRange.upperBound + } + } + } + + return walker.spans.sorted { $0.fullRange.location < $1.fullRange.location } + } + + /// Whether the first line of `range` in `text` opens a callout (`> [!type]`). + private static func isCalloutFirstLine(of range: NSRange, in text: String) -> Bool { + let ns = text as NSString + let nl = ns.range(of: "\n", options: [], range: range) + let lineEnd = nl.location == NSNotFound ? range.upperBound : nl.location + let line = ns.substring(with: NSRange(location: range.location, + length: lineEnd - range.location)) + let trimmed = line.drop(while: { $0 == " " }) + guard trimmed.first == ">" else { return false } + return Callout.parseMarker(String(trimmed.dropFirst())) != nil + } + +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift new file mode 100644 index 0000000..f6d566a --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+CalloutRendering.swift @@ -0,0 +1,472 @@ +import AppKit + +// MARK: - Callout Rendering +// +// A callout is a block quote whose first line is `[!type]` (case-insensitive). +// swift-markdown gives us a plain `.blockquote` span; here we detect the marker +// and render the header line as an icon + title image (hiding the raw +// `[!type] …` source), with a customizable colored border + tinted background. +// Colors resolve per light/dark appearance. While the cursor is inside the +// callout the raw, editable marker is shown instead. + +extension EditorTextView { + + /// A detected callout on a block-quote span, with ranges mapped to absolute + /// offsets within the block string. + struct CalloutInfo { + let marker: Callout.Marker + let style: CalloutStyle + /// `[ '[' … end-of-first-line )` — the marker plus any custom title. + let headerRange: NSRange + /// Capitalized type name, or the custom title if the header has one. + let title: String + /// When the header carries a custom title, the source range of that + /// title text (leading whitespace trimmed). Rendered as real, wrapping + /// text so a long title wraps inside the box instead of clipping. `nil` + /// for a default callout, whose synthesized type name isn't in the + /// source and stays a compact icon+name overlay. + let customTitleRange: NSRange? + } + + /// Returns callout info if `span` (a `.blockquote`) begins with a known + /// `[!type]` marker on its first line, else `nil` (a plain block quote). + func calloutInfo(forBlockquote span: SyntaxHighlighter.Span, markdown: String) -> CalloutInfo? { + guard let firstDelim = span.delimiterRanges.min(by: { $0.location < $1.location }) + else { return nil } + let ns = markdown as NSString + let contentStart = firstDelim.upperBound + let blockEnd = min(span.fullRange.upperBound, ns.length) + guard contentStart < blockEnd else { return nil } + + let searchRange = NSRange(location: contentStart, length: blockEnd - contentStart) + let nl = ns.range(of: "\n", options: [], range: searchRange) + let lineEnd = nl.location == NSNotFound ? blockEnd : nl.location + let firstLine = ns.substring(with: NSRange(location: contentStart, length: lineEnd - contentStart)) + + guard let rel = Callout.parseMarker(firstLine), + let style = Callout.style(for: rel.type, overrides: calloutStyleOverrides) else { return nil } + + func abs(_ r: NSRange) -> NSRange { NSRange(location: r.location + contentStart, length: r.length) } + let marker = Callout.Marker(type: rel.type, + openBracket: abs(rel.openBracket), + typeRange: abs(rel.typeRange), + closeBracket: abs(rel.closeBracket)) + + let titleStart = marker.closeBracket.upperBound + let customRaw = titleStart < lineEnd + ? ns.substring(with: NSRange(location: titleStart, length: lineEnd - titleStart)) : "" + let title = Callout.title(type: marker.type, customTitle: customRaw) + let headerRange = NSRange(location: marker.openBracket.location, + length: lineEnd - marker.openBracket.location) + + // A custom title is the real source text after `]` (leading spaces + // skipped). Rendered as live wrapping text; absent → default callout. + var customTitleRange: NSRange? + if !customRaw.trimmingCharacters(in: .whitespaces).isEmpty { + var s = titleStart + while s < lineEnd, ns.character(at: s) == 0x20 { s += 1 } + customTitleRange = NSRange(location: s, length: lineEnd - s) + } + + return CalloutInfo(marker: marker, style: style, headerRange: headerRange, + title: title, customTitleRange: customTitleRange) + } + + /// Applies callout styling: the box, the icon + title header image, and the + /// recursively-rendered body. Only called for an *inactive* callout — when + /// the cursor is inside, the caller renders the raw `>` source instead so the + /// markers stay editable. + func styleCalloutContent(_ result: NSMutableAttributedString, + span: SyntaxHighlighter.Span, + info: CalloutInfo) { + guard span.fullRange.upperBound <= result.length else { return } + let c = resolvedCalloutColors(info.style) + + // The box is drawn by DecoratedTextLayoutFragment behind every + // paragraph of the callout; the fragments tile into one continuous box. + func box(bottomPad: CGFloat) -> BlockDecoration { + BlockDecoration(.box(background: c.background, + borderColor: c.border, + borderEdges: info.style.borderEdges, + borderWidth: info.style.borderWidth, + bottomPad: bottomPad)) + } + result.addAttribute(.blockDecoration, value: box(bottomPad: 0), + range: span.fullRange) + result.addAttribute(.paragraphStyle, value: calloutParagraphStyle(), + range: span.fullRange) + // Bottom breathing room: the last line's box carries a bottomPad, which + // grows that fragment's frame (see layoutFragmentFrame). The extra space + // is genuine clickable text space below the last line — clicks there + // land on the callout, the next block tiles clear, and the box covers + // it — no dead zone, no trailing paragraph spacing. + let ns = result.string as NSString + var lastLineStart = span.fullRange.location + let nl = ns.range(of: "\n", options: .backwards, + range: span.fullRange) + if nl.location != NSNotFound { lastLineStart = nl.upperBound } + let lastLine = NSRange(location: lastLineStart, + length: span.fullRange.upperBound - lastLineStart) + result.addAttribute(.blockDecoration, value: box(bottomPad: calloutBottomPad), + range: lastLine) + + // End of the header (first) line, before any body lines. + let headerNL = ns.range(of: "\n", options: [], range: span.fullRange) + let headerLineEnd = headerNL.location == NSNotFound + ? span.fullRange.upperBound : headerNL.location + + let header = info.headerRange + let headerLine = NSRange(location: span.fullRange.location, + length: headerLineEnd - span.fullRange.location) + if header.length > 0, header.upperBound <= result.length { + if let titleRange = info.customTitleRange, titleRange.upperBound <= result.length { + // Custom title: hide the `[!type]` marker and render the title as + // real bold + tinted text so a long title WRAPS inside the box. + // + // NOTE: the type icon here is a stroked vector *path* overlay, + // never an image: drawing an image on this (potentially + // multi-line, wrapping) header line wedges TextKit 2's layout to + // a single line — clipping the title — by every image-drawing + // mechanism tried, while shape drawing is unaffected. See + // docs/investigations/archives/callout-title-wrap-investigation.md. Default callouts (the + // `else` below) keep their icon+name image: their synthesized + // type name is short and never wraps, so the single-line image + // overlay never hits the wedge. + let markerHide = NSRange(location: header.location, + length: titleRange.location - header.location) + if markerHide.length > 0 { + result.addAttribute(.font, value: hiddenFont, range: markerHide) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: markerHide) + } + let titleFont = NSFontManager.shared.convert(bodyFont, toHaveTrait: .boldFontMask) + result.addAttribute(.font, value: titleFont, range: titleRange) + result.addAttribute(.foregroundColor, value: c.accent, range: titleRange) + + // Icon before the title: anchored on the hidden `[`, with the + // kern reserving the icon's advance plus a gap so the title + // starts clear of it (applyOverlay's own kern is only the icon + // width — overwrite it). + var iconAdvance: CGFloat = 0 + if let icon = calloutIconPathOverlay(iconName: info.style.iconName, + color: c.accent, titleFont: titleFont, + iconNudge: info.style.iconBaselineNudge) { + let anchor = NSRange(location: header.location, length: 1) + applyOverlay(icon, anchor: anchor, in: result) + iconAdvance = icon.bounds.width + bodyFont.pointSize * 0.3 + result.addAttribute(.kern, value: iconAdvance, range: anchor) + } + + // The title sits at the callout's left padding. The first line + // carries the width-preserved `> ` marker before the (hidden) + // `[!type]`, so its text starts `quoteMarkerWidth` past the + // inset, plus the icon's kerned advance; headIndent adds both so + // wrapped lines align under the title. Top breathing room is + // above the first line only (the box covers it). + let ps = NSMutableParagraphStyle() + ps.lineSpacing = bodyParagraphStyle.lineSpacing + ps.firstLineHeadIndent = 2 + ps.headIndent = 2 + quoteMarkerWidth + iconAdvance + ps.tailIndent = -10 + ps.paragraphSpacingBefore = calloutTopPad + result.addAttribute(.paragraphStyle, value: ps, range: headerLine) + } else { + // Default title (synthesized type name, not in the source, and + // short enough never to wrap): hide the whole header and draw the + // icon + name as one compact overlay image. + result.addAttribute(.font, value: hiddenFont, range: header) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: header) + if let overlay = calloutHeaderOverlay(iconName: info.style.iconName, + title: info.title, color: c.accent, + iconNudge: info.style.iconBaselineNudge) { + applyOverlay(overlay, anchor: NSRange(location: header.location, length: 1), + in: result) + result.addAttribute( + .paragraphStyle, + value: calloutParagraphStyle(minimumLineHeight: overlay.bounds.height + calloutTopPad), + range: headerLine) + } + } + } + + // Render the body (the lines after the header) recursively: strip one + // `>` level, re-style the inner markdown, and splice it back so nested + // code/quotes/callouts/lists/etc. render inside the box. + renderCalloutBody(result, span: span, headerLineEnd: headerLineEnd) + } + + /// Renders a callout's body — every line after the header — by stripping one + /// level of `>` prefix, running the full `styleBlock` over the stripped + /// inner markdown (which recurses into deeper callouts), and splicing the + /// resulting attributes back onto the real characters with the prefixes + /// hidden. Nested boxes/bars stack with the outer callout's box. + private func renderCalloutBody(_ result: NSMutableAttributedString, + span: SyntaxHighlighter.Span, + headerLineEnd: Int) { + let end = span.fullRange.upperBound + // Body starts after the header line's trailing newline. + guard headerLineEnd < end else { return } + let bodyStart = headerLineEnd + 1 // skip the `\n` + guard bodyStart < end else { return } + + let ns = result.string as NSString + let space: unichar = 0x20, gt: unichar = 0x3E, newline: unichar = 0x0A + + // Build the stripped inner markdown (UTF-16 units) with a parallel map + // back to real offsets, hide each line's `>` prefix, and remember each + // line's real and stripped ranges so we can splice paragraph styles and + // decorations per line. + var units: [unichar] = [] + var realIndex: [Int] = [] // stripped UTF-16 offset → real offset + var lineMap: [(real: NSRange, stripped: NSRange)] = [] + var cursor = bodyStart + while cursor < end { + let lineNL = ns.range(of: "\n", options: [], + range: NSRange(location: cursor, length: end - cursor)) + let lineEnd = lineNL.location == NSNotFound ? end : lineNL.location + + // Leading `>` prefix: optional spaces, `>`, optional single space. + var p = cursor + while p < lineEnd, ns.character(at: p) == space { p += 1 } + if p < lineEnd, ns.character(at: p) == gt { + p += 1 + if p < lineEnd, ns.character(at: p) == space { p += 1 } + } + let prefixLen = p - cursor + if prefixLen > 0 { + let pr = NSRange(location: cursor, length: prefixLen) + result.addAttribute(.font, value: hiddenFont, range: pr) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: pr) + } + + let sStart = units.count + for i in p..`-prefix membership: a line without the deeper prefix is its own + // block, so swift-markdown's lazy continuation can't pull a `> ` line + // into an adjacent `> > ` callout/quote. The body is only rendered for an + // inactive callout (the cursor is elsewhere), so inner blocks render + // fully — no cursor reveal needed. + let sub = NSMutableAttributedString(string: stripped, attributes: baseAttributes) + for b in BlockParser.parse(stripped) { + guard b.range.upperBound <= sub.length else { continue } + let styled = styleBlock(b.content, cursorPosition: nil) + styled.enumerateAttributes(in: NSRange(location: 0, length: styled.length), + options: []) { attrs, r, _ in + sub.setAttributes(attrs, + range: NSRange(location: r.location + b.range.location, length: r.length)) + } + } + + spliceStyledBody(sub, realIndex: realIndex, lineMap: lineMap, into: result) + } + + /// Splices an inner-rendered body (`sub`, in stripped space) back onto the + /// real characters. Character attributes are mapped per run (skipping the + /// hidden prefixes); paragraph styles and block decorations are applied per + /// line, inset/stacked so inner content stays inside the outer box. + private func spliceStyledBody(_ sub: NSAttributedString, + realIndex: [Int], + lineMap: [(real: NSRange, stripped: NSRange)], + into result: NSMutableAttributedString) { + let step = 2 + quoteMarkerWidth // one nesting level of horizontal inset + let subLen = sub.length + + // Character attributes (everything except paragraph style / decoration), + // mapped from stripped offsets to real offsets in coalesced runs. + sub.enumerateAttributes(in: NSRange(location: 0, length: subLen), options: []) { attrs, sr, _ in + var ca = attrs + ca[.paragraphStyle] = nil + ca[.blockDecoration] = nil + guard !ca.isEmpty else { return } + var k = sr.location + while k < sr.upperBound { + let runStart = realIndex[k] + var last = runStart + var j = k + 1 + while j < sr.upperBound, realIndex[j] == last + 1 { last = realIndex[j]; j += 1 } + result.addAttributes(ca, range: NSRange(location: runStart, length: last - runStart + 1)) + k = j + } + } + + // Paragraph styles and decorations, per body line. + for lm in lineMap { + let ss = lm.stripped.location + guard ss < subLen else { continue } + + // Paragraph style: inset by one level so inner content stays inside + // the box; preserve any inner style (list indent, centered math, …). + let innerPS = (sub.attribute(.paragraphStyle, at: ss, effectiveRange: nil) + as? NSParagraphStyle) ?? bodyParagraphStyle + let ps = innerPS.mutableCopy() as! NSMutableParagraphStyle + ps.firstLineHeadIndent += step + ps.headIndent += step + if ps.tailIndent == 0 { ps.tailIndent = -10 } + result.addAttribute(.paragraphStyle, value: ps, range: lm.real) + + // Decoration: stack any inner box/bar (inset bumped) under the + // outer callout box already present on this line. + let innerDeco = sub.attribute(.blockDecoration, at: ss, effectiveRange: nil) + let bumped = bumpedDecorations(innerDeco, by: step) + if !bumped.isEmpty { + var stack: [BlockDecoration] = [] + if let outer = result.attribute(.blockDecoration, at: lm.real.location, + effectiveRange: nil) as? BlockDecoration { + stack.append(outer) + } + stack.append(contentsOf: bumped) + result.addAttribute(.blockDecoration, value: BlockDecorationList(stack), range: lm.real) + } + } + } + + /// Returns inner decorations with every `.box` inset increased by `step` + /// (so a nested box sits within its parent); other kinds are unchanged. + private func bumpedDecorations(_ value: Any?, by step: CGFloat) -> [BlockDecoration] { + func bump(_ d: BlockDecoration) -> BlockDecoration { + if case .box = d.kind { return BlockDecoration(d.kind, inset: d.inset + step) } + return d + } + if let list = value as? BlockDecorationList { return list.decorations.map(bump) } + if let single = value as? BlockDecoration { return [bump(single)] } + return [] + } + + // MARK: Colors (appearance-aware) + + private var isDarkAppearance: Bool { + effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } + + private func resolvedCalloutColors(_ style: CalloutStyle) + -> (accent: NSColor, border: NSColor, background: NSColor) { + let dark = isDarkAppearance + let accent = NSColor(hex: style.accentHex(dark: dark)) ?? accentColor + let border = NSColor(hex: style.resolvedBorderHex(dark: dark)) ?? accent + let background: NSColor + if let bgHex = style.explicitBackgroundHex(dark: dark), let bg = NSColor(hex: bgHex) { + background = bg + } else { + background = accent.withAlphaComponent(style.backgroundAlpha) + } + return (accent, border, background) + } + + // MARK: Padding constants (shared by the box and the header image) + + /// Top breathing room — raised on the header line's minimum line height + /// (clickable text space), not dead block padding. + private var calloutTopPad: CGFloat { bodyFont.pointSize * 0.8 } + /// Bottom breathing room. Delivered by growing the last line's layout + /// fragment frame (a box `bottomPad`), so it is genuine clickable text + /// space below the last line — not trailing paragraph spacing, which + /// TextKit 2 leaves out of the fragment and which clicks would miss. + /// Tuned so the *rendered* bottom gap matches the rendered top gap: the + /// header overlay sits low in its line, so the top renders ~0.4·pointSize + /// larger than `calloutTopPad`, and this makes the bottom match it. + var calloutBottomPad: CGFloat { bodyFont.pointSize * 1.14 } + + // MARK: Paragraph style (text insets; the box itself is a BlockDecoration) + + /// Text insets the NSTextBlock padding used to provide. The left inset is + /// kept small so the callout's text lines up with a plain block quote's — + /// the quote's 2pt bar inset matches this 2pt — and the top breathing room + /// lives in the header image (clickable text space). The bottom breathing + /// room is the last line's box `bottomPad` (which grows that fragment's + /// frame), so the drawn box covers it and clicks there land on the + /// callout's last line — no trailing paragraph spacing needed. + private func calloutParagraphStyle(minimumLineHeight: CGFloat = 0) -> NSParagraphStyle { + let ps = NSMutableParagraphStyle() + ps.lineSpacing = bodyParagraphStyle.lineSpacing + ps.firstLineHeadIndent = 2 + // Hanging indent so wrapped body lines align after the `> ` marker, + // matching list items and plain blockquotes. + ps.headIndent = 2 + quoteMarkerWidth + ps.tailIndent = -10 + ps.minimumLineHeight = minimumLineHeight + return ps + } + + // MARK: Header icon (custom title — stroked path, never an image) + + /// The type icon for a custom-title header, as a stroked-path overlay + /// sized to a `pointSize` square and vertically centered on the bold + /// title's optical middle (same optics as the header image below). A path + /// — not an image — because the custom-title line wraps, and an image + /// drawn on a multi-line fragment wedges its layout to one line (see + /// docs/investigations/archives/callout-title-wrap-investigation.md). `nil` for an unknown icon. + private func calloutIconPathOverlay(iconName: String, color: NSColor, + titleFont: NSFont, iconNudge: CGFloat) -> FragmentOverlay? { + let pointSize = bodyFont.pointSize + guard let svgPath = LucideIcons.path(iconName) else { return nil } + let scale = pointSize / 24 // Lucide viewBox → icon square + var transform = CGAffineTransform(scaleX: scale, y: scale) + guard let scaled = svgPath.copy(using: &transform) else { return nil } + // bounds.minY is the icon's *bottom* relative to the baseline: center + // the square on the title's optical middle (midpoint of x-height and + // cap-height centers — matches the header image's icon placement). + let opticalCenter = (titleFont.xHeight + titleFont.capHeight) / 4 + return FragmentOverlay(path: scaled, color: color, lineWidth: 2 * scale, + bounds: CGRect(x: 0, + y: opticalCenter - pointSize / 2 + iconNudge, + width: pointSize, height: pointSize)) + } + + // MARK: Header image (icon + title) + + /// Draws "icon Title" into one image, tinted to the callout color, and + /// wraps it in a `FragmentOverlay`. Returns `nil` if the Lucide icon can't + /// be resolved. The top breathing room is NOT in the image — the caller + /// raises the header line's minimum line height instead. + private func calloutHeaderOverlay(iconName: String, title: String, color: NSColor, + iconNudge: CGFloat) -> FragmentOverlay? { + let pointSize = bodyFont.pointSize + guard let symbol = LucideIcons.image(iconName, color: color, pointSize: pointSize) + else { return nil } + + let titleFont = NSFontManager.shared.convert(bodyFont, toHaveTrait: .boldFontMask) + let titleAttrs: [NSAttributedString.Key: Any] = [.font: titleFont, .foregroundColor: color] + let titleStr = NSAttributedString(string: title, attributes: titleAttrs) + let titleSize = titleStr.size() + + let gap = pointSize * 0.3 + let symW = symbol.size.width, symH = symbol.size.height + let contentHeight = ceil(max(symH, titleSize.height)) + let width = ceil(symW + gap + titleSize.width) + + let image = NSImage(size: NSSize(width: width, height: contentHeight), flipped: false) { _ in + let titleY = (contentHeight - titleSize.height) / 2 + titleStr.draw(at: NSPoint(x: symW + gap, y: titleY)) + // Center the icon on the visual middle of the bold title: the midpoint + // between its x-height center (too low on its own) and cap-height center + // (~1.5px too high on its own). This reads as centered for the + // mostly-lowercase, capital-initial titles. + let baseline = titleY + abs(titleFont.descender) + let opticalCenter = baseline + (titleFont.xHeight + titleFont.capHeight) / 4 + symbol.draw(in: NSRect(x: 0, y: opticalCenter - symH / 2 + iconNudge, width: symW, height: symH)) + return true + } + // Re-rasterize at the screen's backing scale on every draw rather than + // caching a 1× bitmap (which would render the composited title soft). + image.cacheMode = .never + + return FragmentOverlay(image: image, + bounds: CGRect(x: 0, y: -pointSize * 0.15, + width: width, height: contentHeight)) + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift b/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift new file mode 100644 index 0000000..a76db38 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+CodeHighlighting.swift @@ -0,0 +1,39 @@ +import AppKit + +// MARK: - Code Block Syntax Highlighting +// +// Colors a fenced code block's content from `CodeHighlighter` tokens, using the +// Tomorrow palette in light appearance and One Dark in dark. Only foregrounds +// are themed — the block keeps the editor's background — so each palette is +// paired with the appearance whose background it's legible on. + +extension EditorTextView { + + private var prefersDarkCodeTheme: Bool { + effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } + + /// The `NSColor` for a token kind (`nil` = plain code) in the current + /// appearance, derived from the shared `CodeSyntaxPalette` hexes so the + /// editor and Read mode / PDF export color tokens identically. + private func codeColor(_ type: CodeHighlighter.TokenType?) -> NSColor { + NSColor(hex: CodeSyntaxPalette.hex(type, dark: prefersDarkCodeTheme)) ?? .textColor + } + + /// Applies syntax colors to a code block's content range in place. + func highlightCodeBlock(_ result: NSMutableAttributedString, + contentRange: NSRange, language: String?) { + guard contentRange.length > 0, contentRange.upperBound <= result.length else { return } + + // Plain code text first; token colors paint over it. + result.addAttribute(.foregroundColor, value: codeColor(nil), range: contentRange) + + let code = (result.string as NSString).substring(with: contentRange) + for token in CodeHighlighter.tokenize(code, language: language) { + let abs = NSRange(location: contentRange.location + token.range.location, + length: token.range.length) + guard abs.upperBound <= result.length else { continue } + result.addAttribute(.foregroundColor, value: codeColor(token.type), range: abs) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift new file mode 100644 index 0000000..9064a43 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift @@ -0,0 +1,211 @@ +import AppKit + +// MARK: - Image Rendering +// +// `![alt](path)` renders the referenced image inline when the cursor is outside +// the token, and shows the raw, editable markdown when the cursor is inside it +// (the `.image` branch of `styleBlock`). A loaded image is drawn by a +// `FragmentOverlay` anchored on the leading `!` — the same mechanism math and +// list markers use — with the rest of the markdown hidden and the line height +// reserved for the picture. An image that can't be shown (outside the token) +// gets the same overlay treatment, but with a small icon + reason in place of +// the picture, so the user knows *why* — not just that nothing rendered. +// +// Resolution: absolute paths, `~`-paths, and `file:` URLs load directly; +// relative paths resolve against the document's directory. A remote `https` +// image loads only when `allowRemoteImages` is on (mirrors Read mode's +// `allowRemoteImages`/`blockExternalImages`), and asynchronously — loading it +// synchronously on the styling path would block the main thread. `http` never +// loads: App Transport Security refuses the insecure connection outright, +// regardless of the setting (same reasoning as Read mode's DocumentHTML). + +// Loaded images are cached by resolved absolute path (local) or URL string +// (remote), so a recompose doesn't re-read/re-fetch them. NSCache is +// internally thread-safe. +nonisolated(unsafe) private let imageCache = NSCache() + +// Remote URLs currently being fetched, so a burst of re-styles (scrolling, +// cursor moves near the image) doesn't kick off duplicate downloads. Mutated +// only on the main actor: inserted synchronously from `loadRemoteImage` +// (called while styling, always on the main thread) and removed inside the +// fetch completion's `@MainActor` hop. +nonisolated(unsafe) private var inFlightRemoteImages = Set() + +// Remote URLs that were fetched and turned out not to decode as an image, so +// repeated re-styles show "Not an image" instead of re-fetching forever. +nonisolated(unsafe) private var undecodableRemoteImages = Set() + +/// Why an `![alt](destination)` couldn't be shown — the short label a +/// blocked-image placeholder draws next to its icon. Shared by Edit mode +/// (this file) and Read mode/export (`DocumentHTML`) so the two report the +/// same reason, in the same words, for the same failure. +enum ImageLoadFailure { + case httpUnsupported + case blockedBySetting + case notAnImage + case notFound + + var label: String { + switch self { + case .httpUnsupported: return "HTTP connection not supported" + case .blockedBySetting: return "External images blocked" + case .notAnImage: return "Not an image" + case .notFound: return "Image not found" + } + } +} + +extension EditorTextView { + + /// What `styleBlock` should show for an image token when the cursor is + /// outside it. + enum ImageDisplay { + /// The image loaded; draw it. + case image(NSImage) + /// It can't be shown; draw an icon + `failure.label` in its place. + case blocked(ImageLoadFailure) + /// A remote fetch is in flight — transient, not an error; the caller + /// falls back to plain alt text until a recompose picks up the result. + case pending + } + + /// Resolves and (for local files) loads the image referenced by + /// `destination`, classifying why it can't be shown when it can't. + func imageDisplay(destination: String) -> ImageDisplay { + let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dest.isEmpty else { return .blocked(.notFound) } + + if let scheme = URL(string: dest)?.scheme?.lowercased(), scheme == "http" || scheme == "https" { + guard scheme == "https" else { return .blocked(.httpUnsupported) } + guard allowRemoteImages else { return .blocked(.blockedBySetting) } + return loadRemoteImage(dest) + } + guard let url = resolveImageURL(dest) else { return .blocked(.notFound) } + let key = url.path as NSString + if let cached = imageCache.object(forKey: key) { return .image(cached) } + // `resolveImageURL` builds a URL from the path string alone (it doesn't + // check existence), so a missing file and an undecodable one both fail + // `NSImage(contentsOf:)` the same way — check existence first so the two + // get distinct, accurate messages. + guard FileManager.default.fileExists(atPath: url.path) else { return .blocked(.notFound) } + guard let image = NSImage(contentsOf: url) else { return .blocked(.notAnImage) } + imageCache.setObject(image, forKey: key) + return .image(image) + } + + /// Returns the cached/decoded outcome for a remote `urlString`; otherwise + /// starts an async fetch (once per URL, while one is already in flight) + /// and returns `.pending`. The completion caches the image (or remembers a + /// decode failure) and re-styles the document so the result appears — + /// without blocking the main thread on network I/O. + private func loadRemoteImage(_ urlString: String) -> ImageDisplay { + let key = urlString as NSString + if let cached = imageCache.object(forKey: key) { return .image(cached) } + if undecodableRemoteImages.contains(urlString) { return .blocked(.notAnImage) } + guard !inFlightRemoteImages.contains(urlString), let url = URL(string: urlString) else { return .pending } + inFlightRemoteImages.insert(urlString) + + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + let image = data.flatMap { NSImage(data: $0) } + Task { @MainActor in + inFlightRemoteImages.remove(urlString) + if let image { + imageCache.setObject(image, forKey: urlString as NSString) + } else { + undecodableRemoteImages.insert(urlString) + } + self?.recomposeAllDirty() + } + }.resume() + return .pending + } + + /// Resolves a destination string to a local file URL. Returns nil for a + /// remote URL (handled separately, before this is reached) or when a + /// relative path can't be anchored. + private func resolveImageURL(_ destination: String) -> URL? { + let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dest.isEmpty else { return nil } + + if let url = URL(string: dest), let scheme = url.scheme { + return scheme == "file" ? url : nil + } + if dest.hasPrefix("/") { return URL(fileURLWithPath: dest) } + if dest.hasPrefix("~") { + return URL(fileURLWithPath: (dest as NSString).expandingTildeInPath) + } + // Relative to the document's directory. + if let docDir = document?.fileURL?.deletingLastPathComponent() { + return docDir.appendingPathComponent(dest) + } + return nil + } + + /// A `FragmentOverlay` for `destination`'s image or placeholder, or nil + /// while a remote fetch is pending (the caller then shows plain alt text). + /// `width`/`height` are declared pixel dimensions from an HTML `` tag. + func imageOverlay(destination: String, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? { + switch imageDisplay(destination: destination) { + case .image(let image): + return scaledOverlay(image: image, width: width, height: height) + case .blocked(let failure): + return placeholderOverlay(failure: failure) + case .pending: + return nil + } + } + + /// Scales `image` down to fit the text width while keeping its aspect + /// ratio. `bounds.minY == 0` sits the image bottom on the text baseline + /// (the reserved line height makes room above it). Declared `width`/ + /// `height` override the natural size first (one alone scales the other + /// proportionally); the max-width clamp still applies after. + private func scaledOverlay(image: NSImage, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? { + var size = image.size + guard size.width > 0, size.height > 0 else { return nil } + + switch (width, height) { + case let (w?, h?): size = NSSize(width: CGFloat(w), height: CGFloat(h)) + case let (w?, nil): size = NSSize(width: CGFloat(w), + height: size.height * CGFloat(w) / size.width) + case let (nil, h?): size = NSSize(width: size.width * CGFloat(h) / size.height, + height: CGFloat(h)) + case (nil, nil): break + } + + let maxWidth = availableContentWidth + if maxWidth > 0, size.width > maxWidth { + size = NSSize(width: maxWidth, height: size.height * (maxWidth / size.width)) + } + return FragmentOverlay(image: image, + bounds: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + } + + /// Draws "icon reason" into one image (same technique as the callout + /// header: `LucideIcons.image` tinted to match the muted text), so a + /// blocked/missing/undecodable image reads at a glance instead of just + /// showing nothing. + private func placeholderOverlay(failure: ImageLoadFailure) -> FragmentOverlay? { + let pointSize = bodyFont.pointSize + guard let icon = LucideIcons.image("image-off", color: .secondaryLabelColor, pointSize: pointSize) + else { return nil } + + let labelAttrs: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor] + let label = NSAttributedString(string: failure.label, attributes: labelAttrs) + let labelSize = label.size() + + let gap = pointSize * 0.3 + let iconW = icon.size.width, iconH = icon.size.height + let height = ceil(max(iconH, labelSize.height)) + let width = ceil(iconW + gap + labelSize.width) + + let image = NSImage(size: NSSize(width: width, height: height), flipped: false) { _ in + icon.draw(in: NSRect(x: 0, y: (height - iconH) / 2, width: iconW, height: iconH)) + label.draw(at: NSPoint(x: iconW + gap, y: (height - labelSize.height) / 2)) + return true + } + image.cacheMode = .never // re-rasterize at the screen's backing scale, like the callout header + + return FragmentOverlay(image: image, bounds: CGRect(x: 0, y: 0, width: width, height: height)) + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift new file mode 100644 index 0000000..47d2a82 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ListMarkerRendering.swift @@ -0,0 +1,93 @@ +import AppKit + +/// List-item marker styling: maps a list item's leading whitespace to a nesting +/// depth, indents the content by one marker "slot" per level (Apple Notes +/// style), and positions the raw/rendered marker so the text column stays put +/// whether or not the caret is inside the item. Extracted from the `styleBlock` +/// switch in EditorTextView+Rendering. +extension EditorTextView { + + /// Styles the `.listItem` content for one span. The caller has already + /// bounds-checked `span.fullRange` against `result`. + func styleListItemSpan(_ result: NSMutableAttributedString, + span: SyntaxHighlighter.Span, + markdown: String, + ordered: Bool, + checkbox: SyntaxHighlighter.Span.Kind.CheckboxState?, + cursorInToken: Bool) { + // Indentation model (Apple Notes style): each nesting level steps + // in by one marker "slot" (pointSize-wide icon + a space), so a + // child's marker lands under its parent's content. All list types + // share the same slot, so their text lines up. The leading + // whitespace is hidden (by the delimiter styling) and the indent + // comes entirely from the paragraph style. + let markerStr = (markdown as NSString).substring(to: span.contentRange.location) + let leadingWS = markerStr.prefix(while: { $0 == " " || $0 == "\t" }) + let spaceWidth = (" " as NSString).size(withAttributes: [.font: bodyFont]).width + let slotWidth = bodyFont.pointSize + spaceWidth + let depth = listDepth(leadingWhitespace: String(leadingWS)) + let markerStart = listPadding + CGFloat(depth) * slotWidth + let contentIndent = markerStart + slotWidth + // The visible marker text ("- ", "1. ", "- [ ] "), without the + // leading whitespace (which we hide below). + let markerText = String(markerStr.dropFirst(leadingWS.count)) + let markerWidth = (markerText as NSString).size(withAttributes: [.font: bodyFont]).width + let firstLineIndent: CGFloat + // For an active bullet we left-shift the raw "-" onto the dot's + // column; this kern widens its trailing space so the content + // still begins at contentIndent (set after the paragraph style). + var activeBulletSpaceKern: CGFloat = 0 + if ordered || (cursorInToken && checkbox != nil) { + // Ordered marker, or an active checkbox: right-align the marker + // into its slot so the content begins at `contentIndent` — the + // same place as the rendered (inactive) form. This keeps the + // item aligned at every depth (and clicking in doesn't shift + // the text), while leaving the raw "1." / "- [ ]" editable. + // Wrapped lines hang at contentIndent via headIndent. + firstLineIndent = max(2, contentIndent - markerWidth) + } else if cursorInToken { + // Active bullet: sit the raw "-" on the dot's column instead of + // right-aligning it into the slot, so the marker doesn't jump + // sideways when you click into the item. The inactive dot is + // centered in a pointSize-wide box at markerStart, so center the + // dash there too; the kern below keeps the content at + // contentIndent. + let dashWidth = ("-" as NSString).size(withAttributes: [.font: bodyFont]).width + firstLineIndent = max(2, markerStart + (bodyFont.pointSize - dashWidth) / 2) + activeBulletSpaceKern = max(0, contentIndent - (firstLineIndent + markerWidth)) + } else { + // Inactive bullet/checkbox: the marker icon sits at markerStart. + firstLineIndent = markerStart + } + // Hide the leading indentation — the indent is provided entirely by + // the paragraph style. swift-markdown's list-item delimiter range + // starts at the marker and excludes this whitespace, so without + // hiding it here those spaces render visibly and push the first line + // right, breaking alignment with the hanging (wrapped-line) indent. + // (The deep-indent rescue parser already includes the whitespace in + // its delimiter; the delimiter styling below avoids re-showing it.) + let wsLen = leadingWS.count + if wsLen > 0 { + let lead = NSRange(location: 0, length: wsLen) + result.addAttribute(.font, value: hiddenFont, range: lead) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: lead) + } + // Apply paragraph style from position 0 — NSTextView uses the paragraph + // style from the first character of a paragraph. + result.addAttribute(.paragraphStyle, + value: listParagraphStyle(firstLineIndent: firstLineIndent, contentIndent: contentIndent), + range: NSRange(location: 0, length: result.length)) + // Active bullet: widen the marker's trailing space so the content + // lands at contentIndent even though the "-" sits on the dot column. + if activeBulletSpaceKern > 0, span.contentRange.location > 0, + span.contentRange.location <= result.length { + result.addAttribute(.kern, value: activeBulletSpaceKern, + range: NSRange(location: span.contentRange.location - 1, length: 1)) + } + // Strikethrough checked items + if !ordered, checkbox == .checked { + result.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange) + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.contentRange) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift new file mode 100644 index 0000000..3d035c8 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+ListRendering.swift @@ -0,0 +1,202 @@ +import AppKit + +// MARK: - List Rendering +// +// Everything that turns a list item's marker into its rendered form, used by +// the `.listItem` branch of `styleBlock` (in EditorTextView+Rendering.swift): +// +// - Indentation: `listPadding`, `listParagraphStyle`, `listDepth`. Each +// nesting level steps in by one marker "slot" so a child's marker lands +// under its parent's content (Apple Notes style). +// - Marker icons: `checkboxAttachment` (circle), `bulletAttachment` (dot). +// - `styleListDelimiter` applies the right treatment per list type when the +// item is inactive (cursor outside): bullet → dot, checkbox → circle, +// ordered → dimmed "N." right-aligned into the slot. The leading +// whitespace is hidden so the indent comes purely from the paragraph style. + +extension EditorTextView { + + // MARK: Indentation + + /// Fixed padding before the bullet/number marker for all list items. + var listPadding: CGFloat { 16 } + + /// Paragraph style for a list item. `firstLineIndent` positions the marker + /// line; `contentIndent` (the hanging indent) aligns wrapped lines and the + /// text after the marker. + func listParagraphStyle(firstLineIndent: CGFloat, contentIndent: CGFloat) -> NSParagraphStyle { + let ps = NSMutableParagraphStyle() + ps.lineSpacing = bodyParagraphStyle.lineSpacing + ps.paragraphSpacing = bodyParagraphStyle.paragraphSpacing + ps.firstLineHeadIndent = firstLineIndent + ps.headIndent = contentIndent + return ps + } + + /// Nesting depth of a list item from its leading whitespace, using the + /// document's detected indent unit (a tab counts as one unit/level). + func listDepth(leadingWhitespace ws: String) -> Int { + let unit = max(1, listIndentUnit) + var cols = 0 + for ch in ws { cols += (ch == "\t") ? unit : 1 } + return cols / unit + } + + // MARK: Marker Icons + + /// Creates a fragment overlay with an SF Symbol for checkbox rendering. + /// Unchecked: dim outlined `circle`. Checked: filled `checkmark.circle.fill`. + private func checkboxOverlay(checked: Bool) -> FragmentOverlay { + let fontSize = bodyFont.pointSize + let symbolName = checked ? "checkmark.circle.fill" : "circle" + // Checked: white checkmark knocked out of an accent-tinted circle (two + // palette layers — checkmark first, circle second). Unchecked: dim outline. + let palette: [NSColor] = checked ? [.white, accentColor] : [.tertiaryLabelColor] + let config = NSImage.SymbolConfiguration(pointSize: fontSize, weight: .regular) + .applying(NSImage.SymbolConfiguration(paletteColors: palette)) + + // Render the symbol centered in a fontSize-square box so the box (and + // therefore list indentation) stays identical to the previous icon. + let symbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(config) + let box = NSSize(width: fontSize, height: fontSize) + let image = NSImage(size: box, flipped: false) { _ in + guard let symbol else { return true } + let s = symbol.size + symbol.draw(in: NSRect(x: (box.width - s.width) / 2, + y: (box.height - s.height) / 2, + width: s.width, height: s.height)) + return true + } + + // Vertically center the circle relative to the text baseline + return FragmentOverlay(image: image, + bounds: CGRect(x: 0, y: -fontSize * 0.15, + width: fontSize, height: fontSize)) + } + + /// Creates an overlay with a small filled dot for unordered bullets, + /// sized to the same box as the checkbox circle so bullet and todo lists + /// share one indentation (Apple Notes style). + private func bulletOverlay() -> FragmentOverlay { + let fontSize = bodyFont.pointSize + let image = NSImage(size: NSSize(width: fontSize, height: fontSize), flipped: true) { bounds in + let r = fontSize * 0.13 // small dot + let dot = NSRect(x: bounds.midX - r, y: bounds.midY - r, width: 2 * r, height: 2 * r) + // Match the dim used for numbered-list markers (syntaxDimColor). + NSColor.tertiaryLabelColor.setFill() + NSBezierPath(ovalIn: dot).fill() + return true + } + return FragmentOverlay(image: image, + bounds: CGRect(x: 0, y: -fontSize * 0.15, + width: fontSize, height: fontSize)) + } + + // MARK: Marker Styling + + /// Applies custom non-active styling to a list item's delimiter range. + /// - Unordered bullet: small dot attachment. + /// - Unchecked / checked checkbox: circle icon (Apple Notes style). + /// - Ordered: dimmed "N." marker. + /// In all cases the leading whitespace is hidden so the indentation comes + /// from the paragraph style. + func styleListDelimiter( + _ result: NSMutableAttributedString, + markdown: String, + delimiterRange dr: NSRange, + ordered: Bool, + checkbox: SyntaxHighlighter.Span.Kind.CheckboxState? + ) { + if ordered { + // Ordered lists: hide the leading whitespace (indent comes from the + // paragraph style) and dim the "N." marker. + let nsDelim = (markdown as NSString).substring(with: dr) as NSString + let digit = nsDelim.rangeOfCharacter(from: .decimalDigits) + let wsLen = digit.location == NSNotFound ? 0 : digit.location + if wsLen > 0 { + let before = NSRange(location: dr.location, length: wsLen) + result.addAttribute(.font, value: hiddenFont, range: before) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: before) + } + let numStart = dr.location + wsLen + result.addAttribute(.foregroundColor, value: syntaxDimColor, + range: NSRange(location: numStart, length: dr.upperBound - numStart)) + return + } + + if checkbox == nil { + // Plain bullet: render the dash as a small dot (Apple Notes style), + // sized to the checkbox box so all list types share one indent. + let nsDelim = (markdown as NSString).substring(with: dr) as NSString + let markerRel = nsDelim.rangeOfCharacter(from: CharacterSet(charactersIn: "-*+")) + guard markerRel.location != NSNotFound else { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + return + } + let markerAbs = dr.location + markerRel.location + // Hide any leading whitespace before the bullet (matches checkbox). + if markerRel.location > 0 { + let before = NSRange(location: dr.location, length: markerRel.location) + result.addAttribute(.font, value: hiddenFont, range: before) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: before) + } + // Dot overlay on the (hidden) bullet character. + applyOverlay(bulletOverlay(), anchor: NSRange(location: markerAbs, length: 1), + in: result) + // Dim the trailing space(s) after the bullet. + let afterStart = markerAbs + 1 + if afterStart < dr.upperBound { + result.addAttribute(.foregroundColor, value: syntaxDimColor, + range: NSRange(location: afterStart, length: dr.upperBound - afterStart)) + } + return + } + + guard let checkbox = checkbox else { return } + + let nsDelim = (markdown as NSString).substring(with: dr) as NSString + + // --- Checkbox item: replace [ ]/[x] with circle icon --- + let bracketOpen = nsDelim.range(of: "[") + guard bracketOpen.location != NSNotFound else { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + return + } + let afterOpen = NSRange(location: bracketOpen.upperBound, + length: nsDelim.length - bracketOpen.upperBound) + let bracketClose = nsDelim.range(of: "]", options: [], range: afterOpen) + guard bracketClose.location != NSNotFound else { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + return + } + + let cbStart = dr.location + bracketOpen.location + let cbEnd = dr.location + bracketClose.upperBound + + // Hide everything before `[` (the "- " prefix) — zero-width + clear + if bracketOpen.location > 0 { + let before = NSRange(location: dr.location, length: bracketOpen.location) + result.addAttribute(.font, value: hiddenFont, range: before) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: before) + } + + // Circle overlay on the (hidden) `[` character + applyOverlay(checkboxOverlay(checked: checkbox == .checked), + anchor: NSRange(location: cbStart, length: 1), in: result) + + // Hide remaining checkbox characters (` ]`/`x]`) with zero-width + clear + let hideStart = cbStart + 1 + if hideStart < cbEnd { + let hideRange = NSRange(location: hideStart, length: cbEnd - hideStart) + result.addAttribute(.font, value: hiddenFont, range: hideRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange) + } + + // Dim everything after `]` (trailing space) + if cbEnd < dr.upperBound { + let after = NSRange(location: cbEnd, length: dr.upperBound - cbEnd) + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: after) + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift new file mode 100644 index 0000000..1918312 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift @@ -0,0 +1,184 @@ +import AppKit +import SwiftMath + +/// A rendered equation: the image plus its typesetting descent, which we need to +/// sit the math on the surrounding text's baseline. +private final class MathRender { + let image: NSImage + let descent: CGFloat + init(image: NSImage, descent: CGFloat) { + self.image = image + self.descent = descent + } +} + +/// Rendered math is cached so we don't re-typeset on every keystroke or +/// recompose. The key encodes everything that affects the pixels/metrics: +/// latex, display vs inline, font size, and the resolved text color. +// NSCache is internally thread-safe; `nonisolated(unsafe)` opts it out of the +// Swift 6 Sendable check (in practice it's only touched on the main actor). +nonisolated(unsafe) private let mathRenderCache = NSCache() + +extension EditorTextView { + + /// Renders a LaTeX string to a `FragmentOverlay` sized to `fontSize` and + /// aligned to the text baseline, or `nil` if SwiftMath can't parse it (the + /// caller then shows the raw source instead). + func mathOverlay(latex: String, display: Bool, fontSize: CGFloat) -> FragmentOverlay? { + // Resolve the (dynamic) text color against this view's appearance so the + // math renders in the right shade for light/dark — and so the cache key + // differs between the two. + var color = foregroundColor + effectiveAppearance.performAsCurrentDrawingAppearance { + color = self.foregroundColor.usingColorSpace(.deviceRGB) ?? self.foregroundColor + } + let tag = String(format: "%.1f,%.3f,%.3f,%.3f,%.3f", fontSize, + color.redComponent, color.greenComponent, + color.blueComponent, color.alphaComponent) + let key = "\(display ? "D" : "I")|\(tag)|\(latex)" as NSString + + let render: MathRender + if let cached = mathRenderCache.object(forKey: key) { + render = cached + } else { + let mode: MTMathUILabelMode = display ? .display : .text + let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode) + // SwiftMath sizes the image to the exact typographic ascent+descent, + // which crops a glyph's ink overshoot below the baseline — the bottom + // of a lone `x`/`c` sits flush on the image edge and renders clipped. + // A small content inset gives the rasterizer room so the full glyph is + // drawn; it's folded into the descent below so alignment is unchanged. + let insetPad: CGFloat = 2 + math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0) + let (error, image) = math.asImage() + guard error == nil, let image else { return nil } + + // Typeset once more via a label to read ascent/descent, then compute + // the baseline's distance from the image bottom the way SwiftMath's + // asImage does — including its `height < fontSize/2` clamp, which + // re-centers small glyphs (a lone x/c/n). Ignoring the clamp left + // those a pixel below the surrounding text baseline. + let label = MTMathUILabel() + label.latex = latex + label.fontSize = fontSize + label.labelMode = mode + label.layout() + let asc = label.displayList?.ascent ?? 0 + let desc = label.displayList?.descent ?? 0 + let clamped = max(asc + desc, fontSize / 2) + let descent = (asc + desc - clamped) / 2 + desc + insetPad + + render = MathRender(image: image, descent: descent) + mathRenderCache.setObject(render, forKey: key) + } + + var width = render.image.size.width + var height = render.image.size.height + var descent = render.descent + // Interim until SwiftMath line-wrapping ships: if the equation is wider + // than the text area, scale it down to fit (otherwise leave it natural + // size). The baseline descent scales with it. + let maxWidth = availableContentWidth + if maxWidth > 0, width > maxWidth { + let scale = maxWidth / width + width *= scale + height *= scale + descent *= scale + } + // The rendered image's baseline sits exactly one device pixel below the + // surrounding text baseline (measured constant across font sizes — it's a + // fixed rasterization offset, not a size-dependent rounding). Lift the + // image by one device pixel so the math rests on the text baseline. Done + // here, not in the cached descent, so it tracks the window's scale if it + // moves between a Retina and a non-Retina display. + let backingScale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + descent -= 1 / backingScale + // Drop the image so its baseline (descent above the image bottom) lands + // on the text baseline. + return FragmentOverlay(image: render.image, + bounds: CGRect(x: 0, y: -descent, width: width, height: height)) + } + + /// The usable text width for one line — the text container minus its line + /// fragment padding on both sides. Used to cap over-wide equations (and + /// over-wide images). + var availableContentWidth: CGFloat { + guard let container = textContainer else { return 0 } + return container.containerSize.width - 2 * container.lineFragmentPadding + } + + // MARK: - Raw LaTeX Source (shown when the cursor is inside the math) + + /// Colors raw LaTeX source: operators/commands (`_`, `^`, `\sum`, `\cdot`, + /// i.e. a backslash followed by letters) in the theme's math-operator color, + /// and numbers in the math-number color. Other characters keep their color. + func colorMathSource(_ result: NSMutableAttributedString, range: NSRange) { + guard range.length > 0, range.upperBound <= result.length else { return } + let ns = result.string as NSString + let opColor = theme.mathOperatorColor + let numColor = theme.mathNumberColor + let backslash: unichar = 0x5C, underscore: unichar = 0x5F, caret: unichar = 0x5E + + func isAlpha(_ c: unichar) -> Bool { (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) } + func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 } + + var i = range.location + let end = range.upperBound + while i < end { + let c = ns.character(at: i) + if c == backslash { + // Command: backslash + following letters (\sum, \cdot). A + // backslash before a non-letter (\,, \{) colors just the pair. + var j = i + 1 + while j < end, isAlpha(ns.character(at: j)) { j += 1 } + let cmdEnd = j > i + 1 ? j : min(i + 2, end) + result.addAttribute(.foregroundColor, value: opColor, + range: NSRange(location: i, length: cmdEnd - i)) + i = cmdEnd + } else if c == underscore || c == caret { + result.addAttribute(.foregroundColor, value: opColor, + range: NSRange(location: i, length: 1)) + i += 1 + } else if isDigit(c) { + var j = i + 1 + while j < end, isDigit(ns.character(at: j)) { j += 1 } + result.addAttribute(.foregroundColor, value: numColor, + range: NSRange(location: i, length: j - i)) + i = j + } else { + i += 1 + } + } + } + + /// Centered paragraph style for display math. The vertical padding is applied + /// only to the image's (first) line — a multi-line `$$…$$` block is + /// several paragraphs in the text storage (its hidden inner lines), so + /// padding every paragraph would multiply into a huge gap. + /// + /// `imageAscent`/`imageDescent` reserve the equation's height on that line: + /// the line's own characters are hidden (near-zero), so without it the line + /// collapses. They're reserved separately, not as one combined height, + /// because of how TextKit 2 grows a line to meet `minimumLineHeight`: with + /// the hidden anchor's near-zero natural metrics, it adds ~all of the extra + /// height as ascent and pins the baseline at the box's bottom edge (measured + /// empirically — a tall multi-row image split ~54/46 ascent/descent left a + /// matching-sized gap above the equation and an overlap with the next + /// paragraph below, since `minimumLineHeight = full height` reserved that + /// height entirely above the baseline). Reserving only `imageAscent` in + /// `minimumLineHeight` sits the image's top flush with the box's top instead + /// of leaving a surplus gap, and folding `imageDescent` into the *following* + /// spacing (rather than the line's own height) gives the part of the image + /// that hangs below the baseline somewhere to go before the next paragraph. + func displayMathParagraphStyle(padded: Bool, imageAscent: CGFloat = 0, + imageDescent: CGFloat = 0) -> NSParagraphStyle { + let ps = NSMutableParagraphStyle() + ps.alignment = .center + ps.lineSpacing = 0 + let pad = padded ? bodyFont.pointSize * 0.9 : 0 + ps.paragraphSpacingBefore = pad + ps.paragraphSpacing = pad + imageDescent + ps.minimumLineHeight = imageAscent + return ps + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift new file mode 100644 index 0000000..9d57663 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift @@ -0,0 +1,750 @@ +import AppKit + +extension NSAttributedString.Key { + /// Stores a link's destination (URL string) on its visible text so a + /// cmd+click can follow it. Kept separate from the system `.link` attribute + /// to avoid NSTextView's built-in link styling/cursor behavior. + static let editorLinkURL = NSAttributedString.Key("EditorLinkURL") + /// Stores a wikilink's raw `path#heading` target on its visible text so a + /// cmd+click can resolve it to a file or in-document heading. + static let editorWikiTarget = NSAttributedString.Key("EditorWikiTarget") +} + +// MARK: - Word-Level Styling +// +// This file is the heart of the inline live preview. `styleBlock` takes one +// block's raw markdown, parses it into spans (SyntaxHighlighter), and returns +// an NSAttributedString that decorates the *same* characters — the text storage +// always holds the raw markdown, never a stripped version. Formatting is purely +// attribute-based: +// +// - Content gets rich styling (bold/italic, code color, heading size, …). +// - Inline delimiters (`**`, `*`, `` ` ``, `$`) are hidden when the cursor is +// outside the token (near-zero font + clear color) and dimmed when inside. +// - Block markers (`#`, `>`, list bullets) are decorated or dimmed, never +// stripped, so editing stays WYSIWYG-ish and round-trips losslessly. +// +// Larger, self-contained pieces live in sibling files to keep this one focused: +// - EditorTextView+ListMarkerRendering.swift — the `.listItem` styling case +// - EditorTextView+TableRendering.swift — the `.table` styling case +// - EditorTextView+ListRendering.swift — list/checkbox/bullet markers + indent +// - EditorTextView+TableSupport.swift — table border blocks + row parsing +// - EditorTextView+MathRendering.swift — `$…$` / `$$…$$` rendering + raw coloring +// +// What remains here: the styling primitives (fonts/colors/paragraph styles), +// the `styleBlock` switch that dispatches per span kind, and the in-place +// `restyleBlock` / `applyBlockStyle` used to re-style a single block on edits. + +extension EditorTextView { + + /// Color for dimmed syntax delimiters (*, **, `, #, etc.) + var syntaxDimColor: NSColor { .tertiaryLabelColor } + + /// Color for links and wikilinks — always the theme's accent blue, independent of + /// the system accent so links stay consistently blue across user accent preferences. + var linkColor: NSColor { theme.linkBlueColor } + + /// Monospaced font for tables. + var tableFont: NSFont { theme.monospaceFont() } + + /// Monospaced font for code blocks. + var codeBlockFont: NSFont { theme.monospaceFont() } + + /// Font used to visually hide delimiter characters. + /// Near-zero size makes them effectively invisible and zero-width. + var hiddenFont: NSFont { NSFont.systemFont(ofSize: 0.01) } + + /// Monospaced font for inline code spans. + var inlineCodeFont: NSFont { theme.monospaceFont() } + + /// Subtle background color for inline code spans. + var inlineCodeBackground: NSColor { + NSColor(calibratedWhite: 0.5, alpha: 0.1) + } + + /// Paragraph style for thematic breaks. The raw dashes are hidden with a + /// near-zero font, which would collapse the line — so we force the line to a + /// full body-line height and add symmetric breathing space above and below. + /// A `.horizontalRule` BlockDecoration draws the hairline centered in it. + private func thematicBreakParagraphStyle() -> NSParagraphStyle { + let lineHeight = bodyFont.pointSize + theme.lineSpacing + + let ps = NSMutableParagraphStyle() + // Force a real line height despite the hidden (0.01pt) dashes. + ps.minimumLineHeight = lineHeight + ps.maximumLineHeight = lineHeight + // Symmetric breathing space. The rule is drawn centered in the + // fragment, so paragraphSpacingBefore sits above the line (and the + // rule) while paragraphSpacing sits below — equal values keep the + // rule visually equidistant from the text on either side. Kept small so + // the break occupies roughly a body line plus a little air, not a full + // blank line above and below. + let pad = bodyFont.pointSize * 0.2 + ps.paragraphSpacingBefore = pad + ps.paragraphSpacing = pad + return ps + } + + /// How far below the rule fragment's geometric center to draw the hairline. + /// Adjacent text sits at its baseline (low in its line box), so a + /// center-drawn rule looks too close to the line above; this nudge brings + /// it down to the optical midpoint between the surrounding text. Tuned + /// against rendered output (see RenderingRegressionTests / screencapture). + var thematicBreakCenterOffset: CGFloat { bodyFont.pointSize * 0.3 } + + /// Width of the `> ` quote marker in body text. Used as the hanging indent + /// for blockquotes and callouts so wrapped/continuation lines align after + /// the marker (like list items) rather than under the `>`. The marker is + /// rendered width-preserved (clear when inactive, dimmed when active) on + /// each line's first visual line, so subsequent lines hang by this width. + var quoteMarkerWidth: CGFloat { + ("> " as NSString).size(withAttributes: [.font: bodyFont]).width + } + + /// Paragraph style for blockquotes: a 2pt text inset matching the width of + /// the left bar that the `.leftBar` BlockDecoration draws, plus a hanging + /// indent so wrapped lines align after the `> ` marker. + private func blockquoteParagraphStyle() -> NSParagraphStyle { + let ps = NSMutableParagraphStyle() + ps.lineSpacing = bodyParagraphStyle.lineSpacing + ps.paragraphSpacing = bodyParagraphStyle.paragraphSpacing + ps.firstLineHeadIndent = 2 + ps.headIndent = 2 + quoteMarkerWidth + return ps + } + + // MARK: - Delimiter Hiding Classification + + /// Returns true if this span kind's delimiters should be hidden (not just + /// dimmed) when the cursor is not inside the token. + private func isDelimiterHideable(_ kind: SyntaxHighlighter.Span.Kind) -> Bool { + switch kind { + case .bold, .italic, .boldItalic, .strikethrough, .highlight, + .code, .link, .image, .lineBreak, + .heading, .blockquote(_), .footnoteReference, .escape: + return true + case .listItem, .table, .codeBlock, .thematicBreak, .footnoteDefinition, .comment, + .htmlTag, .htmlFormat: + // htmlTag: always colored source (brackets dimmed by the generic + // pass). htmlFormat: handled explicitly in the delimiter loop. + return false + case .wikilink: + // The `[[`, optional `target|`, and `]]` are hidden when rendered, + // dimmed when the cursor is inside (like other inline delimiters). + return true + case .math(let display): + // Inline math hides its `$` like other inline tokens; display math + // is block-level and handled specially. + return !display + } + } + + // MARK: - Unified Styling + + /// Styles raw markdown text with rich attributes. Inline delimiters are hidden + /// unless the cursor is inside the token (in which case they're dimmed). + /// Block-level markers are always dimmed, never hidden. + /// + /// - Parameters: + /// - markdown: Raw markdown text. + /// - cursorPosition: Cursor offset within the markdown (nil = hide all inline delimiters). + func styleBlock(_ markdown: String, cursorPosition: Int? = nil, + hideComments: Bool = false) -> NSAttributedString { + let result = NSMutableAttributedString(string: markdown, attributes: baseAttributes) + guard !markdown.isEmpty else { return result } + + let spans = SyntaxHighlighter.parse(markdown, linkDefinitions: linkDefState.defsText) + + // The font already applied at `loc` — the enclosing heading's when + // inside one, else the base body font. Inline spans derive their font + // from it so `# **bold** and `code`` keeps the heading's size. Spans + // apply in location order, so a heading (at the block start) styles + // its fullRange before any inner span reads the context. + func contextFont(at loc: Int) -> NSFont { + guard loc >= 0, loc < result.length else { return bodyFont } + return result.attribute(.font, at: loc, effectiveRange: nil) as? NSFont ?? bodyFont + } + // The mono font matching `ctx`'s scale: the plain inline-code font in + // body text, scaled up inside a heading. + func monoFont(for ctx: NSFont) -> NSFont { + let scale = ctx.pointSize / bodyFont.pointSize + return scale == 1 ? inlineCodeFont + : theme.monospaceFont(ofSize: inlineCodeFont.pointSize * scale) + } + + for span in spans { + let cursorInToken = cursorPosition.map { + $0 >= span.fullRange.location && $0 <= span.fullRange.upperBound + } ?? false + + // --- Content styling (applied first) --- + switch span.kind { + case .bold: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let bold = NSFontManager.shared.convert(ctx, toHaveTrait: .boldFontMask) + result.addAttribute(.font, value: bold, range: span.contentRange) + + case .italic: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let italic = NSFontManager.shared.convert(ctx, toHaveTrait: .italicFontMask) + result.addAttribute(.font, value: italic, range: span.contentRange) + + case .boldItalic: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + let bi = NSFontManager.shared.convert(ctx, toHaveTrait: [.boldFontMask, .italicFontMask]) + result.addAttribute(.font, value: bi, range: span.contentRange) + + case .code: + guard span.contentRange.upperBound <= result.length else { continue } + let ctx = contextFont(at: span.contentRange.location) + result.addAttribute(.font, value: monoFont(for: ctx), range: span.contentRange) + result.addAttribute(.foregroundColor, value: foregroundColor, range: span.contentRange) + result.addAttribute(.backgroundColor, value: inlineCodeBackground, range: span.contentRange) + + case .codeBlock(let language): + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.font, value: codeBlockFont, range: span.contentRange) + highlightCodeBlock(result, contentRange: span.contentRange, language: language) + + case .strikethrough: + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange) + + case .highlight: + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: span.contentRange) + + case .heading(let level): + guard span.fullRange.upperBound <= result.length else { continue } + let scale: CGFloat = level == 1 ? 1.5 : level == 2 ? 1.3 : level == 3 ? 1.15 : 1.0 + let sized = NSFont(descriptor: bodyFont.fontDescriptor, + size: bodyFont.pointSize * scale) ?? bodyFont + let heading = NSFontManager.shared.convert(sized, toHaveTrait: .boldFontMask) + result.addAttribute(.font, value: heading, range: span.fullRange) + + case .link(let destination): + guard span.contentRange.upperBound <= result.length else { continue } + result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange) + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: span.contentRange) + if !destination.isEmpty { + result.addAttribute(.editorLinkURL, value: destination, range: span.contentRange) + } + + case .wikilink(let target): + guard span.contentRange.upperBound <= result.length else { continue } + // The display text reads as a link; the brackets (and a + // `target|` alias prefix) are hidden/dimmed by the delimiter pass. + result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange) + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, + range: span.contentRange) + if !target.isEmpty { + result.addAttribute(.editorWikiTarget, value: target, range: span.contentRange) + } + + case .image(let destination, let width, let height): + guard span.fullRange.upperBound <= result.length else { continue } + if !cursorInToken, let overlay = imageOverlay(destination: destination, + width: width, height: height) { + // Rendered: draw the image at the leading character (`!` of + // `![alt](path)`, `<` of ``) and hide the rest of the + // source, reserving the line height so the picture has room. + let hideStart = span.fullRange.location + 1 + let hideLen = span.fullRange.upperBound - hideStart + if hideLen > 0 { + let hideRange = NSRange(location: hideStart, length: hideLen) + result.addAttribute(.font, value: hiddenFont, range: hideRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange) + } + applyOverlay(overlay, + anchor: NSRange(location: span.fullRange.location, length: 1), + in: result) + reserveLineHeight(overlay.bounds.height, + forOverlayAt: span.fullRange.location, in: result) + } else if (markdown as NSString).character(at: span.fullRange.location) == 0x3C { + // Active (or pending) ``: show the raw tag as colored + // HTML source, like any other tag. + styleRawHTMLTag(result, range: span.fullRange) + } else { + // Active, or the image couldn't be loaded: show the alt text + // link-colored (same as a plain link); delimiters are dimmed/hidden below. + result.addAttribute(.foregroundColor, value: linkColor, range: span.contentRange) + let italic = NSFontManager.shared.convert(bodyFont, toHaveTrait: .italicFontMask) + result.addAttribute(.font, value: italic, range: span.contentRange) + } + + case .blockquote(let depth): + guard span.fullRange.upperBound <= result.length else { continue } + // A block quote whose first line is `[!type]` is a callout + // (GitHub-flavored) — render it with an icon, colored label, and + // colored bar instead of the plain quote styling. Only depth 0 + // ever detects as a callout: a callout nested inside a plain + // quote stays literal (see SyntaxHighlighter+Walker's + // visitBlockQuote), so no deeper span is ever callout-shaped. + if let callout = calloutInfo(forBlockquote: span, markdown: markdown), !cursorInToken { + styleCalloutContent(result, span: span, info: callout) + } else { + // Plain block quote (any nesting depth). Indent and draw + // this level's own bar regardless of active/inactive — the + // generic delimiter pass (elsewhere in this function) + // separately decides whether this level's own `>` marker + // is hidden (inactive) or shown dimmed (active/editing). + // + // Per-level indentation comes from the width-preserved + // hidden `> ` markers alone (one more per level), so the + // first-line indent stays constant — adding a paragraph + // indent per depth too would double the step. Only the + // hanging indent grows, to keep wrapped lines clear of + // all this line's markers. + // + // A nested quote's span range is a *subset* of its + // ancestors' (processed earlier, in outer-to-inner order: + // the walker emits a parent before descending to its + // children), so stacking here only has to keep whatever + // decoration the ancestor already painted over this same + // range and append this level's own bar — bar x positions + // are absolute per level, independent of the line. + // The fragment vendor reads paragraph-level attributes at + // paragraph offset 0, but a nested span's range starts at + // its *own* `>` — past the ancestors' markers on its first + // line. Extend back to the line start so the line's + // paragraph carries this level's decoration/indent (else + // the line draws only the ancestor's single bar). + let lineStart = (markdown as NSString) + .lineRange(for: NSRange(location: span.fullRange.location, length: 0)).location + let paraRange = NSRange(location: lineStart, + length: span.fullRange.upperBound - lineStart) + let ps = blockquoteParagraphStyle().mutableCopy() as! NSMutableParagraphStyle + ps.headIndent += CGFloat(depth) * quoteMarkerWidth + result.addAttribute(.paragraphStyle, value: ps, range: paraRange) + + // The quote's own bar hugs the text top on its *first* line + // only (see BlockDecoration.hugsTextTop) — interior lines + // fill their whole fragment so the bar tiles gap-free. The + // ancestor stack is read per sub-range: an ancestor's own + // first line (hugging) can coincide with this span's first + // line, but its interior lines never hug. + let firstLineEnd = min((markdown as NSString) + .lineRange(for: NSRange(location: lineStart, length: 0)).upperBound, + paraRange.upperBound) + let firstRange = NSRange(location: lineStart, length: firstLineEnd - lineStart) + let restRange = NSRange(location: firstLineEnd, + length: paraRange.upperBound - firstLineEnd) + for (range, hugs) in [(firstRange, true), (restRange, false)] { + guard range.length > 0 else { continue } + let ownBar = BlockDecoration(.leftBar(color: .tertiaryLabelColor, width: 2), + inset: CGFloat(depth) * quoteMarkerWidth, + hugsTextTop: hugs) + if depth == 0 { + result.addAttribute(.blockDecoration, value: ownBar, range: range) + } else { + let ancestor = result.attribute(.blockDecoration, at: range.location, + effectiveRange: nil) + let kept: [BlockDecoration] + if let list = ancestor as? BlockDecorationList { + kept = list.decorations + } else if let single = ancestor as? BlockDecoration { + kept = [single] + } else { + kept = [] + } + result.addAttribute(.blockDecoration, + value: BlockDecorationList(kept + [ownBar]), + range: range) + } + } + + // Only the outermost span fills content color: `contentRange` + // only trims a span's very first/last delimiter, not ones in + // the middle (a nested quote's own markers on later lines) — + // a nested span's fill would repaint an ancestor's marker + // right back to visible, undoing that ancestor's delimiter + // pass (which already ran, earlier in this same loop). The + // outermost span's fill already covers all nested text, so + // deeper spans don't need to (re-)apply it. + if depth == 0 { + result.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, + range: span.contentRange) + } + } + + case .listItem(let ordered, let checkbox): + guard span.fullRange.upperBound <= result.length else { continue } + styleListItemSpan(result, span: span, markdown: markdown, + ordered: ordered, checkbox: checkbox, + cursorInToken: cursorInToken) + + case .table: + guard span.fullRange.upperBound <= result.length else { continue } + styleTableSpan(result, span: span, cursorInToken: cursorInToken) + + case .thematicBreak: + guard span.fullRange.upperBound <= result.length else { continue } + if cursorInToken { + // Active: show raw dashes, dimmed — but keep the rendered + // rule's vertical metrics (forced line height + breathing + // space) so clicking in doesn't collapse the block's height + // and shift content below. + result.addAttribute(.paragraphStyle, value: thematicBreakParagraphStyle(), range: span.fullRange) + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.fullRange) + } else { + // Non-active: horizontal hairline decoration, hide raw text + result.addAttribute(.paragraphStyle, value: thematicBreakParagraphStyle(), range: span.fullRange) + result.addAttribute(.blockDecoration, + value: BlockDecoration(.horizontalRule(color: .separatorColor, + centerOffset: thematicBreakCenterOffset)), + range: span.fullRange) + result.addAttribute(.font, value: hiddenFont, range: span.fullRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: span.fullRange) + } + + case .math(let display): + guard span.fullRange.upperBound <= result.length else { continue } + if cursorInToken { + // Active: show the raw LaTeX in monospace (like inline code), + // with LaTeX syntax coloring; `$` delimiters dimmed below. + result.addAttribute(.font, value: inlineCodeFont, range: span.fullRange) + colorMathSource(result, range: span.contentRange) + } else { + let latex = (markdown as NSString).substring(with: span.contentRange) + // Size the math to the font already applied at this location, so + // inline math inside a heading matches the heading's size. + let contextFont = result.attribute(.font, at: span.fullRange.location, + effectiveRange: nil) as? NSFont ?? bodyFont + if let overlay = mathOverlay(latex: latex.trimmingCharacters(in: .whitespacesAndNewlines), + display: display, + fontSize: contextFont.pointSize) { + // Draw the rendered image at the first `$` (hidden, with + // kern reserving the image's width) and hide everything + // after it — the rest of the opening delimiter, the + // source, and the close. + let hideStart = span.fullRange.location + 1 + let hideLen = span.fullRange.upperBound - hideStart + let hideRange = NSRange(location: hideStart, length: hideLen) + result.addAttribute(.font, value: hiddenFont, range: hideRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange) + applyOverlay(overlay, + anchor: NSRange(location: span.fullRange.location, length: 1), + in: result) + // A `$$…$$` run gets block layout (centered on its own + // line) only when it owns the whole block. A run sharing + // its line with prose flows inline like `$…$` math. + let displayOwnsBlock: Bool = { + guard display else { return false } + let blockNS = markdown as NSString + let full = span.fullRange + let nonWS = CharacterSet.whitespacesAndNewlines.inverted + let before = NSRange(location: 0, length: full.location) + let after = NSRange(location: full.upperBound, + length: blockNS.length - full.upperBound) + return blockNS.rangeOfCharacter(from: nonWS, options: [], range: before).location == NSNotFound + && blockNS.rangeOfCharacter(from: nonWS, options: [], range: after).location == NSNotFound + }() + if !displayOwnsBlock { + // Inline math — and a display run sharing its line + // with prose — flows within the text line; reserve + // the line height so a tall equation (e.g. scaled to + // a heading's font) doesn't overlap the line below. + reserveLineHeight(overlay.bounds.height, + forOverlayAt: span.fullRange.location, + in: result) + } + // Display math sits centered on its own line, with + // vertical padding and the image's ascent/descent + // reserved on the (first) line that carries it. + if displayOwnsBlock { + let fullStr = result.string as NSString + result.addAttribute(.paragraphStyle, + value: displayMathParagraphStyle(padded: false), + range: span.fullRange) + let nl = fullStr.range(of: "\n", options: [], range: span.fullRange) + let firstLine = nl.location == NSNotFound + ? span.fullRange + : NSRange(location: span.fullRange.location, + length: nl.location - span.fullRange.location + 1) + let imageDescent = -overlay.bounds.minY + let imageAscent = overlay.bounds.height - imageDescent + result.addAttribute(.paragraphStyle, + value: displayMathParagraphStyle(padded: true, + imageAscent: imageAscent, + imageDescent: imageDescent), + range: firstLine) + } + } else { + // Invalid LaTeX: surface the raw source in monospace, tinted. + result.addAttribute(.font, value: inlineCodeFont, range: span.fullRange) + result.addAttribute(.foregroundColor, value: NSColor.systemRed, range: span.fullRange) + } + } + + case .footnoteReference: + guard span.fullRange.upperBound <= result.length else { continue } + // Dim the id like other syntax markers (bullets, etc.) rather than + // coloring it like a link; when rendered (cursor outside), raise and + // shrink it into a superscript and hide the `[^`/`]` (below). When + // active, it stays full size and editable with dimmed delimiters. + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.contentRange) + if !cursorInToken { + let ctx = contextFont(at: span.contentRange.location) + let small = NSFont(descriptor: ctx.fontDescriptor, + size: ctx.pointSize * 0.75) ?? ctx + result.addAttribute(.font, value: small, range: span.contentRange) + result.addAttribute(.baselineOffset, value: ctx.pointSize * 0.35, + range: span.contentRange) + } + + case .footnoteDefinition: + guard span.fullRange.upperBound <= result.length else { continue } + // The `[^id]:` marker is dimmed by the delimiter pass below; the + // definition text after it stays normal. Nothing to add here. + break + + case .comment: + guard span.fullRange.upperBound <= result.length else { continue } + // Reading view hides comments entirely; Edit view dims the whole + // `%%…%%` (delimiters dimmed again in the delimiter pass). The + // content is opaque (no inner markdown), so dimming fullRange is + // enough. + if hideComments { + result.addAttribute(.font, value: hiddenFont, range: span.fullRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: span.fullRange) + } else { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: span.fullRange) + } + + case .lineBreak: + break // Delimiter handling done below + + case .escape: + break // The escaped char keeps base attributes; the backslash + // is hidden/dimmed by the generic delimiter pass below. + + case .htmlTag: + guard span.contentRange.upperBound <= result.length else { continue } + // Always literal: color the element name red like math; the + // `<`/`>`/`/` are dimmed by the generic (non-hideable) pass below. + result.addAttribute(.foregroundColor, value: theme.mathOperatorColor, + range: span.contentRange) + + case .htmlFormat(let tag): + guard span.fullRange.upperBound <= result.length else { continue } + // Inactive: hide the tags (delimiter pass) and apply the rendered + // attribute to the inner content. Active: the raw tags show + // colored (handled in the delimiter pass). + if !cursorInToken { + applyHTMLFormatAttribute(result, tag: tag, range: span.contentRange) + } + } + + // --- Delimiter treatment (applied after content styling so it takes precedence) --- + for dr in span.delimiterRanges { + guard dr.upperBound <= result.length else { continue } + + if case .thematicBreak = span.kind { + // Thematic break: fully handled in content styling above + if cursorInToken { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } + // Non-active: already hidden, don't override + } else if case .table = span.kind { + // Table delimiters (separator row): dimmed when active, hidden when not + if cursorInToken { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } + // Non-active: already hidden by content styling, don't override + } else if case .listItem(let ordered, let checkbox) = span.kind { + // List markers: custom styling when non-active, dimmed when active + if cursorInToken { + // Dim the visible marker, but skip any leading whitespace in + // the delimiter range — it was hidden during content styling + // and dimming it here would re-show it (the rescue parser's + // delimiter includes that whitespace). + let nsDelim = (markdown as NSString).substring(with: dr) as NSString + let firstNonWS = nsDelim.rangeOfCharacter( + from: CharacterSet(charactersIn: " \t").inverted) + let mStart = dr.location + + (firstNonWS.location == NSNotFound ? dr.length : firstNonWS.location) + if mStart < dr.upperBound { + result.addAttribute(.foregroundColor, value: syntaxDimColor, + range: NSRange(location: mStart, length: dr.upperBound - mStart)) + } + } else { + styleListDelimiter(result, markdown: markdown, + delimiterRange: dr, ordered: ordered, + checkbox: checkbox) + } + } else if case .math = span.kind { + // Math: when active, dim the `$`; when not, the attachment and + // source-hiding are already applied in content styling — leave them. + if cursorInToken { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } + } else if case .htmlFormat = span.kind { + // Whitelisted tag pair: show the raw tags (dim brackets, red + // name) when active; hide them when the content is rendered. + if cursorInToken { + styleRawHTMLTag(result, range: dr) + } else { + result.addAttribute(.font, value: hiddenFont, range: dr) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr) + } + } else if case .comment = span.kind { + // Comment `%%`: hidden in reading view, dimmed otherwise — + // matching the content styling above. + if hideComments { + result.addAttribute(.font, value: hiddenFont, range: dr) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr) + } else { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } + } else if case .heading = span.kind, cursorPosition != nil { + // A heading is one logical line. Keep its marker visible + // while the caret is anywhere on that line so moving between + // inline tokens does not make the leading `#` flicker. + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } else if cursorInToken || !isDelimiterHideable(span.kind) { + // Visible: dim the delimiters + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: dr) + } else if case .blockquote(_) = span.kind { + // Blockquote: invisible but preserve width for indentation + result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr) + } else { + // Hidden: make delimiters invisible and near-zero-width + result.addAttribute(.font, value: hiddenFont, range: dr) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: dr) + } + } + } + + return result + } + + /// Applies a whitelisted HTML tag's rendered formatting to `range` (the inner + /// content). Unknown tags are no-ops (handled as colored source elsewhere). + /// Fonts derive from the one already applied at the range (the enclosing + /// heading's, when inside one), so sizes nest like other inline spans. + private func applyHTMLFormatAttribute(_ result: NSMutableAttributedString, + tag: String, range: NSRange) { + let ctx = (range.location < result.length + ? result.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont + : nil) ?? bodyFont + switch tag { + case "u": + result.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range) + case "mark": + result.addAttribute(.backgroundColor, value: NSColor.systemYellow.withAlphaComponent(0.3), range: range) + case "kbd": + let scale = ctx.pointSize / bodyFont.pointSize + let mono = scale == 1 ? inlineCodeFont + : theme.monospaceFont(ofSize: inlineCodeFont.pointSize * scale) + result.addAttribute(.font, value: mono, range: range) + result.addAttribute(.backgroundColor, value: inlineCodeBackground, range: range) + case "sub", "sup": + let small = NSFont(descriptor: ctx.fontDescriptor, size: ctx.pointSize * 0.75) ?? ctx + result.addAttribute(.font, value: small, range: range) + let offset = tag == "sub" ? -ctx.pointSize * 0.25 : ctx.pointSize * 0.35 + result.addAttribute(.baselineOffset, value: offset, range: range) + case "small": + let fine = NSFont(descriptor: ctx.fontDescriptor, size: ctx.pointSize * 0.85) ?? ctx + result.addAttribute(.font, value: fine, range: range) + default: + break + } + } + + /// Dims an HTML tag's punctuation (`<`, `/`, attrs, `>`) and colors its + /// element name red — the active-state look for a `.htmlFormat` pair, matching + /// how `.htmlTag` colored source reads. + private func styleRawHTMLTag(_ result: NSMutableAttributedString, range: NSRange) { + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: range) + let ns = result.string as NSString + var i = range.location + let end = range.upperBound + while i < end, ns.character(at: i) == 0x3C || ns.character(at: i) == 0x2F { i += 1 } // < / + var j = i + func isAlphaNum(_ c: unichar) -> Bool { + (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) || (c >= 0x30 && c <= 0x39) + } + while j < end, isAlphaNum(ns.character(at: j)) { j += 1 } + if j > i { + result.addAttribute(.foregroundColor, value: theme.mathOperatorColor, + range: NSRange(location: i, length: j - i)) + } + } + + /// Plain monospaced styling for source mode: the raw markdown with no + /// markup interpretation (no hidden delimiters, overlays, or decorations). + func sourceStyled(_ markdown: String) -> NSAttributedString { + let mono = theme.monospaceFont(ofSize: bodyFont.pointSize) + let ps = NSMutableParagraphStyle() + ps.lineSpacing = theme.lineSpacing + return NSAttributedString(string: markdown, attributes: [ + .font: mono, + .foregroundColor: foregroundColor, + .paragraphStyle: ps, + ]) + } + + // MARK: - In-Place Block Restyling + + /// Re-styles a single block in the text storage in place (no string mutation). + /// `cursorInBlock` is the cursor offset within the block, or nil to hide + /// all inline delimiters (non-active block). + func restyleBlock(_ blockIndex: Int, cursorInBlock: Int? = nil) { + guard let ts = textStorage, + blockIndex < blocks.count else { return } + + let block = blocks[blockIndex] + guard block.range.upperBound <= ts.length else { return } + + let styled: NSAttributedString + switch viewMode { + case .edit: styled = styleBlock(block.content, cursorPosition: cursorInBlock) + case .reading: styled = styleBlock(block.content, cursorPosition: nil, hideComments: true) + case .source: styled = sourceStyled(block.content) + } + let offset = block.range.location + + styled.enumerateAttributes(in: NSRange(location: 0, length: styled.length), options: []) { attrs, range, _ in + let tsRange = NSRange(location: range.location + offset, length: range.length) + ts.setAttributes(attrs, range: tsRange) + } + + // Reset the separator newlines adjacent to the block. No block's + // styled range covers them, and a character inserted at a block + // boundary inherits its neighbor's attributes (e.g. a display-math + // block's centered paragraph style), which would otherwise stick + // forever — a full recompose leaves separators at base attributes, + // so the in-place path must too. + let nsStr = ts.string as NSString + if offset > 0, nsStr.character(at: offset - 1) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: offset - 1, length: 1)) + } + let after = block.range.upperBound + if after < nsStr.length, nsStr.character(at: after) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: after, length: 1)) + } + } + + /// Re-applies styling to the active block. Called after each keystroke. + func applyBlockStyle() { + guard let ts = textStorage, + let activeIdx = activeBlockIndex, + activeIdx < blocks.count else { return } + + let cursorInBlock = max(0, selectedRange().location - blocks[activeIdx].range.location) + + isUpdating = true + ts.beginEditing() + restyleBlock(activeIdx, cursorInBlock: cursorInBlock) + ts.endEditing() + isUpdating = false + + typingAttributes = baseAttributes + } +} + +// MARK: - ThematicBreakTextBlock + diff --git a/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift b/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift new file mode 100644 index 0000000..5510cb9 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift @@ -0,0 +1,257 @@ +import AppKit + +/// Table styling: the largest single case of the `styleBlock` switch. When the +/// caret is inside, the table shows as dimmed monospace; otherwise it's laid out +/// with a bold header, hidden pipes, kern-padded columns, and drawn borders (via +/// a `.tableRow` BlockDecoration). Row parsing helpers live in +/// EditorTextView+TableSupport; extracted from EditorTextView+Rendering. +extension EditorTextView { + + /// Styles the `.table` content for one span. The caller has already + /// bounds-checked `span.fullRange` against `result`. + func styleTableSpan(_ result: NSMutableAttributedString, + span: SyntaxHighlighter.Span, + cursorInToken: Bool) { + if cursorInToken { + // Active: monospace, all pipes dimmed + result.addAttribute(.font, value: tableFont, range: span.fullRange) + let nsStr = (result.string as NSString) + var sr = span.fullRange + while sr.length > 0 { + let pr = nsStr.range(of: "|", options: [], range: sr) + guard pr.location != NSNotFound else { break } + result.addAttribute(.foregroundColor, value: syntaxDimColor, range: pr) + let ns = pr.upperBound + sr = NSRange(location: ns, length: max(0, span.fullRange.upperBound - ns)) + } + } else { + // Non-active: bold header, hidden pipes, column-width alignment + // via kern, drawn vertical + horizontal borders via TableRowTextBlock, + // with cell padding for breathing room. A cell wider than its + // column instead hides its real characters and gets redrawn + // wrapped via `.tableCellWraps` (see EditorTextView+TextKit2.swift). + let tableNS = (result.string as NSString) + let tableStr = tableNS.substring(with: span.fullRange) + let lines = tableStr.components(separatedBy: "\n") + + let cellHPad = bodyFont.pointSize * 0.3 + let cellVPad = bodyFont.pointSize * 0.15 + + // --- Style each cell's inline markdown and measure the result --- + // Each cell runs through styleBlock so `**bold**`, `code`, links, + // ==marks== etc. render inside tables; hidden delimiters measure + // ~zero, so column widths reflect what's actually visible. Header + // cells are bolded before measuring. + // ponytail: block-level markdown in a cell (`# x`, `- x`) keeps its + // fonts but loses its block chrome (paragraph styles / decorations + // are row-owned, see the transplant below); tall math or image + // overlays get no extra line height in cells. A wrapped (overflowing) + // cell is drawn from a detached scratch text layout rather than the + // live glyph run, so click-to-caret placement inside it is + // approximate while the table is non-active — clicking anywhere in + // the cell still enters the table and lands the caret at the raw + // source's nearest position once active. + let headerCells = splitTableRow(lines[0]) + let numCols = headerCells.count + guard numCols > 0 else { return } + var natural = [CGFloat](repeating: 0, count: numCols) + // Per line: the cell's character range within the line + its + // styled form (empty for the separator row). + var rowCells: [[(start: Int, end: Int, styled: NSAttributedString)]] = [] + for (li, line) in lines.enumerated() { + guard li != 1 else { rowCells.append([]); continue } + let lineNS = line as NSString + var cells: [(start: Int, end: Int, styled: NSAttributedString)] = [] + for cr in cellRanges(in: lineNS) { + let text = lineNS.substring(with: NSRange(location: cr.start, + length: cr.end - cr.start)) + let styled = NSMutableAttributedString( + attributedString: styleBlock(text, cursorPosition: nil)) + if li == 0 { + styled.enumerateAttribute( + .font, in: NSRange(location: 0, length: styled.length) + ) { value, r, _ in + guard let f = value as? NSFont else { return } + styled.addAttribute( + .font, + value: NSFontManager.shared.convert(f, toHaveTrait: .boldFontMask), + range: r) + } + } + cells.append((cr.start, cr.end, styled)) + } + rowCells.append(cells) + for ci in 0.. 1 ? lines[1] : "", + count: numCols) + + // --- Style each row --- + var lineOffset = span.fullRange.location + for (i, line) in lines.enumerated() { + let lineLen = (line as NSString).length + let lineRange = NSRange(location: lineOffset, length: lineLen) + guard lineRange.upperBound <= result.length else { break } + + // Row geometry via the paragraph style; the borders are + // drawn by a .tableRow BlockDecoration. Vertical padding + // becomes paragraph spacing (row gap = trailing + leading + // spacing = 2*cellVPad, same as the old block padding). + let ps = NSMutableParagraphStyle() + ps.lineSpacing = 0 + ps.firstLineHeadIndent = cellHPad + ps.headIndent = cellHPad + if i == 1 { + // Separator row: its text is hidden; force a thin + // strip and draw the horizontal rule through it. + ps.minimumLineHeight = 4 + ps.maximumLineHeight = 4 + ps.paragraphSpacingBefore = 0 + ps.paragraphSpacing = 0 + } else { + ps.paragraphSpacingBefore = cellVPad + ((i == 0) + ? bodyParagraphStyle.paragraphSpacingBefore : 0) + ps.paragraphSpacing = cellVPad + } + result.addAttribute(.paragraphStyle, value: ps, range: lineRange) + result.addAttribute( + .blockDecoration, + value: BlockDecoration(.tableRow(columnXOffsets: borderXOffsets, + width: totalWidth, + leftInset: cellHPad, + separator: i == 1, + bottomBorder: i > 1)), + range: lineRange) + + // Cells whose styled width exceeds their column's (clamped) + // content width can't be kern-aligned in place — they get + // hidden and redrawn wrapped by `.tableCellWraps` instead + // (see DecoratedTextLayoutFragment). Computed once per row so + // both the hide/transplant step and the kern step below agree. + var overflowsCol = [Bool](repeating: false, count: numCols) + if i != 1, i < rowCells.count { + for ci in 0.. colWidths[ci] - 2 * cellHPad + 0.5 + } + } + + if i == 1 { + // Separator row: hide all text + result.addAttribute(.font, value: hiddenFont, range: lineRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: lineRange) + } else if i < rowCells.count { + // Transplant each styled cell's attributes onto the table, + // skipping .paragraphStyle and .blockDecoration — row + // geometry and borders stay owned by the table code. + // Overflowing cells instead hide their real characters and + // get redrawn wrapped, since kern alone can't wrap text. + var wraps: [TableCellWrap] = [] + for (ci, cell) in rowCells[i].enumerated() where ci < numCols { + let offset = lineOffset + cell.start + if overflowsCol[ci] { + let hideRange = NSRange(location: offset, length: cell.end - cell.start) + guard hideRange.upperBound <= result.length else { continue } + result.addAttribute(.font, value: hiddenFont, range: hideRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: hideRange) + wraps.append(TableCellWrap(styled: cell.styled, x: colStartX[ci], + contentWidth: colWidths[ci] - 2 * cellHPad)) + } else { + cell.styled.enumerateAttributes( + in: NSRange(location: 0, length: cell.styled.length) + ) { attrs, r, _ in + let target = NSRange(location: offset + r.location, length: r.length) + guard target.upperBound <= result.length else { return } + for (key, value) in attrs { + guard key != .paragraphStyle && key != .blockDecoration else { continue } + result.addAttribute(key, value: value, range: target) + } + } + } + } + if !wraps.isEmpty { + result.addAttribute(.tableCellWraps, value: TableCellWrapList(wraps), + range: lineRange) + } + } + + // Hide all structural pipes (zero-width + clear). A `\|` is + // escaped cell content, not a separator — leave it visible + // (its `\` is already hidden by the cell's escape span). + let lineNS = line as NSString + for ci in 0.. 0 && lineNS.character(at: ci - 1) == 0x5C) { + let pipeRange = NSRange(location: lineOffset + ci, length: 1) + result.addAttribute(.font, value: hiddenFont, range: pipeRange) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: pipeRange) + } + } + + // Kern-pad each cell to its column width, distributing the slack + // by column alignment (skip separator). Left pads after content; + // right pads before it (kern on the cell's leading hidden pipe, + // which still adds advance though it's near-zero-width); center + // splits the slack. Kern adds advance *after* a glyph, so the + // "before" kern goes on the char preceding the cell content. + if i != 1, i < rowCells.count { + for ci in 0.. 0.5 else { continue } + let leadingIdx = (cr.start - 1 >= 0 && lineNS.character(at: cr.start - 1) == 0x7C) + ? cr.start - 1 : cr.start + let trailingIdx = cr.end - 1 + func kern(_ amount: CGFloat, at idx: Int) { + result.addAttribute(.kern, value: amount, + range: NSRange(location: lineOffset + idx, length: 1)) + } + switch aligns[ci] { + case .left: kern(padding, at: trailingIdx) + case .right: kern(padding, at: leadingIdx) + case .center: + let half = (padding / 2).rounded() + kern(half, at: leadingIdx) + kern(padding - half, at: trailingIdx) + } + } + } + + lineOffset += lineLen + 1 + } + } + } +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift b/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift new file mode 100644 index 0000000..1ccbde1 --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+TableSupport.swift @@ -0,0 +1,129 @@ +import AppKit + +// MARK: - Table Rendering Support +// +// Helpers used by the `.table` branch of `styleBlock` (in +// EditorTextView+Rendering.swift) to lay out GFM tables: +// `splitTableRow` / `cellRanges` parse a pipe-delimited row into its cells. +// +// A rendered table is a run of consecutive single-line paragraphs (one per +// table row) that the BlockParser merges into a single block. Each row +// carries a `.tableRow` BlockDecoration; because every row uses the same +// column X offsets, the per-row vertical strokes line up into continuous +// column borders. A cell too wide for its column renders across multiple +// *visual* sublines within that one paragraph, via `.tableCellWraps` +// (EditorTextView+TextKit2.swift) — the paragraph/row count is unchanged. + +// MARK: - Column Width Distribution + +/// Clamps each column's natural (widest-cell) width to fit `available` total +/// width, so one very wide cell doesn't stretch the whole table off screen. +/// Columns already at or under their fair share (`available / count`) keep +/// their natural width; the slack they don't use is handed to the columns +/// that exceed fair share, split evenly among them and floored at `minWidth`. +/// ponytail: single-pass, not CSS's iterative auto-table-layout fixed point — +/// revisit only if real documents show pathological many-column cases. +func distributeColumnWidths(natural: [CGFloat], available: CGFloat, + minWidth: CGFloat) -> [CGFloat] { + let numCols = natural.count + guard numCols > 0, available > 0 else { return natural } + let fairShare = available / CGFloat(numCols) + var overIdx: [Int] = [] + var usedByUnderShare: CGFloat = 0 + for (ci, width) in natural.enumerated() { + if width <= fairShare { usedByUnderShare += width } else { overIdx.append(ci) } + } + guard !overIdx.isEmpty else { return natural } + let remaining = max(0, available - usedByUnderShare) + let perOverShare = remaining / CGFloat(overIdx.count) + var result = natural + for ci in overIdx { + result[ci] = max(minWidth, min(natural[ci], perOverShare)) + } + return result +} + +// MARK: - Column Alignment + +/// GFM table column alignment, parsed from the separator row's `:` markers. +enum ColumnAlign: Equatable { case left, center, right } + +/// Parses per-column alignment from a table's separator row (`:--`/`:-:`/`--:`). +/// `:` on both ends = center, trailing only = right, otherwise left. Padded to +/// `count` with `.left`. Mirrors swift-markdown's `Table.columnAlignments`, so +/// the live editor and the HTML export agree. +func tableColumnAlignments(separatorRow: String, count: Int) -> [ColumnAlign] { + var aligns = [ColumnAlign](repeating: .left, count: count) + let cells = splitTableRow(separatorRow) + for ci in 0.. [String] { + var parts: [String] = [] + var current = "" + var prevWasBackslash = false + for ch in line { + if ch == "|" && !prevWasBackslash { + parts.append(current) + current = "" + } else { + current.append(ch) + } + prevWasBackslash = (ch == "\\") && !prevWasBackslash + } + parts.append(current) + + // Remove empty/whitespace-only first/last from outer pipes. + if let first = parts.first, first.trimmingCharacters(in: .whitespaces).isEmpty { + parts.removeFirst() + } + if let last = parts.last, last.trimmingCharacters(in: .whitespaces).isEmpty { + parts.removeLast() + } + return parts +} + +/// Returns `(start, end)` character ranges for each cell in a table line. +/// Works with or without outer pipes. `start` is the first content char, +/// `end` is one past the last content char (i.e., the next pipe or line end). +func cellRanges(in line: NSString) -> [(start: Int, end: Int)] { + var pipePos: [Int] = [] + for ci in 0.. 0 && line.character(at: ci - 1) == 0x5C { continue } + pipePos.append(ci) + } + guard !pipePos.isEmpty else { return [] } + + // Build edge list: either the pipe position or a virtual -1/length sentinel. + var edges: [Int] = [] + if pipePos[0] == 0 { + edges.append(contentsOf: pipePos) + } else { + edges.append(-1) + edges.append(contentsOf: pipePos) + } + if pipePos.last != line.length - 1 { + edges.append(line.length) + } + + var result: [(start: Int, end: Int)] = [] + for ei in 0..<(edges.count - 1) { + let s = edges[ei] + 1 + let e = edges[ei + 1] + if e > s { result.append((s, e)) } + } + return result +} diff --git a/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift b/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift new file mode 100644 index 0000000..ad823ef --- /dev/null +++ b/Sources/EdmundCore/Rendering/EditorTextView+WikiLinks.swift @@ -0,0 +1,149 @@ +import AppKit + +// MARK: - Internal Link Following +// +// `[[wikilinks]]` and regular `[](dest)` links share one navigation core: +// - `#heading` → scroll to a heading in the current document, +// - `path` / `path#head` → resolve a file under the opened file's directory +// (a direct child, else a recursive search), open +// it, and scroll to the heading if one was named, +// - external URL (scheme) → open in the default app (regular links only). +// +// Resolution needs only the opened file (its directory), not a vault folder. + +/// Implemented by the owning document so a freshly opened document can be +/// scrolled to a heading after a cross-file link is followed. +@MainActor +public protocol HeadingNavigable: AnyObject { + func navigateToHeading(_ heading: String) +} + +extension EditorTextView { + + // MARK: Hit testing + + /// The wikilink target under a mouse event, or nil if the click doesn't land + /// on wikilink display text. + func wikiTarget(at event: NSEvent) -> String? { + guard let storage = textStorage, let i = clickCharIndex(at: event) else { return nil } + return storage.attribute(.editorWikiTarget, at: i, effectiveRange: nil) as? String + } + + // MARK: Following + + /// Follows a `[[wikilink]]` target (`path#heading`, no scheme, `.md` implied). + public func followWikiLink(_ target: String) { + let (path, heading) = Self.splitHeading(target) + if path.isEmpty { + if let heading { scrollToHeading(heading) } else { NSSound.beep() } + return + } + openLinkedFile(path: path, heading: heading) + } + + /// Follows a regular markdown link destination: an external URL opens in the + /// default app; `#heading` scrolls in-document; a local `path#heading` + /// resolves a file and opens it (scrolling to the heading if named). + public func followLinkDestination(_ destination: String) { + let dest = destination.trimmingCharacters(in: .whitespaces) + if let url = URL(string: dest), let scheme = url.scheme, scheme != "file" { + NSWorkspace.shared.open(url) + return + } + // A markdown link destination is URL-encoded (e.g. `%20` for a space), + // so decode both the path and the heading anchor. + let (rawPath, rawHeading) = Self.splitHeading(dest) + let path = rawPath.removingPercentEncoding ?? rawPath + let heading = rawHeading.map { $0.removingPercentEncoding ?? $0 } + if path.isEmpty { + if let heading { scrollToHeading(heading) } else { NSSound.beep() } + return + } + openLinkedFile(path: path, heading: heading) + } + + /// Resolves `path` to a file and opens it, scrolling the opened document to + /// `heading` when one is named (cross-file, via `HeadingNavigable`). + private func openLinkedFile(path: String, heading: String?) { + guard let fileURL = resolveLinkedFile(path) else { NSSound.beep(); return } + NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) { _, _, _ in + guard let heading else { return } + // Re-find the document by URL on the main actor (the NSDocument + // isn't Sendable, so we don't capture it across the boundary). By + // now its content has loaded (showWindows → loadContent). + Task { @MainActor in + let doc = NSDocumentController.shared.document(for: fileURL) + (doc as? HeadingNavigable)?.navigateToHeading(heading) + } + } + } + + // MARK: Heading navigation + + /// Scrolls to the first heading block whose text matches `heading` + /// (case-insensitive). Beeps if there is no such heading. + public func scrollToHeading(_ heading: String) { + let want = heading.lowercased() + for block in blocks { + guard case .heading = block.kind, + Self.headingText(block.content).lowercased() == want else { continue } + let loc = block.range.location + setSelectedRange(NSRange(location: loc, length: 0)) + scrollRangeToVisible(NSRange(location: loc, length: 0)) + return + } + NSSound.beep() + } + + /// The text of a heading line, stripped of its leading `#`s and whitespace. + static func headingText(_ line: String) -> String { + var s = Substring(line) + while s.first == "#" { s = s.dropFirst() } + return s.trimmingCharacters(in: .whitespaces) + } + + /// Splits `path#heading` (or `#heading`, or `path`) into a path and the + /// deepest heading component (so `Note#H1#H2` targets `H2`). A nil heading + /// means none was named. + static func splitHeading(_ s: String) -> (path: String, heading: String?) { + let ns = s as NSString + let hash = ns.range(of: "#") + guard hash.location != NSNotFound else { + return (s.trimmingCharacters(in: .whitespaces), nil) + } + let path = ns.substring(to: hash.location).trimmingCharacters(in: .whitespaces) + let rest = ns.substring(from: hash.upperBound) + let heading = rest.split(separator: "#").last + .map { $0.trimmingCharacters(in: .whitespaces) } + .flatMap { $0.isEmpty ? nil : $0 } + return (path, heading) + } + + // MARK: File resolution + + /// Resolves a link `path` to a file URL. Absolute / `~` / `file:` paths load + /// directly; otherwise it resolves under the opened file's directory — a + /// direct child first, else a recursive search by filename — appending `.md` + /// when the path has no extension (Obsidian-style wikilinks omit it). + func resolveLinkedFile(_ path: String) -> URL? { + if let url = URL(string: path), url.scheme == "file" { return url } + if path.hasPrefix("/") { return URL(fileURLWithPath: path) } + if path.hasPrefix("~") { return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) } + + guard let docDir = document?.fileURL?.deletingLastPathComponent() else { return nil } + let fm = FileManager.default + let rel = (path as NSString).pathExtension.isEmpty ? path + ".md" : path + + let direct = docDir.appendingPathComponent(rel) + if fm.fileExists(atPath: direct.path) { return direct } + + // Recursive search by the link's filename (Obsidian resolves by name). + let wantName = (rel as NSString).lastPathComponent.lowercased() + if let walker = fm.enumerator(at: docDir, includingPropertiesForKeys: nil) { + for case let url as URL in walker where url.lastPathComponent.lowercased() == wantName { + return url + } + } + return nil + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextStorage.swift b/Sources/EdmundCore/TextView/EditorTextStorage.swift new file mode 100644 index 0000000..1522d8c --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextStorage.swift @@ -0,0 +1,146 @@ +import AppKit +import CoreText + +/// A text storage subclass whose `fixAttributes` does font substitution only. +/// +/// The editor explicitly manages every attribute on every character (custom +/// keys like `.blockDecoration` / `.fragmentOverlay` included), so the +/// framework's default attribute "fixing" has nothing useful to add — and +/// historically it stripped attributes the renderer depended on. +public class EditorTextStorage: NSTextStorage { + private let backing = NSMutableAttributedString() + + /// The accumulated string mutation since the last consume, expressed as + /// "this range of the OLD string was replaced, shifting lengths by + /// `delta`". Multiple mutations coalesce into the conservative hull. + /// This is the single funnel for all string edits (typing, paste, IME), + /// so the incremental block parser can re-split only the affected lines. + public struct PendingEdit { + public var oldRange: NSRange + public var delta: Int + } + public private(set) var pendingEdit: PendingEdit? + + /// Returns and clears the accumulated edit. + public func consumePendingEdit() -> PendingEdit? { + defer { pendingEdit = nil } + return pendingEdit + } + + /// Drops accumulated-edit tracking. Programmatic whole-document + /// replacements (recompose after load/undo/indent) call this — they + /// re-parse from scratch themselves. + public func clearPendingEdit() { + pendingEdit = nil + } + + /// Coalesces a new edit (given in CURRENT-string coordinates) into the + /// pending edit (kept in OLD-string coordinates). + private func accumulateEdit(currentRange r: NSRange, delta d: Int) { + guard var p = pendingEdit else { + pendingEdit = PendingEdit(oldRange: r, delta: d) + return + } + // Map the new edit's bounds back to old-string coordinates and take + // the hull. Positions at/after the previous edit's replacement shift + // back by the previous delta; the max() keeps positions inside or + // before it clamped to the previous old range. + let start = min(p.oldRange.location, r.location) + let end = max(p.oldRange.upperBound, r.upperBound - p.delta) + p.oldRange = NSRange(location: start, length: max(0, end - start)) + p.delta += d + pendingEdit = p + } + + override public var string: String { backing.string } + + override public func attributes( + at location: Int, effectiveRange range: NSRangePointer? + ) -> [NSAttributedString.Key: Any] { + backing.attributes(at: location, effectiveRange: range) + } + + override public func replaceCharacters(in range: NSRange, with str: String) { + let delta = (str as NSString).length - range.length + accumulateEdit(currentRange: range, delta: delta) + backing.replaceCharacters(in: range, with: str) + edited(.editedCharacters, range: range, changeInLength: delta) + } + + override public func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { + let delta = attrString.length - range.length + accumulateEdit(currentRange: range, delta: delta) + backing.replaceCharacters(in: range, with: attrString) + edited([.editedCharacters, .editedAttributes], range: range, + changeInLength: delta) + } + + override public func setAttributes( + _ attrs: [NSAttributedString.Key: Any]?, range: NSRange + ) { + backing.setAttributes(attrs, range: range) + edited(.editedAttributes, range: range, changeInLength: 0) + } + + override public func fixAttributes(in range: NSRange) { + // We deliberately skip the framework's default attribute fixing — the + // editor manages all attributes itself, and the default pass has a + // history of stripping what the renderer depends on. + // + // But one part of fixing is still needed: font substitution. The body + // font (a serif/mono) has no glyphs for emoji, CJK, etc.; without + // substitution those render as missing-glyph boxes. So we do font + // fixing ourselves, leaving every other attribute (attachments included) + // untouched. + fixFontSubstitution(in: range) + } + + /// Replaces the font on any character the run's font cannot render with a + /// fallback font that can (e.g. Apple Color Emoji), preserving the original + /// size. Substitutions are computed first, then applied, so we never mutate + /// the attribute we're enumerating mid-pass. + private func fixFontSubstitution(in range: NSRange) { + guard range.length > 0, range.upperBound <= backing.length else { return } + let ns = backing.string as NSString + var fixes: [(NSRange, NSFont)] = [] + + backing.enumerateAttribute(.font, in: range, options: []) { value, runRange, _ in + // Skip the tiny hidden-delimiter font — those chars are invisible + // and are plain ASCII delimiters the base font already covers. + guard let font = value as? NSFont, font.pointSize > 1.0 else { return } + + // Fast path: does the font cover the whole run? + let runChars = Array(ns.substring(with: runRange).utf16) + var runGlyphs = [CGGlyph](repeating: 0, count: runChars.count) + if CTFontGetGlyphsForCharacters(font as CTFont, runChars, &runGlyphs, runChars.count) { + return + } + + // Substitute per composed-character sequence so we never split a + // grapheme (emoji ZWJ sequences, skin-tone modifiers, é, …). + var i = runRange.location + let end = runRange.upperBound + while i < end { + let seq = ns.rangeOfComposedCharacterSequence(at: i) + let seqRange = NSRange(location: seq.location, + length: min(seq.length, end - seq.location)) + let seqStr = ns.substring(with: seqRange) + let seqChars = Array(seqStr.utf16) + var seqGlyphs = [CGGlyph](repeating: 0, count: seqChars.count) + let covered = CTFontGetGlyphsForCharacters( + font as CTFont, seqChars, &seqGlyphs, seqChars.count) + if !covered { + let substitute = CTFontCreateForString( + font as CTFont, seqStr as CFString, + CFRange(location: 0, length: seqChars.count)) as NSFont + fixes.append((seqRange, substitute)) + } + i = seqRange.upperBound + } + } + + for (r, f) in fixes { + backing.addAttribute(.font, value: f, range: r) + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Composition.swift b/Sources/EdmundCore/TextView/EditorTextView+Composition.swift new file mode 100644 index 0000000..531bcd5 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Composition.swift @@ -0,0 +1,254 @@ +import AppKit + +// MARK: - Display Composition & Coordinate Mapping +// +// `recompose` rebuilds the whole text storage by styling every block and +// joining them; `recomposeIncremental` re-styles only the block(s) the cursor +// moved between, which is what runs on most edits. Because the text storage +// always equals the raw source (word-level rendering, no string stripping), +// the display↔raw coordinate mapping is the identity — display offsets are +// used as raw offsets directly. + +extension EditorTextView { + + // MARK: - Display Composition + // + // Text storage content = rawSource, always. + // Styling is attribute-only; the string never changes during recompose. + + /// Full recompose: replaces the entire text storage with `rawSource` in + /// base attributes, then styles via the dirty flush — viewport-first when + /// the editor is in a scroll view, everything synchronously otherwise. + /// Used when rawSource was rebuilt (initial load, undo/redo, indent) and + /// for content changes that bypass the edit path. + func recompose(cursorInRaw: Int, selectionInRaw: NSRange? = nil) { + guard let ts = textStorage else { return } + + Log.measure("Full recompose (\(blocks.count) blocks)", category: .compose, level: .debug) { + isUpdating = true + let fullRange = NSRange(location: 0, length: ts.length) + ts.beginEditing() + ts.replaceCharacters(in: fullRange, + with: NSAttributedString(string: rawSource, + attributes: baseAttributes)) + ts.endEditing() + // Whole-document replacement; blocks are re-parsed by our callers. + (ts as? EditorTextStorage)?.clearPendingEdit() + isUpdating = false + + for i in blocks.indices { blocks[i].isStyled = false } + recomposeDirty(IndexSet(blocks.indices), cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: true) + } + } + + /// Range-bounded recompose: replaces only `oldRange` (pre-edit storage + /// coordinates) with `newText` in base attributes, then restyles the given + /// dirty blocks. Unlike `recompose`, the storage — and its TextKit 2 layout + /// — outside `oldRange` is untouched, so content above the edit keeps its + /// laid-out positions and the viewport can't lurch. For edits that rebuild + /// `rawSource` but change only a contiguous span (Tab / Shift-Tab indent): + /// callers update `rawSource` and re-parse `blocks` first, then pass the old + /// span's range and its replacement text. + func recomposeReplacing(oldRange: NSRange, with newText: String, + dirty: IndexSet, cursorInRaw: Int, + selectionInRaw: NSRange? = nil) { + guard let ts = textStorage else { return } + + isUpdating = true + ts.beginEditing() + ts.replaceCharacters(in: oldRange, + with: NSAttributedString(string: newText, + attributes: baseAttributes)) + ts.endEditing() + // Programmatic replacement; the incremental parser must not see it. + (ts as? EditorTextStorage)?.clearPendingEdit() + isUpdating = false + + for idx in dirty where idx < blocks.count { blocks[idx].isStyled = false } + recomposeDirty(dirty, cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: true) + } + + /// Dirty-set recompose: restyles exactly the given block indexes in place. + /// Attribute-only — the storage string is never touched. This is the + /// single styling path for edits, activation changes, and theme / + /// appearance refreshes; `recompose` (string-replacing) remains only for + /// paths that rebuild `rawSource` (load, undo, indent). + /// + /// `settingSelection` is true for selection-driven and whole-document + /// callers (preserving the old recompose behavior); the edit path leaves + /// the caret where NSTextView already placed it to avoid re-entrant + /// selection notifications. + func recomposeDirty( + _ dirty: IndexSet, + cursorInRaw: Int, + selectionInRaw: NSRange? = nil, + settingSelection: Bool = false + ) { + guard let ts = textStorage else { return } + + isUpdating = true + + let newActiveIndex = blockIndexForRawOffset(cursorInRaw) + activeBlockIndex = newActiveIndex + + // Lazy rendering: a LARGE dirty set (load, theme change, a fence + // absorbing half the document) is restyled synchronously only near + // the viewport; the rest goes to the idle drain / scroll promotion. + // Small sets — every normal interaction — are styled in full, so + // user-visible state transitions are never deferred. Without a + // scroll view (headless), everything is synchronous. + var syncSet = dirty + if dirty.count > 8, let bounds = syncStylingBlockRange() { + syncSet = dirty.filteredIndexSet { bounds.contains($0) } + if let active = newActiveIndex, dirty.contains(active) { + syncSet.insert(active) + } + } + let deferred = dirty.subtracting(syncSet) + + let nsString = ts.string as NSString + ts.beginEditing() + for idx in syncSet where idx < blocks.count { + let cursorInBlock: Int? = (idx == newActiveIndex) + ? max(0, cursorInRaw - blocks[idx].range.location) : nil + restyleBlock(idx, cursorInBlock: cursorInBlock) + blocks[idx].isStyled = true + + // Full recompose resets separator newlines to base attributes as + // a side effect of rebuilding the whole string; do the same for + // dirty blocks so stale paragraph styles can't linger on the `\n` + // after e.g. a former callout. + let sep = blocks[idx].range.upperBound + if sep < nsString.length && nsString.character(at: sep) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1)) + } + } + ts.endEditing() + + // A block whose paragraph style we just changed (e.g. a freshly created + // list item's indent) can keep a stale first-line layout: on the edit + // path `insertText` already laid the line out with the base typing + // attributes, and TextKit 2 doesn't re-measure the first-line indent for + // an attribute-only change. Force the restyled blocks to re-lay-out so + // the new indent shows immediately instead of after the next cursor move. + if let tlm = textLayoutManager { + for idx in syncSet where idx < blocks.count { + if let range = blockTextRange(blocks[idx].range, tlm) { + tlm.invalidateLayout(for: range) + } + } + } + + for idx in deferred where idx < blocks.count { + blocks[idx].isStyled = false + } + + if settingSelection { + if let rawSel = selectionInRaw, rawSel.length > 0 { + let len = ts.length + let displaySel = NSRange( + location: min(rawSel.location, len), + length: max(0, min(rawSel.upperBound, len) - min(rawSel.location, len)) + ) + setSelectedRange(displaySel) + } else { + let clamped = min(cursorInRaw, ts.length) + setSelectedRange(NSRange(location: clamped, length: 0)) + } + } + + typingAttributes = baseAttributes + + isUpdating = false + + if !deferred.isEmpty { + scheduleProgressiveStyling() + } else { + // Small documents stay fully laid out (no TextKit 2 height + // estimates): re-lay the blocks this flush invalidated once this + // interaction settles. Cheap on the per-keystroke path — only the + // invalidated fragments are re-laid. + scheduleFullLayoutSettle() + } + } + + /// Maps a block's raw NSRange to an `NSTextRange` for layout invalidation. + func blockTextRange(_ nsRange: NSRange, _ tlm: NSTextLayoutManager) -> NSTextRange? { + guard let start = tlm.location(tlm.documentRange.location, offsetBy: nsRange.location), + let end = tlm.location(start, offsetBy: nsRange.length) else { return nil } + return NSTextRange(location: start, end: end) + } + + /// Incremental recompose: only re-styles the old and new active blocks. + /// Used when the cursor moves between blocks without changing content. + /// `settingSelection` is false when the caller already owns the caret (a + /// user-driven cursor move) — re-setting it would trigger AppKit's + /// scroll-the-selection-into-view, fighting typewriter centering. + func recomposeIncremental(cursorInRaw: Int, selectionInRaw: NSRange? = nil, + settingSelection: Bool = true) { + var dirty = IndexSet() + if let oldIdx = activeBlockIndex, oldIdx < blocks.count { dirty.insert(oldIdx) } + if let newIdx = blockIndexForRawOffset(cursorInRaw) { dirty.insert(newIdx) } + recomposeDirty(dirty, cursorInRaw: cursorInRaw, + selectionInRaw: selectionInRaw, settingSelection: settingSelection) + } + + /// Restyles every block in place (attribute-only). For theme and + /// appearance changes: the string is unchanged but every attribute + /// derives from the new theme/appearance. + func recomposeAllDirty() { + for i in blocks.indices { blocks[i].isStyled = false } + recomposeDirty(IndexSet(blocks.indices), + cursorInRaw: currentCursorInRaw(), + settingSelection: true) + } + + /// The block-index window to style synchronously: the TextKit 2 viewport + /// plus a margin, or — before any layout exists (fresh load) — a window + /// around the active block. Returns nil without a scroll view (headless): + /// callers then style everything synchronously. + func syncStylingBlockRange() -> ClosedRange? { + guard enclosingScrollView != nil, !blocks.isEmpty, + let tlm = textLayoutManager else { return nil } + + if let viewport = tlm.textViewportLayoutController.viewportRange { + let docStart = tlm.documentRange.location + let start = tlm.offset(from: docStart, to: viewport.location) + let end = tlm.offset(from: docStart, to: viewport.endLocation) + if let s = blockIndexForRawOffset(start), + let e = blockIndexForRawOffset(max(start, end)) { + let margin = max(16, e - s + 1) + return max(0, s - margin) ... min(blocks.count - 1, e + margin) + } + } + // No viewport yet (first layout hasn't happened): style a generous + // window around the cursor; the drain and scroll promotion cover the rest. + let anchor = activeBlockIndex ?? 0 + return max(0, anchor - 200) ... min(blocks.count - 1, anchor + 200) + } + + // MARK: - Coordinate Mapping + // + // With text storage = rawSource, display offset = raw offset (identity). + + /// Binary search over the (sorted, adjacent) block ranges. An offset at a + /// block's `upperBound` — the trailing `\n` separator — belongs to that + /// block; offsets past the last block clamp to it. + func blockIndexForRawOffset(_ rawOffset: Int) -> Int? { + guard !blocks.isEmpty else { return nil } + var lo = 0 + var hi = blocks.count - 1 + // First block whose inclusive upper bound reaches the offset. + while lo < hi { + let mid = (lo + hi) / 2 + if blocks[mid].range.upperBound < rawOffset { + lo = mid + 1 + } else { + hi = mid + } + } + return lo + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift b/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift new file mode 100644 index 0000000..2e82960 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+ContentWidth.swift @@ -0,0 +1,50 @@ +import AppKit + +// MARK: - Content width (centered reading column) +// +// The text column has a physical maximum width (set in cm in Settings and +// converted to points using the display's real PPI). Windows wider than the +// cap get symmetric side margins that center the column; narrower windows +// fill edge-to-edge as usual. This is CSS `max-width` semantics: the cap is +// an absolute physical size, not a fraction of the window or the screen, so +// the column doesn't widen when you make the window bigger. + +extension EditorTextView { + + /// Padding applied on each side of the text column at all window sizes. + static let contentBaseInset: CGFloat = 24 + + /// The symmetric horizontal inset for a given view width and max-column width. + /// `maxContentWidth == .greatestFiniteMagnitude` → base inset only (fills the window). + /// When the window is too narrow to fit `maxContentWidth`, the column also fills. + public static func horizontalInset(viewWidth: CGFloat, maxContentWidth: CGFloat) -> CGFloat { + let available = viewWidth - 2 * contentBaseInset + guard available > maxContentWidth else { return contentBaseInset } + return contentBaseInset + (available - maxContentWidth) / 2 + } + + /// Recomputes the horizontal text inset from the current bounds + max-column cap, + /// preserving the vertical inset. Usually no recompose — only the inset + /// changes and TextKit 2 reflows wrapped text on its own. The exception is + /// image overlays: their scaled-to-fit size is baked into the styled + /// attribute at render time (§4 fragmentOverlay), not recomputed at draw + /// time, so a column narrower than an already-rendered image needs those + /// blocks restyled to shrink it. + public func updateContentInset() { + let target = Self.horizontalInset(viewWidth: bounds.width, + maxContentWidth: maxContentWidthPoints) + guard abs(textContainerInset.width - target) > 0.5 else { return } + textContainerInset = NSSize(width: target, height: textContainerInset.height) + + let imageBlocks = IndexSet(blocks.indices.filter { blocks[$0].content.contains("![") }) + guard !imageBlocks.isEmpty else { return } + for idx in imageBlocks { blocks[idx].isStyled = false } + recomposeDirty(imageBlocks, cursorInRaw: currentCursorInRaw(), settingSelection: true) + } + + /// Recompute the centered inset as the view width changes (window resize). + public override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + updateContentInset() + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift b/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift new file mode 100644 index 0000000..19dc74d --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift @@ -0,0 +1,96 @@ +import AppKit + +// MARK: - Editor diagnostics +// +// The edit pipeline lives in the live NSTextView / TextKit 2 / input-context +// layer, which can't be reproduced or inspected headlessly — so bugs there +// (caret drift, sync desyncs) are hard to pin down from a recording alone. These +// helpers capture the decisive live state: +// +// - `traceEdit` — a one-line snapshot of caret + flags + lengths, emitted only +// when verbose editor tracing is on (Settings ▸ Advanced). Sprinkled at the +// key pipeline points so a reproduction yields a readable keystroke-level +// trail in `~/.edmund/logs`. +// - `verifyEditorInvariants` — checks the two model invariants after a sync. A +// cheap length check is effectively always on (logs an error if the +// storage==rawSource invariant ever breaks); the full structural check runs +// under verbose tracing (and asserts in DEBUG). + +extension EditorTextView { + + /// Compact live-state prefix: caret, active block, marked-text, the sync + /// flags, and storage-vs-rawSource lengths — everything that explains a caret + /// drift or a stranded sync. + var diagnosticState: String { + let sel = selectedRange() + let marked = markedRange() + let markedDesc = marked.location == NSNotFound + ? "-" : "{\(marked.location),\(marked.length)}" + let storLen = textStorage?.length ?? -1 + let rawLen = (rawSource as NSString).length + return "sel={\(sel.location),\(sel.length)} active=\(activeBlockIndex.map(String.init) ?? "nil") " + + "marked=\(markedDesc) up=\(isUpdating ? "Y" : "N") undo=\(isUndoRedoing ? "Y" : "N") " + + "blocks=\(blocks.count) storLen=\(storLen) rawLen=\(rawLen)" + + (storLen == rawLen ? "" : " ⚠︎LEN-MISMATCH") + } + + /// One verbose trace line: ` | `. No-op (and the message + /// closure isn't built) unless verbose editor tracing is on. + func traceEdit(_ event: @autoclosure () -> String) { + Log.trace("\(event()) | \(diagnosticState)", category: .edit) + } + + /// Under verbose tracing, logs a condensed call stack for a suspicious + /// selection change — one arriving mid-recompose (up=Y) or while a + /// pendingEdit is unconsumed. Those are exactly the changes behind the + /// issue-#156 caret drifts, and the stack names the AppKit path that + /// moved the caret. + func traceSelectionOrigin() { + guard Log.shouldTrace else { return } + let frames = Thread.callStackSymbols.dropFirst(2).prefix(14).map { frame in + // "3 AppKit 0x00: -[NSTextView foo] + 12" → drop index/module/addr. + let parts = frame.split(separator: " ", omittingEmptySubsequences: true) + return parts.count > 3 ? parts[3...].joined(separator: " ") : frame + } + Log.trace("selection origin:\n " + frames.joined(separator: "\n "), + category: .edit) + } + + /// A short, newline-escaped, length-capped rendering of edit text for traces. + func logSnippet(_ s: String?) -> String { + guard let s else { return "nil" } + let flat = s.replacingOccurrences(of: "\n", with: "⏎") + return flat.count <= 24 ? "\"\(flat)\"" : "\"\(flat.prefix(24))…\"(\(s.count))" + } + + /// Validates the editor's two model invariants and reports violations. The + /// length check is O(1) and the error is written whenever logging is on (the + /// always-on tripwire for a desync); the structural checks are O(n) and run + /// only under verbose tracing. In DEBUG any violation also trips an assertion. + func verifyEditorInvariants(_ context: String) { + guard let ts = textStorage else { return } + let storLen = ts.length + let rawLen = (rawSource as NSString).length + if storLen != rawLen { + Log.error("invariant: storage.length \(storLen) != rawSource.length \(rawLen) [\(context)]", + category: .edit) + assertionFailure("storage != rawSource length after \(context)") + return + } + guard Log.shouldTrace else { return } + if ts.string != rawSource { + Log.error("invariant: storage string != rawSource (same length) [\(context)]", category: .edit) + assertionFailure("storage string != rawSource after \(context)") + } + let reconstructed = blocks.map(\.content).joined(separator: blockSeparator) + if reconstructed != rawSource { + Log.error("invariant: blocks do not reconstruct rawSource [\(context)]", category: .edit) + assertionFailure("blocks != rawSource after \(context)") + } + if let bad = blocks.first(where: { $0.range.upperBound > rawLen }) { + Log.error("invariant: block range \(bad.range) exceeds rawSource \(rawLen) [\(context)]", + category: .edit) + assertionFailure("block range out of bounds after \(context)") + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift b/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift new file mode 100644 index 0000000..d704e29 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift @@ -0,0 +1,284 @@ +import AppKit + +/// The edit pipeline: how keystrokes flow from NSTextView into `rawSource`, +/// reparse, and an attribute-only restyle of exactly the affected blocks. +extension EditorTextView { + + /// NSTextView copies the attributes next to the caret into `typingAttributes`. + /// When the caret sits beside a hidden delimiter (our near-zero-size + /// `hiddenFont` + clear color), newly inserted text inherits that invisible + /// font. Regular typing self-heals via `applyBlockStyle` on each keystroke, + /// but IME composition (e.g. Pinyin) is deferred while marked — so the + /// composing text would render invisibly and input appears broken. Refuse the + /// invisible font here so composition is always visible; the block restyle + /// still applies the correct final styling on commit. + public override var typingAttributes: [NSAttributedString.Key: Any] { + get { super.typingAttributes } + set { + var attrs = newValue + if let font = attrs[.font] as? NSFont, font.pointSize < 1.0 { + attrs[.font] = bodyFont + if (attrs[.foregroundColor] as? NSColor) == .clear { + attrs[.foregroundColor] = foregroundColor + } + } + super.typingAttributes = attrs + } + } + + public override func shouldChangeText(in affectedCharRange: NSRange, replacementString: String?) -> Bool { + if isUpdating { + traceEdit("shouldChangeText REJECTED (isUpdating) range=\(affectedCharRange) repl=\(logSnippet(replacementString))") + return false + } + if let replacement = replacementString { + if !isUndoRedoing { + recordUndoIfNeeded(editRange: affectedCharRange, replacement: replacement) + } + } + traceEdit("shouldChangeText OK range=\(affectedCharRange) repl=\(logSnippet(replacementString))") + scheduleBypassedEditSyncCheck() + return true + } + + /// AppKit does not pair every storage mutation with `didChangeText()`. + /// Known case: after a drag of the selected text whose drop falls through + /// to no valid target (e.g. released past the end of the document), the + /// drag-move's source deletion runs shouldChangeText → replaceCharacters + /// and never calls didChangeText. rawSource/blocks then silently freeze: + /// every later edit does its offset math against the stale model (the + /// issue-#156 caret leap) and autosave writes the stale rawSource. + /// didChangeText consumes the storage's pendingEdit synchronously within + /// the same event turn, so a pendingEdit still unconsumed on the next + /// run-loop pass is exactly the "didChangeText was bypassed" signal — + /// heal by running the sync it would have run. + private func scheduleBypassedEditSyncCheck() { + guard !bypassedEditCheckScheduled else { return } + bypassedEditCheckScheduled = true + // RunLoop.perform, not DispatchQueue.main.async: identical "next + // run-loop pass" timing in the app, but also drainable by + // `RunLoop.main.run(until:)` in tests. + RunLoop.main.perform { [weak self] in + // The main run loop only ever performs on the main thread; the + // closure just isn't statically annotated as such. + MainActor.assumeIsolated { + guard let self else { return } + self.bypassedEditCheckScheduled = false + guard let storage = self.textStorage as? EditorTextStorage, + storage.pendingEdit != nil, + !self.isUpdating, !self.isUndoRedoing, + // During IME composition the unconsumed pendingEdit is + // legitimate — didChangeText defers the sync until commit. + !self.hasMarkedText() else { return } + // Permanent breadcrumb (release builds too): if a desync recurs, + // grep ~/.edmund/logs for this line to see which path bypassed + // didChangeText. + Log.info("healing storage edit that bypassed didChangeText: " + + "storLen=\(storage.length) rawLen=\((self.rawSource as NSString).length)", + category: .edit) + // The bypassing path also skips TextKit 2's selection fixup — + // it stays queued and fires at the NEXT endEditing (our restyle), + // via _fixSelectionAfterChangeInCharacterRange, mapping the + // stale selection against post-edit coordinates. That's the + // issue-#156 caret leap: the fixer lands the caret a couple of + // lines away. Collapse the selection to the edit's end point + // ourselves before syncing; the late fixer then has a valid + // caret to map and leaves it in place. + var caretAfterEdit: Int? + if let pending = storage.pendingEdit { + let newLength = max(0, pending.oldRange.length + pending.delta) + caretAfterEdit = min(pending.oldRange.location + newLength, storage.length) + self.setSelectedRange(NSRange(location: caretAfterEdit!, length: 0)) + } + self.syncRawSourceFromDisplay() + // The queued fixer fires during the sync's endEditing and moves + // the caret even when it was just set to a valid spot — so + // re-assert after the sync; by the next edit the fixer state is + // clean (verified: follow-up deletes behave). + if let caret = caretAfterEdit { + self.setSelectedRange(NSRange(location: min(caret, storage.length), + length: 0)) + } + self.document?.updateChangeCount(.changeDone) + } + } + } + + public override func didChangeText() { + super.didChangeText() + guard !isUpdating, !isUndoRedoing else { + traceEdit("didChangeText SKIPPED sync (isUpdating=\(isUpdating) isUndoRedoing=\(isUndoRedoing))") + return + } + // While an input method is composing (marked text — e.g. emoji search, + // accent/IME entry), the storage holds provisional text. Re-styling it + // (setAttributes over the marked range) interferes with the composition + // and can abort it, so defer all syncing/restyling until the IME commits + // — which fires didChangeText again with no marked text. + guard !hasMarkedText() else { + traceEdit("didChangeText DEFERRED sync (marked text active)") + return + } + syncRawSourceFromDisplay() + document?.updateChangeCount(.changeDone) + scrollCursorToCenter() + } + + /// Syncs rawSource from the text storage, re-parses blocks, and restyles + /// exactly the blocks the edit affected: the parser's changed window, the + /// old and new active blocks, and — when the document-global list indent + /// unit moved — every list block. One flush, attribute-only; the storage + /// string is never replaced on the edit path. + private func syncRawSourceFromDisplay() { + guard let ts = textStorage else { return } + + let oldIndentUnit = listIndentUnit + rawSource = ts.string + let sel = selectedRange() + let cursorRaw = min(sel.location, (rawSource as NSString).length) + + // Where this edit should leave the caret, derived from the storage's + // pending edit (same hull formula as the bypassed-edit heal). TextKit 2's + // queued selection fixup (`_fixSelectionAfterChangeInCharacterRange`) can + // fire during `recomposeDirty`'s `endEditing` below and remap a stale + // selection, leaping the caret to a block boundary — issue #156 round 7, + // the round-6 mechanism on the *normal* edit path (armed by a cross-block + // caret move rather than a drag bypass). This path styles with + // `settingSelection` false and otherwise trusts NSTextView's caret, so a + // leap here sticks and every later edit re-triggers it. Capture the + // intended caret now, before `consumePendingEdit` clears it, and re-assert + // it after the restyle if the fixup moved it. + let expectedCaret: Int? = (ts as? EditorTextStorage)?.pendingEdit.map { + min($0.oldRange.location + max(0, $0.oldRange.length + $0.delta), ts.length) + } + + let oldCount = blocks.count + let oldActive = activeBlockIndex + // Incremental parse from the storage's accumulated edit — O(edit); + // full positional-diff parse as the fallback. + let newBlocks: [Block] + let changed: Range + // Whether the document's link reference definitions changed — a `[label]: + // url` line added/removed/edited can affect a reference link in *any* + // block, so on change every bracket-bearing block is restyled below. + var defsChanged = false + if let pending = (ts as? EditorTextStorage)?.consumePendingEdit(), + let incremental = BlockParser.incrementalParse(text: rawSource, + old: blocks, + editedOldRange: pending.oldRange, + delta: pending.delta) { + (newBlocks, changed) = incremental + #if DEBUG + verifyIncrementalParse(newBlocks) + #endif + // Update the indent histogram and link definitions from exactly the + // replaced blocks (old) and their replacements (new) — O(edit), same + // effect as a whole-document rescan. + let oldDefState = linkDefState + let suffixCount = newBlocks.count - changed.upperBound + for i in changed.lowerBound ..< (oldCount - suffixCount) { + listIndentState.remove(blocks[i].content) + linkDefState.remove(blocks[i].content) + } + for i in changed { + listIndentState.add(newBlocks[i].content) + linkDefState.add(newBlocks[i].content) + } + listIndentUnit = listIndentState.unit + defsChanged = linkDefState != oldDefState + } else { + (newBlocks, changed) = BlockParser.parseWithDiff(rawSource, previous: blocks) + let oldDefState = linkDefState + rebuildListIndentState() + rebuildLinkDefState() + defsChanged = linkDefState != oldDefState + } + blocks = newBlocks + + var dirty = IndexSet(integersIn: changed) + + // Map the old active block through the diff so its deactivation + // restyle lands on the right index: prefix indices are unchanged, + // suffix indices shift by the count delta, and anything inside the + // window is already dirty. + if let old = oldActive { + let suffixCount = newBlocks.count - changed.upperBound + if old < changed.lowerBound { + dirty.insert(old) + } else if old >= oldCount - suffixCount { + dirty.insert(old + (newBlocks.count - oldCount)) + } + } + + // The block under the cursor gets cursor-aware delimiter styling + // (this also subsumes the old per-keystroke applyBlockStyle pass). + if let newActive = blockIndexForRawOffset(cursorRaw) { + dirty.insert(newActive) + } + + // listIndentUnit is document-global: when it changes, the rendered + // indentation of every list block changes with it. + if listIndentUnit != oldIndentUnit { + for (i, block) in blocks.enumerated() where block.kind == .listItem { + dirty.insert(i) + } + } + + // A changed link definition can flip any reference link (even a bare + // `[label]` shortcut) elsewhere in the document, so restyle every block + // that could hold one. Bracket-free blocks can't, so skip them. + if defsChanged { + for (i, block) in blocks.enumerated() where block.content.contains("[") { + dirty.insert(i) + } + } + + recomposeDirty(dirty, cursorInRaw: cursorRaw) + + // If the queued selection fixup leaped the caret off the edit point + // during the restyle's `endEditing`, put it back. Only fires on a real + // mismatch — normal edits already sit at `expectedCaret`, so this is a + // no-op then (no spurious selection notification). Permanent breadcrumb + // (release builds too): a recurrence prints which edit leaped. + if let want = expectedCaret { + let now = selectedRange() + if now.location != want || now.length != 0 { + Log.info("re-asserting caret after fixup leap (normal path): " + + "\(now.location)→\(want)", category: .edit) + setSelectedRange(NSRange(location: want, length: 0)) + } + } + + renumberOrderedListRunsIfNeeded(touching: changed) + + traceEdit("synced cursorRaw=\(cursorRaw) changed=\(changed.lowerBound)..<\(changed.upperBound) oldActive=\(oldActive.map(String.init) ?? "nil")") + verifyEditorInvariants("syncRawSourceFromDisplay") + } + + #if DEBUG + /// Debug oracle for the incremental parser: every incremental result must + /// equal a from-scratch parse (content, ranges, kinds — IDs are allowed + /// to differ). Skipped under MD_PERF so measurements stay representative. + private func verifyIncrementalParse(_ incremental: [Block]) { + guard ProcessInfo.processInfo.environment["MD_PERF"] == nil else { return } + let reference = BlockParser.parse(rawSource) + guard incremental.count == reference.count else { + assertionFailure(""" + incremental parse diverged: \(incremental.count) blocks \ + vs \(reference.count) reference + """) + return + } + for (a, b) in zip(incremental, reference) { + if a.content != b.content || a.range != b.range || a.kind != b.kind { + assertionFailure(""" + incremental parse diverged at \(a.range): \ + \(String(reflecting: a.content)) [\(a.kind)] vs \ + \(String(reflecting: b.content)) [\(b.kind)] at \(b.range) + """) + return + } + } + } + #endif +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift b/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift new file mode 100644 index 0000000..6036f48 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift @@ -0,0 +1,220 @@ +import AppKit + +// MARK: - Lazy Styling: idle drain + scroll promotion +// +// The dirty flush styles only blocks near the viewport synchronously and +// leaves the rest marked `isStyled == false` (base attributes after a load, +// or briefly-stale styling after an offscreen structural change). Two +// mechanisms converge the document: +// +// - The idle drain: time-budgeted main-thread slices restyling unstyled +// blocks until none remain, so document height settles and offscreen +// content is ready before the user gets there. +// - Scroll promotion: when the clip view scrolls, unstyled blocks entering +// the viewport window are styled synchronously so the user never sees raw +// base-attributed text. + +extension EditorTextView { + + /// Schedules the idle drain (coalesced; safe to call repeatedly). + func scheduleProgressiveStyling() { + guard !progressiveStylingScheduled else { return } + progressiveStylingScheduled = true + DispatchQueue.main.async { [weak self] in + self?.progressiveStylingScheduled = false + self?.drainStylingSlice() + } + } + + /// Restyles unstyled blocks for ~6 ms, then reschedules itself if any + /// remain. Reads current state each slice, so edits/undo/load between + /// slices are naturally accommodated. Internal so tests can drive the + /// drain synchronously. + func drainStylingSlice() { + guard let ts = textStorage else { return } + guard !isUpdating else { scheduleProgressiveStyling(); return } + // Restyling marked text aborts IME composition — wait it out. + guard !hasMarkedText() else { scheduleProgressiveStyling(); return } + + let start = ContinuousClock.now + let budget = Duration.milliseconds(6) + + isUpdating = true + let nsString = ts.string as NSString + let cursor = selectedRange().location + var remaining = false + // Blocks restyled this slice need their TextKit 2 layout invalidated + // afterward: restyling is attribute-only, and TextKit 2 doesn't + // re-measure a fragment's geometry (height, first-line indent) for an + // attribute-only change — so a deferred block whose styled height differs + // from its base/estimated height would otherwise keep a stale fragment, + // leaving an empty band on screen. `recomposeDirty` invalidates its + // synchronously-styled blocks for the same reason. + var restyled = IndexSet() + // Explicit pool: styling churns through transient images/attributed + // strings, and a caller may run many slices without a run-loop turn. + autoreleasepool { + ts.beginEditing() + // Resume the scan where the last slice stopped (`drainCursor` is a + // hint — edits shift indices, the wrap-around pass self-corrects). + // Rescanning from 0 each slice made the drain quadratic: deep + // slices burned their whole budget skipping styled blocks. + let count = blocks.count + var scanned = 0 + var idx = min(drainCursor, max(0, count - 1)) + while scanned < count { + if idx >= count { idx = 0 } + if !blocks[idx].isStyled { + let cursorInBlock: Int? = (idx == activeBlockIndex) + ? max(0, cursor - blocks[idx].range.location) : nil + restyleBlock(idx, cursorInBlock: cursorInBlock) + blocks[idx].isStyled = true + restyled.insert(idx) + let sep = blocks[idx].range.upperBound + if sep < nsString.length && nsString.character(at: sep) == 0x0A { + ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1)) + } + if ContinuousClock.now - start > budget { + remaining = true + idx += 1 + break + } + } + idx += 1 + scanned += 1 + } + drainCursor = idx + ts.endEditing() + } + + if let tlm = textLayoutManager { + for idx in restyled where idx < blocks.count { + if let range = blockTextRange(blocks[idx].range, tlm) { + tlm.invalidateLayout(for: range) + } + } + } + isUpdating = false + + if remaining { + scheduleProgressiveStyling() + } else { + scheduleFullLayoutSettle() + } + } + + /// TextKit 2 only gives a fragment a real frame once it's laid out; + /// everything else is a height *estimate*, and estimate corrections are + /// what make the scroller jump, drag-selection autoscroll oscillate, and + /// scroll targets land wrong. For small documents we can afford to lay + /// everything out once styling has converged, so no estimates remain. + /// `ensureLayout` is incremental — already-laid-out fragments are skipped + /// — so repeated settles after edits only re-lay the invalidated blocks. + /// (Large documents keep viewport-based layout: a full layout there is + /// the process-killing path that motivated `scrollRangeToVisible`'s + /// override.) + /// + /// Runs on the next run-loop pass, wrapped in `preservingViewportAnchor`: + /// correcting estimates *above* the viewport shifts every laid-out + /// position below them, so doing it synchronously inside a caller's own + /// anchored restyle would poison that caller's before/after measurement. + func scheduleFullLayoutSettle() { + guard !fullLayoutSettleScheduled else { return } + fullLayoutSettleScheduled = true + // RunLoop.perform, not DispatchQueue.main.async, so tests can drain it + // with `RunLoop.main.run(until:)`. + RunLoop.main.perform { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.fullLayoutSettleScheduled = false + guard !self.isUpdating, !self.hasMarkedText(), + let tlm = self.textLayoutManager else { return } + self.repairContentAboveOrigin() + guard (self.textStorage?.length ?? 0) <= Self.fullLayoutMaxLength, + self.blocks.allSatisfy({ $0.isStyled }) else { return } + self.preservingViewportAnchor { + tlm.ensureLayout(for: tlm.documentRange) + } + } + } + } + + /// TextKit 2 can leave the document's first fragment at a *negative* y + /// after edits near the top: layout proceeding upward from a viewport + /// anchor with a wrong height estimate assigns origins above 0, and + /// nothing renormalizes them. The symptom is the first line sitting above + /// the visible area with the scroller already at the top — unreachable. + /// Repair: re-lay from the document start (anchoring the first fragment + /// back at y 0) inside `preservingViewportAnchor`, which compensates the + /// clip origin so what the user is looking at doesn't move — and the + /// content above becomes scrollable again. + func repairContentAboveOrigin() { + guard let tlm = textLayoutManager else { return } + var firstMinY: CGFloat? + tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: []) { + firstMinY = $0.layoutFragmentFrame.minY + return false + } + guard let firstMinY, firstMinY < -0.5 else { return } + + // Bound the re-lay to start→viewport-end (the bug only manifests with + // the viewport near the top, so this is small); bail on huge spans + // rather than risk the full-document layout cost on a large file. + var end = tlm.documentRange.endLocation + if let vp = tlm.textViewportLayoutController.viewportRange { + end = vp.endLocation + } + guard tlm.offset(from: tlm.documentRange.location, to: end) <= 60_000, + let range = NSTextRange(location: tlm.documentRange.location, end: end) + else { return } + Log.info("repairing content above origin: firstMinY=\(firstMinY)", + category: .compose) + preservingViewportAnchor { + tlm.invalidateLayout(for: range) + tlm.ensureLayout(for: range) + } + } + + /// Styles any unstyled blocks inside the current viewport window. Forces a + /// viewport layout first because callers may run before the next layout + /// pass (the viewport range would otherwise be stale). + func promoteVisibleUnstyledBlocks() { + textLayoutManager?.textViewportLayoutController.layoutViewport() + guard let bounds = syncStylingBlockRange() else { return } + let unstyled = IndexSet(bounds.filter { !blocks[$0].isStyled }) + guard !unstyled.isEmpty else { return } + recomposeDirty(unstyled, cursorInRaw: selectedRange().location) + } + + /// Observes clip-view scrolling for promotion. Called from + /// `viewDidMoveToWindow`. + func installScrollPromotionObserver() { + guard let clipView = enclosingScrollView?.contentView else { return } + clipView.postsBoundsChangedNotifications = true + // viewDidMoveToWindow can fire more than once; keep one observation. + NotificationCenter.default.removeObserver( + self, name: NSView.boundsDidChangeNotification, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(clipViewBoundsDidChange(_:)), + name: NSView.boundsDidChangeNotification, + object: clipView + ) + } + + @objc private func clipViewBoundsDidChange(_ note: Notification) { + // Promotion forces a viewport layout and may restyle blocks (changing + // their heights). Running that synchronously inside the scroll + // notification fights the momentum scroll and makes the viewport + // bounce. Defer to the next run-loop turn (coalesced), so each scroll + // tick just scrolls and styling catches up between ticks. + guard !isUpdating, !pendingPromotion else { return } + pendingPromotion = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingPromotion = false + guard !self.isUpdating else { return } + self.promoteVisibleUnstyledBlocks() + } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift b/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift new file mode 100644 index 0000000..1d4dc57 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+SelectionTracking.swift @@ -0,0 +1,96 @@ +import AppKit + +/// Caret-movement handling: when the caret crosses into a different block we +/// restyle the old and new active blocks (revealing/hiding their raw markdown); +/// within a block we just update which token's delimiters are shown. +extension EditorTextView { + + @objc func selectionDidChange(_ notification: Notification) { + traceEdit("selectionDidChange") + // A selection change landing mid-recompose is the drift signature + // (issue #156); the stack names the AppKit path that moved the caret. + if isUpdating { traceSelectionOrigin() } + guard !isUpdating else { return } + // NSTextView moves the selection DURING an edit, before didChangeText + // runs the sync — at that moment `blocks` still has pre-edit ranges. + // Styling here would apply stale ranges/content against the new text, + // spilling wrong attributes across block boundaries. The pending edit + // is exactly the "storage ahead of blocks" signal; didChangeText's + // flush styles the active block anyway. + if let storage = textStorage as? EditorTextStorage, + storage.pendingEdit != nil { return } + // Don't restyle while an input method is composing (see didChangeText). + guard !hasMarkedText() else { return } + + let sel = selectedRange() + let rawOffset = sel.location + let newActiveIndex = blockIndexForRawOffset(rawOffset) + + if newActiveIndex != activeBlockIndex && !pendingRecompose { + pendingRecompose = true + // Capture the flag now — it's reset synchronously after mouseDown + // returns, before this async block runs. + let fromMouse = suppressTypewriterCentering + DispatchQueue.main.async { [weak self, fromMouse] in + guard let self = self else { return } + // Always clear the flag first. If we bail out below (a recompose is + // mid-flight and will set the active block itself), leaving it set + // would permanently wedge active-block switching — the cursor could + // never re-activate a block, so e.g. a callout would stay rendered + // with un-editable zero-width marker characters. + self.pendingRecompose = false + guard !self.isUpdating else { return } + // Never restyle (mutate storage / invalidate layout) while an + // input method is composing. This async block was scheduled + // before composition began, so — unlike the synchronous guard + // above — `hasMarkedText()` can have flipped true in between. + // Running `recomposeDirty` over storage that holds a live + // composition can strand the marked text in the input context, + // after which `didChangeText` keeps bailing on its own + // marked-text guard and the storage/`rawSource` invariant breaks + // — the "delete drift" bug. The active-block restyle is applied + // anyway when composition commits (didChangeText → recomposeDirty). + guard !self.hasMarkedText() else { return } + + // Restyle the new active block now. DEFER the old active block + // if it's off screen: deactivating it (rendered ↔ raw — callout + // box, checklist marker, …) changes its height, and doing that + // synchronously while the user is looking elsewhere shifts the + // whole viewport. Marking it unstyled hands it to the async + // drain, which TextKit 2 lays out without disturbing the + // viewport. Don't re-set the selection (that triggers AppKit's + // autoscroll-to-selection on stale layout). + let loc = self.selectedRange().location + let newIdx = self.blockIndexForRawOffset(loc) + var dirty = IndexSet() + if let n = newIdx { dirty.insert(n) } + var deferred = false + if let old = self.activeBlockIndex, old != newIdx, old < self.blocks.count { + if let vis = self.syncStylingBlockRange(), vis.contains(old) { + dirty.insert(old) // visible — restyle in place + } else { + self.blocks[old].isStyled = false // off screen — defer + deferred = true + } + } + // Only the new (visible) active block changes height now, so the + // caret anchor's delta is small and reliable. Typewriter mode + // centers on the post-restyle layout instead. + if self.typewriterModeEnabled && !fromMouse { + self.recomposeDirty(dirty, cursorInRaw: loc) + self.scrollCursorToCenter() + } else { + self.preservingViewportAnchor { + self.recomposeDirty(dirty, cursorInRaw: loc) + } + } + if deferred { self.scheduleProgressiveStyling() } + } + return + } else if newActiveIndex == activeBlockIndex { + // Same block — update active token (re-style to show/hide delimiters) + applyBlockStyle() + } + if !suppressTypewriterCentering { scrollCursorToCenter() } + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift b/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift new file mode 100644 index 0000000..8538ade --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift @@ -0,0 +1,666 @@ +import AppKit + +// MARK: - TextKit 2 Support +// +// The editor runs on TextKit 2 (NSTextLayoutManager): layout is viewport-based +// — the system only lays out what's on screen, which is what makes large +// documents tractable. The hard rule that follows: never touch +// `NSTextView.layoutManager` or store NSTextBlock/NSTextTable attributes — +// either silently switches the view back to TextKit 1 for good. +// +// Two custom attributes drive a custom layout fragment: +// +// - `.blockDecoration` (paragraph-level): callout boxes, quote bars, table +// borders, thematic-break rules. Fragment frames tile vertically, so +// per-paragraph drawing renders a multi-line quote run as one continuous +// box/bar. +// - `.fragmentOverlay` (character-level): images drawn at a character's +// position — callout header (icon + title), rendered math, list bullets and +// checkboxes. TextKit 1 rendered `.attachment` over any character; TextKit 2 +// only honors attachments on U+FFFC, which the storage==rawSource invariant +// forbids. Instead the anchor character is hidden, `.kern` reserves the +// image's advance width (the same trick the table renderer uses for column +// alignment), and the fragment draws the image at the anchor's position. +// - `.tableCellWraps` (paragraph-level): a table cell too wide for its column +// can't wrap in place — TextKit 2 only wraps a whole paragraph at the +// container's edge, it has no notion of an independent per-cell flow region +// (that's what NSTextTable/NSTextBlock exist for, and they're banned). So an +// overflowing cell's real characters are hidden, and its styled text is laid +// out separately in a small detached text stack sized to the column's +// width; the fragment draws the resulting lines stacked at the cell's x. + +public extension NSAttributedString.Key { + /// Paragraph-level decoration drawn behind the text by + /// `DecoratedTextLayoutFragment`. Value: `BlockDecoration`. + static let blockDecoration = NSAttributedString.Key("MarkdownEditor.blockDecoration") + /// Character-level image drawn at the character's position by + /// `DecoratedTextLayoutFragment`. Value: `FragmentOverlay`. The styling + /// code pairs it with a hidden anchor glyph plus `.kern` for layout space. + static let fragmentOverlay = NSAttributedString.Key("MarkdownEditor.fragmentOverlay") + /// A table row's overflowing cells, wrapped and drawn by + /// `DecoratedTextLayoutFragment`. Value: `TableCellWrapList`. + static let tableCellWraps = NSAttributedString.Key("MarkdownEditor.tableCellWraps") +} + +/// Value object describing what to draw behind a decorated paragraph. +/// Reference type (NSObject) so it lives in attributed strings; value +/// equality so attribute-run merging and the test oracle behave. +public final class BlockDecoration: NSObject, @unchecked Sendable { + + public enum Kind: Equatable { + /// Filled box across the text column (callouts), with optional borders. + /// `bottomPad` extends the fill/border below the fragment's text frame — + /// TextKit 2 does not include trailing `paragraphSpacing` in the + /// fragment height, so a callout's last line carries the bottom padding + /// here (and a matching paragraphSpacing pushes the next block clear). + case box(background: NSColor, borderColor: NSColor?, + borderEdges: CalloutStyle.Edges, borderWidth: CGFloat, + bottomPad: CGFloat) + /// Vertical bar just left of the paragraph's text (plain block quotes). + case leftBar(color: NSColor, width: CGFloat) + /// Table-row chrome: vertical column borders at text-relative x + /// offsets, and a horizontal rule through the separator row. `width` + /// is the table's full width; `leftInset` the text's inset from the + /// table's left edge. `bottomBorder` draws a full-width line at this + /// row's bottom edge — the grid line between data rows (the header/ + /// separator boundary already gets its line from `separator`). + case tableRow(columnXOffsets: [CGFloat], width: CGFloat, + leftInset: CGFloat, separator: Bool, bottomBorder: Bool) + /// Horizontal hairline across the text column, drawn `centerOffset` + /// points below the fragment's vertical center. The offset compensates + /// for adjacent text sitting at its baseline (low in its line box), so + /// the rule looks equidistant from the text above and below rather + /// than hugging the line above it. + case horizontalRule(color: NSColor, centerOffset: CGFloat) + } + + public let kind: Kind + /// For `.box`: horizontal inset (points) from the text column's left and + /// right edges, non-zero for a box nested inside another box (e.g. a + /// callout inside a callout), so the inner box sits within the outer one. + /// For `.leftBar`: rightward shift (points) from the outermost bar + /// position — one `quoteMarkerWidth` per nesting level, mirroring the + /// hidden `> ` marker that indents the text, so each nested quote's bar + /// (e.g. `> > text`) sits just left of its own level's text. Absolute per + /// level: the same level's bar lands at the same x on every line, which + /// keeps stacked bars tiling into continuous columns. Ignored by other + /// kinds. + public let inset: CGFloat + /// For `.leftBar`: start the bar at the first line's glyph top (baseline + /// minus ascender) instead of the fragment top. The line box carries its + /// extra spacing (lineSpacing) *above* the glyphs, so a bar over the full + /// fragment pokes past the text. Set only on a quote run's first line — + /// interior lines must fill the whole fragment so consecutive lines' bars + /// tile without gaps. Ignored by other kinds. + public let hugsTextTop: Bool + + public init(_ kind: Kind, inset: CGFloat = 0, hugsTextTop: Bool = false) { + self.kind = kind + self.inset = inset + self.hugsTextTop = hugsTextTop + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? BlockDecoration else { return false } + return kind == other.kind && inset == other.inset + && hugsTextTop == other.hugsTextTop + } + + public override var hash: Int { + switch kind { + case .box: return 1 + case .leftBar: return 2 + case .tableRow: return 3 + case .horizontalRule: return 4 + } + } +} + +/// An ordered stack of decorations drawn behind one paragraph, outermost +/// first. Used when nesting puts more than one box/bar on the same line — e.g. +/// a callout's outer box plus an inner nested callout's box. A single +/// decoration still uses a bare `BlockDecoration`; the fragment reads either. +public final class BlockDecorationList: NSObject, @unchecked Sendable { + public let decorations: [BlockDecoration] + + public init(_ decorations: [BlockDecoration]) { + self.decorations = decorations + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? BlockDecorationList else { return false } + return decorations == other.decorations + } + + public override var hash: Int { decorations.count } +} + +/// An image or stroked vector path drawn at a character's laid-out position, +/// with attachment-style bounds: `bounds.origin.y` is the drawing's bottom +/// relative to the text baseline (negative descends below it). +/// +/// The path form exists because of a TextKit 2 wedge: drawing an *image* on a +/// wrapping, multi-line layout fragment collapses that fragment's layout to a +/// single line, while drawing a *shape* does not (see +/// docs/investigations/archives/callout-title-wrap-investigation.md). Overlays that can share a line +/// with wrapping text (the custom-callout-title icon) must use the path form. +public final class FragmentOverlay: NSObject, @unchecked Sendable { + public let image: NSImage? + /// Stroked path in bounds-local coordinates (y-down, origin at the + /// bounds' top-left), pre-scaled to the bounds size. + public let path: CGPath? + public let pathColor: NSColor? + public let pathLineWidth: CGFloat + public let bounds: CGRect + + public init(image: NSImage, bounds: CGRect) { + self.image = image + self.path = nil + self.pathColor = nil + self.pathLineWidth = 0 + self.bounds = bounds + super.init() + } + + public init(path: CGPath, color: NSColor, lineWidth: CGFloat, bounds: CGRect) { + self.image = nil + self.path = path + self.pathColor = color + self.pathLineWidth = lineWidth + self.bounds = bounds + super.init() + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? FragmentOverlay else { return false } + return other.image === image && other.path == path + && other.pathColor == pathColor && other.pathLineWidth == pathLineWidth + && other.bounds == bounds + } + + public override var hash: Int { Int(bounds.width) ^ Int(bounds.height) } +} + +/// A table cell too wide for its column: its real characters are hidden, and +/// this holds what to draw instead. `x` is text-relative (same coordinate +/// space as `BlockDecoration.tableRow`'s `columnXOffsets`) — the cell's +/// content start. `contentWidth` is the column's clamped content width (the +/// width the cell's text must wrap within). +public final class TableCellWrap: NSObject, @unchecked Sendable { + public let styled: NSAttributedString + public let x: CGFloat + public let contentWidth: CGFloat + + public init(styled: NSAttributedString, x: CGFloat, contentWidth: CGFloat) { + self.styled = styled + self.x = x + self.contentWidth = contentWidth + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TableCellWrap else { return false } + return other.styled.string == styled.string + && abs(other.x - x) < 0.5 && abs(other.contentWidth - contentWidth) < 0.5 + } + + public override var hash: Int { styled.string.hashValue } +} + +/// A table row's overflowing cells, one `TableCellWrap` per overflowing cell. +public final class TableCellWrapList: NSObject, @unchecked Sendable { + public let wraps: [TableCellWrap] + + public init(_ wraps: [TableCellWrap]) { + self.wraps = wraps + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TableCellWrapList else { return false } + return wraps == other.wraps + } + + public override var hash: Int { wraps.count } +} + +/// Layout fragment that draws its paragraph's `BlockDecoration` behind the +/// text and any `FragmentOverlay` images at their characters' positions. +final class DecoratedTextLayoutFragment: NSTextLayoutFragment { + + /// Decorations drawn behind the paragraph, outermost first. + let decorations: [BlockDecoration] + /// Paragraph-relative anchor offsets and their overlays. + let overlays: [(offset: Int, overlay: FragmentOverlay)] + /// Whether the text is antialiased (editor-wide setting). + let antialias: Bool + /// Each overflowing cell's x and pre-laid-out lines, from a detached + /// scratch text stack sized to the column's content width. The stack + /// itself is retained (`scratchStacks`) so the line fragments stay valid. + private let resolvedCellWraps: [(x: CGFloat, lines: [NSTextLineFragment])] + private let scratchStacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] + + init(textElement: NSTextElement, range: NSTextRange?, + decorations: [BlockDecoration], + overlays: [(offset: Int, overlay: FragmentOverlay)], + cellWraps: [TableCellWrap], + antialias: Bool) { + self.decorations = decorations + self.overlays = overlays + self.antialias = antialias + var resolved: [(x: CGFloat, lines: [NSTextLineFragment])] = [] + var stacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] = [] + for wrap in cellWraps { + let contentStorage = NSTextContentStorage() + contentStorage.textStorage = NSTextStorage(attributedString: wrap.styled) + let layoutManager = NSTextLayoutManager() + contentStorage.addTextLayoutManager(layoutManager) + let container = NSTextContainer( + size: NSSize(width: max(1, wrap.contentWidth), height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + layoutManager.textContainer = container + var lines: [NSTextLineFragment] = [] + layoutManager.enumerateTextLayoutFragments( + from: layoutManager.documentRange.location, options: [.ensuresLayout] + ) { frag in + lines.append(contentsOf: frag.textLineFragments) + return true + } + resolved.append((wrap.x, lines)) + stacks.append((contentStorage, layoutManager, container)) + } + self.resolvedCellWraps = resolved + self.scratchStacks = stacks + super.init(textElement: textElement, range: range) + } + + /// Extra row height needed to fit the tallest wrapped cell, beyond the + /// row's natural (single-line) height. + private var tableRowExtraHeight: CGFloat { + guard !resolvedCellWraps.isEmpty else { return 0 } + let tallest = resolvedCellWraps + .map { $0.lines.reduce(0) { $0 + $1.typographicBounds.height } } + .max() ?? 0 + return max(0, tallest - super.layoutFragmentFrame.height) + } + + required init?(coder: NSCoder) { + fatalError("DecoratedTextLayoutFragment does not support coding") + } + + /// Fragment-local x of the text container's left edge. The fragment's + /// frame hugs the laid-out text, so container x = 0 sits at -frame.minX. + private var containerLeft: CGFloat { -layoutFragmentFrame.minX } + + private var containerWidth: CGFloat { + textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width + } + + /// A box decoration's `bottomPad` grows the fragment's own frame (not just + /// its drawing): TextKit 2 leaves trailing `paragraphSpacing` out of the + /// fragment, so padding added that way is dead space — clicks there miss the + /// text. Making the fragment frame taller means the line fragments stay + /// anchored at the top, the extra height is genuine clickable space below + /// the last line, the next block tiles clear of it, and the box (drawn over + /// the full frame height) covers it. Mirrors how the header's raised + /// minimumLineHeight makes the top padding clickable text space. + /// + /// Padding is *summed* across stacked boxes: when a nested callout is the + /// last line of its parent, the line needs the nested box's bottom padding + /// *and* the parent's below it (see `draw`), so both fit. + private var boxBottomPad: CGFloat { + decorations.reduce(0) { acc, deco in + if case .box(_, _, _, _, let bottomPad) = deco.kind { return acc + bottomPad } + return acc + } + } + + /// Height to actually paint a filled decoration (box / left bar) over, + /// which is *not* always the full frame height. When a callout or quote is + /// the last block AND the document ends with a newline, TextKit 2 folds the + /// document's final empty line into this (the preceding) layout fragment + /// instead of giving it its own fragment — it shows up as a trailing + /// zero-length line fragment. Painting the decoration over the full frame + /// then floods the callout color onto that trailing empty line (the + /// "extra colored line at the bottom" bug). Detect the absorbed empty line + /// and stop the fill at the last real content line plus the box's bottom + /// padding. + var decorationDrawHeight: CGFloat { + let full = layoutFragmentFrame.height + let lines = textLineFragments + guard lines.count > 1, let last = lines.last, + last.characterRange.length == 0 else { return full } + // Bottom of the last line that actually holds text (fragment-local). + let contentBottom = lines.dropLast().map { $0.typographicBounds.maxY }.max() ?? 0 + // `super` frame excludes our bottomPad; its extent past the content is + // exactly the absorbed empty line. Remove that, keep the bottomPad. + let emptyLineHeight = max(0, super.layoutFragmentFrame.height - contentBottom) + return max(0, full - emptyLineHeight) + } + + override var layoutFragmentFrame: CGRect { + var frame = super.layoutFragmentFrame + // A row is never both a box and a table row, so at most one of these + // two is ever nonzero. + frame.size.height += boxBottomPad + tableRowExtraHeight + return frame + } + + override var renderingSurfaceBounds: CGRect { + var bounds = super.renderingSurfaceBounds + let frame = layoutFragmentFrame + if !decorations.isEmpty { + bounds = bounds.union(CGRect(x: containerLeft - 4, y: 0, + width: containerWidth + 8, height: frame.height)) + } + for (offset, overlay) in overlays { + if let rect = overlayRect(anchorOffset: offset, overlay: overlay) { + bounds = bounds.union(rect.insetBy(dx: -2, dy: -2)) + } + } + return bounds + } + + override func draw(at point: CGPoint, in context: CGContext) { + context.saveGState() + // Decorations are stacked outermost-first. Each box stops short of the + // fragment bottom by the padding of the boxes drawn before it, so an + // outer box's bottom padding stays visible *below* an inner nested box + // (e.g. the parent callout's padding under a nested callout) instead of + // being covered by it. + var precedingBottomPad: CGFloat = 0 + for decoration in decorations { + drawDecoration(decoration, at: point, in: context, bottomInset: precedingBottomPad) + if case .box(_, _, _, _, let bottomPad) = decoration.kind { + precedingBottomPad += bottomPad + } + } + context.restoreGState() + context.saveGState() + context.setShouldAntialias(antialias) + super.draw(at: point, in: context) + context.restoreGState() + for (offset, overlay) in overlays { + guard let rect = overlayRect(anchorOffset: offset, overlay: overlay) else { continue } + let drawRect = rect.offsetBy(dx: point.x, dy: point.y) + if let image = overlay.image { + // Draw the (resolution-independent) NSImage into the flipped context, + // so it rasterizes at the screen's backing scale — crisp on Retina, + // and positioned precisely. (Converting to a CGImage first would bake + // it at 1×, then upscale: soft, and quantized a pixel low.) The math + // image carries a small transparent inset, so the flipped draw can't + // clip a descender at the image edge. + let nsContext = NSGraphicsContext(cgContext: context, flipped: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = nsContext + image.draw(in: drawRect, from: .zero, operation: .sourceOver, + fraction: 1, respectFlipped: true, hints: nil) + NSGraphicsContext.restoreGraphicsState() + } else if let path = overlay.path, let color = overlay.pathColor { + // Stroke the vector path directly in CG — never rasterize it to + // an image first: an image drawn on a multi-line fragment wedges + // its layout to one line (see the FragmentOverlay note). Path + // coords are bounds-local and y-down, matching this flipped + // context, so a translate places them. + context.saveGState() + context.translateBy(x: drawRect.minX, y: drawRect.minY) + context.addPath(path) + context.setStrokeColor(color.cgColor) + context.setLineWidth(overlay.pathLineWidth) + context.setLineCap(.round) + context.setLineJoin(.round) + context.strokePath() + context.restoreGState() + } + } + // Overflowing table cells: the real characters are hidden, so draw + // each cell's pre-wrapped lines here instead, stacked top-down at the + // cell's column x. Left-aligned regardless of the column's declared + // alignment (ponytail: not requested; upgrade path is the same + // per-line x-shift math the kern-based alignment above already uses). + for cellWrap in resolvedCellWraps { + var y = point.y + for line in cellWrap.lines { + line.draw(at: CGPoint(x: point.x + cellWrap.x, y: y), in: context) + y += line.typographicBounds.height + } + } + } + + /// Fragment-local rect for an overlay image, anchored to the character at + /// the given paragraph-relative offset. + private func overlayRect(anchorOffset: Int, overlay: FragmentOverlay) -> CGRect? { + guard let line = textLineFragments.first(where: { + NSLocationInRange(anchorOffset, $0.characterRange) + }) ?? textLineFragments.last else { return nil } + let anchorX = line.typographicBounds.minX + + line.locationForCharacter(at: anchorOffset).x + // Baseline (flipped coords): the line's glyph origin sits at its + // typographic origin plus the ascent-derived glyph origin. + let baselineY = line.typographicBounds.minY + line.glyphOrigin.y + return CGRect(x: anchorX + overlay.bounds.minX, + y: baselineY - overlay.bounds.height - overlay.bounds.minY, + width: overlay.bounds.width, + height: overlay.bounds.height) + } + + /// Fragment-local y of the first line's glyph top (baseline minus the + /// line's font ascender). The line box can hold extra space above the + /// glyphs (lineSpacing lands there), which a text-hugging bar skips. + private var firstLineGlyphTop: CGFloat? { + guard let line = textLineFragments.first, + line.characterRange.length > 0, + let font = line.attributedString.attribute( + .font, at: line.characterRange.location, effectiveRange: nil) as? NSFont + else { return nil } + return line.typographicBounds.minY + line.glyphOrigin.y - font.ascender + } + + private func drawDecoration(_ decoration: BlockDecoration, at point: CGPoint, + in context: CGContext, bottomInset: CGFloat = 0) { + let frame = layoutFragmentFrame + // Filled decorations (box, bar) stop above an absorbed trailing empty + // line; center-line decorations (rule, table) still use the full frame. + let fillHeight = decorationDrawHeight + // Fragment-local rect spanning the full text column for this fragment. + let columnRect = CGRect(x: point.x + containerLeft, y: point.y, + width: containerWidth, height: fillHeight) + + switch decoration.kind { + case .box(let background, let borderColor, let edges, let borderWidth, _): + // The fragment frame already includes any box bottomPad (see + // layoutFragmentFrame), so columnRect covers the padded area. A + // nested box insets symmetrically so it sits within its parent box, + // and stops `bottomInset` short of the frame bottom so the enclosing + // box's padding shows below it. + var columnRect = decoration.inset > 0 + ? columnRect.insetBy(dx: decoration.inset, dy: 0) + : columnRect + columnRect.size.height -= bottomInset + context.setFillColor(background.cgColor) + context.fill(columnRect) + if let borderColor, !edges.isEmpty { + context.setFillColor(borderColor.cgColor) + if edges.contains(.left) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.minY, + width: borderWidth, height: columnRect.height)) + } + if edges.contains(.right) { + context.fill(CGRect(x: columnRect.maxX - borderWidth, y: columnRect.minY, + width: borderWidth, height: columnRect.height)) + } + if edges.contains(.top) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.minY, + width: columnRect.width, height: borderWidth)) + } + if edges.contains(.bottom) { + context.fill(CGRect(x: columnRect.minX, y: columnRect.maxY - borderWidth, + width: columnRect.width, height: borderWidth)) + } + } + + case .leftBar(let color, let width): + // The bar sits immediately left of the text (the paragraph style + // insets the text by the bar's width) — or `inset` further right, + // for a nested quote's bar next to its own level's text. + var barTop = point.y + var barHeight = fillHeight + if decoration.hugsTextTop, let glyphTop = firstLineGlyphTop { + barTop += glyphTop + barHeight -= glyphTop + } + context.setFillColor(color.cgColor) + context.fill(CGRect(x: point.x - width + decoration.inset, y: barTop, + width: width, height: barHeight)) + + case .tableRow(let xOffsets, let width, let leftInset, let separator, let bottomBorder): + // Offsets are text-relative; the fragment's origin is the text start. + context.setStrokeColor(NSColor.separatorColor.cgColor) + context.setLineWidth(1) + for x in xOffsets { + let lineX = round(point.x + x) + 0.5 + context.move(to: CGPoint(x: lineX, y: point.y)) + context.addLine(to: CGPoint(x: lineX, y: point.y + frame.height)) + } + if separator { + let y = round(point.y + frame.height / 2) + 0.5 + context.move(to: CGPoint(x: point.x - leftInset, y: y)) + context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y)) + } + if bottomBorder { + let y = round(point.y + frame.height) + 0.5 + context.move(to: CGPoint(x: point.x - leftInset, y: y)) + context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y)) + } + context.strokePath() + + case .horizontalRule(let color, let centerOffset): + context.setStrokeColor(color.cgColor) + context.setLineWidth(1) + let y = round(point.y + frame.height / 2 + centerOffset) + 0.5 + context.move(to: CGPoint(x: columnRect.minX, y: y)) + context.addLine(to: CGPoint(x: columnRect.maxX, y: y)) + context.strokePath() + } + } +} + +// MARK: - Fragment Vending + +extension EditorTextView: NSTextLayoutManagerDelegate { + public nonisolated func textLayoutManager( + _ textLayoutManager: NSTextLayoutManager, + textLayoutFragmentFor location: NSTextLocation, + in textElement: NSTextElement + ) -> NSTextLayoutFragment { + guard let paragraph = textElement as? NSTextParagraph, + paragraph.attributedString.length > 0 else { + return NSTextLayoutFragment(textElement: textElement, + range: textElement.elementRange) + } + let str = paragraph.attributedString + let decoValue = str.attribute(.blockDecoration, at: 0, effectiveRange: nil) + let decorations: [BlockDecoration] + if let list = decoValue as? BlockDecorationList { + decorations = list.decorations + } else if let single = decoValue as? BlockDecoration { + decorations = [single] + } else { + decorations = [] + } + var overlays: [(offset: Int, overlay: FragmentOverlay)] = [] + str.enumerateAttribute(.fragmentOverlay, + in: NSRange(location: 0, length: str.length), + options: []) { value, range, _ in + if let overlay = value as? FragmentOverlay { + overlays.append((range.location, overlay)) + } + } + let cellWrapsValue = str.attribute(.tableCellWraps, at: 0, effectiveRange: nil) + let cellWraps = (cellWrapsValue as? TableCellWrapList)?.wraps ?? [] + // A plain fragment suffices only when there's nothing to draw over the + // text and antialiasing is on (the default); otherwise vend the custom + // fragment so its draw can disable antialiasing. + guard !decorations.isEmpty || !overlays.isEmpty || !cellWraps.isEmpty || !textAntialias + else { + return NSTextLayoutFragment(textElement: textElement, + range: textElement.elementRange) + } + return DecoratedTextLayoutFragment(textElement: textElement, + range: textElement.elementRange, + decorations: decorations, + overlays: overlays, + cellWraps: cellWraps, + antialias: textAntialias) + } +} + +// MARK: - Overlay Application + +extension EditorTextView { + /// Renders `overlay` at `anchor` (a single character): hides the anchor + /// glyph, reserves the image's advance width with kern so following text + /// flows around it, and stores the overlay for the layout fragment to draw. + /// + /// The kern is capped just short of the full line width: a full-width + /// image/equation (the common case — anything wider than the column gets + /// scaled to exactly fill it) would otherwise reserve 100% of the line, + /// leaving zero room for the hidden markdown text that follows the anchor + /// on the same line. TextKit then force-wraps that hidden run onto a new + /// line fragment — and since `minimumLineHeight` (reserveLineHeight) is a + /// paragraph-wide property applying to every line fragment, that phantom + /// wrapped line also inflates to the overlay's full height, doubling the + /// reserved space below the image. The slack is comfortably larger than + /// any realistic hidden-text width (near-zero at `hiddenFont`'s size). + func applyOverlay(_ overlay: FragmentOverlay, anchor: NSRange, + in result: NSMutableAttributedString) { + guard anchor.upperBound <= result.length else { return } + let kernSlack: CGFloat = 8 + let kernWidth = min(overlay.bounds.width, max(0, availableContentWidth - kernSlack)) + result.addAttribute(.font, value: hiddenFont, range: anchor) + result.addAttribute(.foregroundColor, value: NSColor.clear, range: anchor) + result.addAttribute(.kern, value: kernWidth, range: anchor) + result.addAttribute(.fragmentOverlay, value: overlay, range: anchor) + } + + /// Reserves vertical room for an overlay taller than the text line that + /// carries it. A `FragmentOverlay` only reserves horizontal advance (kern), + /// so — unlike the old `NSTextAttachment`, which grew its line fragment — + /// a tall image (e.g. inline math scaled to a heading's size) would + /// otherwise overlap the line below. Raises the enclosing paragraph's + /// `minimumLineHeight` to fit, preserving any other paragraph attributes. + func reserveLineHeight(_ height: CGFloat, forOverlayAt location: Int, + in result: NSMutableAttributedString) { + guard location < result.length else { return } + let ns = result.string as NSString + // The enclosing paragraph (between newlines): minimumLineHeight is a + // paragraph attribute, and for the heading/inline cases the math sits + // on a single line, so this grows exactly the line that needs it. + let para = ns.paragraphRange(for: NSRange(location: location, length: 0)) + let base = (result.attribute(.paragraphStyle, at: location, effectiveRange: nil) + as? NSParagraphStyle) ?? bodyParagraphStyle + guard height > base.minimumLineHeight else { return } + let ps = (base.mutableCopy() as! NSMutableParagraphStyle) + ps.minimumLineHeight = height + result.addAttribute(.paragraphStyle, value: ps, range: para) + } +} + +// MARK: - Stack Construction + +public extension EditorTextView { + /// Builds the TextKit 2 text system chain and returns the wired editor: + /// EditorTextStorage → NSTextContentStorage → NSTextLayoutManager + /// → NSTextContainer → EditorTextView + static func makeTextKit2(frame: NSRect, containerSize: NSSize) -> EditorTextView { + let contentStorage = NSTextContentStorage() + contentStorage.textStorage = EditorTextStorage() + + let layoutManager = NSTextLayoutManager() + contentStorage.addTextLayoutManager(layoutManager) + + let container = NSTextContainer(size: containerSize) + container.widthTracksTextView = true + layoutManager.textContainer = container + + return EditorTextView(frame: frame, textContainer: container) + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift b/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift new file mode 100644 index 0000000..edf9b24 --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+TypewriterScroll.swift @@ -0,0 +1,238 @@ +import AppKit + +/// Viewport stability: typewriter centering, viewport-top anchoring across +/// height-changing restyles, and a fragment-based `scrollRangeToVisible` that +/// avoids AppKit's TextKit 2 scroll-to-range (which kills the process on large +/// documents). The `typewriterModeEnabled` stored flag lives on the main class. +extension EditorTextView { + + /// Runs `body` (which restyles the active block, changing its height) while + /// pinning the line at the TOP of the viewport to the same screen position + /// — so the part of the document the user is looking at doesn't move, even + /// when the height change (or a lazy-layout height estimate) is above the + /// caret. Anchoring the viewport top rather than the caret is what removes + /// the residual lurch: a caret anchor holds the caret but lets the content + /// above it slide. + /// + /// Reliability: `layoutViewport()` first guarantees the on-screen fragments + /// are laid out, so the top fragment's character offset is correct; both + /// samples then go through `lineRect` (which forces layout) for a + /// consistent measurement. A mis-measure degrades to no scroll — never a + /// yank or a jump to the document start. + func preservingViewportAnchor(_ body: () -> Void) { + guard let scrollView = enclosingScrollView, let tlm = textLayoutManager else { + body(); return + } + tlm.textViewportLayoutController.layoutViewport() + let visible = scrollView.contentView.bounds + let topPoint = CGPoint(x: 0, y: visible.minY - textContainerOrigin.y) + guard let frag = tlm.textLayoutFragment(for: topPoint) else { body(); return } + let anchorOffset = tlm.offset(from: tlm.documentRange.location, + to: frag.rangeInElement.location) + let beforeY = lineRect(forCharacterAt: anchorOffset)?.minY + + body() + + guard let beforeY, let afterY = lineRect(forCharacterAt: anchorOffset)?.minY else { return } + let delta = afterY - beforeY + guard abs(delta) > 0.5 else { return } + let newY = max(0, visible.origin.y + delta) + scrollView.contentView.scroll(to: NSPoint(x: visible.origin.x, y: newY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + + /// Runs a restyle (`body`) while keeping the viewport visually stable: in + /// typewriter mode it re-centers on the caret afterward; otherwise it pins + /// the viewport top (`preservingViewportAnchor`) so content above the edit + /// doesn't shift. The selection-driven cursor-move path uses the same + /// split inline; this wraps it for the indent path's two call sites. + func stabilizingViewport(_ body: () -> Void) { + if typewriterModeEnabled { + body() + scrollCursorToCenter() + } else { + preservingViewportAnchor(body) + } + } + + /// Scrolls the view so the cursor's line fragment is vertically centered + /// in the visible area — but only in typewriter mode. + func scrollCursorToCenter() { + guard typewriterModeEnabled else { return } + centerViewportOnCaret() + } + + /// Scrolls the view so the caret's line fragment is vertically centered in + /// the visible area, regardless of typewriter mode. Used by typewriter + /// centering and by undo/redo when the restored edit is off-screen. + /// + /// The caret's geometry must be laid out for real first — a TextKit 2 + /// estimate for an off-screen caret would center on the wrong place (or not + /// move at all). When the caret is too far from the viewport to lay out + /// cheaply, fall back to a plain reveal rather than risk a huge layout. + func centerViewportOnCaret() { + guard let scrollView = enclosingScrollView else { return } + guard ensureCaretRegionLaidOut() else { + scrollRangeToVisible(selectedRange()); return + } + guard let lineRect = caretLineRect() else { return } + let cursorY = lineRect.midY + textContainerOrigin.y + + let visibleHeight = scrollView.contentView.bounds.height + let targetY = cursorY - visibleHeight / 2 + let maxY = max(0, frame.height - visibleHeight) + let clampedY = min(max(0, targetY), maxY) + + scrollView.contentView.scroll(to: NSPoint(x: 0, y: clampedY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + // Settle passes: the target Y was computed from geometry that can + // still contain TextKit 2 height *estimates* above the caret. After + // the scroll, the viewport's own layout is real — re-measure and + // correct any residual error (typically converges in one pass; + // bounded for safety). + guard let tlm = textLayoutManager else { return } + for _ in 0..<3 { + tlm.textViewportLayoutController.layoutViewport() + guard let settled = caretLineRect() else { return } + let settledTarget = settled.midY + textContainerOrigin.y - visibleHeight / 2 + let settledMaxY = max(0, frame.height - visibleHeight) + let settledY = min(max(0, settledTarget), settledMaxY) + guard abs(settledY - scrollView.contentView.bounds.origin.y) > 1 else { return } + scrollView.contentView.scroll(to: NSPoint(x: 0, y: settledY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } + + /// Lays out the span between the current viewport and the caret so the + /// caret's line geometry is real rather than a TextKit 2 estimate. Returns + /// false when that span is too large to lay out cheaply (the caller should + /// fall back to a plain reveal) — this is the guard that keeps a deep caret + /// in a 1–2 MB document from triggering the process-killing full-document + /// layout that motivated the rest of this file. + @discardableResult + func ensureCaretRegionLaidOut() -> Bool { + guard let tlm = textLayoutManager else { return false } + let caretOffset = selectedRange().location + tlm.textViewportLayoutController.layoutViewport() + + let lo: Int, hi: Int + if let vp = tlm.textViewportLayoutController.viewportRange { + let vpStart = tlm.offset(from: tlm.documentRange.location, to: vp.location) + let vpEnd = tlm.offset(from: tlm.documentRange.location, to: vp.endLocation) + lo = min(vpStart, caretOffset); hi = max(vpEnd, caretOffset) + } else { + // No viewport yet (first layout): lay out from the document start. + lo = 0; hi = caretOffset + } + + let cap = 60_000 // ~a few screenfuls; bounds the layout cost + guard hi - lo <= cap, + let a = tlm.location(tlm.documentRange.location, offsetBy: max(0, lo)), + let b = tlm.location(tlm.documentRange.location, offsetBy: hi), + let range = NSTextRange(location: a, end: b) else { return false } + tlm.ensureLayout(for: range) + return true + } + + /// Whether the caret's line fragment lies within the viewport defined by + /// `origin` (a clip-view bounds origin), in view coordinates. + func caretIsVisible(forViewportOrigin origin: CGPoint) -> Bool { + guard let scrollView = enclosingScrollView, + let lineRect = caretLineRect() else { return false } + let visible = CGRect(origin: origin, size: scrollView.contentView.bounds.size) + let caretInView = CGRect(x: visible.minX, + y: lineRect.minY + textContainerOrigin.y, + width: 1, height: lineRect.height) + return visible.intersects(caretInView) + } + + /// Whether any part of `range`'s vertical span (start line through end + /// line) lies within the viewport defined by `origin`. Used by undo/redo + /// to decide hold-vs-center for the restored change. + func rangeIsVisible(_ range: NSRange, forViewportOrigin origin: CGPoint) -> Bool { + guard let scrollView = enclosingScrollView, + let startRect = lineRect(forCharacterAt: range.location) else { return false } + let endRect = range.length > 0 + ? (lineRect(forCharacterAt: range.upperBound) ?? startRect) : startRect + let visible = CGRect(origin: origin, size: scrollView.contentView.bounds.size) + let span = CGRect(x: visible.minX, + y: min(startRect.minY, endRect.minY) + textContainerOrigin.y, + width: 1, + height: max(startRect.maxY, endRect.maxY) - min(startRect.minY, endRect.minY)) + return visible.intersects(span) + } + + /// The caret line's rect in text-container coordinates (TextKit 2: lays + /// out only the caret's fragment, positions above are estimated). + private func caretLineRect() -> CGRect? { + lineRect(forCharacterAt: selectedRange().location) + } + + /// The line rect for the character at `offset`, in text-container + /// coordinates. Lays out only the offset's own fragment — forcing layout + /// from the document start would lay out (and could OOM on) the whole 1–2 + /// MB document for a deep caret. For carets in or near the viewport the + /// position is exact; for far jumps it may be a TextKit 2 estimate that + /// the scroll anchoring + promotion settle once the region is reached. + func lineRect(forCharacterAt offset: Int) -> CGRect? { + guard let tlm = textLayoutManager else { return nil } + guard let loc = tlm.location(tlm.documentRange.location, offsetBy: offset) + else { return nil } + tlm.ensureLayout(for: NSTextRange(location: loc)) + guard let fragment = tlm.textLayoutFragment(for: loc) else { return nil } + let frame = fragment.layoutFragmentFrame + + guard let paraStart = fragment.textElement?.elementRange?.location else { return frame } + let offsetInPara = tlm.offset(from: paraStart, to: loc) + let line = fragment.textLineFragments.first { + NSLocationInRange(offsetInPara, $0.characterRange) + } ?? fragment.textLineFragments.last + guard let line else { return frame } + return line.typographicBounds.offsetBy(dx: frame.minX, dy: frame.minY) + } + + /// AppKit's TextKit 2 implementation of scroll-to-range kills the process + /// on large documents (observed reproducibly at ~1.5 MB; silent kill, no + /// crash report). NSTextView calls it internally after every insertion + /// (caret autoscroll), so replace it with the minimal fragment-based + /// scroll: lay out just the target's fragment and move the clip view. + public override func scrollRangeToVisible(_ range: NSRange) { + guard let scrollView = enclosingScrollView else { return } + // Bound the range by its two ends so an extended selection follows the + // end being modified rather than always its start. + let visible = scrollView.contentView.bounds + guard let startRect = lineRect(forCharacterAt: range.location) else { return } + let endRect = range.length > 0 + ? (lineRect(forCharacterAt: range.upperBound) ?? startRect) : startRect + let top = min(startRect.minY, endRect.minY) + textContainerOrigin.y + let bottom = max(startRect.maxY, endRect.maxY) + textContainerOrigin.y + let margin: CGFloat = 8 + + var targetY = visible.origin.y + if top < visible.minY && bottom > visible.maxY { + // The range overflows the viewport on both sides (e.g. a drag + // selection grown taller than the screen). Follow the end + // *nearest* the viewport — that's the end being extended. Always + // scrolling to the top here fought the drag's downward autoscroll + // and made the viewport oscillate up and down mid-drag. + let overflowTop = visible.minY - top + let overflowBottom = bottom - visible.maxY + targetY = overflowBottom <= overflowTop + ? bottom + margin - visible.height + : top - margin + } else if top < visible.minY { + targetY = top - margin + } else if bottom > visible.maxY { + // Prefer keeping the bottom edge visible; fall back to the top if + // the range is taller than the viewport. + targetY = min(bottom + margin - visible.height, top - margin) + } else { + return // already visible + } + let maxY = max(0, frame.height - visible.height) + let clampedY = min(max(0, targetY), maxY) + scrollView.contentView.scroll(to: NSPoint(x: visible.origin.x, y: clampedY)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView+Undo.swift b/Sources/EdmundCore/TextView/EditorTextView+Undo.swift new file mode 100644 index 0000000..bae991b --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView+Undo.swift @@ -0,0 +1,174 @@ +import AppKit + +// MARK: - Custom Undo/Redo +// +// Custom undo stack operating on rawSource snapshots. Completely bypasses +// NSTextView's built-in undo (allowsUndo = false) because recompose +// replaces the entire text storage, invalidating position-based undo. + +extension EditorTextView { + + @objc public func undo(_ sender: Any?) { + performUndo() + } + + @objc public func redo(_ sender: Any?) { + performRedo() + } + + func classifyEdit(range: NSRange, replacement: String) -> EditType { + if replacement == "\n" { return .other } // Enter always starts a new group + if replacement.count == 1 && range.length == 0 { return .insert } + if replacement.isEmpty && range.length == 1 { return .delete } + return .other + } + + /// Push an undo snapshot if this edit starts a new coalescing group. + func recordUndoIfNeeded(editRange: NSRange, replacement: String) { + let editType = classifyEdit(range: editRange, replacement: replacement) + + let shouldPush = undoStack.isEmpty + || editType == .other + || editType != lastEditType + || activeBlockIndex != lastEditBlockIndex + + if shouldPush { + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + redoStack.removeAll() + } + + lastEditType = editType + lastEditBlockIndex = activeBlockIndex + } + + func performUndo() { + guard let snapshot = undoStack.popLast() else { return } + redoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + restoreSnapshot(snapshot) + } + + func performRedo() { + guard let snapshot = redoStack.popLast() else { return } + undoStack.append(UndoSnapshot(rawSource: rawSource, cursorInRaw: currentCursorInRaw())) + restoreSnapshot(snapshot) + } + + /// The single contiguous span that differs between two strings, as the + /// replaced range in `old` (UTF-16) plus its replacement text from `new`. + /// nil when the strings are equal. Boundaries never split a surrogate + /// pair, so the result is always safe to select or restyle. + nonisolated static func textDiff(old: String, new: String) -> (oldRange: NSRange, replacement: String)? { + let o = old as NSString + let n = new as NSString + guard !o.isEqual(to: new) else { return nil } + + var prefix = 0 + let maxPrefix = min(o.length, n.length) + while prefix < maxPrefix && o.character(at: prefix) == n.character(at: prefix) { + prefix += 1 + } + var suffix = 0 + let maxSuffix = min(o.length, n.length) - prefix + while suffix < maxSuffix + && o.character(at: o.length - 1 - suffix) == n.character(at: n.length - 1 - suffix) { + suffix += 1 + } + // Widen rather than split a surrogate pair at either boundary. + while prefix > 0 && UTF16.isLeadSurrogate(o.character(at: prefix - 1)) { + prefix -= 1 + } + while suffix > 0 && UTF16.isTrailSurrogate(o.character(at: o.length - suffix)) { + suffix -= 1 + } + + let oldRange = NSRange(location: prefix, length: o.length - suffix - prefix) + let replacement = n.substring(with: NSRange(location: prefix, + length: n.length - suffix - prefix)) + return (oldRange, replacement) + } + + private func restoreSnapshot(_ snapshot: UndoSnapshot) { + // Diff the current text against the snapshot: the changed span is what + // this undo/redo actually touches, so it drives the selection and the + // viewport — not the caret stored at snapshot time (which, for redo, + // is wherever the caret happened to sit when undo was invoked). + guard let diff = Self.textDiff(old: rawSource, new: snapshot.rawSource) else { + // Nothing changed textually — just restore the caret. + let clamped = min(snapshot.cursorInRaw, (rawSource as NSString).length) + setSelectedRange(NSRange(location: clamped, length: 0)) + return + } + + isUndoRedoing = true + let oldIndentUnit = listIndentUnit + let oldActive = activeBlockIndex + let oldCount = blocks.count + + rawSource = snapshot.rawSource + rebuildListIndentState() + rebuildLinkDefState() + let (newBlocks, changed) = BlockParser.parseWithDiff(rawSource, previous: blocks) + blocks = newBlocks + + var dirty = IndexSet(integersIn: changed) + // Map the old active block through the diff (same scheme as the edit + // path): prefix indices are unchanged, suffix indices shift by the + // count delta, anything inside the window is already dirty. + if let old = oldActive { + let suffixCount = newBlocks.count - changed.upperBound + if old < changed.lowerBound { + dirty.insert(old) + } else if old >= oldCount - suffixCount { + dirty.insert(old + (newBlocks.count - oldCount)) + } + } + // listIndentUnit is document-global: when it changes, every list + // block's rendered indentation changes with it. + if listIndentUnit != oldIndentUnit { + for (i, block) in blocks.enumerated() where block.kind == .listItem { + dirty.insert(i) + } + } + + // The changed text in restored coordinates: select it so the user sees + // exactly what this undo/redo did. A pure deletion has no new text to + // select — the caret goes to the deletion point instead. + let changedInNew = NSRange(location: diff.oldRange.location, + length: (diff.replacement as NSString).length) + let selection: NSRange? = changedInNew.length > 0 ? changedInNew : nil + + // Range-bounded storage replacement: layout outside the changed span + // stays real. (The old full `recompose` reset the whole document to + // TextKit 2 height estimates, and centering math done on estimates is + // what made the post-undo scroll land too far down.) + let apply = { + self.recomposeReplacing(oldRange: diff.oldRange, with: diff.replacement, + dirty: dirty, cursorInRaw: changedInNew.location, + selectionInRaw: selection) + } + + if typewriterModeEnabled { + // Typewriter: always center on the changed text. + apply() + centerViewportOnCaret() + } else if let scrollView = enclosingScrollView { + // If any of the changed text is already on screen, hold the + // viewport perfectly still; otherwise center the change. + let savedOrigin = scrollView.contentView.bounds.origin + apply() + ensureCaretRegionLaidOut() + if rangeIsVisible(changedInNew, forViewportOrigin: savedOrigin) { + scrollView.contentView.scroll(to: savedOrigin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } else { + centerViewportOnCaret() + } + } else { + apply() + } + + isUndoRedoing = false + lastEditType = .other + lastEditBlockIndex = nil + } +} diff --git a/Sources/EdmundCore/TextView/EditorTextView.swift b/Sources/EdmundCore/TextView/EditorTextView.swift new file mode 100644 index 0000000..1d578be --- /dev/null +++ b/Sources/EdmundCore/TextView/EditorTextView.swift @@ -0,0 +1,525 @@ +import AppKit + +/// A single NSTextView with word-level inline preview. +/// +/// ## Architecture +/// +/// `rawSource` is the **sole source of truth** for document content. +/// The text storage always contains rawSource — no delimiter stripping. +/// All formatting is achieved through NSAttributedString attributes: +/// - Inline delimiters (`**`, `*`, `` ` ``, etc.) are hidden via near-zero +/// font size when the cursor is not inside the token. +/// - Block-level markers (`#`, `>`, `-`, etc.) are always visible and dimmed. +/// - Content gets rich text styling (bold, italic, colors, etc.). +/// +/// **Edits** flow through NSTextView's normal path: +/// 1. `shouldChangeText` records an undo snapshot (coalesced), returns `true` +/// 2. NSTextView applies the edit to the text storage +/// 3. `didChangeText` fires — we sync `rawSource` and re-style the block +/// +/// **Cursor movement** is detected via `didChangeSelectionNotification`. +/// When the cursor moves to a different block, we restyle both blocks. +/// When it moves within a block, we update which token's delimiters +/// are visible (the "active token"). +/// +/// **Undo/Redo** uses custom stacks of `rawSource` snapshots, completely +/// bypassing NSTextView's built-in undo. +public class EditorTextView: NSTextView { + + // MARK: - Document Link + + /// Weak reference to the owning NSDocument, used for dirty-state tracking. + /// Set by Document.makeWindowControllers(). Not available in unit tests. + public weak var document: NSDocument? + + // MARK: - State (internal for @testable import) + + public var rawSource: String = "" + /// Columns of leading whitespace that make up one list-nesting level, + /// detected from the document (the smallest indent used, or one tab). + /// Defaults to 4. Used to map a list item's indentation to a nesting depth. + /// Maintained incrementally from `listIndentState` on the edit path; + /// rebuilt by the whole-document paths (load, undo, indent). + public var listIndentUnit: Int = 4 + /// Histogram of indented-list-line indents (see ListIndentState) backing + /// the incremental `listIndentUnit`. + var listIndentState = ListIndentState() + + /// Rebuilds the indent histogram from the whole document. O(n) — for the + /// paths that rebuilt rawSource anyway; the edit path updates per block. + func rebuildListIndentState() { + listIndentState = ListIndentState.build(from: rawSource) + listIndentUnit = listIndentState.unit + } + + /// Document-wide link reference definitions (`[label]: url`), fed into each + /// block's parse so GFM reference links resolve across blocks. Maintained + /// incrementally on the edit path; rebuilt by the whole-document paths. + var linkDefState = LinkDefinitionState() + + func rebuildLinkDefState() { + linkDefState = LinkDefinitionState.build(from: rawSource) + } + /// Line ending of the most recently loaded content. The buffer itself is + /// always LF; this is remembered so saves preserve the file's style. + public var originalLineEnding: LineEnding = .lf + var blocks: [Block] = [] + var activeBlockIndex: Int? = nil + var isUpdating = false + /// Coalesces the async active-block restyle scheduled from a caret move + /// (internal so EditorTextView+SelectionTracking can clear it). + var pendingRecompose = false + /// Coalesces idle-drain scheduling (see EditorTextView+LazyStyling). + var progressiveStylingScheduled = false + /// Coalesces scroll-driven promotion onto the next run-loop turn, off the + /// scroll notification (see EditorTextView+LazyStyling). + var pendingPromotion = false + /// Coalesces the didChangeText-bypass check scheduled from + /// shouldChangeText (see EditorTextView+EditFlow). + var bypassedEditCheckScheduled = false + /// Where the idle drain resumes scanning for unstyled blocks (a hint; + /// it wraps around and self-corrects after edits shift indices). + var drainCursor = 0 + /// Coalesces the deferred full-document layout settle for small documents + /// (see EditorTextView+LazyStyling `scheduleFullLayoutSettle`). + var fullLayoutSettleScheduled = false + /// Documents at or below this UTF-16 length are laid out in full once + /// styling converges, eliminating TextKit 2 height estimates (and the + /// scroll jumps they cause). See `scheduleFullLayoutSettle`. + static let fullLayoutMaxLength = 100_000 + + // MARK: - Custom Undo/Redo State + + struct UndoSnapshot { + let rawSource: String + let cursorInRaw: Int + } + + enum EditType { case insert, delete, other } + + var undoStack: [UndoSnapshot] = [] + var redoStack: [UndoSnapshot] = [] + var lastEditBlockIndex: Int? = nil + var lastEditType: EditType = .other + var isUndoRedoing = false + + /// The separator between blocks in the display. + /// Must match what BlockParser splits on. + let blockSeparator = "\n" + + // MARK: - Theme (user-configurable visual settings) + + /// The UserDefaults domain backing theme persistence. Defaults to the + /// shared `.standard` store; tests override it to isolate from the real + /// domain (and from each other under parallel execution). + public var themeDefaults: UserDefaults = .standard + + public var theme: EditorTheme = .load() { + didSet { textAntialias = theme.antialias } + } + + /// Mirror of `theme.antialias`, readable from the `nonisolated` + /// layout-fragment vendor. + nonisolated(unsafe) var textAntialias = true + + /// How the document is presented: + /// - `edit` — live preview; the block under the caret reveals its raw + /// markdown (the default editing experience). + /// - `reading` — everything rendered, no raw ever revealed; read-only. + /// - `source` — plain monospaced raw markdown, no styling. + public enum ViewMode: Sendable { case edit, reading, source } + + public var viewMode: ViewMode = .edit { + didSet { + guard oldValue != viewMode else { return } + isEditable = (viewMode != .reading) + // Re-style every block under the new mode (viewport-first for big docs). + guard !blocks.isEmpty else { return } + recomposeDirty(IndexSet(integersIn: 0.. Int? { + guard let tlm = textLayoutManager, + let storage = textStorage, storage.length > 0 else { return nil } + + var point = convert(event.locationInWindow, from: nil) + point.x -= textContainerOrigin.x + point.y -= textContainerOrigin.y + + guard let fragment = tlm.textLayoutFragment(for: point) else { return nil } + let frame = fragment.layoutFragmentFrame + let pointInFragment = CGPoint(x: point.x - frame.minX, y: point.y - frame.minY) + // Reject clicks past the end of a line: typographic bounds cover only + // the line's used extent. + guard let line = fragment.textLineFragments.first(where: { + $0.typographicBounds.contains(pointInFragment) + }) else { return nil } + + let indexInParagraph = line.characterIndex(for: pointInFragment) + guard indexInParagraph >= 0, + let paraStart = fragment.textElement?.elementRange?.location else { return nil } + let charIndex = tlm.offset(from: tlm.documentRange.location, to: paraStart) + indexInParagraph + return charIndex < storage.length ? charIndex : nil + } + + /// The raw destination string of the regular link under a mouse event, or + /// nil if the click doesn't land directly on link text. The destination is + /// resolved by `followLinkDestination` (external URL, `#heading`, or file). + private func linkDestination(at event: NSEvent) -> String? { + guard let storage = textStorage, let charIndex = clickCharIndex(at: event) else { return nil } + return storage.attribute(.editorLinkURL, at: charIndex, effectiveRange: nil) as? String + } + + // MARK: - Stranded-Composition Recovery + + /// Regaining first-responder status: recover from any stranded input-method + /// composition. If a marked-text (IME / accent / emoji) composition is ever + /// left uncommitted, `didChangeText` keeps bailing on its `hasMarkedText()` + /// guard, so the text storage drifts away from `rawSource` and every edit + /// then does offset math against a frozen block model — the "delete drift" + /// bug. Returning focus is a reliable "composition is over" signal (the view + /// can't become first responder while it already holds an active + /// composition), so when the invariant is broken here we commit any stranded + /// marked text and resync the model from the storage the user actually sees. + /// This formalizes the focus-switch recovery users already stumble into. + public override func becomeFirstResponder() -> Bool { + let became = super.becomeFirstResponder() + if became { recoverFromStrandedCompositionIfNeeded() } + return became + } + + func recoverFromStrandedCompositionIfNeeded() { + guard let ts = textStorage, ts.string != rawSource else { return } + // Rare and high-signal: this only fires when focus returns to a desynced + // editor. The flag snapshot tells us *why* the sync was stranded the next + // time the bug appears (marked text vs a leaked isUpdating/isUndoRedoing). + Log.info(""" + recovered stranded desync on focus regain: hasMarked=\(hasMarkedText()) \ + isUpdating=\(isUpdating) isUndoRedoing=\(isUndoRedoing) \ + storageΔ=\((ts.string as NSString).length - (rawSource as NSString).length) + """, category: .compose) + if hasMarkedText() { unmarkText() } + rawSource = ts.string + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource, previous: blocks) + recompose(cursorInRaw: min(selectedRange().location, (rawSource as NSString).length)) + } + + // MARK: - Helpers + + func currentCursorInRaw() -> Int { + return selectedRange().location + } + + /// Detects the indentation unit (columns per nesting level) used by list + /// items in `source`: the smallest positive leading-space count, or 4 when + /// tabs are used (a tab counts as one level) or nothing is found. + static func detectListIndentUnit(_ source: String) -> Int { + var minSpaces = Int.max + for line in source.split(separator: "\n", omittingEmptySubsequences: false) { + var spaces = 0 + var sawTab = false + for ch in line { + if ch == " " { spaces += 1 } + else if ch == "\t" { sawTab = true; break } + else { break } + } + let rest = line.drop(while: { $0 == " " || $0 == "\t" }) + guard startsWithListMarker(rest) else { continue } + if sawTab { return 4 } + if spaces > 0 { minSpaces = min(minSpaces, spaces) } + } + return minSpaces == Int.max ? 4 : minSpaces + } + + nonisolated static func startsWithListMarker(_ s: Substring) -> Bool { + guard let first = s.first else { return false } + if first == "-" || first == "*" || first == "+" { + return s.dropFirst().first == " " + } + if first.isNumber { + let afterDigits = s.drop(while: { $0.isNumber }) + if let d = afterDigits.first, d == "." || d == ")" { + return afterDigits.dropFirst().first == " " + } + } + return false + } + + // MARK: - Content Loading (called by Document) + + /// Replace the editor's content. Used by NSDocument on file open. + public func loadContent(_ content: String) { + Log.measure("Loaded document (\(content.count) chars)", category: .document) { + // Remember the file's line ending, then normalize the buffer to LF so + // block parsing and rendering never see a stray `\r`. A file that mixes + // styles is normalized to LF on save too (rather than its dominant style), + // so its endings become consistent. + originalLineEnding = LineEnding.isInconsistent(in: content) ? .lf : LineEnding.detect(in: content) + rawSource = LineEnding.normalize(content) + rebuildListIndentState() + rebuildLinkDefState() + blocks = BlockParser.parse(rawSource) + Log.blockStructure(blocks) + undoStack.removeAll() + redoStack.removeAll() + recompose(cursorInRaw: 0) + } + } +} + +// MARK: - String UTF-16 Index Helper + +extension String { + func utf16Index(at offset: Int) -> String.Index { + return String.Index(utf16Offset: offset, in: self) + } +} diff --git a/Sources/edmd/About/AboutView.swift b/Sources/edmd/About/AboutView.swift new file mode 100644 index 0000000..63323f2 --- /dev/null +++ b/Sources/edmd/About/AboutView.swift @@ -0,0 +1,40 @@ +// Note: `swift run` does not bundle resources, so the app icon and version +// string will not render correctly. Build and launch via ./scripts/build-app.sh. +import SwiftUI +import AppKit + +struct AboutView: View { + var body: some View { + let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" + + VStack(spacing: 6) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 96, height: 96) + .padding(.bottom, 4) + + Text("Edmund") + .font(.title2.weight(.semibold)) + + Text("Version \(short) (\(build))") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text("Copyright \u{00A9} 2026 Yina Tang") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Link("GitHub", destination: URL(string: "https://github.com/I7T5/Edmund")!) + .focusEffectDisabled() + Link("License", destination: URL(string: "https://github.com/I7T5/Edmund/blob/main/LICENSE")!) + .focusEffectDisabled() + } + .font(.caption) + .padding(.top, 4) + } + .padding(20) + .frame(width: 280) + } +} diff --git a/Sources/edmd/About/AboutWindowController.swift b/Sources/edmd/About/AboutWindowController.swift new file mode 100644 index 0000000..7139ac7 --- /dev/null +++ b/Sources/edmd/About/AboutWindowController.swift @@ -0,0 +1,15 @@ +import AppKit +import SwiftUI + +final class AboutWindowController: NSWindowController { + convenience init() { + let hosting = NSHostingController(rootView: AboutView()) + hosting.sizingOptions = [.preferredContentSize] + let window = NSWindow(contentViewController: hosting) + window.styleMask = [.titled, .closable] + window.title = "" + window.center() + window.isReleasedWhenClosed = false + self.init(window: window) + } +} diff --git a/Sources/edmd/App/Document.swift b/Sources/edmd/App/Document.swift new file mode 100644 index 0000000..470174d --- /dev/null +++ b/Sources/edmd/App/Document.swift @@ -0,0 +1,632 @@ +import AppKit +import UniformTypeIdentifiers +import EdmundCore + +/// NSDocument subclass that provides standard macOS document lifecycle: +/// file open/save, dirty-dot indicator, click-to-rename in the titlebar, +/// recent documents, and more — all for free. +/// +/// The actual editing is delegated entirely to `EditorTextView`. +class Document: NSDocument, HeadingNavigable { + + var editor: EditorTextView! + private var statusBar: StatusBarView! + private var viewModeButton: NSButton? + private static let viewModeItemID = NSToolbarItem.Identifier("viewMode") + + /// Session-only zoom scale (View ▸ Actual Size/Zoom In/Zoom Out), applied on + /// top of the persisted font size and content width. Not saved — each new + /// window starts back at 100%. + private var zoomFactor: CGFloat = 1.0 + private static let zoomStep: CGFloat = 0.1 + private static let zoomRange: ClosedRange = 0.5...3.0 + + /// Editor scroll view and its container, held so Read mode can swap the + /// editor out for a `ReadModeWebView` (created lazily on first read). + private var scrollView: NSScrollView! + private var containerView: NSView! + private var readView: ReadModeWebView? + + /// Content loaded from disk before the editor window exists. + /// `nonisolated(unsafe)` because `read(from:ofType:)` may be called + /// off the main actor, but the value is only consumed on main via `showWindows`. + nonisolated(unsafe) var pendingContent: String? + + // MARK: - Type Registration + // + // Without an Info.plist (SPM executable), NSDocument's default readableTypes + // and writableTypes are empty, which causes NSDocumentController to disable + // Open/Save entirely. We override them here. + + override class var readableTypes: [String] { + ["public.plain-text", "net.daringfireball.markdown"] + } + + override class var writableTypes: [String] { + ["net.daringfireball.markdown", "public.plain-text"] + } + + override class var autosavesInPlace: Bool { + AppSettings.autoSaveWithVersions + } + + override class func isNativeType(_ name: String) -> Bool { + return readableTypes.contains(name) + } + + // A single writable type keeps the save panel from showing a file-format + // popup. Everything we write is markdown, so there's nothing to choose. + override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] { + ["net.daringfireball.markdown"] + } + + // The `net.daringfireball.markdown` UTI prefers the ".markdown" extension; + // force ".md" instead, which is what people actually expect. + override func fileNameExtension(forType typeName: String, + saveOperation: NSDocument.SaveOperationType) -> String? { + "md" + } + + // `fileNameExtension(forType:…)` alone isn't enough: for an untitled save + // the panel still seeds its name field from the markdown UTI's preferred + // extension (".markdown"). Force the default name to end in ".md" and let + // the user type any other extension if they really want one. + override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool { + savePanel.allowedContentTypes = [] + savePanel.allowsOtherFileTypes = true + let base = (savePanel.nameFieldStringValue as NSString).deletingPathExtension + savePanel.nameFieldStringValue = (base.isEmpty ? "Untitled" : base) + ".md" + return true + } + + // MARK: - Window Setup + + override func makeWindowControllers() { + // Default content size for first launch. Any saved size is applied as a + // full window frame at the end of setup (below), once the toolbar is in + // place — so the frame round-trips exactly and doesn't drift by the + // title bar + toolbar height each time. + let windowWidth: CGFloat = 800 + let windowHeight: CGFloat = 560 + + let window = DocumentWindow( + contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + // Don't persist/restore document windows: macOS state restoration + // otherwise reopens the last-edited file on the next launch, so a fresh + // start (or File ▸ New) shows that document instead of a blank Untitled. + window.isRestorable = AppSettings.reopenWindows + window.minSize = NSSize(width: 320, height: 400) + window.backgroundColor = NSColor.textBackgroundColor + + // Build the TextKit 2 text system chain (viewport-based layout). + editor = EditorTextView.makeTextKit2( + frame: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), + containerSize: NSSize(width: windowWidth, height: CGFloat.greatestFiniteMagnitude) + ) + editor.minSize = NSSize(width: 0, height: 0) + editor.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude) + editor.isVerticallyResizable = true + editor.isHorizontallyResizable = false + editor.autoresizingMask = [.width] + editor.textContainerInset = NSSize(width: 24, height: 18) + // Centered reading column (see EditorTextView+ContentWidth). Convert the + // persisted cm value to points using the main screen PPI at window-creation + // time; recomputed on resize (setFrameSize) and when the window moves to a + // different display (windowDidChangeScreen). + let initScreen = NSScreen.main + editor.maxContentWidthPoints = initScreen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000 + editor.updateContentInset() + editor.allowRemoteImages = !AppSettings.blockExternalImages + editor.typewriterModeEnabled = AppDelegate.typewriterModeEnabled() + editor.document = self + + // Toolbar holds the right-aligned view-mode toggle (and gives the + // titlebar extra height for roomy traffic lights). Set it only after + // `editor` exists — assigning the toolbar synchronously vends its items. + let toolbar = NSToolbar(identifier: "MainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true // persists layout per "MainToolbar" + window.toolbar = toolbar + window.toolbarStyle = .unified + window.titlebarSeparatorStyle = .line + + // Wire the window's secondary-click interception now that the toolbar has + // synchronously vended the view-mode button (see DocumentWindow). + window.viewModeButton = viewModeButton + window.makeViewModeMenu = { [weak self] in self?.viewModeMenu() ?? NSMenu() } + + let statusBarHeight: CGFloat = 22 + let contentBounds = window.contentView!.bounds + + // The text view fills the whole window; the status bar floats over its + // bottom edge, revealed on hover. + scrollView = NSScrollView(frame: contentBounds) + scrollView.autoresizingMask = [.width, .height] + scrollView.hasVerticalScroller = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.documentView = editor + + // Floating status bar: hidden by default, fades in when the pointer + // enters its strip. Counts on the left, line ending on the right. + statusBar = StatusBarView(frame: NSRect( + x: 0, y: 0, width: contentBounds.width, height: statusBarHeight + )) + statusBar.autoresizingMask = [.width] + + containerView = NSView(frame: contentBounds) + containerView.autoresizesSubviews = true + containerView.addSubview(scrollView) + containerView.addSubview(statusBar) // overlay, on top of the text + + window.contentView = containerView + + NotificationCenter.default.addObserver( + self, selector: #selector(editorDidChange(_:)), + name: NSText.didChangeNotification, object: editor + ) + NotificationCenter.default.addObserver( + self, selector: #selector(editorSelectionDidChange(_:)), + name: NSTextView.didChangeSelectionNotification, object: editor + ) + NotificationCenter.default.addObserver( + self, selector: #selector(windowDidResize(_:)), + name: NSWindow.didResizeNotification, object: window + ) + NotificationCenter.default.addObserver( + self, selector: #selector(windowDidChangeScreen(_:)), + name: NSWindow.didChangeScreenNotification, object: window + ) + + // Restore the last window's frame size (the toolbar is now installed, so + // the frame is final). Applied as a frame, not a contentRect, so it + // round-trips exactly with what windowDidResize saves. Then center. + if let savedSize = AppSettings.lastWindowSize { + window.setFrame(NSRect(origin: window.frame.origin, size: savedSize), display: false) + } + window.center() + + let wc = NSWindowController(window: window) + addWindowController(wc) + window.makeFirstResponder(editor) + // Honor the persisted source-mode preference for the editing view. + if AppSettings.sourceMode { setViewMode(.source) } + updateStatusBar() + } + + @objc private func windowDidResize(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + // Save the full frame size; it's restored verbatim via setFrame on the + // next window, so the size round-trips exactly (no title-bar/toolbar drift). + AppSettings.lastWindowSize = window.frame.size + } + + /// Reapply the content-width cap in points when the window moves to a + /// display with a different physical PPI (e.g. external monitor). + @objc private func windowDidChangeScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let screen = window.screen else { return } + editor?.maxContentWidthPoints = screen.cmToPoints(AppSettings.maxContentWidthCm) * zoomFactor + } + + // MARK: - Zoom (View ▸ Actual Size / Zoom In / Zoom Out) + + @objc func zoomIn(_ sender: Any?) { setZoom(zoomFactor + Self.zoomStep) } + @objc func zoomOut(_ sender: Any?) { setZoom(zoomFactor - Self.zoomStep) } + @objc func actualSize(_ sender: Any?) { setZoom(1.0) } + + /// Scales font size (standard + code) and max content width together by + /// `factor`, off the persisted base values — never off the currently + /// applied (possibly already-zoomed) theme, so repeated zooming doesn't + /// compound rounding error and Actual Size always returns to the true base. + private func setZoom(_ factor: CGFloat) { + guard let editor else { return } + zoomFactor = min(Self.zoomRange.upperBound, max(Self.zoomRange.lowerBound, factor)) + + let base = EditorTheme.load(from: editor.themeDefaults) + var zoomed = base + zoomed.fontSize = base.fontSize * zoomFactor + zoomed.monospaceFontSize = base.monospaceFontSize * zoomFactor + editor.applyTheme(zoomed, persist: false) + + let screen = editor.window?.screen ?? NSScreen.main + editor.maxContentWidthPoints = (screen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000) * zoomFactor + + refreshReadView() + } + + @objc private func editorDidChange(_ notification: Notification) { + updateStatusBar() + // Keep an open Read view in sync with edits (it renders a snapshot). + refreshReadView() + } + + @objc private func editorSelectionDidChange(_ notification: Notification) { + updateStatusBar() + } + + private func updateStatusBar() { + guard let editor = editor, let statusBar = statusBar else { return } + let text = editor.rawSource + let nsText = text as NSString + let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count + let charCount = text.count + + // Cursor position: 0-based character location and 1-based line number. + let cursorOffset = editor.selectedRange().location + let location = min(cursorOffset, nsText.length) + let upToCursor = nsText.substring(to: location) + let line = upToCursor.isEmpty ? 1 : upToCursor.components(separatedBy: "\n").count + + // The buffer is always LF; show the file's remembered original ending. + statusBar.setMetrics(words: wordCount, characters: charCount, + location: location, line: line, + lineEnding: editor.originalLineEnding.displayName) + } + + // MARK: - Reading + + override nonisolated func read(from data: Data, ofType typeName: String) throws { + guard let contents = String(data: data, encoding: .utf8) else { + Log.error("Read failed: \(data.count) bytes not valid UTF-8", category: .io) + throw NSError(domain: NSOSStatusErrorDomain, code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not read file as UTF-8"]) + } + Log.info("Read \(data.count) bytes from disk", category: .io) + pendingContent = contents + } + + /// Called after makeWindowControllers when opening an existing file. + override func showWindows() { + super.showWindows() + if let content = pendingContent { + editor?.loadContent(content) + pendingContent = nil + warnIfInconsistentLineEndings(in: content) + } + updateStatusBar() + } + + /// Warn (once, suppressibly) when an opened file mixed line-ending styles. + /// The buffer has already been normalized to a single style for editing. + private func warnIfInconsistentLineEndings(in content: String) { + guard LineEnding.isInconsistent(in: content), + !AppSettings.suppressInconsistentLineEndingWarning, + let window = windowControllers.first?.window else { return } + + let alert = NSAlert() + alert.messageText = "Inconsistent Line Endings" + alert.informativeText = "This document mixes different line endings. " + + "It will be saved using \(editor?.originalLineEnding.displayName ?? "LF") throughout." + alert.addButton(withTitle: "OK") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Do not warn about inconsistent line endings" + alert.beginSheetModal(for: window) { _ in + if alert.suppressionButton?.state == .on { + AppSettings.suppressInconsistentLineEndingWarning = true + } + } + } + + /// Cross-file link following: scroll this document's editor to a heading + /// once it's on screen (the content has already loaded in showWindows). + func navigateToHeading(_ heading: String) { + editor?.scrollToHeading(heading) + } + + // MARK: - Rename & Move (manual — NSDocument's built-in versions + // are disabled without Info.plist / .app bundle) + + override func rename(_ sender: Any?) { + guard let url = fileURL, let window = windowControllers.first?.window else { return } + let panel = NSSavePanel() + panel.directoryURL = url.deletingLastPathComponent() + panel.nameFieldStringValue = url.lastPathComponent + panel.prompt = "Rename" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let newURL = panel.url else { return } + do { + try FileManager.default.moveItem(at: url, to: newURL) + self.fileURL = newURL + } catch { + NSAlert(error: error).runModal() + } + } + } + + override func move(_ sender: Any?) { + guard let url = fileURL, let window = windowControllers.first?.window else { return } + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.prompt = "Move" + panel.message = "Choose a new location for \"\(url.lastPathComponent)\"" + panel.beginSheetModal(for: window) { response in + guard response == .OK, let destDir = panel.url else { return } + let newURL = destDir.appendingPathComponent(url.lastPathComponent) + do { + try FileManager.default.moveItem(at: url, to: newURL) + self.fileURL = newURL + } catch { + NSAlert(error: error).runModal() + } + } + } + + // MARK: - View Mode (edit / reading / source) + + private func icon(for mode: EditorTextView.ViewMode) -> NSImage? { + let name: String + switch mode { + // Source is a raw-text view of the same editing mode as Edit, so it + // shares the pencil icon rather than getting a distinct glyph. + case .edit, .source: name = "pencil" + case .reading: name = "book" + } + return NSImage(systemSymbolName: name, accessibilityDescription: label(for: mode)) + } + + private func label(for mode: EditorTextView.ViewMode) -> String { + switch mode { + case .edit: return "Edit" + case .reading: return "Read" + case .source: return "Source" + } + } + + /// Shows the active mode's icon on the button and keeps the tooltip in sync. + private func refreshViewModeButton() { + guard let editor else { return } + viewModeButton?.image = icon(for: editor.viewMode)? + .withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) + viewModeButton?.toolTip = "View mode: \(label(for: editor.viewMode))" + } + + private func setViewMode(_ mode: EditorTextView.ViewMode) { + editor.viewMode = mode + applyViewMode(mode) + refreshViewModeButton() + } + + /// Swaps the on-screen view for the mode: Read mode shows the rendered-HTML + /// `ReadModeWebView`; Edit and Source stay on the editor's scroll view. + private func applyViewMode(_ mode: EditorTextView.ViewMode) { + guard let containerView else { return } + if mode == .reading { + let read = readView ?? { + let v = ReadModeWebView() + v.frame = scrollView.frame + v.autoresizingMask = [.width, .height] + // Below the floating status bar so counts stay visible. + containerView.addSubview(v, positioned: .below, relativeTo: statusBar) + // Route internal navigation through the editor's link resolver + // (which resolves against this document's directory and opens via + // NSDocumentController) instead of navigating the webview. + v.onOpenWikiLink = { [weak self] in self?.editor.followWikiLink($0) } + v.onOpenInternalLink = { [weak self] in self?.editor.followLinkDestination($0) } + readView = v + return v + }() + read.render(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions) + read.isHidden = false + scrollView.isHidden = true + editor.window?.makeFirstResponder(read) + } else { + readView?.isHidden = true + scrollView.isHidden = false + editor.window?.makeFirstResponder(editor) + } + } + + /// Re-renders an open Read view from the editor's current source + theme. + /// No-op unless Read mode is the active, visible view — so settings/edit + /// broadcasts stay cheap when the user is in Edit or Source mode. + func refreshReadView() { + guard let read = readView, !read.isHidden, editor?.viewMode == .reading else { return } + read.render(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions) + } + + /// The opened file's directory, used to resolve relative image paths for + /// inlining (nil for an unsaved document). + private var documentDirectory: URL? { + fileURL?.deletingLastPathComponent() + } + + /// Built-in callout styles merged with the editor's user overrides, so Read + /// mode and the PDF match exactly what the editor draws. + private var mergedCallouts: [String: CalloutStyle] { + var m = Callout.defaultStyles + for (k, v) in editor.calloutStyleOverrides { m[k] = v } + return m + } + + /// Read-mode/export render options derived from user settings. Reuses the + /// editor's own `maxContentWidthPoints` (already the cm setting converted via + /// the window's screen PPI) so Read mode's column matches Edit mode's. + private var renderOptions: ReadRenderOptions { + ReadRenderOptions(preserveBlankLines: AppSettings.renderBlankLinesAsBreaks, + allowRemoteImages: !AppSettings.blockExternalImages, + maxContentWidthPoints: Double(editor.maxContentWidthPoints)) + } + + // MARK: - Export / Print + + @objc func exportToPDF(_ sender: Any?) { + let name = (displayName as NSString).deletingPathExtension + MarkdownPrinter.exportPDF(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions, + suggestedName: name.isEmpty ? "Untitled" : name, + window: windowControllers.first?.window) + } + + @objc override func printDocument(_ sender: Any?) { + MarkdownPrinter.print(markdown: editor.rawSource, + theme: editor.theme, + callouts: mergedCallouts, + baseURL: documentDirectory, + options: renderOptions, + window: windowControllers.first?.window) + } + + /// The editing-side view: Source when source mode is on, otherwise Edit. + /// Read is the other half of the toggle. + private var editingMode: EditorTextView.ViewMode { + AppSettings.sourceMode ? .source : .edit + } + + @objc private func selectEditMode(_ sender: Any?) { setViewMode(editingMode) } + @objc private func selectReadingMode(_ sender: Any?) { setViewMode(.reading) } + + /// The "Show source in editor" checkbox (button menu and View menu). + /// Persists the setting and, if we're in the editing view, swaps it to + /// the new editing mode right away. + @objc func toggleSourceMode(_ sender: Any?) { + AppSettings.sourceMode.toggle() + if editor.viewMode != .reading { setViewMode(editingMode) } + } + + /// Keeps the View-menu "Show Source in Editor" checkmark in sync with the setting. + override func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(toggleSourceMode(_:)) { + item.state = AppSettings.sourceMode ? .on : .off + } + return super.validateMenuItem(item) + } + + /// Toggle the editing view ↔ Read (the View-menu ⌘E item and the toolbar + /// button). With source mode on the editing view is Source, so this flips + /// Source ↔ Read; otherwise Edit ↔ Read. + @objc func toggleViewMode(_ sender: Any?) { + setViewMode(editor.viewMode == .reading ? editingMode : .reading) + } + + /// One mode menu item: icon + title, checked when `on`. + private func menuItem(_ title: String, _ image: NSImage?, + _ action: Selector, on: Bool) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + item.image = image + item.state = on ? .on : .off + return item + } + + /// The right-click menu: Edit / Read selection, a divider, then the + /// "Show source in editor" checkbox. Built fresh each time so state stays current. + fileprivate func viewModeMenu() -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false // actions always fire on selection + let inEditing = editor?.viewMode != .reading + menu.addItem(menuItem("Edit", icon(for: .edit), + #selector(selectEditMode(_:)), on: inEditing)) + menu.addItem(menuItem("Read", icon(for: .reading), + #selector(selectReadingMode(_:)), on: !inEditing)) + menu.addItem(.separator()) + menu.addItem(menuItem("Show source in editor", nil, + #selector(toggleSourceMode(_:)), on: AppSettings.sourceMode)) + return menu + } + + // MARK: - Writing + + override func data(ofType typeName: String) throws -> Data { + // The buffer is always LF; restore the file's original line ending on + // write so opening, then saving, doesn't silently rewrite every line. + let normalized = editor?.rawSource ?? "" + let ending = editor?.originalLineEnding ?? .lf + let text = ending == .lf + ? normalized + : normalized.replacingOccurrences(of: "\n", with: ending.string) + guard let data = text.data(using: .utf8) else { + Log.error("Save failed: could not encode \(text.count) chars as UTF-8", category: .io) + throw NSError(domain: NSOSStatusErrorDomain, code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not encode text as UTF-8"]) + } + Log.info("Saving \(data.count) bytes (\(ending.displayName))", category: .io) + return data + } +} + +// MARK: - Toolbar (view-mode toggle) + +extension Document: NSToolbarDelegate { + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, Self.viewModeItemID] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, .space, Self.viewModeItemID] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + guard itemIdentifier == Self.viewModeItemID else { return nil } + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "View Mode" + item.visibilityPriority = .high + + // Left-click toggles the editing view ↔ Read. The right-click mode menu + // is handled upstream in DocumentWindow.sendEvent — every view-level + // approach (the view's `menu`, rightMouseDown, a gesture recognizer) + // loses the secondary click to the toolbar's "Customize Toolbar…" menu. + let button = NSButton(image: NSImage(), target: self, + action: #selector(toggleViewMode(_:))) + button.bezelStyle = .texturedRounded + button.imagePosition = .imageOnly + viewModeButton = button + item.view = button + refreshViewModeButton() + return item + } +} + +/// Document window that intercepts a secondary (right / control) click on the +/// view-mode toolbar button and shows the mode menu itself. `sendEvent` is the +/// single funnel all window events pass through *before* the toolbar/titlebar +/// can turn the click into its own "Customize Toolbar…" context menu, so this is +/// the one place the interception reliably wins. +final class DocumentWindow: NSWindow { + weak var viewModeButton: NSView? + var makeViewModeMenu: (() -> NSMenu)? + + override func sendEvent(_ event: NSEvent) { + if isSecondaryClick(event), let button = viewModeButton, + button.bounds.contains(button.convert(event.locationInWindow, from: nil)), + let menu = makeViewModeMenu?() { + menu.popUp(positioning: nil, + at: NSPoint(x: 0, y: button.bounds.maxY + 4), in: button) + return + } + super.sendEvent(event) + } + + private func isSecondaryClick(_ event: NSEvent) -> Bool { + switch event.type { + case .rightMouseDown: return true + case .leftMouseDown: return event.modifierFlags.contains(.control) + default: return false + } + } +} diff --git a/Sources/edmd/App/DocumentController.swift b/Sources/edmd/App/DocumentController.swift new file mode 100644 index 0000000..557915e --- /dev/null +++ b/Sources/edmd/App/DocumentController.swift @@ -0,0 +1,88 @@ +import AppKit +import UniformTypeIdentifiers + +/// Custom document controller that registers our Document class for markdown files. +/// +/// Without an Info.plist (SPM executable), NSDocumentController's default +/// `openDocument:` is broken because it relies on CFBundleDocumentTypes for +/// type validation. We override it to show the Open panel ourselves and +/// create Document instances directly. +class DocumentController: NSDocumentController { + + // MARK: - Type Registration + + override var documentClassNames: [String] { + ["Document"] + } + + override var defaultType: String? { + "net.daringfireball.markdown" + } + + override func documentClass(forType typeName: String) -> AnyClass? { + Document.self + } + + override func typeForContents(of url: URL) throws -> String { + let ext = url.pathExtension.lowercased() + if ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" { + return "net.daringfireball.markdown" + } + return "public.plain-text" + } + + // MARK: - Open Document (manual implementation) + + /// Completely replaces NSDocumentController's openDocument: because the + /// default implementation refuses to show the panel without Info.plist + /// type registrations. + @MainActor + override func openDocument(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + // Allow all files — our read(from:ofType:) handles UTF-8 decoding. + panel.allowedContentTypes = [] + panel.allowsOtherFileTypes = true + + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + self.openDocument(withContentsOf: url, display: true) { _, _, error in + if let error = error { + NSAlert(error: error).runModal() + } + } + } + } + + // MARK: - Untitled Window Cleanup + // + // Apple's documented single funnel for opening an existing file — the Open + // panel, Recent Items, and drag-and-drop all call this — so hooking it + // here catches every "open another file" path in one place. + override func openDocument(withContentsOf url: URL, display displayDocument: Bool, + completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void) { + super.openDocument(withContentsOf: url, display: displayDocument) { document, alreadyOpen, error in + if let document, error == nil { + self.closeLastUntouchedUntitledWindow(keeping: document) + } + completionHandler(document, alreadyOpen, error) + } + } + + /// Closes the most-recently-opened blank Untitled window the user never + /// typed into — e.g. the automatic blank document from launch — once a + /// real file opens. Only the last one, so opening several Untitled + /// windows on purpose still leaves the earlier ones alone. + /// `isDocumentEdited` already tracks "untyped": edits call + /// `updateChangeCount` (`EditorTextView+EditFlow.swift`), so an untouched + /// Untitled document is never marked edited. + private func closeLastUntouchedUntitledWindow(keeping opened: NSDocument) { + let stale = documents.last { doc in + guard let doc = doc as? Document, doc !== opened else { return false } + return doc.fileURL == nil && !doc.isDocumentEdited + } + stale?.close() + } +} diff --git a/Sources/edmd/App/FormatMenu.swift b/Sources/edmd/App/FormatMenu.swift new file mode 100644 index 0000000..1be01c3 --- /dev/null +++ b/Sources/edmd/App/FormatMenu.swift @@ -0,0 +1,189 @@ +import AppKit +import EdmundCore + +// MARK: - Format menu (declarative command registry) +// +// The Format menu and its shortcuts are described by a single table of +// `MenuCommand`s and built from it. Action methods live on `EditorTextView` +// (and `Document` for the view-mode cycle); items use a nil target so they +// route through the responder chain to the focused editor — exactly like the +// undo/redo items in `setupMenuBar`. +// +// Groundwork for user-configurable shortcuts: every command carries a stable +// `id` and a default `Shortcut`. A later pass can resolve a per-`id` override +// from UserDefaults before building the item; nothing is persisted yet. + +/// A key equivalent: the (lowercased) key plus its modifier flags. +struct Shortcut { + let key: String + let modifiers: NSEvent.ModifierFlags + + static func cmd(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command]) } + static func cmdShift(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .shift]) } + static func cmdOpt(_ key: String) -> Shortcut { Shortcut(key: key, modifiers: [.command, .option]) } +} + +/// One actionable menu command. +struct MenuCommand { + let id: String + let title: String + let action: Selector + var shortcut: Shortcut? = nil + var tag: Int = 0 + var representedObject: Any? = nil + + func makeItem() -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: shortcut?.key ?? "") + item.keyEquivalentModifierMask = shortcut?.modifiers ?? [] + item.tag = tag + item.representedObject = representedObject + // nil target → responder chain (focused EditorTextView / Document). + return item + } +} + +@MainActor +enum FormatMenu { + + /// The top-level "Format" menu item (with its submenu). + static func build() -> NSMenuItem { + let formatItem = NSMenuItem() + let menu = NSMenu(title: "Format") + + menu.addItem(headingSubmenuItem()) + menu.addItem(thematicBreakCommand.makeItem()) + menu.addItem(.separator()) + + for cmd in listCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(.separator()) + + for cmd in linkCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(.separator()) + + for cmd in blockCommands { menu.addItem(cmd.makeItem()) } + menu.addItem(calloutSubmenuItem()) + menu.addItem(footnoteCommand.makeItem()) + menu.addItem(.separator()) + + menu.addItem(fontSubmenuItem()) + + formatItem.submenu = menu + return formatItem + } + + /// The "Toggle View Mode" item (⌘E) for the View menu — bracketed by + /// dividers by the caller. + static func viewModeToggleItem() -> NSMenuItem { + MenuCommand(id: "view.toggleMode", title: "Toggle View Mode", + action: #selector(Document.toggleViewMode(_:)), + shortcut: .cmd("e")).makeItem() + } + + // MARK: - Groups + + private static let listCommands: [MenuCommand] = [ + MenuCommand(id: "format.bulletedList", title: "Bulleted List", + action: #selector(EditorTextView.formatBulletedList(_:)), shortcut: .cmdOpt("b")), + MenuCommand(id: "format.numberedList", title: "Numbered List", + action: #selector(EditorTextView.formatNumberedList(_:))), + MenuCommand(id: "format.checklist", title: "Checklist", + action: #selector(EditorTextView.formatChecklist(_:)), shortcut: .cmd("l")), + ] + + private static let linkCommands: [MenuCommand] = [ + MenuCommand(id: "format.link", title: "Link", + action: #selector(EditorTextView.formatLink(_:)), shortcut: .cmd("k")), + MenuCommand(id: "format.wikilink", title: "Wikilink", + action: #selector(EditorTextView.formatWikilink(_:))), + MenuCommand(id: "format.image", title: "Image", + action: #selector(EditorTextView.formatImage(_:))), + ] + + private static let thematicBreakCommand = MenuCommand(id: "format.thematicBreak", title: "Thematic Break", + action: #selector(EditorTextView.formatThematicBreak(_:))) + + private static let footnoteCommand = MenuCommand(id: "format.footnote", title: "Footnote", + action: #selector(EditorTextView.formatFootnote(_:))) + + private static let blockCommands: [MenuCommand] = [ + MenuCommand(id: "format.table", title: "Table", + action: #selector(EditorTextView.formatTable(_:))), + MenuCommand(id: "format.codeBlock", title: "Code Block", + action: #selector(EditorTextView.formatCodeBlock(_:))), + MenuCommand(id: "format.mathBlock", title: "Math Block", + action: #selector(EditorTextView.formatMathBlock(_:))), + MenuCommand(id: "format.blockQuote", title: "Block Quote", + action: #selector(EditorTextView.formatBlockQuote(_:)), shortcut: .cmdShift("b")), + ] + + private static let fontCommands: [MenuCommand] = [ + MenuCommand(id: "format.bold", title: "Bold", + action: #selector(EditorTextView.formatBold(_:)), shortcut: .cmd("b")), + MenuCommand(id: "format.italic", title: "Italic", + action: #selector(EditorTextView.formatItalic(_:)), shortcut: .cmd("i")), + MenuCommand(id: "format.underline", title: "Underline", + action: #selector(EditorTextView.formatUnderline(_:)), shortcut: .cmd("u")), + MenuCommand(id: "format.strikethrough", title: "Strikethrough", + action: #selector(EditorTextView.formatStrikethrough(_:))), + MenuCommand(id: "format.highlight", title: "Highlight", + action: #selector(EditorTextView.formatHighlight(_:))), + MenuCommand(id: "format.code", title: "Code", + action: #selector(EditorTextView.formatCode(_:))), + MenuCommand(id: "format.math", title: "Math", + action: #selector(EditorTextView.formatInlineMath(_:))), + MenuCommand(id: "format.keyboard", title: "Keyboard", + action: #selector(EditorTextView.formatKeyboard(_:))), + MenuCommand(id: "format.comment", title: "Comments", + action: #selector(EditorTextView.formatComment(_:))), + ] + + /// GitHub alert types (uppercase in source: `> [!NOTE]`). + private static let githubCalloutTypes = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"] + + /// Obsidian-only callout types (lowercase). note/tip/warning are omitted + /// since they duplicate NOTE/TIP/WARNING already in the GitHub group. + private static let obsidianCalloutTypes = [ + "abstract", "info", "todo", "success", "question", + "failure", "danger", "bug", "example", "quote", + ] + + // MARK: - Submenus + + private static func headingSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Heading", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Heading") + for level in 1...6 { + menu.addItem(MenuCommand(id: "format.heading\(level)", title: "Heading \(level)", + action: #selector(EditorTextView.formatHeading(_:)), + tag: level).makeItem()) + } + item.submenu = menu + return item + } + + private static func calloutSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Alert / Callout", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Alert / Callout") + for type in githubCalloutTypes { + menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, + action: #selector(EditorTextView.formatCallout(_:)), + representedObject: type).makeItem()) + } + menu.addItem(.separator()) + for type in obsidianCalloutTypes { + menu.addItem(MenuCommand(id: "format.callout.\(type)", title: type.capitalized, + action: #selector(EditorTextView.formatCallout(_:)), + representedObject: type).makeItem()) + } + item.submenu = menu + return item + } + + private static func fontSubmenuItem() -> NSMenuItem { + let item = NSMenuItem(title: "Font", action: nil, keyEquivalent: "") + let menu = NSMenu(title: "Font") + for cmd in fontCommands { menu.addItem(cmd.makeItem()) } + item.submenu = menu + return item + } +} diff --git a/Sources/edmd/App/ReproScript.swift b/Sources/edmd/App/ReproScript.swift new file mode 100644 index 0000000..4278113 --- /dev/null +++ b/Sources/edmd/App/ReproScript.swift @@ -0,0 +1,207 @@ +#if DEBUG +import AppKit +import EdmundCore + +/// In-process repro driver: `-debug.reproScript ` replays a keystroke +/// script against the front document through the real AppKit key-event path +/// (window.sendEvent → keyDown → interpretKeyEvents → insertText / +/// deleteBackward). Exists because TCC-denied automation sessions cannot post +/// CGEvents at the app; this keeps live-app bug repros scriptable without +/// Accessibility permission. Commands, one per line: +/// sleep wait before the next command +/// caret place the caret before the first occurrence of +/// type type text, one key event per character +/// backspace press delete n times (300ms apart) +/// tab / backtab indent / dedent the selected list line(s) +/// scroll scroll the clip view to y (bypasses the caret/typewriter +/// recentering, so a block can be driven off-screen) +/// logsel log the current selection +@MainActor +enum ReproScript { + + static func runIfRequested() { + guard let path = UserDefaults.standard.string(forKey: "debug.reproScript"), + let script = try? String(contentsOfFile: path, encoding: .utf8) else { return } + Log.info("repro script: \(path)", category: .app) + var delay: TimeInterval = 1.5 // let the document finish opening + for line in script.split(separator: "\n") { + let parts = line.split(separator: " ", maxSplits: 1).map(String.init) + guard let cmd = parts.first, !cmd.hasPrefix("#") else { continue } + let arg = parts.count > 1 ? parts[1] : "" + switch cmd { + case "sleep": + delay += (Double(arg) ?? 0) / 1000 + case "caret": + schedule(after: delay) { editor in + let r = (editor.rawSource as NSString).range(of: arg) + guard r.location != NSNotFound else { + Log.info("repro caret: needle not found: \(arg)", category: .app) + return + } + editor.setSelectedRange(NSRange(location: r.location, length: 0)) + } + case "caretoff": + // Absolute-offset caret move (arrow-key-like: fromMouse=false). + schedule(after: delay) { editor in + let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length) + editor.setSelectedRange(NSRange(location: n, length: 0)) + } + case "clickoff": + // Absolute-offset caret move on the MOUSE path: sets + // suppressTypewriterCentering for the selection change so the + // +SelectionTracking restyle captures fromMouse=true and takes + // the preservingViewportAnchor branch (what a real click does). + schedule(after: delay) { editor in + editor.reproClickSelect(Int(arg) ?? 0) + } + case "realclickoff": + // Absolute-offset caret move via a REAL synthesized mouse click + // at the glyph's on-screen position: goes through hit-testing and + // NSTextView.mouseDown, the genuine mouse path (fromMouse=true), + // which programmatic setSelectedRange does not replicate. Needed + // because faithful keystroke replay alone does not arm the + // round-7 drift — the arming caret moves were real clicks. + schedule(after: delay) { editor in + let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length) + var actual = NSRange() + let scr = editor.firstRect(forCharacterRange: NSRange(location: n, length: 0), + actualRange: &actual) + guard let screen = editor.window?.screen else { return } + // firstRect: Cocoa screen coords (origin bottom-left). CGEvent + // wants top-left origin. + let cocoaPt = CGPoint(x: scr.midX, y: scr.midY) + let p = CGPoint(x: cocoaPt.x, y: screen.frame.maxY - cocoaPt.y) + func post(_ t: CGEventType) { + CGEvent(mouseEventSource: nil, mouseType: t, mouseCursorPosition: p, + mouseButton: .left)?.post(tap: .cghidEventTap) + } + post(.mouseMoved); post(.leftMouseDown); post(.leftMouseUp) + } + case "selrange": + // "selrange N M" — select M chars at offset N. + schedule(after: delay) { editor in + let f = arg.split(separator: " ") + guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return } + editor.setSelectedRange(NSRange(location: n, length: m)) + } + case "caretend": + // Place the caret at the very end of the document (the phantom + // empty final line when rawSource ends in "\n"). Needles can't + // target an empty line, so this is the only way to sit there. + schedule(after: delay) { editor in + let end = (editor.rawSource as NSString).length + editor.setSelectedRange(NSRange(location: end, length: 0)) + } + case "type": + for ch in arg { + let s = String(ch) + // Direct action call (not a synthesized NSEvent through the + // input context): the storage mutation + queued-fixup path is + // identical, but avoids the input-context fragility that a + // long scripted replay hits when programmatic selections and + // synthetic key events interleave. + schedule(after: delay) { $0.insertText(s, replacementRange: NSRange(location: NSNotFound, length: 0)) } + delay += 0.08 + } + case "backspace": + for _ in 0 ..< (Int(arg) ?? 1) { + schedule(after: delay) { $0.deleteBackward(nil) } + delay += 0.3 + } + case "return": + schedule(after: delay) { $0.insertText("\n", replacementRange: NSRange(location: NSNotFound, length: 0)) } + delay += 0.05 + case "tab": + schedule(after: delay) { $0.insertTab(nil) } + delay += 0.05 + case "backtab": + schedule(after: delay) { $0.insertBacktab(nil) } + delay += 0.05 + case "bypassdelete": + // Mimics AppKit's drag-move source deletion (the issue-#156 + // trigger): select the range, run shouldChangeText and the + // storage mutation, and never call didChangeText — the + // bypassed-edit heal then fires on the next run-loop pass. + schedule(after: delay) { editor in + let r = (editor.rawSource as NSString).range(of: arg) + guard r.location != NSNotFound else { + Log.info("repro bypassdelete: needle not found: \(arg)", category: .app) + return + } + editor.setSelectedRange(r) + guard editor.shouldChangeText(in: r, replacementString: "") else { return } + editor.textStorage?.replaceCharacters(in: r, with: "") + } + case "bypassoff": + // "bypassoff N M" — offset form of bypassdelete: delete M chars + // at N via shouldChangeText + storage mutation, no didChangeText. + schedule(after: delay) { editor in + let f = arg.split(separator: " ") + guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return } + let r = NSRange(location: n, length: m) + editor.setSelectedRange(r) + guard editor.shouldChangeText(in: r, replacementString: "") else { return } + editor.textStorage?.replaceCharacters(in: r, with: "") + } + case "assertcaret": + // PASS iff the caret sits exactly before the first occurrence + // of — position-independent drift check for soaks. + schedule(after: delay) { editor in + let want = (editor.rawSource as NSString).range(of: arg).location + let sel = editor.selectedRange() + let ok = sel.location == want && sel.length == 0 + Log.info("repro assertcaret \(ok ? "PASS" : "FAIL") " + + "sel=\(sel) want=\(want) needle=\(arg)", category: .app) + } + case "logsel": + schedule(after: delay) { editor in + Log.info("repro logsel sel=\(editor.selectedRange()) " + + "rawLen=\((editor.rawSource as NSString).length) " + + "docs=\(NSDocumentController.shared.documents.count)", + category: .app) + } + case "scroll": + // Scrolls the clip view directly (bypassing the caret, so the + // active block can be driven off-screen independent of where + // typewriter-mode recentering would otherwise put it). + // `scroll(to:)` posts boundsDidChange, same as a real drag/wheel + // scroll, so promotion/idle-drain react exactly as they would live. + schedule(after: delay) { editor in + guard let clipView = editor.enclosingScrollView?.contentView else { return } + let y = CGFloat(Double(arg) ?? 0) + let proposed = NSRect(origin: NSPoint(x: 0, y: y), size: clipView.bounds.size) + let clamped = clipView.constrainBoundsRect(proposed) + clipView.scroll(to: clamped.origin) + editor.enclosingScrollView?.reflectScrolledClipView(clipView) + Log.info("repro scroll y=\(y) clamped=\(clamped.origin.y)", category: .app) + } + default: + break + } + delay += 0.02 + } + } + + private static func schedule(after: TimeInterval, + _ body: @escaping @MainActor (EditorTextView) -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + after) { + guard let doc = NSDocumentController.shared.documents.first as? Document, + let editor = doc.editor else { return } + body(editor) + } + } + + /// Sends a key event through the window so it takes the full AppKit + /// keyDown route, exactly like a physical keystroke. + private static func press(_ chars: String, keyCode: UInt16, in editor: EditorTextView) { + guard let window = editor.window, + let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, + characters: chars, charactersIgnoringModifiers: chars, + isARepeat: false, keyCode: keyCode) else { return } + window.makeFirstResponder(editor) + window.sendEvent(event) + } +} +#endif diff --git a/Sources/edmd/App/ViewMenu.swift b/Sources/edmd/App/ViewMenu.swift new file mode 100644 index 0000000..b041d03 --- /dev/null +++ b/Sources/edmd/App/ViewMenu.swift @@ -0,0 +1,55 @@ +import AppKit +import EdmundCore + +// MARK: - View menu + +@MainActor +enum ViewMenu { + + /// The top-level "View" menu item (with its submenu). + static func build() -> NSMenuItem { + let viewMenuItem = NSMenuItem() + let viewMenu = NSMenu(title: "View") + + // Routes through the responder chain to the key window's toolbar. + // AppKit auto-inserts "Show Tab Bar"/"Show All Tabs" above this at + // runtime (window tabbing is on by default) — that position isn't + // ours to control short of disabling tabbing outright. + viewMenu.addItem(withTitle: "Customize Toolbar…", + action: #selector(NSWindow.runToolbarCustomizationPalette(_:)), + keyEquivalent: "") + viewMenu.addItem(.separator()) + + let typewriterItem = viewMenu.addItem( + withTitle: "Typewriter Scroll", + action: #selector(AppDelegate.toggleTypewriterMode(_:)), + keyEquivalent: "") + typewriterItem.state = AppDelegate.typewriterModeEnabled() ? .on : .off + + // View-mode toggle (Edit ↔ Read) + the Source-mode checkbox. + viewMenu.addItem(.separator()) + viewMenu.addItem(FormatMenu.viewModeToggleItem()) + viewMenu.addItem(withTitle: "Show Source in Editor", + action: #selector(Document.toggleSourceMode(_:)), + keyEquivalent: "") + viewMenu.addItem(.separator()) + + // Zoom (font size + max content width, scaled together). Target nil + // routes through the responder chain to the key window's Document. + // Kept last, directly above the separator AppKit inserts before its + // automatic "Enter/Exit Full Screen" item at the menu's end. + viewMenu.addItem(withTitle: "Actual Size", + action: #selector(Document.actualSize(_:)), + keyEquivalent: "0") + viewMenu.addItem(withTitle: "Zoom In", + action: #selector(Document.zoomIn(_:)), + keyEquivalent: "=") + viewMenu.addItem(withTitle: "Zoom Out", + action: #selector(Document.zoomOut(_:)), + keyEquivalent: "-") + viewMenu.addItem(.separator()) + + viewMenuItem.submenu = viewMenu + return viewMenuItem + } +} diff --git a/Sources/edmd/App/main.swift b/Sources/edmd/App/main.swift new file mode 100644 index 0000000..fcb27b2 --- /dev/null +++ b/Sources/edmd/App/main.swift @@ -0,0 +1,289 @@ +import AppKit +import EdmundCore +import Sparkle + +// Entry point for the app the user knows as "Edmund" (CFBundleName). The +// executable target — and so this binary at Edmund.app/Contents/MacOS/edmd — is +// named `edmd`, an expansion of "Editor for Markdown". The backronym is the +// app's original working name; it survives only here and in Package.swift, never +// in anything user-facing. + +// --- App Delegate ----------------------------------------------------------- + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { + + var aboutWindowController: AboutWindowController? + var settingsWindowController: SettingsWindowController? + // startingUpdater: true kicks off the scheduled background check immediately; + // the "Check for Updates…" menu item targets this controller directly. + let updaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + + // MARK: - Typewriter Mode (persisted) + + static let typewriterModeKey = "EditorTypewriterMode" + + /// Whether typewriter scrolling is enabled. Defaults to on (the historical + /// behavior) when nothing has been saved yet. + static func typewriterModeEnabled(_ defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: typewriterModeKey) as? Bool ?? true + } + + func applicationDidFinishLaunching(_ notification: Notification) { + AppSettings.applyLogging() + Log.info("Edmund launched", category: .app) + AppSettings.applyAppearance() + setupMenuBar() + + // Opt-in (default off): upload any crash reports macOS wrote for us since + // last launch. Fire-and-forget; never blocks startup. + if AppSettings.sendCrashLogs { + CrashReporter.uploadPendingReports( + alreadySent: AppSettings.sentCrashReports, + onSent: { AppSettings.sentCrashReports.insert($0) }) + } + + // Open file from command-line argument. When a file is given, + // `applicationShouldOpenUntitledFile` suppresses the otherwise-automatic + // blank document, so we don't end up with two windows. + let args = CommandLine.arguments + if args.count > 1 { + let url = URL(fileURLWithPath: args[1]) + Log.info("Opening file from launch argument: \(url.path)", category: .document) + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { _, _, _ in } + } + + #if DEBUG + ReproScript.runIfRequested() + #endif + } + + // Auto-open a blank document on launch only when no file was passed on the + // command line (otherwise the file arg + the blank doc make two windows). + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { + CommandLine.arguments.count <= 1 && AppSettings.startupAction == .createNewDocument + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + AppSettings.reopenWindows + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + // Reopen a new untitled document when the app is activated with no windows. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + if AppSettings.startupAction == .createNewDocument { + NSDocumentController.shared.newDocument(nil) + } + } + return true + } + + // MARK: - Settings + + @MainActor @objc func showAbout(_ sender: Any?) { + if aboutWindowController == nil { + aboutWindowController = AboutWindowController() + } + aboutWindowController?.showWindow(nil) + aboutWindowController?.window?.makeKeyAndOrderFront(nil) + } + + @MainActor @objc func showSettings(_ sender: Any?) { + if settingsWindowController == nil { + settingsWindowController = SettingsWindowController() + } + settingsWindowController?.showWindow(nil) + settingsWindowController?.window?.makeKeyAndOrderFront(nil) + } + + // MARK: - View + + /// Toggles typewriter scrolling, persists the choice, and applies it to every + /// open document immediately. + @MainActor @objc func toggleTypewriterMode(_ sender: Any?) { + let newValue = !AppDelegate.typewriterModeEnabled() + UserDefaults.standard.set(newValue, forKey: AppDelegate.typewriterModeKey) + for case let doc as Document in NSDocumentController.shared.documents { + doc.editor?.typewriterModeEnabled = newValue + } + } + + @MainActor func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(toggleTypewriterMode(_:)) { + menuItem.state = AppDelegate.typewriterModeEnabled() ? .on : .off + } + return true + } + + // MARK: - Open Document + + /// Manual Open panel — bypasses NSDocumentController's type validation + /// which is broken without Info.plist. + @MainActor @objc func openDocumentManually(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + + let complete: (NSApplication.ModalResponse) -> Void = { response in + guard response == .OK, let url = panel.url else { return } + NSDocumentController.shared.openDocument( + withContentsOf: url, display: true + ) { _, _, error in + if let error = error { + NSAlert(error: error).runModal() + } + } + } + + // Attach the panel to the front window as a sheet so it's always visible + // — a free-floating panel can open off-screen or behind the window + // (the app's windows can launch off-screen), which looks like a hang. + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + panel.beginSheetModal(for: window, completionHandler: complete) + } else { + panel.begin(completionHandler: complete) + } + } + + // MARK: - Menu Bar + + @MainActor private func setupMenuBar() { + let mainMenu = NSMenu() + + // App menu (required for Cmd+Q) + let appMenuItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About Edmund", + action: #selector(AppDelegate.showAbout(_:)), + keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Settings\u{2026}", + action: #selector(AppDelegate.showSettings(_:)), + keyEquivalent: ",") + + let updatesItem = appMenu.addItem( + withTitle: "Check for Updates\u{2026}", + action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), + keyEquivalent: "") + updatesItem.target = updaterController + + appMenu.addItem(NSMenuItem.separator()) + + appMenu.addItem(withTitle: "Quit Edmund", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + // File menu — NSDocument provides the standard actions + let fileMenuItem = NSMenuItem() + let fileMenu = NSMenu(title: "File") + + fileMenu.addItem(withTitle: "New", + action: #selector(NSDocumentController.newDocument(_:)), + keyEquivalent: "n") + + fileMenu.addItem(withTitle: "Open\u{2026}", + action: #selector(AppDelegate.openDocumentManually(_:)), + keyEquivalent: "o") + + // Recent documents submenu + let recentMenuItem = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "") + let recentMenu = NSMenu(title: "Open Recent") + recentMenu.addItem(withTitle: "Clear Menu", + action: #selector(NSDocumentController.clearRecentDocuments(_:)), + keyEquivalent: "") + recentMenuItem.submenu = recentMenu + fileMenu.addItem(recentMenuItem) + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Save", + action: #selector(NSDocument.save(_:)), + keyEquivalent: "s") + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Rename\u{2026}", + action: #selector(Document.rename(_:)), + keyEquivalent: "") + + fileMenu.addItem(withTitle: "Move To\u{2026}", + action: #selector(Document.move(_:)), + keyEquivalent: "") + + fileMenu.addItem(NSMenuItem.separator()) + + fileMenu.addItem(withTitle: "Export as PDF\u{2026}", + action: #selector(Document.exportToPDF(_:)), + keyEquivalent: "") + + fileMenu.addItem(withTitle: "Print\u{2026}", + action: #selector(Document.printDocument(_:)), + keyEquivalent: "p") + + fileMenuItem.submenu = fileMenu + mainMenu.addItem(fileMenuItem) + + // Edit menu (required for Cmd+C/V/X/A/Z) + let editMenuItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + + editMenu.addItem(withTitle: "Undo", + action: #selector(EditorTextView.undo(_:)), + keyEquivalent: "z") + + let redoItem = editMenu.addItem(withTitle: "Redo", + action: #selector(EditorTextView.redo(_:)), + keyEquivalent: "z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + + editMenu.addItem(NSMenuItem.separator()) + + editMenu.addItem(withTitle: "Cut", + action: #selector(NSText.cut(_:)), + keyEquivalent: "x") + + editMenu.addItem(withTitle: "Copy", + action: #selector(NSText.copy(_:)), + keyEquivalent: "c") + + editMenu.addItem(withTitle: "Paste", + action: #selector(NSText.paste(_:)), + keyEquivalent: "v") + + editMenu.addItem(withTitle: "Select All", + action: #selector(NSText.selectAll(_:)), + keyEquivalent: "a") + + editMenuItem.submenu = editMenu + mainMenu.addItem(editMenuItem) + + // Format menu — built from the declarative command registry. + mainMenu.addItem(FormatMenu.build()) + + // View menu — built in its own file (ViewMenu.swift). + mainMenu.addItem(ViewMenu.build()) + + NSApplication.shared.mainMenu = mainMenu + } +} + +// --- Launch ----------------------------------------------------------------- +let app = NSApplication.shared + +// Must be created before NSDocumentController.shared is first accessed. +let _ = DocumentController() + +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.activate(ignoringOtherApps: true) +app.run() diff --git a/Sources/edmd/Settings/AdvancedSettingsView.swift b/Sources/edmd/Settings/AdvancedSettingsView.swift new file mode 100644 index 0000000..eca1821 --- /dev/null +++ b/Sources/edmd/Settings/AdvancedSettingsView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import AppKit + +struct AdvancedSettingsView: View { + @AppStorage(AppSettings.Key.blockExternalImages) private var blockExternalImages = true + @AppStorage(AppSettings.Key.diagnosticLogging) private var diagnosticLogging = false + @AppStorage(AppSettings.Key.verboseEditorDiagnostics) private var verboseEditorDiagnostics = false + @AppStorage(AppSettings.Key.logRetention) private var logRetention = AppSettings.LogRetention.twoWeeks + // Crash-log sending is dormant until the receiving server exists — the toggle + // is hidden (commented out below) so it isn't offered with nowhere to send to. + // Uncomment this and the "Crash reports:" GridRow once the server is live. + // @AppStorage(AppSettings.Key.sendCrashLogs) private var sendCrashLogs = false + @State private var showingWarnings = false + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 18) { + GridRow { + Text("Privacy & Security:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Block external images", isOn: $blockExternalImages) + .onChange(of: blockExternalImages) { refreshOpenReadViews() } + Text("For more information, refer to this [proposal](https://github.com/opencloud-eu/opencloud/issues/1145).") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + + // TODO: Add a "Enable HTTP whitelist" toggle here + // with a short scrollable view of the whitelist that allows user addition + // with +/- signs at the bottom-right corner + // Implement later + } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("Diagnostics:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Save diagnostic logs", isOn: $diagnosticLogging) + .onChange(of: diagnosticLogging) { AppSettings.applyLogging() } + HStack(spacing: 6) { + Text("Clear logs after:") + Picker("", selection: $logRetention) { + ForEach(AppSettings.LogRetention.allCases) { Text($0.label).tag($0) } + } + .labelsHidden() + .fixedSize() + .onChange(of: logRetention) { AppSettings.applyLogging() } + } + .disabled(!diagnosticLogging) + .padding(.leading, 20) + Text("Logs are kept locally at ~/.edmund/logs and will never leave that folder unless you move them. They are only useful if you want to improve your bug reports / GitHub issues.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + Toggle("Verbose editor tracing", isOn: $verboseEditorDiagnostics) + .onChange(of: verboseEditorDiagnostics) { AppSettings.applyLogging() } + .disabled(!diagnosticLogging) + .padding(.leading, 20) + Text("Records every keystroke, caret move, and sync — for reproducing tricky editor bugs (caret drift). Noisy; leave off unless asked.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 360, alignment: .leading) + .padding(.leading, 40) +} + } + + // Dormant until the crash-report server exists (see note above and + // CrashReporter). The launch-time upload path and the `sendCrashLogs` + // setting stay in place but inert (default off); only this UI is hidden. + // GridRow { + // Text("Crash reports:") + // .gridColumnAlignment(.trailing) + // VStack(alignment: .leading, spacing: 6) { + // Toggle("Automatically send crash logs", isOn: $sendCrashLogs) + // Text("Crash logs are sent only to us and will be used and stored for crash fix purposes only.") + // .foregroundStyle(.secondary) + // .controlSize(.small) + // .fixedSize(horizontal: false, vertical: true) + // .frame(width: 380, alignment: .leading) + // .padding(.leading, 20) + // } + // } + + GridRow { + Text("Dialog warnings:") + .gridColumnAlignment(.trailing) + Button("Manage Warnings…") { showingWarnings = true } + } + } + .settingsPanePadding() + .sheet(isPresented: $showingWarnings) { + ManageWarningsView() + } + } + + /// Pushes the toggle to every open document's editor (Edit mode's inline + /// image overlay) and Read view, so the change takes effect immediately. + private func refreshOpenReadViews() { + for case let document as Document in NSDocumentController.shared.documents { + document.editor?.allowRemoteImages = !blockExternalImages + document.refreshReadView() + } + } +} + +/// The Manage Warnings sheet: per-warning suppression toggles. +private struct ManageWarningsView: View { + @AppStorage(AppSettings.Key.suppressInconsistentLineEndingWarning) + private var suppressLineEnding = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Suppress the following warnings:") + Toggle("Inconsistent line endings", isOn: $suppressLineEnding) + HStack { + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + } + .scenePadding() + .frame(width: 360) + } +} diff --git a/Sources/edmd/Settings/AntialiasingText.swift b/Sources/edmd/Settings/AntialiasingText.swift new file mode 100644 index 0000000..1c67e23 --- /dev/null +++ b/Sources/edmd/Settings/AntialiasingText.swift @@ -0,0 +1,88 @@ +import SwiftUI +import AppKit + +/// A label that previews a font by drawing its name in that font, with optional +/// antialiasing control — the bezeled font-display field used in the Appearance +/// settings (mirrors CotEditor's `AntialiasingText`). +struct AntialiasingText: NSViewRepresentable { + private var text: String + private var antialiasDisabled = false + private var font: NSFont? + + init(_ text: String) { + self.text = text + } + + func makeNSView(context: Context) -> NSTextField { + let nsView = AntialiasingTextField(string: text) + nsView.isEditable = false + nsView.isSelectable = false + nsView.alignment = .center + nsView.lineBreakMode = .byTruncatingMiddle + nsView.allowsExpansionToolTips = true + nsView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // Pin a fixed, stable height so a 16pt preview fits with a little + // breathing room. (Deriving it from `frame.height` collapses the field — + // the frame is zero-height before Auto Layout has sized it.) + nsView.heightAnchor.constraint(equalToConstant: 24).isActive = true + + return nsView + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + nsView.stringValue = text + nsView.font = font + (nsView as? AntialiasingTextField)?.antialiasDisabled = antialiasDisabled + } + + /// Sets whether antialiasing is disabled when drawing the text. + func antialiasDisabled(_ disabled: Bool = true) -> Self { + var view = self + view.antialiasDisabled = disabled + return view + } + + /// Sets the font to preview the text in. + func font(nsFont font: NSFont?) -> Self { + var view = self + view.font = font + return view + } +} + +private final class AntialiasingTextField: NSTextField { + var antialiasDisabled = false { + didSet { needsDisplay = true } + } + + override static var cellClass: AnyClass? { + get { CenteringTextFieldCell.self } + set { _ = newValue } + } + + override func draw(_ dirtyRect: NSRect) { + if antialiasDisabled { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.shouldAntialias = false + } + super.draw(dirtyRect) + if antialiasDisabled { + NSGraphicsContext.restoreGraphicsState() + } + } +} + +private final class CenteringTextFieldCell: NSTextFieldCell { + override func titleRect(forBounds rect: NSRect) -> NSRect { + var titleRect = super.titleRect(forBounds: rect) + let titleSize = attributedStringValue.size() + titleRect.origin.y = (rect.minY + (rect.height - titleSize.height) / 2).rounded(.up) + titleRect.size.height = rect.height - titleRect.origin.y + return titleRect + } + + override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { + attributedStringValue.draw(in: titleRect(forBounds: cellFrame)) + } +} diff --git a/Sources/edmd/Settings/AppSettings.swift b/Sources/edmd/Settings/AppSettings.swift new file mode 100644 index 0000000..df473ba --- /dev/null +++ b/Sources/edmd/Settings/AppSettings.swift @@ -0,0 +1,324 @@ +// AppSettings — UserDefaults-backed model for every Settings value. +// The rest of the app reads these accessors; the SwiftUI panes bind to the +// same keys via @AppStorage. + +import AppKit +import EdmundCore + +enum AppSettings { + enum StartupAction: String, CaseIterable, Identifiable { + case createNewDocument + case doNothing + var id: Self { self } + var label: String { + switch self { + case .createNewDocument: return "Create New Document" + case .doNothing: return "Do Nothing" + } + } + } + + enum ConflictResolution: String, CaseIterable, Identifiable { + case keepCurrent + case ask + case updateToModified + var id: Self { self } + var label: String { + switch self { + case .keepCurrent: return "Keep Edmund’s edition" + case .ask: return "Ask how to resolve" + case .updateToModified: return "Update to modified edition" + } + } + } + + enum AppearanceMode: String, CaseIterable, Identifiable { + case matchSystem + case light + case dark + var id: Self { self } + var label: String { + switch self { + case .matchSystem: return "Match System" + case .light: return "Light" + case .dark: return "Dark" + } + } + /// Display order in the Appearance pane (left to right). + static let displayOrder: [AppearanceMode] = [.light, .dark, .matchSystem] + } + + /// How long diagnostic logs are kept before being pruned on launch. + enum LogRetention: String, CaseIterable, Identifiable { + case oneDay, twoDays, oneWeek, twoWeeks, thirtyDays, never + var id: Self { self } + var label: String { + switch self { + case .oneDay: return "1 day" + case .twoDays: return "2 days" + case .oneWeek: return "1 week" + case .twoWeeks: return "2 weeks" + case .thirtyDays: return "30 days" + case .never: return "Never" + } + } + /// The retention window in seconds; `nil` means keep forever. + var timeInterval: TimeInterval? { + let day: TimeInterval = 24 * 60 * 60 + switch self { + case .oneDay: return day + case .twoDays: return 2 * day + case .oneWeek: return 7 * day + case .twoWeeks: return 14 * day + case .thirtyDays: return 30 * day + case .never: return nil + } + } + } + + enum Key { + static let reopenWindows = "settings.general.reopenWindows" + // Must match Sparkle's own default key exactly — Sparkle reads/writes this string. + static let automaticallyChecksForUpdates = "SUAutomaticallyChecksForUpdates" + static let startupAction = "settings.general.startupAction" + static let autoSaveWithVersions = "settings.general.autoSaveWithVersions" + static let conflictResolution = "settings.general.conflictResolution" + static let appearanceMode = "settings.appearance.mode" + static let maxContentWidthCm = "settings.appearance.maxContentWidthCm" + // "cm" / "in" override the locale default for the content-width control. + static let contentWidthUnit = "settings.appearance.contentWidthUnit" + static let suppressInconsistentLineEndingWarning = "settings.general.suppressInconsistentLineEndingWarning" + static let diagnosticLogging = "settings.general.diagnosticLogging" + static let verboseEditorDiagnostics = "settings.advanced.verboseEditorDiagnostics" + static let blockExternalImages = "settings.advanced.blockExternalImages" + static let logRetention = "settings.general.logRetention" + static let renderBlankLinesAsBreaks = "settings.reading.renderBlankLinesAsBreaks" + static let sourceMode = "settings.view.sourceMode" + static let sendCrashLogs = "settings.advanced.sendCrashLogs" + static let sentCrashReports = "settings.advanced.sentCrashReports" + static let lastWindowWidth = "settings.window.lastWidth" + static let lastWindowHeight = "settings.window.lastHeight" + } + + /// Maximum text-column width in centimetres. Wider windows center the + /// column at this physical width; narrower windows fill edge-to-edge. + /// Default: a comfortable 12 cm / 5 in reading column. + static var maxContentWidthCm: Double { + get { + guard UserDefaults.standard.object(forKey: Key.maxContentWidthCm) != nil else { + return defaultMaxContentWidthCm + } + return UserDefaults.standard.double(forKey: Key.maxContentWidthCm) + } + set { UserDefaults.standard.set(newValue, forKey: Key.maxContentWidthCm) } + } + + /// Out-of-the-box reading column: 5 in for US locales, 12 cm elsewhere. + /// This is also the slider's magnetic snap point. + static var defaultMaxContentWidthCm: Double { + Locale.current.measurementSystem == .us ? 5.0 * 2.54 : 12.0 + } + + /// Full frame size of the last document window, to reopen new windows at the + /// same dimensions (applied via setFrame). Returns nil when nothing is saved. + /// The floor only rejects garbage/zero values — every real window size, + /// including ones smaller than the default, is remembered. + static var lastWindowSize: NSSize? { + get { + let w = UserDefaults.standard.double(forKey: Key.lastWindowWidth) + let h = UserDefaults.standard.double(forKey: Key.lastWindowHeight) + guard w >= 100, h >= 100 else { return nil } + return NSSize(width: w, height: h) + } + set { + guard let s = newValue else { return } + UserDefaults.standard.set(Double(s.width), forKey: Key.lastWindowWidth) + UserDefaults.standard.set(Double(s.height), forKey: Key.lastWindowHeight) + } + } + + static var reopenWindows: Bool { + get { UserDefaults.standard.bool(forKey: Key.reopenWindows) } + set { UserDefaults.standard.set(newValue, forKey: Key.reopenWindows) } + } + + /// Source mode: an alternate form of Edit mode that shows the raw markdown. + /// When on, the editing half of the view-mode toggle is Source instead of + /// Edit (so the toggle flips Source ↔ Read). Defaults off. + static var sourceMode: Bool { + get { UserDefaults.standard.bool(forKey: Key.sourceMode) } + set { UserDefaults.standard.set(newValue, forKey: Key.sourceMode) } + } + + /// Read mode: render runs of blank lines as proportional vertical space + /// (preserving the author's spacing). Defaults on. The toggle UI lives in a + /// future Reading-settings tab; the value is already honored here. + static var renderBlankLinesAsBreaks: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.renderBlankLinesAsBreaks) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.renderBlankLinesAsBreaks) + } + set { UserDefaults.standard.set(newValue, forKey: Key.renderBlankLinesAsBreaks) } + } + + static var startupAction: StartupAction { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.startupAction), + let action = StartupAction(rawValue: raw) else { + return .createNewDocument + } + return action + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.startupAction) } + } + + static var autoSaveWithVersions: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.autoSaveWithVersions) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.autoSaveWithVersions) + } + set { UserDefaults.standard.set(newValue, forKey: Key.autoSaveWithVersions) } + } + + static var conflictResolution: ConflictResolution { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.conflictResolution), + let resolution = ConflictResolution(rawValue: raw) else { + return .ask + } + return resolution + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.conflictResolution) } + } + + static var appearanceMode: AppearanceMode { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.appearanceMode), + let mode = AppearanceMode(rawValue: raw) else { + return .matchSystem + } + return mode + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.appearanceMode) } + } + + static var suppressInconsistentLineEndingWarning: Bool { + get { UserDefaults.standard.bool(forKey: Key.suppressInconsistentLineEndingWarning) } + set { UserDefaults.standard.set(newValue, forKey: Key.suppressInconsistentLineEndingWarning) } + } + + /// Whether diagnostic logging is on. Defaults to off; the user can opt in. + static var diagnosticLogging: Bool { + get { UserDefaults.standard.bool(forKey: Key.diagnosticLogging) } + set { UserDefaults.standard.set(newValue, forKey: Key.diagnosticLogging) } + } + + /// Verbose editor tracing: high-volume per-edit / per-caret-move trace lines + /// for diagnosing live-NSTextView / TextKit 2 editor bugs (caret drift, sync + /// desyncs) that can't be reproduced headlessly. Off by default — turned on + /// only when capturing a reproduction. Requires diagnostic logging to be on. + static var verboseEditorDiagnostics: Bool { + get { UserDefaults.standard.bool(forKey: Key.verboseEditorDiagnostics) } + set { UserDefaults.standard.set(newValue, forKey: Key.verboseEditorDiagnostics) } + } + + /// Whether Read mode / export blocks remote (`http`/`https`) image loads. + /// Defaults on: no surprise network requests until the user opts out. + static var blockExternalImages: Bool { + get { + guard UserDefaults.standard.object(forKey: Key.blockExternalImages) != nil else { + return true + } + return UserDefaults.standard.bool(forKey: Key.blockExternalImages) + } + set { UserDefaults.standard.set(newValue, forKey: Key.blockExternalImages) } + } + + /// Whether to auto-send crash reports on launch. Opt-in: defaults off, since + /// it sends data off-device. (UI currently commented out — see + /// AdvancedSettingsView — until the receiving server exists.) + static var sendCrashLogs: Bool { + get { UserDefaults.standard.bool(forKey: Key.sendCrashLogs) } + set { UserDefaults.standard.set(newValue, forKey: Key.sendCrashLogs) } + } + + /// Filenames of crash reports already uploaded, so we don't resend them. + /// Bounded on write by dropping entries whose `.ips` file no longer exists. + static var sentCrashReports: Set { + get { Set(UserDefaults.standard.stringArray(forKey: Key.sentCrashReports) ?? []) } + set { + let onDisk = (try? FileManager.default.contentsOfDirectory( + atPath: CrashReporter.diagnosticReportsDirectory.path)).map(Set.init) ?? [] + let pruned = onDisk.isEmpty ? newValue : newValue.intersection(onDisk) + UserDefaults.standard.set(Array(pruned), forKey: Key.sentCrashReports) + } + } + + static var logRetention: LogRetention { + get { + guard let raw = UserDefaults.standard.string(forKey: Key.logRetention), + let value = LogRetention(rawValue: raw) else { + return .twoWeeks + } + return value + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: Key.logRetention) } + } + + /// Where diagnostic logs live: `~/.edmund/logs`. + static var logDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".edmund/logs", isDirectory: true) + } + + /// Pushes the current logging settings into the `Log` facility. Called at + /// launch and whenever the toggle or retention changes. + static func applyLogging() { + Log.configure(enabled: diagnosticLogging, + directory: logDirectory, + retention: logRetention.timeInterval) + Log.setVerbose(verboseEditorDiagnostics) + } + + @MainActor static func applyAppearance() { + switch appearanceMode { + case .matchSystem: + NSApp.appearance = nil + case .light: + NSApp.appearance = NSAppearance(named: .aqua) + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + } + } +} + +// MARK: - Screen physical-unit helpers + +extension NSScreen { + /// Physical pixels-per-inch from the display's actual diagonal/width size + /// (via Core Graphics — not the nominal 72 pt/in). Falls back to 109 PPI + /// (the typical value for a 27-inch 5K iMac) when the display ID can't be read. + var physicalPPI: CGFloat { + guard let n = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return 109 + } + let mm = CGDisplayScreenSize(CGDirectDisplayID(n.uint32Value)) + guard mm.width > 0 else { return 109 } + // frame.width is in points (not pixels); mm.width is physical mm. + return frame.width / (mm.width / 25.4) + } + + /// Convert a physical centimetre value to AppKit points on this display. + func cmToPoints(_ cm: Double) -> CGFloat { + CGFloat(cm) / 2.54 * physicalPPI + } + + /// The display's full physical width in centimetres. + var physicalWidthCm: Double { + Double(frame.width / physicalPPI) * 2.54 + } +} diff --git a/Sources/edmd/Settings/AppearanceSettingsView.swift b/Sources/edmd/Settings/AppearanceSettingsView.swift new file mode 100644 index 0000000..42ec377 --- /dev/null +++ b/Sources/edmd/Settings/AppearanceSettingsView.swift @@ -0,0 +1,237 @@ +// The Appearance settings pane: appearance mode, fonts, and line height. +// The app accent comes from the AccentColor asset (see Resources/Assets.xcassets), +// so there is no in-app accent picker — native controls follow the asset / system. + +import SwiftUI +import AppKit +import EdmundCore + +struct AppearanceSettingsView: View { + @ObservedObject var fonts: FontSettings + @AppStorage(AppSettings.Key.appearanceMode) private var appearanceMode = AppSettings.AppearanceMode.matchSystem + @AppStorage(AppSettings.Key.maxContentWidthCm) private var maxContentWidthCm = AppSettings.defaultMaxContentWidthCm + /// "" follows the locale; "cm"/"in" override it (toggled via the unit button). + @AppStorage(AppSettings.Key.contentWidthUnit) private var unitOverride = "" + + // MARK: - Unit helpers + + /// Imperial when the user picked "in", metric when "cm", else the locale default. + private var usesImperial: Bool { + switch unitOverride { + case "in": return true + case "cm": return false + default: return Locale.current.measurementSystem == .us + } + } + private func toggleUnit() { unitOverride = usesImperial ? "cm" : "in" } + + private var unitLabel: String { usesImperial ? "in" : "cm" } + /// Stepper increment in display units (0.5 cm ≈ 0.25 in). + private var stepSize: Double { usesImperial ? 0.25 : 0.5 } + + /// Lower bound ≈ 3 inches (7.62 cm); upper bound is the full physical width + /// of the main display, so the column can be capped anywhere up to the + /// screen edge. + private var minCm: Double { 7.62 } + private var maxCm: Double { NSScreen.main?.physicalWidthCm ?? 50 } + private var displayRange: ClosedRange { + usesImperial ? (minCm / 2.54)...(maxCm / 2.54) : minCm...maxCm + } + + /// Magnetic snap target in display units: 5 in / 12 cm (the default width). + private var snapDisplayValue: Double { usesImperial ? 5.0 : 12.0 } + + /// Two-way binding between stored cm and the display unit. + private var displayValueBinding: Binding { + Binding( + get: { usesImperial ? maxContentWidthCm / 2.54 : maxContentWidthCm }, + set: { maxContentWidthCm = usesImperial ? $0 * 2.54 : $0 } + ) + } + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 12) { + GridRow { + Text("Appearance:") + .gridColumnAlignment(.trailing) + Picker("", selection: $appearanceMode) { + ForEach(AppSettings.AppearanceMode.displayOrder) { Text($0.label).tag($0) } + } + .pickerStyle(.radioGroup) + .horizontalRadioGroupLayout() + .labelsHidden() + .onChange(of: appearanceMode) { AppSettings.applyAppearance() } + } + + GridRow { + Text("Max content width:") + .gridColumnAlignment(.trailing) + HStack(spacing: 8) { + ContentWidthSlider( + cmValue: $maxContentWidthCm, + usesImperial: usesImperial, + displayRange: displayRange, + snapDisplayValue: snapDisplayValue + ) + .frame(width: 200, height: 20) + + // Field width is sized so its stepper's chevrons line up + // vertically with the font rows' steppers (slider 200 + two + // 8-pt gaps + field == 240, the font rows' label width). + TextField("", value: displayValueBinding, + format: .number.precision(.fractionLength(1))) + .multilineTextAlignment(.trailing) + .frame(width: 32) + Stepper("", value: displayValueBinding, + in: displayRange, step: stepSize) + .labelsHidden() + // Clickable unit toggle styled exactly like a plain label — + // .plain strips all button chrome so only the text shows. + Button(action: toggleUnit) { Text(unitLabel) } + .buttonStyle(.plain) + .help("Switch between centimetres and inches") + } + .onChange(of: maxContentWidthCm) { applyContentWidthToOpenDocuments() } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("Standard font:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + fontRow(summary: fonts.standardSummary, + font: fonts.standardFont, + antialias: fonts.antialias, + size: Binding(get: { Double(fonts.standardFont.pointSize) }, + set: { fonts.setStandardSize(CGFloat($0)) }), + select: fonts.selectStandardFont) + HStack(spacing: 16) { + Toggle("Antialias", isOn: $fonts.antialias) + Toggle("Ligatures", isOn: $fonts.standardLigatures) + } + } + } + + GridRow { + Text("Monospaced font:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + fontRow(summary: fonts.monospaceSummary, + font: fonts.monospaceFont, + antialias: fonts.antialias, + size: Binding(get: { Double(fonts.monospaceFont.pointSize) }, + set: { fonts.setMonospaceSize(CGFloat($0)) }), + select: fonts.selectMonospaceFont) + HStack(spacing: 16) { + Toggle("Antialias", isOn: $fonts.antialias) + Toggle("Ligatures", isOn: $fonts.monospaceLigatures) + } + } + } + + GridRow { + Text("Line height:") + .gridColumnAlignment(.trailing) + HStack(spacing: 6) { + let lineHeight = Binding(get: { Double(fonts.lineHeight) }, + set: { fonts.setLineHeight(CGFloat($0)) }) + TextField("", value: lineHeight, format: .number.precision(.fractionLength(1))) + .multilineTextAlignment(.trailing) + .frame(width: 56) + Stepper("", value: lineHeight, in: 1...3, step: 0.1) + .labelsHidden() + Text("times") + } + } + } + .settingsPanePadding() + } + + /// Pushes a content-width change to every open editor live, converting cm + /// to points using each editor's window screen PPI (or main screen as fallback). + private func applyContentWidthToOpenDocuments() { + for case let document as Document in NSDocumentController.shared.documents { + let screen = document.editor?.window?.screen ?? NSScreen.main + guard let screen else { continue } + document.editor?.maxContentWidthPoints = screen.cmToPoints(maxContentWidthCm) + document.refreshReadView() + } + } + + @ViewBuilder + private func fontRow(summary: String, font: NSFont, antialias: Bool, + size: Binding, select: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + AntialiasingText(summary) + .antialiasDisabled(!antialias) + .font(nsFont: font) + .frame(width: 240) + Stepper("", value: size, in: 8...72, step: 1) + .labelsHidden() + Button("Select…", action: select) + .fixedSize() + } + } +} + +// MARK: - Continuous NSSlider + +/// Wraps NSSlider so the content-width control can use cm/in units and a +/// magnetic snap onto the default value. +private struct ContentWidthSlider: NSViewRepresentable { + @Binding var cmValue: Double + let usesImperial: Bool + let displayRange: ClosedRange + /// Magnetic snap target in display units (the default width); dragging + /// within `snapTolerance` of it locks onto it exactly. + let snapDisplayValue: Double + private var snapTolerance: Double { usesImperial ? 0.15 : 0.4 } + + func cmToDisplay(_ cm: Double) -> Double { usesImperial ? cm / 2.54 : cm } + func displayToCm(_ d: Double) -> Double { usesImperial ? d * 2.54 : d } + + /// Snap `display` onto the default value when it lands close enough. + func snapped(_ display: Double) -> Double { + abs(display - snapDisplayValue) < snapTolerance ? snapDisplayValue : display + } + + private func clamp(_ v: Double) -> Double { + max(displayRange.lowerBound, min(displayRange.upperBound, v)) + } + + func makeNSView(context: Context) -> NSSlider { + NSSlider(value: clamp(cmToDisplay(cmValue)), + minValue: displayRange.lowerBound, + maxValue: displayRange.upperBound, + target: context.coordinator, + action: #selector(Coordinator.sliderChanged(_:))) + } + + func updateNSView(_ slider: NSSlider, context: Context) { + context.coordinator.parent = self + // Range changes when the unit is toggled (cm ↔ in). + slider.minValue = displayRange.lowerBound + slider.maxValue = displayRange.upperBound + let display = clamp(cmToDisplay(cmValue)) + if abs(slider.doubleValue - display) > 0.001 { + slider.doubleValue = display + } + } + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + @MainActor + final class Coordinator: NSObject { + var parent: ContentWidthSlider + init(parent: ContentWidthSlider) { self.parent = parent } + + @objc func sliderChanged(_ sender: NSSlider) { + let snapped = parent.snapped(sender.doubleValue) + if snapped != sender.doubleValue { sender.doubleValue = snapped } + parent.cmValue = parent.displayToCm(snapped) + } + } +} diff --git a/Sources/edmd/Settings/FontSettings.swift b/Sources/edmd/Settings/FontSettings.swift new file mode 100644 index 0000000..917ee9c --- /dev/null +++ b/Sources/edmd/Settings/FontSettings.swift @@ -0,0 +1,129 @@ +// FontSettings — owns the editor fonts, line height, and accent hex, bridges the +// AppKit font panel, and applies changes to every open document. + +import SwiftUI +import AppKit +import EdmundCore + +// MARK: - Font / theme state + +/// Owns the editor's standard/monospace fonts and line height, bridges the +/// AppKit font panel, and applies font/line-height changes to open documents +/// (the genuinely AppKit-bound part of the Appearance pane). +@MainActor +final class FontSettings: NSObject, ObservableObject { + @Published var standardFont: NSFont + @Published var monospaceFont: NSFont + @Published var lineHeight: CGFloat + @Published var standardLigatures: Bool { didSet { applyLigatures() } } + @Published var monospaceLigatures: Bool { didSet { applyLigatures() } } + /// A single editor-wide antialias setting (both font toggles share it). + @Published var antialias: Bool { didSet { applyAntialias() } } + + private var theme: EditorTheme + private enum Target { case standard, monospace } + private var target: Target = .standard + + override init() { + let theme = EditorTheme.load() + self.theme = theme + standardFont = theme.bodyFont + monospaceFont = theme.monospaceFont() + standardLigatures = theme.standardLigatures + monospaceLigatures = theme.monospaceLigatures + antialias = theme.antialias + let size = theme.bodyFont.pointSize + lineHeight = size > 0 ? max(1, min(3, (size + theme.lineSpacing) / size)) : 1 + super.init() + } + + var standardSummary: String { Self.summary(standardFont) } + var monospaceSummary: String { Self.summary(monospaceFont) } + + func selectStandardFont() { beginFontPanel(.standard, current: standardFont) } + func selectMonospaceFont() { beginFontPanel(.monospace, current: monospaceFont) } + + func setStandardSize(_ size: CGFloat) { + standardFont = NSFont(descriptor: standardFont.fontDescriptor, size: size) ?? standardFont + applyTheme() + } + + func setMonospaceSize(_ size: CGFloat) { + monospaceFont = NSFont(descriptor: monospaceFont.fontDescriptor, size: size) ?? monospaceFont + applyMonospace() + } + + func setLineHeight(_ value: CGFloat) { + lineHeight = max(1, min(3, value)) + applyTheme() + } + + @objc func changeFont(_ sender: NSFontManager) { + switch target { + case .standard: + standardFont = sender.convert(standardFont) + applyTheme() + case .monospace: + monospaceFont = sender.convert(monospaceFont) + applyMonospace() + } + } + + private func beginFontPanel(_ target: Target, current: NSFont) { + self.target = target + let manager = NSFontManager.shared + manager.target = self + manager.action = #selector(changeFont(_:)) + manager.setSelectedFont(current, isMultiple: false) + manager.orderFrontFontPanel(nil) + } + + private func applyMonospace() { + var updated = theme + updated.monospaceFontName = monospaceFont.fontName + updated.monospaceFontSize = monospaceFont.pointSize + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyLigatures() { + var updated = theme + updated.standardLigatures = standardLigatures + updated.monospaceLigatures = monospaceLigatures + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyAntialias() { + var updated = theme + updated.antialias = antialias + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyTheme() { + var updated = theme + updated.fontName = standardFont.fontName + updated.fontSize = standardFont.pointSize + updated.lineSpacing = max(0, (lineHeight - 1) * standardFont.pointSize) + theme = updated + updated.save() + applyToDocuments(updated) + } + + private func applyToDocuments(_ theme: EditorTheme) { + for case let document as Document in NSDocumentController.shared.documents { + document.editor?.applyTheme(theme) + // Reflect the theme change live in an open Read view too. + document.refreshReadView() + } + } + + private static func summary(_ font: NSFont) -> String { + let name = font.displayName ?? font.familyName ?? font.fontName + return "\(name) \(Int(round(font.pointSize)))" + } +} diff --git a/Sources/edmd/Settings/GeneralSettingsView.swift b/Sources/edmd/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..fad33dd --- /dev/null +++ b/Sources/edmd/Settings/GeneralSettingsView.swift @@ -0,0 +1,77 @@ +// The General settings pane (startup, document saving, conflict resolution). + +import SwiftUI +import AppKit + +// MARK: - General + +struct GeneralSettingsView: View { + @AppStorage(AppSettings.Key.automaticallyChecksForUpdates) + private var autoCheckUpdates = true + @AppStorage(AppSettings.Key.reopenWindows) private var reopenWindows = false + @AppStorage(AppSettings.Key.startupAction) private var startupAction = AppSettings.StartupAction.createNewDocument + @AppStorage(AppSettings.Key.autoSaveWithVersions) private var autoSave = true + @AppStorage(AppSettings.Key.conflictResolution) private var conflict = AppSettings.ConflictResolution.ask + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, verticalSpacing: 18) { + GridRow { + Text("Software updates:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Automatically check for updates", isOn: $autoCheckUpdates) + } + } + + GridRow { + Divider().gridCellColumns(2) + } + + GridRow { + Text("On startup:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 6) { + Toggle("Reopen windows from last session", isOn: $reopenWindows) + Text("When nothing else is open:") + Picker("", selection: $startupAction) { + ForEach(AppSettings.StartupAction.allCases) { Text($0.label).tag($0) } + } + .labelsHidden() + .fixedSize() + .padding(.leading, 20) + } + } + + GridRow { + Text("Document save:") + .gridColumnAlignment(.trailing) + VStack(alignment: .leading, spacing: 4) { + Toggle("Enable Auto Save with Versions", isOn: $autoSave) + Text("A system feature that automatically overwrites your files while editing. Even if turned off, Edmund creates a backup in case it unexpectedly quits.") + .foregroundStyle(.secondary) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 380, alignment: .leading) + .padding(.leading, 20) + } + } + + GridRow { + Text("When document is changed by another application:") + .gridCellColumns(2) + } + .padding(.bottom, -8) + + GridRow { + Color.clear.frame(width: 1, height: 1) + Picker("", selection: $conflict) { + ForEach(AppSettings.ConflictResolution.allCases) { Text($0.label).tag($0) } + } + .pickerStyle(.radioGroup) + .labelsHidden() + } + + } + .settingsPanePadding() + } +} diff --git a/Sources/edmd/Settings/SettingsControls.swift b/Sources/edmd/Settings/SettingsControls.swift new file mode 100644 index 0000000..5896f28 --- /dev/null +++ b/Sources/edmd/Settings/SettingsControls.swift @@ -0,0 +1,15 @@ +// Shared Settings view helpers. + +import SwiftUI + +extension View { + /// Consistent pane padding: CotEditor-style breathing room (scene padding at + /// the top, a little more on the sides and bottom). + func settingsPanePadding() -> some View { + self.padding(EdgeInsets(top: 20, leading: 28, bottom: 28, trailing: 28)) + .frame(width: 600, alignment: .leading) + // Don't auto-focus (and draw a focus ring around) the first control + // when a pane opens — Settings has no use for keyboard-focus rings. + .focusEffectDisabled() + } +} diff --git a/Sources/edmd/Settings/SettingsWindowController.swift b/Sources/edmd/Settings/SettingsWindowController.swift new file mode 100644 index 0000000..9c5b68f --- /dev/null +++ b/Sources/edmd/Settings/SettingsWindowController.swift @@ -0,0 +1,92 @@ +// SettingsWindowController — the Settings window. +// +// Built on NSTabViewController (`.toolbar` style), which provides the native +// preference toolbar, pane selection, and per-pane window sizing. Each pane is a +// SwiftUI view hosted in an NSHostingController. Pane switching mirrors +// CotEditor: hide the content, animate the window resize, then reveal it. + +import AppKit +import SwiftUI + +final class SettingsWindowController: NSWindowController { + convenience init() { + let tabController = SettingsTabViewController() + let window = NSWindow(contentViewController: tabController) + window.styleMask = [.titled, .closable] + window.title = "Settings" + window.toolbarStyle = .preference + window.center() + window.isReleasedWhenClosed = false + self.init(window: window) + } +} + +/// Hosts the Settings panes as toolbar tabs and animates the window resize on +/// each switch. +final class SettingsTabViewController: NSTabViewController { + /// Owns the editor font / line-height state and the font-panel plumbing. + private let fonts = FontSettings() + + override func viewDidLoad() { + super.viewDidLoad() + tabStyle = .toolbar + // The window title follows this controller's title; keep it "Settings" + // (NSTabViewController otherwise blanks it to the selected pane's nil title + // on each switch, showing "Untitled"). + title = "Settings" + + addPane(GeneralSettingsView(), label: "General", symbol: "gearshape") + addPane(AppearanceSettingsView(fonts: fonts), label: "Appearance", symbol: "eyeglasses") + addPane(AdvancedSettingsView(), label: "Advanced", symbol: "gearshape.2") + } + + private func addPane(_ view: some View, label: String, symbol: String) { + let hosting = NSHostingController(rootView: view) + // Report a definite size so the tab controller can size the window to it. + hosting.sizingOptions = [.preferredContentSize] + let item = NSTabViewItem(viewController: hosting) + item.label = label + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + addTabViewItem(item) + } + + override func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) { + super.tabView(tabView, didSelect: tabViewItem) + title = "Settings" // re-assert after super resets it to the pane's nil title + guard let tabViewItem else { return } + switchPane(to: tabViewItem) + } + + /// Resize the window to fit the newly selected pane, keeping the top-left + /// fixed. The content is hidden during the resize so nothing stretches + /// mid-animation, then revealed once the window is at its final size. + private func switchPane(to tabViewItem: NSTabViewItem) { + guard let window = view.window, + let contentSize = tabViewItem.view?.frame.size else { return } + + let frame = window.frameRect(forContentSize: contentSize) + + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + window.setFrame(frame, display: true) + return + } + + view.isHidden = true + NSAnimationContext.runAnimationGroup { context in + context.allowsImplicitAnimation = true + context.duration = window.animationResizeTime(frame) + window.setFrame(frame, display: true) + } completionHandler: { [weak self] in + self?.view.isHidden = false + } + } +} + +private extension NSWindow { + /// The window frame for the given content size, keeping the top-left fixed. + func frameRect(forContentSize contentSize: NSSize) -> NSRect { + let frameSize = frameRect(forContentRect: NSRect(origin: .zero, size: contentSize)).size + return NSRect(origin: frame.origin, size: frameSize) + .offsetBy(dx: 0, dy: frame.height - frameSize.height) + } +} diff --git a/Sources/edmd/Views/StatusBarView.swift b/Sources/edmd/Views/StatusBarView.swift new file mode 100644 index 0000000..878aaff --- /dev/null +++ b/Sources/edmd/Views/StatusBarView.swift @@ -0,0 +1,215 @@ +import AppKit +import EdmundCore + +// MARK: - Status Bar View + +/// Floating status bar. Hidden by default and revealed when the pointer enters +/// its strip (or pinned visible via the context menu). It draws everything +/// itself — a vertical gradient from the editor background fading to transparent, +/// the enabled document-count fields on the left, and the line ending on the +/// right — so there are no subviews to truncate the text. +final class StatusBarView: NSView { + + static let labelFont = NSFont.systemFont(ofSize: 11) + + private var prefs = StatusBarPrefs.load() + + // Latest metrics pushed from the document. + private var words = 0 + private var characters = 0 + private var location = 0 + private var lineNumber = 1 + private var lineEnding = "LF" + + private var trackingArea: NSTrackingArea? + private var isHovering = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + alphaValue = prefs.autoHide ? 0 : 1 + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - Data + + func setMetrics(words: Int, characters: Int, location: Int, line: Int, lineEnding: String) { + self.words = words + self.characters = characters + self.location = location + self.lineNumber = line + self.lineEnding = lineEnding + needsDisplay = true + } + + // MARK: - Visibility + + private var shouldBeVisible: Bool { !prefs.autoHide || isHovering } + + private func refreshVisibility(animated: Bool) { + let target: CGFloat = shouldBeVisible ? 1 : 0 + guard abs(alphaValue - target) > 0.001 else { return } + if animated { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.3 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().alphaValue = target + } + } else { + alphaValue = target + } + } + + // MARK: - Hover Tracking + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let ta = trackingArea { removeTrackingArea(ta) } + let ta = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, userInfo: nil) + addTrackingArea(ta) + trackingArea = ta + } + + override func mouseEntered(with event: NSEvent) { + isHovering = true + refreshVisibility(animated: true) + } + + override func mouseExited(with event: NSEvent) { + isHovering = false + refreshVisibility(animated: true) + } + + /// When the bar is hidden, let clicks fall through to the text view beneath. + override func hitTest(_ point: NSPoint) -> NSView? { + if !shouldBeVisible && alphaValue < 0.01 { return nil } + return super.hitTest(point) + } + + // MARK: - Context Menu (double- or right-click) + + override func rightMouseDown(with event: NSEvent) { + NSMenu.popUpContextMenu(buildMenu(), with: event, for: self) + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + NSMenu.popUpContextMenu(buildMenu(), with: event, for: self) + } else { + super.mouseDown(with: event) + } + } + + private func buildMenu() -> NSMenu { + let menu = NSMenu() + + let autoHide = NSMenuItem(title: "Auto-hide", + action: #selector(toggleAutoHide), keyEquivalent: "") + autoHide.target = self + autoHide.state = prefs.autoHide ? .on : .off + menu.addItem(autoHide) + + menu.addItem(.separator()) + let header = NSMenuItem(title: "Show Fields", action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + + let fields: [(title: String, key: String, on: Bool)] = [ + ("Words", "words", prefs.showWords), + ("Characters", "characters", prefs.showCharacters), + ("Location", "location", prefs.showLocation), + ("Line", "line", prefs.showLine), + ("Line Ending", "lineEnding", prefs.showLineEnding), + ] + for field in fields { + let item = NSMenuItem(title: field.title, + action: #selector(toggleField(_:)), keyEquivalent: "") + item.target = self + item.representedObject = field.key + item.state = field.on ? .on : .off + item.indentationLevel = 1 + menu.addItem(item) + } + return menu + } + + @objc private func toggleAutoHide() { + prefs.autoHide.toggle() + prefs.save() + refreshVisibility(animated: true) + } + + @objc private func toggleField(_ sender: NSMenuItem) { + switch sender.representedObject as? String { + case "words": prefs.showWords.toggle() + case "characters": prefs.showCharacters.toggle() + case "location": prefs.showLocation.toggle() + case "line": prefs.showLine.toggle() + case "lineEnding": prefs.showLineEnding.toggle() + default: return + } + prefs.save() + needsDisplay = true + } + + // MARK: - Drawing + + private var labelAttrs: [NSAttributedString.Key: Any] { + [.font: Self.labelFont, .foregroundColor: NSColor.secondaryLabelColor] + } + private var valueAttrs: [NSAttributedString.Key: Any] { + [.font: Self.labelFont, .foregroundColor: NSColor.labelColor] + } + + override func draw(_ dirtyRect: NSRect) { + // Vertical gradient: the editor background, fully opaque at the bottom and + // softening only slightly toward the top so the bar reads clearly even + // when overlaid on text. textBackgroundColor is semantic (light/dark). + let base = NSColor.textBackgroundColor + if let gradient = NSGradient(starting: base, ending: base.withAlphaComponent(0.85)) { + gradient.draw(in: bounds, angle: 90) // 90° = bottom → top + } + + let hMargin: CGFloat = 12 + + // Left: enabled count fields ("Label: value", label dimmed, value bold-ish). + let info = NSMutableAttributedString() + func field(_ name: String, _ value: String) { + if info.length > 0 { + info.append(NSAttributedString(string: " ", attributes: labelAttrs)) + } + info.append(NSAttributedString(string: "\(name): ", attributes: labelAttrs)) + info.append(NSAttributedString(string: value, attributes: valueAttrs)) + } + if prefs.showWords { field("Words", "\(words)") } + if prefs.showCharacters { field("Characters", "\(characters)") } + if prefs.showLocation { field("Location", "\(location)") } + if prefs.showLine { field("Line", "\(lineNumber)") } + + if info.length > 0 { + let size = info.size() + info.draw(at: NSPoint(x: hMargin, y: (bounds.height - size.height) / 2)) + } + + // Right: line ending, preceded by a short vertical divider. + if prefs.showLineEnding { + let value = NSAttributedString(string: lineEnding, attributes: valueAttrs) + let size = value.size() + let x = bounds.maxX - hMargin - size.width + value.draw(at: NSPoint(x: x, y: (bounds.height - size.height) / 2)) + + if info.length > 0 { + NSColor.separatorColor.setStroke() + let dx = round(x - 12) + 0.5 + let divider = NSBezierPath() + divider.move(to: NSPoint(x: dx, y: 5)) + divider.line(to: NSPoint(x: dx, y: bounds.height - 5)) + divider.lineWidth = 1 + divider.stroke() + } + } + } +} diff --git a/Tests/EdmundTests/ActiveBulletMarkerTests.swift b/Tests/EdmundTests/ActiveBulletMarkerTests.swift new file mode 100644 index 0000000..9c8431a --- /dev/null +++ b/Tests/EdmundTests/ActiveBulletMarkerTests.swift @@ -0,0 +1,50 @@ +import Testing +import AppKit +@testable import EdmundCore + +// When you click into a bullet item, the raw "-" should stay on the dot's +// column (where the rendered • sits) rather than jumping a slot to the right. +// Content must still hang at the same indent so the text doesn't move. +@Suite("Active bullet marker column") +@MainActor +struct ActiveBulletMarkerTests { + + private func ps(_ editor: EditorTextView, _ s: String, cursor: Int?) -> NSParagraphStyle? { + let st = editor.styleBlock(s, cursorPosition: cursor) + return st.attribute(.paragraphStyle, at: st.length - 1, effectiveRange: nil) as? NSParagraphStyle + } + + @Test("Active bullet marker stays on the dot column (not right-aligned into the slot)") + func bulletStaysOnDotColumn() { + let editor = makeEditor() + let active = ps(editor, "- item", cursor: 3)! + let inactive = ps(editor, "- item", cursor: nil)! + let slot = editor.bodyFont.pointSize + + (" " as NSString).size(withAttributes: [.font: editor.bodyFont]).width + // The active dash sits on the inactive dot's column (within a fraction of + // a slot), not a full slot to the right of it. + #expect(abs(active.firstLineHeadIndent - inactive.firstLineHeadIndent) < slot * 0.5) + // Content is unchanged so the text doesn't shift when clicking in. + #expect(abs(active.headIndent - inactive.headIndent) < 0.5) + } + + @Test("Active bullet kerns its trailing space so content keeps the hanging indent") + func bulletKernsTrailingSpace() { + let editor = makeEditor() + let st = editor.styleBlock("- item", cursorPosition: 3) + // "- item": the space is index 1; it carries positive kern to push the + // content out to the content indent even though the dash sits left. + let kern = st.attribute(.kern, at: 1, effectiveRange: nil) as? CGFloat + #expect((kern ?? 0) > 0) + } + + @Test("Active ordered marker still right-aligns into its slot") + func orderedStillRightAligns() { + let editor = makeEditor() + let active = ps(editor, "1. item", cursor: 4)! + // Ordered numbers keep right-alignment (periods line up), so the first + // line indent is well right of the bullet column. + #expect(active.firstLineHeadIndent < active.headIndent) + #expect(active.firstLineHeadIndent > editor.listPadding + 1) + } +} diff --git a/Tests/EdmundTests/BlockParserTests.swift b/Tests/EdmundTests/BlockParserTests.swift new file mode 100644 index 0000000..1d0eae0 --- /dev/null +++ b/Tests/EdmundTests/BlockParserTests.swift @@ -0,0 +1,780 @@ +import Testing +import Foundation +@testable import EdmundCore + +@Suite("BlockParser") +struct BlockParserTests { + + // MARK: - Basic Splitting + + @Test("Empty string produces one empty block") + func emptyString() { + let blocks = BlockParser.parse("") + #expect(blocks.count == 1) + #expect(blocks[0].content == "") + #expect(blocks[0].range == NSRange(location: 0, length: 0)) + } + + @Test("A callout merges its block-quote lines into one block") + func calloutMerges() { + let blocks = BlockParser.parse("> [!note]\n> line one\n> line two\n\nafter") + // The three `>` lines form one callout block; blank + "after" follow. + #expect(blocks.count == 3) + #expect(blocks[0].content == "> [!note]\n> line one\n> line two") + #expect(blocks[2].content == "after") + } + + @Test("Consecutive block-quote lines merge into one block") + func blockquoteLinesMerge() { + // One NSTextBlock per quote (vs one per line) avoids the NSTextView + // table-cell deletion restriction at per-line boundaries. + let blocks = BlockParser.parse("> a\n> b\n\nafter") + #expect(blocks.count == 3) + #expect(blocks[0].content == "> a\n> b") + #expect(blocks[2].content == "after") + } + + @Test("An unknown [!type] still merges as a plain block quote") + func unknownCalloutMergesAsQuote() { + let blocks = BlockParser.parse("> [!bogus]\n> b") + #expect(blocks.count == 1) + } + + @Test("Single line produces one block") + func singleLine() { + let blocks = BlockParser.parse("hello") + #expect(blocks.count == 1) + #expect(blocks[0].content == "hello") + #expect(blocks[0].range == NSRange(location: 0, length: 5)) + } + + @Test("Two lines produce two blocks") + func twoLines() { + let blocks = BlockParser.parse("hello\nworld") + #expect(blocks.count == 2) + #expect(blocks[0].content == "hello") + #expect(blocks[0].range == NSRange(location: 0, length: 5)) + #expect(blocks[1].content == "world") + #expect(blocks[1].range == NSRange(location: 6, length: 5)) + } + + @Test("Three lines produce three blocks") + func threeLines() { + let blocks = BlockParser.parse("a\nb\nc") + #expect(blocks.count == 3) + #expect(blocks[0].content == "a") + #expect(blocks[1].content == "b") + #expect(blocks[2].content == "c") + #expect(blocks[0].range == NSRange(location: 0, length: 1)) + #expect(blocks[1].range == NSRange(location: 2, length: 1)) + #expect(blocks[2].range == NSRange(location: 4, length: 1)) + } + + @Test("Trailing newline creates empty block (Enter at end)") + func trailingNewline() { + let blocks = BlockParser.parse("hello\n") + #expect(blocks.count == 2) + #expect(blocks[0].content == "hello") + #expect(blocks[1].content == "") + #expect(blocks[1].range == NSRange(location: 6, length: 0)) + } + + @Test("Multiple trailing newlines create multiple empty blocks") + func multipleTrailingNewlines() { + let blocks = BlockParser.parse("hello\n\n") + #expect(blocks.count == 3) + #expect(blocks[0].content == "hello") + #expect(blocks[1].content == "") + #expect(blocks[2].content == "") + } + + @Test("Only newlines produce empty blocks") + func onlyNewlines() { + let blocks = BlockParser.parse("\n\n") + #expect(blocks.count == 3) + for block in blocks { + #expect(block.content == "") + } + } + + // MARK: - Ranges Are Contiguous + + @Test("Block ranges cover the full string with separators between them") + func rangesContiguous() { + let text = "alpha\nbeta\ngamma" + let blocks = BlockParser.parse(text) + let nsText = text as NSString + + // First block starts at 0 + #expect(blocks[0].range.location == 0) + + // Each block's end + 1 (separator) == next block's start + for i in 0..<(blocks.count - 1) { + #expect(blocks[i].range.upperBound + 1 == blocks[i + 1].range.location) + } + + // Last block ends at or before string length + #expect(blocks.last!.range.upperBound <= nsText.length) + } + + // MARK: - ID Preservation + + @Test("Re-parsing unchanged text preserves block IDs") + func idPreservation() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nworld", previous: blocks1) + + #expect(blocks1[0].id == blocks2[0].id) + #expect(blocks1[1].id == blocks2[1].id) + } + + @Test("Changed block gets a new ID") + func idChangedBlock() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nearth", previous: blocks1) + + #expect(blocks1[0].id == blocks2[0].id) // "hello" unchanged + #expect(blocks1[1].id != blocks2[1].id) // "world" → "earth" + } + + @Test("Added block gets a new ID, existing blocks keep theirs") + func idAddedBlock() { + let blocks1 = BlockParser.parse("hello\nworld") + let blocks2 = BlockParser.parse("hello\nworld\nnew", previous: blocks1) + + #expect(blocks2.count == 3) + #expect(blocks1[0].id == blocks2[0].id) + #expect(blocks1[1].id == blocks2[1].id) + // blocks2[2] is new — just verify it exists with the right content + #expect(blocks2[2].content == "new") + } + + @Test("Removed block: remaining blocks keep their IDs") + func idRemovedBlock() { + let blocks1 = BlockParser.parse("hello\nworld\nfoo") + let blocks2 = BlockParser.parse("hello\nfoo", previous: blocks1) + + #expect(blocks2.count == 2) + #expect(blocks1[0].id == blocks2[0].id) // "hello" + #expect(blocks1[2].id == blocks2[1].id) // "foo" + } + + @Test("Each previous block ID is used at most once") + func idUniqueness() { + let blocks1 = BlockParser.parse("a\na\na") // three identical blocks + let blocks2 = BlockParser.parse("a\na\na", previous: blocks1) + + // Each reused at most once: all three IDs should still be unique + let ids = blocks2.map(\.id) + #expect(Set(ids).count == 3) + } + + // MARK: - Markdown Content (parser doesn't interpret, just preserves) + + @Test("Markdown syntax is preserved as-is in block content") + func markdownPreserved() { + let text = "**bold**\n*italic*\n`code`" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "**bold**") + #expect(blocks[1].content == "*italic*") + #expect(blocks[2].content == "`code`") + } + + // MARK: - Edge Cases + + @Test("Single character") + func singleChar() { + let blocks = BlockParser.parse("x") + #expect(blocks.count == 1) + #expect(blocks[0].content == "x") + #expect(blocks[0].range == NSRange(location: 0, length: 1)) + } + + @Test("Single newline produces two empty blocks") + func singleNewline() { + let blocks = BlockParser.parse("\n") + #expect(blocks.count == 2) + #expect(blocks[0].content == "") + #expect(blocks[1].content == "") + } + + @Test("Ranges are correct for multi-byte characters") + func multiByte() { + // NSRange uses UTF-16 offsets. "café" is 4 UTF-16 code units, + // "é" is 1 code unit (U+00E9). + let text = "café\nnext" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "café") + #expect(blocks[0].range == NSRange(location: 0, length: 4)) + #expect(blocks[1].content == "next") + #expect(blocks[1].range == NSRange(location: 5, length: 4)) + } + + @Test("Emoji ranges use UTF-16 length") + func emoji() { + // "👋" is 2 UTF-16 code units (surrogate pair) + let text = "👋\nhi" + let blocks = BlockParser.parse(text) + #expect(blocks[0].content == "👋") + #expect(blocks[0].range.length == ("👋" as NSString).length) + #expect(blocks[1].content == "hi") + } + + // MARK: - Table Merging + + @Test("Table with header and separator merges into single block") + func tableMerge() { + let text = "| A | B |\n| --- | --- |\n| 1 | 2 |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == text) + } + + @Test("Table between paragraphs") + func tableBetweenParagraphs() { + let text = "above\n| A | B |\n| --- | --- |\n| 1 | 2 |\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "| A | B |\n| --- | --- |\n| 1 | 2 |") + #expect(blocks[2].content == "below") + } + + @Test("Table with multiple data rows") + func tableMultipleRows() { + let text = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == text) + } + + @Test("Single pipe line without separator is not a table") + func singlePipeNotTable() { + let text = "| not a table |" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "| not a table |") + } + + @Test("Table range covers full text") + func tableRange() { + let text = "| A |\n| --- |\n| 1 |" + let blocks = BlockParser.parse(text) + #expect(blocks[0].range == NSRange(location: 0, length: (text as NSString).length)) + } + + @Test("Delimiter row with fewer cells than the header is not a table") + func tableRejectsMismatchedDelimiterCount() { + let blocks = BlockParser.parse("| a | b |\n|---|") + #expect(blocks.map(\.kind) != [.table]) + } + + @Test("Delimiter row with matching cell count is still a table") + func tableAcceptsMatchingDelimiterCount() { + let blocks = BlockParser.parse("| a | b |\n|---|---|") + #expect(blocks.map(\.kind) == [.table]) + } + + // MARK: - Code Fence Merging + + @Test("Fenced code block merges into single block") + func codeFenceMerge() { + let text = "```\nhello\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```\nhello\n```") + } + + @Test("Code fence with language merges into single block") + func codeFenceWithLanguage() { + let text = "```swift\nlet x = 1\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```swift\nlet x = 1\n```") + } + + @Test("Tilde code fence merges into single block") + func tildeFenceMerge() { + let text = "~~~\ncode\n~~~" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "~~~\ncode\n~~~") + } + + @Test("Code fence between paragraphs") + func codeFenceBetweenParagraphs() { + let text = "above\n```\ncode\n```\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "```\ncode\n```") + #expect(blocks[2].content == "below") + } + + @Test("Unclosed code fence merges to end of document") + func unclosedFence() { + let text = "```\nline1\nline2" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "```\nline1\nline2") + } + + @Test("Code fence with multiple content lines") + func multiLineFence() { + let text = "```\na\nb\nc\n```" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content.contains("a\nb\nc")) + } + + @Test("Multi-line $$ display math merges into a single block") + func displayMathMerge() { + let text = "$$\nx = y\n$$" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$\nx = y\n$$") + } + + @Test("One-line $$…$$ stays a single block") + func displayMathOneLine() { + let blocks = BlockParser.parse("$$ x = y $$") + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$ x = y $$") + } + + @Test("Display math between paragraphs splits correctly") + func displayMathBetweenParagraphs() { + let text = "above\n$$\nx\n$$\nbelow" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 3) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "$$\nx\n$$") + #expect(blocks[2].content == "below") + } + + @Test("Display math opener with content on the same line") + func displayMathOpenerWithContent() { + let text = "$$ x +\ny $$" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "$$ x +\ny $$") + } + + @Test("Code fence range covers full text") + func codeFenceRange() { + let text = "```\nhello\n```" + let blocks = BlockParser.parse(text) + #expect(blocks[0].range == NSRange(location: 0, length: (text as NSString).length)) + } + + // MARK: - Blockquotes (consecutive lines merge into one block) + + @Test("Consecutive blockquote lines merge into one block") + func blockquoteMergedBlock() { + let text = "> line1\n> line2\n> line3" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "> line1\n> line2\n> line3") + } + + @Test("A blockquote lazily continues a following bare line") + func blockquoteBetweenParagraphs() { + let text = "above\n> line1\n> line2\nbelow" + let blocks = BlockParser.parse(text) + // `below` lacks `>` but lazily continues the quote's paragraph (CommonMark). + #expect(blocks.count == 2) + #expect(blocks[0].content == "above") + #expect(blocks[1].content == "> line1\n> line2\nbelow") + } + + @Test("Single blockquote line is one block") + func singleBlockquoteLine() { + let text = "> just one" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].content == "> just one") + } + + @Test("A merged blockquote's range spans all its lines") + func blockquoteRange() { + let text = "> a\n> b" + let blocks = BlockParser.parse(text) + #expect(blocks.count == 1) + #expect(blocks[0].range == NSRange(location: 0, length: 7)) + } + + @Test("A bare line then `> second` all lazily continue one quote (GFM ex. 228)") + func nonConsecutiveBlockquotes() { + let text = "> first\nparagraph\n> second" + let blocks = BlockParser.parse(text) + // No blank line separates them, so `paragraph` lazily continues the + // quote and `> second` continues the same still-open paragraph. + #expect(blocks.count == 1) + #expect(blocks[0].content == "> first\nparagraph\n> second") + } + + // MARK: - Changed Window (parseWithDiff) + + @Test("Unchanged re-parse yields an empty window") + func diffUnchanged() { + let (b1, _) = BlockParser.parseWithDiff("a\nb\nc") + let (b2, changed) = BlockParser.parseWithDiff("a\nb\nc", previous: b1) + #expect(changed.isEmpty) + #expect(b1.map(\.id) == b2.map(\.id)) + } + + @Test("Editing one block yields a one-block window") + func diffSingleEdit() { + let (b1, _) = BlockParser.parseWithDiff("a\nb\nc") + let (b2, changed) = BlockParser.parseWithDiff("a\nbX\nc", previous: b1) + #expect(changed == 1..<2) + #expect(b2[0].id == b1[0].id) + #expect(b2[2].id == b1[2].id) + #expect(b2[1].id != b1[1].id) + } + + @Test("Splitting a block yields a two-block window (count change)") + func diffSplit() { + let (b1, _) = BlockParser.parseWithDiff("hello world\ntail") + let (b2, changed) = BlockParser.parseWithDiff("hello\nworld\ntail", previous: b1) + #expect(b2.count == 3) + #expect(changed == 0..<2) + #expect(b2[2].id == b1[1].id) // suffix keeps its ID + } + + @Test("Deleting a blank merges a paragraph into the quote (lazy continuation)") + func diffMerge() { + let (b1, _) = BlockParser.parseWithDiff("> a\n\ntail") // quote, blank, paragraph + #expect(b1.count == 3) + // Removing the blank lets `tail` lazily continue the quote's paragraph. + let (b2, _) = BlockParser.parseWithDiff("> a\ntail", previous: b1) + #expect(b2.count == 1) + #expect(b2[0].content == "> a\ntail") + #expect(b2[0].kind == .quoteRun(isCallout: false)) + } + + @Test("Identical-content documents: window covers only the edit") + func diffIdenticalContent() { + let (b1, _) = BlockParser.parseWithDiff("a\na\na\na") + let (b2, changed) = BlockParser.parseWithDiff("a\nX\na\na", previous: b1) + #expect(changed == 1..<2) + // Prefix and suffix matches keep their IDs positionally. + #expect(b2[0].id == b1[0].id) + #expect(b2[2].id == b1[2].id) + #expect(b2[3].id == b1[3].id) + } + + @Test("Overlap clamp: duplicating a block doesn't double-match") + func diffOverlapClamp() { + let (b1, _) = BlockParser.parseWithDiff("a") + let (b2, changed) = BlockParser.parseWithDiff("a\na", previous: b1) + #expect(b2.count == 2) + #expect(changed.count == 1) + #expect(Set(b2.map(\.id)).count == 2) + } + + // MARK: - Block Kinds + + @Test("Kinds are classified per block") + func kinds() { + let text = """ + # Title + plain + - item + 3. ordered + --- + > quote + + > [!note] + > body + | a | b | + | --- | --- | + ``` + code + ``` + $$ + x + $$ + """ + let blocks = BlockParser.parse(text) + let kinds = blocks.map(\.kind) + #expect(kinds == [ + .heading(level: 1), + .paragraph, + .listItem, + .listItem, + .thematicBreak, + .quoteRun(isCallout: false), + .blank, + .quoteRun(isCallout: true), + .table, + .fence, + .mathDisplay, + ]) + } + + @Test("Blank and whitespace-only lines are .blank") + func blankKinds() { + let blocks = BlockParser.parse("a\n\n \nb") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .blank, .paragraph]) + } + + @Test("Thematic break beats list classification for '- - -'") + func hrBeatsList() { + let blocks = BlockParser.parse("- - -") + #expect(blocks[0].kind == .thematicBreak) + } + + // MARK: - Setext headings + + @Test("Paragraph + === underline merges into an h1 block") + func setextH1() { + let blocks = BlockParser.parse("Title\n===\nbody") + #expect(blocks.count == 2) + #expect(blocks[0].content == "Title\n===") + #expect(blocks[0].kind == .heading(level: 1)) + #expect(blocks[1].content == "body") + } + + @Test("Paragraph + --- underline merges into an h2 block (setext beats rule)") + func setextH2() { + let blocks = BlockParser.parse("Title\n---") + #expect(blocks.count == 1) + #expect(blocks[0].kind == .heading(level: 2)) + } + + @Test("A setext underline merges the whole preceding paragraph run (GFM Example 51)") + func setextMultiLineContent() { + let blocks = BlockParser.parse("Foo\nbar\n---") + #expect(blocks.count == 1) + #expect(blocks[0].kind == .heading(level: 2)) + #expect(blocks[0].content == "Foo\nbar\n---") + } + + @Test("Without an underline, each paragraph line stays its own block") + func noUnderlineKeepsPerLineBlocks() { + let blocks = BlockParser.parse("Foo\nbar") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + #expect(blocks[0].content == "Foo") + #expect(blocks[1].content == "bar") + } + + @Test("A setext run doesn't swallow a preceding non-paragraph block") + func setextRunStopsAtHeading() { + let blocks = BlockParser.parse("# h\nFoo\n===") + #expect(blocks.map(\.kind) == [.heading(level: 1), .heading(level: 1)]) + #expect(blocks[1].content == "Foo\n===") + } + + @Test("--- after a blank line stays a thematic break") + func ruleAfterBlank() { + let blocks = BlockParser.parse("para\n\n---") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .thematicBreak]) + } + + @Test("--- after a list item stays a thematic break") + func ruleAfterList() { + let blocks = BlockParser.parse("- item\n---") + #expect(blocks.map(\.kind) == [.listItem, .thematicBreak]) + } + + @Test("Spaced '- - -' after a paragraph is not a setext underline") + func spacedRuleNotUnderline() { + let blocks = BlockParser.parse("para\n- - -") + #expect(blocks.map(\.kind) == [.paragraph, .thematicBreak]) + } + + @Test("Underline with trailing non-space text is not a setext underline") + func underlineWithTrailingText() { + let blocks = BlockParser.parse("para\n=== x") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + } + + @Test("Setext underline can't follow a heading or blank line") + func underlineNeedsParagraph() { + let blocks = BlockParser.parse("# h\n===") + #expect(blocks.map(\.kind) == [.heading(level: 1), .paragraph]) + } + + // MARK: - Indented code blocks + + @Test("A run of 4-space lines at document start merges into one code block") + func indentedCodeRun() { + let blocks = BlockParser.parse(" a\n b") + #expect(blocks.count == 1) + #expect(blocks[0].content == " a\n b") + #expect(blocks[0].kind == .indentedCode) + } + + @Test("Tab indentation opens a code block") + func tabIndentedCode() { + let blocks = BlockParser.parse("\tcode") + #expect(blocks.map(\.kind) == [.indentedCode]) + } + + @Test("Indented code needs a preceding blank line") + func indentedCodeNeedsBlank() { + let blocks = BlockParser.parse("foo\n bar") + #expect(blocks.map(\.kind) == [.paragraph, .paragraph]) + } + + @Test("Indented run after a blank line is a code block") + func indentedCodeAfterBlank() { + let blocks = BlockParser.parse("foo\n\n bar") + #expect(blocks.map(\.kind) == [.paragraph, .blank, .indentedCode]) + } + + @Test("An interior blank line stays inside the code block (GFM Example 82)") + func indentedCodeBlankSplits() { + let blocks = BlockParser.parse(" a\n\n b") + #expect(blocks.map(\.kind) == [.indentedCode]) + #expect(blocks[0].content == " a\n\n b") + } + + @Test("A blank line before non-code content is not swallowed into the code block") + func indentedCodeTrailingBlankNotSwallowed() { + let blocks = BlockParser.parse(" a\n\nx") + #expect(blocks.map(\.kind) == [.indentedCode, .blank, .paragraph]) + #expect(blocks[0].content == " a") + } + + @Test("Multiple interior blank lines and chunks all stay in one code block") + func indentedCodeMultipleInteriorBlanks() { + let blocks = BlockParser.parse(" a\n\n\n b\n\npara") + #expect(blocks.map(\.kind) == [.indentedCode, .blank, .paragraph]) + #expect(blocks[0].content == " a\n\n\n b") + } + + @Test("A deeply indented list item is rescued as a list, not code") + func indentedListBeatsCode() { + let blocks = BlockParser.parse(" - item") + #expect(blocks.map(\.kind) == [.listItem]) + } + + @Test("An indented line followed by a setext-ish underline stays code + rule") + func indentedCodeNotSetext() { + let blocks = BlockParser.parse(" code\n---") + #expect(blocks.map(\.kind) == [.indentedCode, .thematicBreak]) + } +} + +// GFM §4.6: the seven HTML-block start conditions and their end conditions. +@Suite("BlockParser — HTML blocks") +struct BlockParserHTMLBlockTests { + + @Test("Type 6 block tag runs to the blank line") + func type6() { + let blocks = BlockParser.parse("
        \n*foo*\n
        \n\npara") + #expect(blocks.map(\.kind) == [.htmlBlock, .blank, .paragraph]) + #expect(blocks[0].content == "
        \n*foo*\n
        ") + } + + @Test("Type 6 unterminated runs to EOF") + func type6EOF() { + let blocks = BlockParser.parse("
        \n*foo*") + #expect(blocks.map(\.kind) == [.htmlBlock]) + } + + @Test("Type 6 interrupts a paragraph") + func type6Interrupts() { + let blocks = BlockParser.parse("para\n
        \nx") + #expect(blocks.map(\.kind) == [.paragraph, .htmlBlock]) + } + + @Test("Type 1 line") + func type1() { + let blocks = BlockParser.parse("\nafter") + #expect(blocks.map(\.kind) == [.htmlBlock, .paragraph]) + #expect(blocks[0].content == "") + } + + @Test("Type 1 end condition can be on the start line") + func type1OneLine() { + let blocks = BlockParser.parse("
        y
        ") + #expect(blocks.map(\.kind) == [.htmlBlock]) + } + + @Test("Types 2–5: comment, PI, declaration, CDATA") + func types2to5() { + let comment = BlockParser.parse("\nok") + #expect(comment.map(\.kind) == [.htmlBlock, .paragraph]) + #expect(comment[0].content == "") + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + #expect(BlockParser.parse("").map(\.kind) == [.htmlBlock]) + } + + @Test("Type 2 unterminated comment runs to EOF") + func type2EOF() { + #expect(BlockParser.parse(" after") + #expect(!out.contains("secret")) + #expect(out.contains("before")) + #expect(out.contains("after")) + } + + @Test("Block-level HTML comment is hidden") + func blockHTMLComment() { + #expect(!html("para\n\n\n\nend").contains("secret")) + } + + @Test("Inline emits the asset-pass placeholder with declared dimensions") + func inlineImgTag() { + let out = html("pic \"a here") + #expect(out.contains("\"a")) + } + + @Test("Block-level line emits the placeholder too") + func blockImgTag() { + let out = html("") + #expect(out.contains("\"\"")) + } + + @Test("An without a src passes through raw (no placeholder)") + func imgWithoutSrc() { + let out = html("x y") + #expect(!out.contains("md-image")) + #expect(out.contains("")) + } + + @Test("Bare www autolink renders as a real anchor") + func wwwAutolink() { + let out = html("visit www.example.com now") + #expect(out.contains("www.example.com")) + } + + @Test("Email autolink renders as a mailto anchor") + func emailAutolink() { + let out = html("mail foo@bar.example.com please") + #expect(out.contains("foo@bar.example.com")) + } + + @Test("Autolink trailing punctuation stays outside the anchor") + func autolinkTrim() { + let out = html("see www.example.com.") + #expect(out.contains("www.example.com.")) + } + + @Test("No autolink inside inline code") + func autolinkNotInCode() { + let out = html("`www.example.com`") + #expect(!out.contains("x")) + #expect(out.contains("http://b.example.com")) + } +} + +@Suite("HTMLRenderer — footnotes") +struct HTMLRendererFootnoteTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Reference becomes a superscript link; raw [^id] doesn't leak into the page") + func reference() { + let out = html("Hello world.[^1]\n\n[^1]: a note") + #expect(out.contains("1")) + #expect(!out.contains("[^1]")) + } + + @Test("Definition moves to a bottom section with a backlink, not rendered in place") + func definitionMovesToBottom() { + let out = html("See.[^1]\n\n[^1]: the note text\n\nMore content.") + #expect(!out.contains("

        [^1]")) + #expect(out.contains("


          ")) + #expect(out.contains("
        1. the note text
        2. ")) + // The unrelated paragraph after the definition still renders normally. + #expect(out.contains("

          More content.

          ")) + } + + @Test("Definition body keeps its inline markdown formatting") + func definitionBodyKeepsFormatting() { + let out = html("See.[^1]\n\n[^1]: has **bold** text") + #expect(out.contains("
        3. has bold text")) + } + + @Test("No footnotes: no bottom section emitted") + func noFootnotesNoSection() { + #expect(!html("plain paragraph, no notes").contains("footnotes-sep")) + } + + @Test("Document produced by the Format-menu Insert Footnote command renders correctly in Read mode") + @MainActor func viaFormatMenuCommand() { + let editor = makeEditor() + editor.loadContent("Hello world.") + editor.formatFootnote(nil) + editor.insertText("This is the note.", replacementRange: editor.selectedRange()) + let out = html(editor.rawSource) + #expect(out.contains("1")) + #expect(out.contains("
        4. This is the note.")) + #expect(!out.contains("[^1]")) + } +} + +@Suite("HTMLRenderer — callouts") +struct HTMLRendererCalloutTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Known callout type → callout div with title and body") + func basicCallout() { + let out = html("> [!note]\n> Body text.") + #expect(out.contains("
          ")) + #expect(out.contains("
          ")) + // Inline Lucide SVG (note → pencil), tinted by CSS via currentColor. + #expect(out.contains("Note")) + #expect(out.contains("

          Body text.

          ")) + } + + @Test("Custom title is used verbatim") + func customTitle() { + let out = html("> [!warning] Watch out here\n> Careful.") + #expect(out.contains("callout callout-warning")) + #expect(out.contains("Watch out here")) + } + + @Test("Unknown type stays a plain block quote") + func unknownType() { + let out = html("> [!bogus]\n> hi") + #expect(out.hasPrefix("
          ")) + #expect(!out.contains("callout")) + } + + // Callouts are strict in BOTH modes: a bare (lazy) line after a callout body + // does NOT join the callout — it renders as a sibling, matching edit-mode + // block segmentation (BlockParser keeps callouts strict; see + // BlockquoteLazyContinuationTests.calloutStaysStrict for the edit side). + @Test("A lazy line after a callout renders as a sibling, not in the body") + func calloutBodyStrict() { + let out = html("> [!note]\n> body\nlazy") + #expect(out.contains("

          body

          ")) + // `lazy` sits after the callout div closes, not inside the body. + #expect(out.contains("

          lazy

          ")) + #expect(!out.contains("body\nlazy")) + } + + @Test("GFM ex.228 tail: `> y` after a lazy line is a sibling quote, not the callout") + func calloutLazyTailWithQuote() { + let out = html("> [!note]\n> body\nlazy\n> y") + #expect(out.contains("

          body

          ")) + #expect(out.contains("

          lazy

          ")) + #expect(out.contains("

          y

          ")) + // Exactly one callout — the second `> y` was not pulled into it. + #expect(out.components(separatedBy: "class=\"callout ").count == 2) + } + + @Test("A fully `>`-prefixed callout body is unchanged (no lazy split)") + func calloutFullyQuotedUnchanged() { + let out = html("> [!note]\n> a\n> b") + #expect(out.contains("

          a\nb

          ")) + #expect(!out.contains("

        ")) + } +} diff --git a/Tests/EdmundTests/HTMLTagRenderingTests.swift b/Tests/EdmundTests/HTMLTagRenderingTests.swift new file mode 100644 index 0000000..cb53564 --- /dev/null +++ b/Tests/EdmundTests/HTMLTagRenderingTests.swift @@ -0,0 +1,282 @@ +import Testing +import Foundation +import AppKit +@testable import EdmundCore + +// HTML tags in edit mode: every recognized tag is colored source (name red, +// brackets dimmed); a whitelist (u/kbd/mark/sub/sup) additionally renders its +// formatting when the caret is outside the token. Read mode passes raw HTML +// through per GFM, filtered by tagfilter (§6.11) + hardening. + +@Suite("SyntaxHighlighter — HTML tags") +struct HTMLTagParseTests { + + private func kinds(_ text: String) -> [SyntaxHighlighter.Span.Kind] { + SyntaxHighlighter.parse(text).map(\.kind) + } + + @Test("Whitelisted pair → htmlFormat") + func pair() { + let spans = SyntaxHighlighter.parse("hi") + let fmt = spans.first { if case .htmlFormat = $0.kind { return true }; return false } + #expect(fmt != nil) + #expect(fmt?.contentRange == NSRange(location: 3, length: 2)) // "hi" + #expect(fmt?.delimiterRanges == [NSRange(location: 0, length: 3), // + NSRange(location: 5, length: 4)]) // + } + + @Test("Unknown tag → htmlTag (colored only)") + func unknown() { + let spans = SyntaxHighlighter.parse("a b") + let tag = spans.first { if case .htmlTag = $0.kind { return true }; return false } + #expect(tag?.contentRange == NSRange(location: 3, length: 4)) // "span" + #expect(!spans.contains { if case .htmlFormat = $0.kind { return true }; return false }) + } + + @Test("Unpaired whitelist tag → htmlTag, not htmlFormat") + func unpaired() { + let k = kinds(" alone") + #expect(k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test("Escaped `\\` is not an HTML tag") + func escaped() { + let k = kinds("\\") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test("No HTML tag inside inline code") + func insideCode() { + let k = kinds("`x`") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test(" → .comment with delimiters") + func htmlComment() { + let spans = SyntaxHighlighter.parse("a b") + let comment = spans.first { if case .comment = $0.kind { return true }; return false } + #expect(comment != nil) + #expect(comment?.fullRange == NSRange(location: 2, length: 13)) + #expect(comment?.delimiterRanges == [NSRange(location: 2, length: 4), // + } + + @Test("A tag inside an HTML comment belongs to the comment, not the tag pass") + func commentSwallowsTags() { + let k = kinds("") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + #expect(!k.contains { if case .htmlFormat = $0 { return true }; return false }) + } + + @Test(" → image span carrying src and declared dimensions") + func imgTag() { + let text = "\"a" + let spans = SyntaxHighlighter.parse(text) + let images = spans.filter { if case .image = $0.kind { return true }; return false } + #expect(images.count == 1) + guard let img = images.first else { return } + if case .image(let dest, let w, let h) = img.kind { + #expect(dest == "cat.png") + #expect(w == 120) + #expect(h == 80) + } + #expect(img.fullRange == NSRange(location: 0, length: (text as NSString).length)) + #expect((text as NSString).substring(with: img.contentRange) == "cat.png") + #expect(!spans.contains { if case .htmlTag = $0.kind { return true }; return false }) + } + + @Test(" without dimensions parses with nil width/height") + func imgNoDims() { + let spans = SyntaxHighlighter.parse("") + guard case .image(let dest, let w, let h)? = + spans.first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span"); return } + #expect(dest == "cat.png") + #expect(w == nil && h == nil) + } + + @Test(" without a quoted src stays colored source (htmlTag)") + func imgWithoutSrc() { + let k = kinds("") + #expect(!k.contains { if case .image = $0 { return true }; return false }) + #expect(k.contains { if case .htmlTag = $0 { return true }; return false }) + } + + @Test("Hyphenated element name is a tag (§6.10)") + func hyphenName() { + let spans = SyntaxHighlighter.parse("a b") + let tag = spans.first { if case .htmlTag = $0.kind { return true }; return false } + #expect(tag?.contentRange == NSRange(location: 3, length: 10)) // "my-element" + } + + @Test("A quoted attribute value may contain `>`") + func quotedGT() { + let text = "b\">" + let spans = SyntaxHighlighter.parse(text) + let tags = spans.filter { if case .htmlTag = $0.kind { return true }; return false } + #expect(tags.count == 1) + #expect(tags.first?.fullRange == NSRange(location: 0, length: (text as NSString).length)) + } + + @Test(" accepts single-quoted and unquoted attribute values") + func imgAltQuoting() { + guard case .image(let dest1, _, _)? = SyntaxHighlighter.parse("") + .first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span for single-quoted src"); return } + #expect(dest1 == "cat.png") + + guard case .image(let dest2, let w, _)? = SyntaxHighlighter.parse("") + .first(where: { if case .image = $0.kind { return true }; return false })?.kind + else { Issue.record("no image span for unquoted src"); return } + #expect(dest2 == "cat.png") + #expect(w == 120) + } + + @Test("A closing tag with attributes is not a tag (§6.10)") + func badCloseTag() { + let k = kinds("") + #expect(!k.contains { if case .htmlTag = $0 { return true }; return false }) + } + + @Test("PI, declaration, and CDATA tokens become dimmed source") + func otherRawHTML() { + for text in ["x y", "x y", "x &<]]> y"] { + #expect(kinds(text).contains { if case .htmlTag = $0 { return true }; return false }, + "no htmlTag span in \(text)") + } + } +} + +@Suite("Rendering — HTML tags") +@MainActor +struct HTMLTagRenderingTests { + + private func attr(_ key: NSAttributedString.Key, at i: Int, in s: NSAttributedString) -> Any? { + guard i < s.length else { return nil } + return s.attribute(key, at: i, effectiveRange: nil) + } + + @Test("Unknown tag: name red, brackets dimmed") + func coloredSource() { + let editor = makeEditor() + let styled = editor.styleBlock("a b") + #expect(attr(.foregroundColor, at: 3, in: styled) as? NSColor == editor.theme.mathOperatorColor) + #expect(isDimmed(at: 2, in: styled)) // < + #expect(isDimmed(at: 7, in: styled)) // > + } + + @Test("Inactive pair: tags hidden, content rendered") + func pairInactive() { + let editor = makeEditor() + let styled = editor.styleBlock("hi", cursorPosition: nil) + #expect(isHidden(at: 0, in: styled)) // + #expect(isHidden(at: 5, in: styled)) // + #expect(attr(.underlineStyle, at: 3, in: styled) as? Int == NSUnderlineStyle.single.rawValue) + } + + @Test("Active pair: tags shown (not hidden), content not underlined") + func pairActive() { + let editor = makeEditor() + let styled = editor.styleBlock("hi", cursorPosition: 4) + #expect(!isHidden(at: 0, in: styled)) + #expect(attr(.underlineStyle, at: 3, in: styled) == nil) + } + + @Test("kbd, mark, sub, sup map to their attributes") + func attributeMap() { + let editor = makeEditor() + + let kbd = editor.styleBlock("K", cursorPosition: nil) + #expect((attr(.font, at: 5, in: kbd) as? NSFont) == editor.inlineCodeFont) + #expect(attr(.backgroundColor, at: 5, in: kbd) as? NSColor == editor.inlineCodeBackground) + + let mark = editor.styleBlock("M", cursorPosition: nil) + #expect(attr(.backgroundColor, at: 6, in: mark) != nil) + + let sub = editor.styleBlock("2", cursorPosition: nil) + #expect((attr(.baselineOffset, at: 5, in: sub) as? CGFloat ?? 0) < 0) + + let sup = editor.styleBlock("2", cursorPosition: nil) + #expect((attr(.baselineOffset, at: 5, in: sup) as? CGFloat ?? 0) > 0) + + let small = editor.styleBlock("fine", cursorPosition: nil) + let f = attr(.font, at: 8, in: small) as? NSFont + #expect(f != nil && f!.pointSize < editor.bodyFont.pointSize) + } + + @Test("HTML comment: dimmed in edit view, hidden in reading view") + func htmlComment() { + let editor = makeEditor() + let edit = editor.styleBlock("a b", cursorPosition: nil) + #expect(!isHidden(at: 5, in: edit)) // visible (dimmed) in edit mode + + let read = editor.styleBlock("a b", cursorPosition: nil, hideComments: true) + #expect(isHidden(at: 5, in: read)) // gone in reading view + } + + @Test("Inner markdown still styles: **b**") + func innerMarkdown() { + let editor = makeEditor() + let styled = editor.styleBlock("**b**", cursorPosition: nil) + // 'b' is at offset 5 (after and **). + #expect(attr(.underlineStyle, at: 5, in: styled) as? Int == NSUnderlineStyle.single.rawValue) + let f = attr(.font, at: 5, in: styled) as? NSFont + #expect(f != nil && NSFontManager.shared.traits(of: f!).contains(.boldFontMask)) + } + + @Test("An .htmlBlock renders as colored source: tags colored, markdown literal") + func htmlBlockSource() { + let editor = makeEditor() + let styled = editor.styleBlock("

        \n**text**\n
        ", cursorPosition: nil) + // "div" name colored like any recognized tag (offset 1). + #expect(attr(.foregroundColor, at: 1, in: styled) as? NSColor == editor.theme.mathOperatorColor) + // The `**` asterisks stay visible — raw HTML source, no markdown spans. + #expect(!isHidden(at: 6, in: styled)) + #expect(!isHidden(at: 7, in: styled)) + } +} + +@Suite("HTMLRenderer — whitelisted HTML passes through") +struct HTMLTagExportTests { + + private func html(_ md: String) -> String { HTMLRenderer.render(markdown: md) } + + @Test("Whitelisted tags render as real tags") + func passesThrough() { + #expect(html("x").contains("x")) + #expect(html("K").contains("K")) + #expect(html("m").contains("m")) + #expect(html("H2O").contains("2")) + #expect(html("x2").contains("2")) + #expect(html("fine").contains("fine")) + } + + @Test("Benign attributes kept, event handlers stripped") + func hardensAttributes() { + let out = html("hi") + #expect(out.contains("hi")) + #expect(!out.contains("onclick")) + } + + @Test("Inner markdown still renders inside a passed tag") + func innerMarkdown() { + #expect(html("**b**").contains("b")) + } + + @Test("Non-whitelisted inline tag passes through raw (GFM)") + func unknownPassesThrough() { + let out = html("a x b") + #expect(out.contains("x")) + } + + @Test("A nested script inside a passed tag is tagfiltered") + func nestedScriptTagfiltered() { + let out = html("") + #expect(out.contains("")) + #expect(!out.contains(" String { + let theme = EditorTheme(fontName: "Iowan Old Style", fontSize: 16, + linkBlueHex: "#3366E6", codeHex: "#8A2425", + lineSpacing: 4, paragraphSpacingBefore: 2) + return HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: dark) + } + + @Test("Derives custom properties from the theme") + func vars() { + let out = css(dark: false) + #expect(out.contains("--accent: #3366E6;")) + #expect(out.contains("--code: #8A2425;")) + #expect(out.contains("--body-size: 16px;")) + // 1.2 + 4/16 = 1.45 + #expect(out.contains("--line-height: 1.45;")) + // Multi-word family is quoted with a fallback stack. + #expect(out.contains("\"Iowan Old Style\"")) + } + + @Test("Reading column max-width matches the editor's physical cap; uncapped by default") + func pageMaxWidth() { + let theme = EditorTheme(fontName: "Iowan Old Style", fontSize: 16, + linkBlueHex: "#3366E6", codeHex: "#8A2425", + lineSpacing: 4, paragraphSpacingBefore: 2) + let capped = HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: false, + maxContentWidthPoints: 340) + #expect(capped.contains("--page-max-width: 340px;")) + + let uncapped = HTMLTheme.css(theme, callouts: Callout.defaultStyles, dark: false) + #expect(uncapped.contains("--page-max-width: none;")) + } + + @Test("Emits per-callout-type colors with derived rgba background") + func calloutVars() { + let out = css(dark: false) + #expect(out.contains(".callout-note {")) + #expect(out.contains("--c-accent: #086DDD;")) + // note's background is derived from the accent at backgroundAlpha 0.08. + #expect(out.contains("rgba(8, 109, 221, 0.08)")) + } + + @Test("Dark appearance picks dark color variants") + func darkVariant() { + // 'caution' has darkColorHex #F85149. + #expect(css(dark: true).contains("--c-accent: #F85149;")) + #expect(css(dark: false).contains("--c-accent: #CF222E;")) + #expect(css(dark: true).contains("--bg: #1e1e1e;")) + } + + @Test("Wide tables scroll horizontally instead of wrapping cell text") + func tableScrolls() { + let out = css(dark: false) + #expect(out.contains(".table-wrap { overflow-x: auto; margin: 1em 0; }")) + #expect(!out.contains("overflow-wrap")) + } + + @Test("Emits code token colors from the shared palette, per appearance") + func codeTokenColors() { + let light = css(dark: false) + #expect(light.contains("pre code .tok-keyword { color: \(CodeSyntaxPalette.hex(.keyword, dark: false)); }")) + #expect(light.contains("pre code { color: \(CodeSyntaxPalette.hex(nil, dark: false)); }")) + let dark = css(dark: true) + #expect(dark.contains("pre code .tok-string { color: \(CodeSyntaxPalette.hex(.string, dark: true)); }")) + // The palettes differ between appearances. + #expect(CodeSyntaxPalette.hex(.keyword, dark: false) != CodeSyntaxPalette.hex(.keyword, dark: true)) + } +} diff --git a/Tests/EdmundTests/HeightStabilityTests.swift b/Tests/EdmundTests/HeightStabilityTests.swift new file mode 100644 index 0000000..ac5e120 --- /dev/null +++ b/Tests/EdmundTests/HeightStabilityTests.swift @@ -0,0 +1,181 @@ +import Testing +import AppKit +@testable import EdmundCore + +/// Activating a block (moving the caret into it) reveals its raw markdown +/// markers. For most block kinds the rendered and active forms must occupy the +/// same vertical space — otherwise content below shifts the moment you click in, +/// which is the scroll "lurch" the editor has been chasing. This suite pins that +/// invariant per block kind by measuring a reference line BELOW the block: if the +/// block's height changes between rendered and active, the reference line moves. +/// +/// Known/accepted exceptions (tracked in todo.md): thematic break and heading +/// legitimately change height (different font size when the `#`s appear). +@Suite("Height stability: rendered vs active") +@MainActor +struct HeightStabilityTests { + + /// Loads `block` followed by a reference paragraph, measures the reference + /// line's Y rendered (caret elsewhere) vs active (caret in the block), and + /// returns how far it moved. `< ~1pt` means no height change. + private func referenceShift(forBlock block: String, + caretMarker: String) -> CGFloat { + let editor = makeEditor() + // A leading paragraph keeps the measured block off the document origin, + // and the reference paragraph sits below it. + let doc = "lead paragraph\n\n\(block)\n\nREFERENCE LINE\n\n" + editor.loadContent(doc) + ensureFullLayout(editor) + + let ns = editor.rawSource as NSString + let refOffset = ns.range(of: "REFERENCE LINE").location + #expect(refOffset != NSNotFound) + + // Rendered: caret in the lead paragraph, block inactive. + let leadOffset = ns.range(of: "lead paragraph").location + editor.setSelectedRange(NSRange(location: leadOffset, length: 0)) + editor.recomposeIncremental(cursorInRaw: leadOffset) + ensureFullLayout(editor) + guard let yRendered = editor.lineRect(forCharacterAt: refOffset)?.minY else { + Issue.record("no rendered line rect"); return .greatestFiniteMagnitude + } + + // Active: caret inside the block. + let caretOffset = ns.range(of: caretMarker).location + #expect(caretOffset != NSNotFound) + editor.setSelectedRange(NSRange(location: caretOffset, length: 0)) + editor.recomposeIncremental(cursorInRaw: caretOffset) + ensureFullLayout(editor) + guard let yActive = editor.lineRect(forCharacterAt: refOffset)?.minY else { + Issue.record("no active line rect"); return .greatestFiniteMagnitude + } + + return abs(yActive - yRendered) + } + + private let tolerance: CGFloat = 1.0 + + @Test("Inline bold has no height change") + func inlineBold() { + #expect(referenceShift(forBlock: "A line with **bold** word.", + caretMarker: "bold") < tolerance) + } + + @Test("Inline italic has no height change") + func inlineItalic() { + #expect(referenceShift(forBlock: "A line with *italic* word.", + caretMarker: "italic") < tolerance) + } + + @Test("Inline strikethrough has no height change") + func inlineStrikethrough() { + #expect(referenceShift(forBlock: "A line with ~~struck~~ word.", + caretMarker: "struck") < tolerance) + } + + @Test("Inline highlight has no height change") + func inlineHighlight() { + #expect(referenceShift(forBlock: "A line with ==marked== word.", + caretMarker: "marked") < tolerance) + } + + @Test("Inline code has no height change") + func inlineCode() { + #expect(referenceShift(forBlock: "A line with `code` word.", + caretMarker: "code") < tolerance) + } + + @Test("Inline link has no height change") + func inlineLink() { + #expect(referenceShift(forBlock: "A line with [text](https://e.com) word.", + caretMarker: "text") < tolerance) + } + + @Test("Bulleted list item has no height change") + func bulletedList() { + #expect(referenceShift(forBlock: "- bullet item", + caretMarker: "bullet item") < tolerance) + } + + @Test("Numbered list item has no height change") + func numberedList() { + #expect(referenceShift(forBlock: "1. numbered item", + caretMarker: "numbered item") < tolerance) + } + + @Test("Checklist item has no height change") + func checklist() { + #expect(referenceShift(forBlock: "- [ ] task item", + caretMarker: "task item") < tolerance) + } + + @Test("Blockquote has no height change") + func blockquote() { + #expect(referenceShift(forBlock: "> quoted text", + caretMarker: "quoted text") < tolerance) + } + + // MARK: Wrapping cases + // + // The todo's real concern: revealing markers widens the active form, which + // can tip a long line onto an extra wrapped line. These use content long + // enough to wrap several times in the ~500pt container, with slack so the + // marker width doesn't sit on a wrap boundary — the wrapped height must + // still match exactly. + + @Test("Wrapping bold paragraph has no height change") + func wrappingBold() { + let long = "Alpha bravo charlie delta echo foxtrot golf hotel india " + + "**juliett** kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Wrapping list item has no height change") + func wrappingListItem() { + let long = "- Alpha bravo charlie delta echo foxtrot golf hotel india " + + "juliett kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu wraps here." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Wrapping checklist item has no height change") + func wrappingChecklist() { + let long = "- [ ] Alpha bravo charlie delta echo foxtrot golf hotel india " + + "juliett kilo lima mike november oscar papa quebec romeo sierra " + + "tango uniform victor whiskey xray yankee zulu wraps here." + #expect(referenceShift(forBlock: long, caretMarker: "juliett") < tolerance) + } + + @Test("Heading has no height change") + func heading() { + // An existing heading keeps its size when activated — the `#` markers + // appear without changing line height. (The todo's "different font size" + // note is about the paragraph↔heading content transition of typing `#`, + // a different scenario than this rendered↔active toggle.) + #expect(referenceShift(forBlock: "# Big Heading", + caretMarker: "Big Heading") < tolerance) + } + + @Test("Thematic break has no height change") + func thematicBreak() { + // The active form keeps the rendered rule's forced line height and + // breathing space, so clicking into the `---` no longer shifts content. + #expect(referenceShift(forBlock: "---", caretMarker: "---") < tolerance) + } + + // MARK: Discriminator — keeps the all-green suite honest + // + // todo.md excludes math from the zero-change requirement ("Inline items + // (except math)"): display math renders as an image when inactive and as + // raw multi-line LaTeX when active, so it legitimately changes height. + // Asserting it DOES shift proves the harness can still detect a real height + // change — otherwise a `referenceShift` that always returned 0 would pass + // every test above vacuously. + + @Test("Display math changes height (excluded from zero-change)") + func displayMathChangesHeight() { + let shift = referenceShift(forBlock: "$$\ne = mc^2\n$$", caretMarker: "mc^2") + #expect(shift > tolerance, "expected a height change, got \(shift)") + } +} diff --git a/Tests/EdmundTests/ImageRenderingTests.swift b/Tests/EdmundTests/ImageRenderingTests.swift new file mode 100644 index 0000000..1e0f036 --- /dev/null +++ b/Tests/EdmundTests/ImageRenderingTests.swift @@ -0,0 +1,173 @@ +import Testing +import AppKit +@testable import EdmundCore + +@Suite("Image rendering") +@MainActor +struct ImageRenderingTests { + + /// Writes a tiny solid PNG to a temp file and returns its absolute path + /// (absolute so resolution doesn't need a document directory). + private func tempPNGPath() -> String { + let size = NSSize(width: 24, height: 16) + let img = NSImage(size: size) + img.lockFocus() + NSColor.systemBlue.setFill() + NSRect(origin: .zero, size: size).fill() + img.unlockFocus() + let rep = NSBitmapImageRep(data: img.tiffRepresentation!)! + let data = rep.representation(using: .png, properties: [:])! + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("md-img-test-\(UUID().uuidString).png") + try! data.write(to: url) + return url.path + } + + @Test("Image syntax parses to an image span carrying the destination") + func parsesImage() { + let dests = SyntaxHighlighter.parse("![alt](pic.png)").compactMap { s -> String? in + if case .image(let d, _, _) = s.kind { return d }; return nil + } + #expect(dests == ["pic.png"]) + } + + @Test("Rendered image draws an overlay and hides the raw markdown") + func rendersOverlay() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](\(tempPNGPath()))", cursorPosition: nil) + // Overlay anchored on the leading `!`. + let overlay = styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect(overlay != nil) + // The rest of the markdown is hidden (near-zero font). + let f = styled.attribute(.font, at: 5, effectiveRange: nil) as? NSFont + #expect((f?.pointSize ?? 99) < 1.0) + } + + @Test("Active image shows the raw markdown (no overlay)") + func activeShowsRaw() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](\(tempPNGPath()))", cursorPosition: 3) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) == nil) + } + + @Test("HTML renders an overlay and hides the raw tag") + func htmlImgRendersOverlay() { + let editor = makeEditor() + let styled = editor.styleBlock("", cursorPosition: nil) + // Overlay anchored on the leading `<`. + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + let f = styled.attribute(.font, at: 5, effectiveRange: nil) as? NSFont + #expect((f?.pointSize ?? 99) < 1.0) + } + + @Test("Active HTML shows the raw tag (no overlay)") + func htmlImgActiveShowsRaw() { + let editor = makeEditor() + let styled = editor.styleBlock("", cursorPosition: 3) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) == nil) + } + + @Test("Declared width/height size the overlay exactly; one alone scales proportionally") + func declaredDimensions() { + let editor = makeEditor() + let path = tempPNGPath() // natural 24×16 + + let exact = editor.imageOverlay(destination: path, width: 12, height: 10) + #expect(exact?.bounds.size == CGSize(width: 12, height: 10)) + + let widthOnly = editor.imageOverlay(destination: path, width: 12) + #expect(widthOnly?.bounds.size == CGSize(width: 12, height: 8)) + + let heightOnly = editor.imageOverlay(destination: path, height: 8) + #expect(heightOnly?.bounds.size == CGSize(width: 12, height: 8)) + + let natural = editor.imageOverlay(destination: path) + #expect(natural?.bounds.size == CGSize(width: 24, height: 16)) + } + + @Test("Missing local image shows a blocked-image placeholder overlay, not just alt text") + func fallbackShowsPlaceholder() { + let editor = makeEditor() + let styled = editor.styleBlock("![alt](/no/such/file.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.notFound) = editor.imageDisplay(destination: "/no/such/file.png") else { + Issue.record("expected .blocked(.notFound)"); return + } + } + + @Test("A path that resolves but isn't image data is classified as 'not an image'") + func notAnImage() { + let editor = makeEditor() + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("md-not-image-\(UUID().uuidString).png") + try! Data("not a png".utf8).write(to: url) + guard case .blocked(.notAnImage) = editor.imageDisplay(destination: url.path) else { + Issue.record("expected .blocked(.notAnImage)"); return + } + } + + @Test("Plain-http image always shows the placeholder, even with remote images allowed") + func httpImageAlwaysBlocked() { + let editor = makeEditor() + editor.allowRemoteImages = true + let styled = editor.styleBlock("![alt](http://example.com/x.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.httpUnsupported) = editor.imageDisplay(destination: "http://example.com/x.png") else { + Issue.record("expected .blocked(.httpUnsupported)"); return + } + } + + @Test("Https image shows the placeholder while remote images are disallowed") + func httpsImageBlockedByDefault() { + let editor = makeEditor() + editor.allowRemoteImages = false + let styled = editor.styleBlock("![alt](https://example.com/x.png)", cursorPosition: nil) + #expect(styled.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) != nil) + guard case .blocked(.blockedBySetting) = editor.imageDisplay(destination: "https://example.com/x.png") else { + Issue.record("expected .blocked(.blockedBySetting)"); return + } + } + + @Test("Https image is pending (no placeholder yet) while the fetch is in flight") + func httpsImagePendingWhileFetching() { + let editor = makeEditor() + editor.allowRemoteImages = true + // `.invalid` is RFC 2606-reserved (never resolves) — the request + // fails async, off the assertion below; only the synchronous + // first-call return value (.pending, before any response) matters. + let dest = "https://example.invalid/pending-\(UUID().uuidString).png" + guard case .pending = editor.imageDisplay(destination: dest) else { + Issue.record("expected .pending"); return + } + } + + @Test("Narrowing the max-content-width column shrinks an already-rendered image") + func shrinksOnColumnNarrow() { + let editor = EditorTextView.makeTextKit2( + frame: NSRect(x: 0, y: 0, width: 800, height: 300), + containerSize: NSSize(width: 800, height: CGFloat.greatestFiniteMagnitude)) + let suite = "EdmundTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + editor.themeDefaults = defaults + editor.theme = .load(from: defaults) + + let content = "![alt](\(tempPNGPath()))\n\nsecond paragraph" // image is 24x16, well under 800pt + editor.loadContent(content) + // `loadContent` puts the cursor at offset 0, inside the image block, + // which renders raw markdown (no overlay) while active. Move the + // cursor to the second block so the image renders as an overlay. + editor.recomposeIncremental(cursorInRaw: content.count) + let before = editor.textStorage?.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect(before?.bounds.width == 24) + + // Cap the column narrower than the image's natural width. + editor.maxContentWidthPoints = 22 + + let after = editor.textStorage?.attribute(.fragmentOverlay, at: 0, effectiveRange: nil) as? FragmentOverlay + #expect((after?.bounds.width ?? 9999) <= editor.availableContentWidth + 0.01) + #expect(after!.bounds.width < before!.bounds.width) + // Aspect ratio preserved (24x16 -> half width -> half height). + #expect(abs(after!.bounds.height / after!.bounds.width - 16.0 / 24.0) < 0.01) + } +} diff --git a/Tests/EdmundTests/IncrementalParseFuzzTests.swift b/Tests/EdmundTests/IncrementalParseFuzzTests.swift new file mode 100644 index 0000000..de2aef3 --- /dev/null +++ b/Tests/EdmundTests/IncrementalParseFuzzTests.swift @@ -0,0 +1,68 @@ +import Testing +import AppKit +@testable import EdmundCore + +/// Seeded random edits through the full editor pipeline. In DEBUG builds +/// every edit also runs the incremental-vs-full parse assertion inside +/// syncRawSourceFromDisplay, so this doubles as the incremental parser's +/// fuzz net; the oracle assertion here checks the styled storage too. +@Suite("Incremental parse — fuzz") +struct IncrementalParseFuzzTests { + + @Test("Random edits keep blocks and styling equivalent to a full parse", + arguments: [UInt64(1), 7, 1234, 0xBEEF]) + @MainActor func randomEdits(seed: UInt64) { + var rng = SeededGenerator(seed: seed) + let editor = makeEditor() + editor.loadContent(makeLargeMarkdown(approximateBytes: 6_000, seed: seed)) + + // Fragments biased toward structure-changing characters. + let fragments = ["\n", "\n\n", "```", "```\n", "$$", "|", ">", "> ", + "# ", "- ", "---\n", "x", "hello **world**", + "| a | b |", "[!note]", " - indented\n", "\t- tabbed\n", + "===\n", "===", " code\n", "\tcode\n", + "
        ", "
        \n", "", "\n", "
        ", "", "\n",
        +                         // Blockquote lazy continuation: bare lines after `>`.
        +                         "> q\nlazy", "> a\nb\n> c", "> a\n\nb", "lazy\n"]
        +
        +        for _ in 0..<120 {
        +            let ns = editor.rawSource as NSString
        +            let len = ns.length
        +            switch Int.random(in: 0..<10, using: &rng) {
        +            case 0..<5:   // insert a fragment somewhere (edges included)
        +                let loc = Int.random(in: 0...len, using: &rng)
        +                let frag = fragments.randomElement(using: &rng)!
        +                editor.setSelectedRange(NSRange(location: loc, length: 0))
        +                editor.insertText(frag, replacementRange: NSRange(location: loc, length: 0))
        +            case 5..<8 where len > 0:   // delete a span (possibly multi-block)
        +                let loc = Int.random(in: 0.. 0:       // replace a span with a fragment
        +                let loc = Int.random(in: 0..