docs(editor): document hybrid editing investigation

This commit is contained in:
Feror 2026-05-29 18:01:29 +02:00
parent 9161b42d9d
commit 527a99c2e7

View file

@ -0,0 +1,243 @@
# 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.