243 lines
9 KiB
Markdown
243 lines
9 KiB
Markdown
# Editor Investigation
|
|
|
|
Date: 2026-05
|
|
|
|
Status: Milestone 1 validation
|
|
|
|
Related decisions:
|
|
|
|
- D-008: Hybrid Markdown Editing Is a Core Feature
|
|
- D-009: The Editor Is the Highest-Priority System
|
|
- D-010: Git Access Must Be Abstracted
|
|
- D-012: Platform Focus
|
|
- D-013: Editor Technology Selection
|
|
|
|
## Question
|
|
|
|
Can Sapling support a high-quality hybrid Markdown editing experience where the active line shows Markdown source and inactive lines show rendered Markdown?
|
|
|
|
## Prototype Summary
|
|
|
|
The Milestone 1 prototype uses a SwiftUI application shell with a native text view bridge:
|
|
|
|
- macOS: `NSTextView` through `NSViewRepresentable`
|
|
- iOS path: `UITextView` through `UIViewRepresentable`
|
|
- shared editor state in `SaplingEditor`
|
|
- document loading and saving through `HybridMarkdownEditorViewModel`
|
|
- line tracking through `EditorState`, `EditorLine`, and `EditorSelection`
|
|
- in-place line styling through `NSTextStorage`
|
|
|
|
The prototype validates that native text views can support the first version of Sapling's editor without coupling the app directly to AppKit.
|
|
|
|
## Current Architecture
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
App["SwiftUI app shell"] --> Editor["HybridMarkdownEditor"]
|
|
Editor --> Bridge["Native text view bridge"]
|
|
Bridge --> Mac["NSTextView on macOS"]
|
|
Bridge --> IOS["UITextView on iOS"]
|
|
Editor --> Coordinator["EditorCoordinator"]
|
|
Coordinator --> State["EditorState"]
|
|
State --> Document["EditorDocument"]
|
|
State --> Lines["EditorLine array"]
|
|
State --> Selection["EditorSelection"]
|
|
Bridge --> TextKit["Text storage and selection APIs"]
|
|
```
|
|
|
|
The important boundary is the Sapling editor abstraction. Application code talks to `HybridMarkdownEditorViewModel` and editor state, not to `NSTextView`.
|
|
|
|
## Architectures Explored
|
|
|
|
### SwiftUI TextEditor
|
|
|
|
SwiftUI `TextEditor` was already present in the early prototype and worked for simple active-line editing.
|
|
|
|
Benefits:
|
|
|
|
- Minimal SwiftUI integration.
|
|
- Fast to prototype.
|
|
- Good enough for plain text entry.
|
|
|
|
Limitations:
|
|
|
|
- Does not expose reliable low-level selection and cursor APIs.
|
|
- Does not provide direct TextKit access.
|
|
- Makes line-level source/render switching difficult.
|
|
- Offers limited control over attributed rendering and layout.
|
|
|
|
Conclusion: `TextEditor` is not suitable as Sapling's primary editor implementation.
|
|
|
|
### NSTextView With Attributed Styling
|
|
|
|
The implemented spike keeps Markdown source as the actual text buffer and applies visual styling to inactive lines with `NSTextStorage`.
|
|
|
|
Benefits:
|
|
|
|
- Native cursor movement remains intact.
|
|
- Native selection ranges remain tied to the real source buffer.
|
|
- Undo, find, spell checking, and keyboard navigation come from AppKit.
|
|
- Active line detection can be derived from selection location.
|
|
- Inactive lines can be styled differently from the active source line.
|
|
|
|
Limitations:
|
|
|
|
- Styling alone cannot remove Markdown delimiters without affecting layout.
|
|
- It cannot render block elements such as images, tables, or Mermaid diagrams as real embedded views.
|
|
- Changing font sizes per line affects line height and may create visual jumps.
|
|
- Reapplying attributes on every selection change must be optimized for large documents.
|
|
|
|
Conclusion: This is sufficient for Milestone 1 and likely sufficient for an early Milestone 2 implementation of headings, emphasis, links, lists, and task lists.
|
|
|
|
### Line Replacement
|
|
|
|
Line replacement would swap inactive Markdown source with rendered text while preserving the source elsewhere.
|
|
|
|
Benefits:
|
|
|
|
- More closely matches the desired hybrid model.
|
|
- Can hide Markdown delimiters from inactive lines.
|
|
|
|
Risks:
|
|
|
|
- Cursor offsets no longer map directly to source offsets.
|
|
- Selection crossing active and inactive lines becomes complex.
|
|
- Undo behavior may become fragile.
|
|
- TextKit may fight source/render buffer divergence.
|
|
|
|
Conclusion: Worth exploring after the attributed-string path, but risky as the primary approach.
|
|
|
|
### Overlay Rendering
|
|
|
|
Overlay rendering would keep source text in the editor but draw rendered content above inactive lines.
|
|
|
|
Benefits:
|
|
|
|
- Source buffer remains canonical.
|
|
- Rendered views could support richer blocks.
|
|
- Cursor math can still use the real text buffer.
|
|
|
|
Risks:
|
|
|
|
- Requires precise layout synchronization with TextKit.
|
|
- Hit testing and selection feedback are difficult.
|
|
- Accessibility must be designed carefully.
|
|
|
|
Conclusion: Promising for richer Milestone 3 rendering, but heavier than needed for Milestone 2.
|
|
|
|
### Mixed Text and Render Layers
|
|
|
|
A custom layout could combine text editing lines with rendered SwiftUI/AppKit views.
|
|
|
|
Benefits:
|
|
|
|
- Maximum control over rendering.
|
|
- Can support images, diagrams, tables, and custom blocks.
|
|
|
|
Risks:
|
|
|
|
- Requires building editor infrastructure Sapling does not yet have.
|
|
- Cursor movement, selection, IME, undo, accessibility, and text input become product-critical systems.
|
|
|
|
Conclusion: This may be necessary in the long term, but it should not be the first Milestone 2 implementation.
|
|
|
|
## TextKit Findings
|
|
|
|
`NSTextView` gives Sapling the hooks needed for the prototype:
|
|
|
|
- `selectedRange()` gives the cursor and selection range.
|
|
- `NSTextStorage` allows line-level attributes without replacing source text.
|
|
- `NSTextViewDelegate` reports text and selection changes.
|
|
- Native editing behavior remains available while the app tracks editor state.
|
|
|
|
The key finding is that Sapling can preserve a single source buffer and still present different active and inactive line states. This reduces risk compared with maintaining separate source and render buffers.
|
|
|
|
## AttributedString and NSAttributedString
|
|
|
|
Swift `AttributedString` is useful for renderer-facing APIs and SwiftUI previews.
|
|
|
|
`NSAttributedString` and `NSTextStorage` are still required for the actual native editor bridge because the text view operates on TextKit types.
|
|
|
|
Recommended split:
|
|
|
|
- Use `AttributedString` in cross-platform renderer interfaces.
|
|
- Use `NSAttributedString` and `NSTextStorage` inside platform adapters.
|
|
- Keep conversion and styling code contained in `SaplingEditor`.
|
|
|
|
## Cursor Challenges
|
|
|
|
The prototype validates basic active-line detection:
|
|
|
|
- The selected range location maps to a line index.
|
|
- The active line switches as the cursor moves.
|
|
- The active line can be styled as source while other lines are styled as rendered.
|
|
|
|
Open challenges:
|
|
|
|
- Multi-line selection across source and rendered lines.
|
|
- Selection after visually hidden or replaced Markdown delimiters.
|
|
- Cursor placement around links, emphasis markers, and task markers.
|
|
- IME composition and marked text handling.
|
|
- Very large documents where full restyling on each selection change is too expensive.
|
|
|
|
Cursor correctness is the main reason to keep the canonical source text inside `NSTextView` for now.
|
|
|
|
## Rendering Challenges
|
|
|
|
The current spike uses line-level styling, not full Markdown rendering.
|
|
|
|
Validated:
|
|
|
|
- Headings can be styled by line.
|
|
- Emphasis and links can be styled inline.
|
|
- Task markers can be visually deemphasized.
|
|
- Active source line can remain monospaced and visibly distinct.
|
|
|
|
Not validated:
|
|
|
|
- True delimiter removal without cursor side effects.
|
|
- Embedded rendered blocks inside the text layout.
|
|
- Images, tables, code block widgets, Mermaid, LaTeX, and attachments.
|
|
- Precise layout matching between rendered inactive lines and raw active lines.
|
|
|
|
## Hybrid Editing Feasibility
|
|
|
|
Hybrid editing is realistically achievable for Milestone 2 if the first implementation is scoped to line-level attributed styling.
|
|
|
|
The safest path is:
|
|
|
|
1. Keep the Markdown source as the only editable text buffer.
|
|
2. Detect the active line from the native selection range.
|
|
3. Apply source styling to the active line.
|
|
4. Apply rendered-style attributes to inactive lines.
|
|
5. Delay true line replacement and overlay rendering until selection behavior is better understood.
|
|
|
|
This will not produce perfect rendered Markdown, but it should validate the writing feel and cursor behavior before Sapling invests in a custom editor engine.
|
|
|
|
## Recommended Path Forward
|
|
|
|
Use `NSTextView` for Milestone 2 on macOS and keep the iOS `UITextView` adapter available behind the same editor abstraction.
|
|
|
|
Immediate next steps:
|
|
|
|
- Move Markdown styling into a dedicated renderer adapter.
|
|
- Add incremental line invalidation instead of restyling the whole buffer.
|
|
- Add tests for line ranges, trailing newlines, and multi-line selections.
|
|
- Manually test long documents, undo/redo, IME input, and keyboard navigation.
|
|
- Explore delimiter hiding with temporary attributes only after selection mapping is robust.
|
|
|
|
Future investigation:
|
|
|
|
- Use TextKit layout fragments or overlay views for rich block rendering.
|
|
- Prototype image and code block rendering outside the text buffer.
|
|
- Reevaluate whether a custom editor engine is required after Milestone 2.
|
|
|
|
## Decision
|
|
|
|
`NSTextView` is sufficient for Milestone 2 validation.
|
|
|
|
Hybrid editing is achievable, but the near-term implementation should be attribute-based rather than replacement-based.
|
|
|
|
A future custom editor engine may be required for full rich block rendering, but Sapling should not start there.
|
|
|
|
The architecture should continue with a SwiftUI shell, a Sapling editor abstraction, and native platform text views hidden behind replaceable adapters.
|