docs/internal/typora-seamless-canvas-plan.md
Phase 2 COMPLETE: Core features implemented, usability-tested, and working.
ConcealManager (Rust) with marker-based position trackingaddConceal(bufferId, namespace, start, end, replacement?) plugin APIclearConcealNamespace(bufferId, namespace) plugin API**bold**, *italic*, ***bold-italic***, `code`, ~~strikethrough~~[text](url) → styled "text" with blue underline, cursor-aware reveal| → │, separator rows → ├──┼──┤, cursor-aware per-row revealViewTransformRequest hook (multi-cursor aware)| Test | Result | Notes |
|---|---|---|
| T1: Compose mode toggle | PASS | Line numbers hide, text reflows, status bar confirms |
| T2: Emphasis concealment | PASS | All 5 types (bold, italic, code, strikethrough, bold-italic) |
| T3: Emphasis cursor reveal | PASS | Markers appear when cursor enters span, hide when leaving |
| T4: Link concealment | PASS | [text](url) → "text" styled blue+underline; ![img]() unaffected |
| T5: Link cursor reveal | PASS | Full syntax revealed when cursor enters link |
| T6: Table grid rendering | PASS | Box-drawing characters, proper corner pieces |
| T7: Table cursor reveal | PASS | Row with cursor shows raw pipes, others show grid |
| T8: Visual line movement | PASS | Down moves through visual lines (Col 1→79→160→236→next line) |
| T9: Multi-cursor conceal | SKIPPED | Multi-cursor add works via Ctrl+Alt+↑/↓ but not manually tested |
| T10: Wrapping/hanging indent | PASS | Lists get hanging indent, code blocks don't wrap |
| T11: Edit emphasis text | PASS | Typed inside bold span, markers revealed, edit preserved |
| T12: Edit link text | PASS | Edited link text, [...]() syntax preserved |
| T13: Edit table cell | PASS | Edited cell, raw pipes revealed on cursor row |
| T14: Edit wrapped paragraph | PASS | Text inserted, wrapping re-adjusted correctly |
The flicker has two related causes stemming from the async plugin architecture.
There are two separate command processing paths in the event loop:
Path A: Between frames (main.rs:2726 → process_async_messages())
process_async_messages() → process_plugin_commands()
→ plugin_manager.process_commands() // try_recv() from channel
→ handle_plugin_command(cmd) // applies AddConceal, SubmitViewTransform, etc.
→ sets plugin_render_requested = true
→ returns needs_render = true
Path B: During render (render.rs:251-325)
render() {
run_hook("view_transform_request", base_tokens) // NON-BLOCKING send to plugin thread
process_commands() // try_recv() — race condition!
render_content() // uses whatever state exists
}
The view_transform_request hook and process_commands() happen in the same render() call, but there is a race:
run_hook() sends the hook request to the plugin thread via an mpsc channel (line 512 in thread.rs) — non-blocking, returns immediatelyprocess_commands() calls try_recv() — if the JS hasn't finished yet, the queue is emptyrender_content() proceeds with stale state (old or missing view transform)Result: The current frame renders with stale transforms from the previous frame. The new transforms arrive and are processed in the next process_async_messages() call, causing plugin_render_requested = true and triggering a second render — but there's a visible flash of the stale frame.
Scroll glitch: When scrolling changes the viewport, the current frame's base tokens are NEW (for the new viewport position), but the view transform is from the PREVIOUS viewport. The mismatch shows raw markdown briefly until the plugin re-transforms for the new viewport.
Typing flicker: When the user types, the buffer content changes. The current frame's base tokens reflect the edit, but the conceals/overlays are from BEFORE the edit (stale byte offsets). This causes momentary misalignment — markers appear at wrong positions for one frame.
Frame N: viewport scrolls to new position
→ base_tokens = tokens for new viewport
→ run_hook("view_transform_request", new_tokens) // sent to plugin thread
→ process_commands() // EMPTY — plugin hasn't responded yet
→ render_content() uses STALE view_transform from Frame N-1
→ FLASH: wrong/raw content visible
Frame N+1: process_async_messages() picks up plugin's response
→ SubmitViewTransform, AddConceal, AddOverlay applied
→ plugin_render_requested = true → triggers render
→ render_content() uses CORRECT view_transform
→ Correct content now visible
The total flicker duration is ~16ms (1 frame at 60fps) plus plugin thread latency.
Make run_hook("view_transform_request") blocking — wait for the JS handler to complete and its commands to arrive before proceeding with render_content().
// In render.rs, replace non-blocking run_hook with:
self.plugin_manager.run_hook_sync("view_transform_request", args, timeout_ms: 50);
let commands = self.plugin_manager.process_commands();
// Now render_content() has up-to-date state
Pros: Eliminates race completely. Simple change. Cons: Blocks the render thread. If JS handler takes >16ms, frame rate drops. Risk of deadlock if plugin calls back synchronously.
Instead of rendering with base tokens when no transform is available for the current viewport, re-use the previous frame's transform. Only update the transform when a new one arrives.
The current code already does this partially — view_state.view_transform persists across frames. The problem is that render_content() rebuilds base tokens for the new viewport but uses the old transform (which has tokens for the old viewport).
Enhancement: When viewport changes and no new transform has arrived yet, skip rendering the content area entirely (keep the old frame's content) until the plugin responds. Or render a "loading" indicator.
// In build_view_data(), if viewport changed but transform is stale:
if transform_viewport_start != viewport.top_byte {
return previous_frame_view_data.clone(); // reuse old frame
}
Pros: No visible glitch — old content stays until new content ready. Cons: Slightly delayed viewport updates. Need to store viewport info with transforms.
Store the viewport that each transform was built for. In render_content(), if the stored transform's viewport doesn't match the current viewport, hold the old visual output until a matching transform arrives.
struct SplitViewState {
view_transform: Option<ViewTransformPayload>,
transform_viewport_start: usize, // NEW: which viewport this transform was built for
// ...
}
In build_view_data():
let transform = if let Some(vt) = view_transform {
if vt.viewport_start == viewport.top_byte {
Some(vt) // Transform matches current viewport — use it
} else {
None // Stale transform — fall through to base tokens + suppress output
}
} else {
None
};
Pros: Clean. Never shows stale content. Cons: Brief content freeze during scroll (might feel laggy).
Run the markdown compose JS handler on the main thread during render, rather than on the async plugin thread. This would require either:
view_transform_requestPros: Zero latency, zero flicker. Cons: Significant architectural change. Blocks render thread on JS execution.
When a state change occurs (scroll, edit, cursor move) that would invalidate the current transform/conceal state, set a flag that suppresses rendering for one frame. The next frame picks up the plugin's response and renders correctly.
// In event handling (scroll/edit/cursor):
self.view_transform_invalidated = true;
// In render():
if self.view_transform_invalidated {
// Don't draw content area — keep previous frame
// Just process the hook + commands
self.view_transform_invalidated = false;
return;
}
Pros: Simple. No visible glitch. Cons: Adds 16ms latency to all user interactions in compose mode. May feel slightly less responsive.
The typing flicker specifically happens because clearConcealNamespace + addConceal creates a window where conceals are cleared but not yet re-added. Instead:
The conceal ranges use MarkerId for start/end positions, which auto-adjust with buffer edits. The real problem is that the plugin calls clearConcealNamespace followed by addConceal — the clear removes all conceals, and the adds haven't arrived yet.
Fix: Make clearConcealNamespace lazy — don't actually clear until the namespace gets new entries, then atomically replace.
// In ConcealManager:
fn clear_namespace_deferred(&mut self, namespace: OverlayNamespace) {
self.pending_clears.insert(namespace);
}
fn add(&mut self, range: ConcealRange) {
// If this namespace has a pending clear, execute it now (just before adding)
if self.pending_clears.remove(&range.namespace) {
self.ranges.retain(|r| r.namespace != range.namespace);
}
self.ranges.push(range);
}
Pros: Targeted fix for the typing flicker. Minimal change. No latency added. Cons: Only fixes the conceal flicker, not the scroll glitch.
Combine Alternative B/C (hold old content during scroll) with Alternative F (atomic conceal swap for typing). This would:
Clickable links via OSC codes: Link text should use terminal OSC 8 hyperlink escape codes to make them natively clickable in the terminal, not just styled with blue+underline.
Header # concealment: Hide # prefix markers, show styled heading text.
Task list checkbox interaction: - [ ] → ☐, - [x] → ☑, click to toggle.
Code block fence concealment: Hide ``` fences, show language label.
Image link rendering:  → styled placeholder with alt text.
The plugin system provides:
transformViewTokens: Receives base tokens, returns modified tokens. Can inject virtual tokens (source_offset=null) and omit source tokens.getCursorPosition(), cursor_moved hook.mouse_click hook with content coordinates.Key constraint: tokens with source_offset=null are not navigable — cursor backtracks to the nearest Some(byte_offset). This means you can hide source tokens by omitting them, but you cannot replace them with navigable alternatives. The conceal ranges API handles this at the editor level, removing bytes from the token stream.
Typora's "blur" behavior requires cursor-aware conditional rendering: show raw syntax when the cursor is inside a block, hide syntax and show rich rendering when the cursor is elsewhere. This needs two primitives that don't exist yet:
Change: Add cursor_positions: Vec<usize> to the ViewTransformRequest hook args.
Rationale: Currently, plugins must call getCursorPosition() separately. Since the view transform is called on every render, the cursor position should be included in the hook data to avoid an extra round-trip and ensure consistency (cursor may move between the hook fire and the API call).
Rust changes:
hooks.rs: Add cursor_positions field to HookArgs::ViewTransformRequestsplit_rendering.rs: Include primary cursor (and all multi-cursor positions) when building hook argscursor_positions: number[] to the hook dataEffort: Small. Purely additive, no breaking changes.
Change: Allow ViewTokenWire to carry both a source_offset: Some(N) AND a modified kind that differs from the actual buffer byte at offset N.
Current behavior:
{ source_offset: Some(42), kind: { Text: "|" } } — displays the character at byte 42 (the kind is informational, rendering reads from source){ source_offset: null, kind: { Text: "│" } } — displays │ but cursor cannot land hereNew behavior with replacement:
{ source_offset: Some(42), kind: { Text: "│" }, replacement: true } — displays │ but cursor maps to byte 42Implementation approach: Add an optional replacement: bool flag to ViewTokenWire. When replacement=true and source_offset=Some(N):
ViewLineIterator uses the token's kind text for display (not the buffer byte)char_source_bytes[i] = Some(N) — cursor still maps to byte NRust changes:
api.rs: Add pub replacement: bool to ViewTokenWire (default false, #[serde(default)])view_pipeline.rs (ViewLineIterator::next): When replacement=true, use token's text content instead of looking up source byte. Still record source_offset in char_source_bytes.add_char! macro already takes the character and source offset separately — just need to pass the replacement char instead of the source char.Effort: Medium. Targeted change in ViewLineIterator, no architectural overhaul.
Change: editor.addConcealRange(bufferId, namespace, start, end, options?) where options include optional replacement text and cursor behavior.
editor.addConcealRange(bufferId, "md-syntax", startByte, endByte, {
replacement?: string, // Text to show instead (null = hide completely)
replacementStyle?: { fg?, bg?, bold?, italic? },
cursorBehavior: "skip" | "land-at-start" | "expand",
// "skip": cursor arrows past it
// "land-at-start": cursor can land at start byte but doesn't enter
// "expand": entering the range triggers a re-render showing raw content
});
Rationale: This is higher-level than replacement tokens. The editor manages the concealment lifecycle, cursor skipping, and expansion. Plugins declare what to hide rather than how.
Rust changes:
ConcealRange struct in view/ moduleConcealManager similar to VirtualTextManager with marker-based position trackingbuild_base_tokens or ViewLineIterator to filter/replace concealed rangesinput.rs to handle skip/land behaviorEffort: Large. New subsystem, but provides the cleanest UX for all Typora features.
Recommendation: Implement Primitive 1 + 2 first (small+medium effort, unlocks everything at plugin level). Primitive 3 can come later as a convenience API built on top.
With Primitive 1 (cursor position in hook args):
globalThis.onMarkdownViewTransform = function(data) {
const cursorPos = data.cursor_positions[0]; // primary cursor byte offset
const blocks = parseMarkdownBlocks(sourceText);
// Find which block the cursor is in
const focusedBlock = blocks.find(b =>
cursorPos >= b.startByte + viewportStart &&
cursorPos <= b.endByte + viewportStart
);
// For each block: if focused, emit raw tokens. If blurred, emit concealed tokens.
};
No Rust changes needed for this phase — plugin can already call getCursorPosition(). Primitive 1 just makes it faster and race-free.
Blurred state (cursor outside span):
**bold** patterns** marker tokens from output (or use replacement tokens to map them to zero-width)Focused state (cursor inside span):
**bold** syntax)Token splitting needed: Base tokens may contain **bold** as one Text token. Plugin must split it:
Input: { source_offset: 10, kind: { Text: "**bold**" } }
Output: [
// Omit or conceal bytes 10-11 (the leading **)
{ source_offset: 12, kind: { Text: "bold" } },
// Omit or conceal bytes 16-17 (the trailing **)
]
With Primitive 2 (replacement tokens), the markers can be replaced with empty strings or zero-width spaces while keeping cursor mapping.
Blurred state:
# prefix tokensFocused state:
# prefix (normal editing)Simpler than emphasis — the prefix is always at line start, easy to isolate in token stream.
Blurred state: [Link Text](https://url) → Link Text (styled as link)
[ token (1 byte)Link Text tokens normally](https://url) tokensFocused state:
[Link Text](https://url) syntaxInteraction: Ctrl+Click to follow link (already possible via mouse_click hook + checking modifiers).
Blurred state:  → show a placeholder or the alt text styled distinctively
 tokensaddVirtualText(pos, "🖼", ...)Focused state: Show full  syntax
Advanced (future): Actual image rendering would require terminal image protocol support (iTerm2/Kitty image protocol) — out of scope for this plan.
Blurred state: Replace pipe characters with box-drawing characters, pad cells for alignment.
This is the most complex feature. Requires:
│ instead of ||---|---| with ─────┼─────┼─────Source: | Name | Age |
|-------|-----|
| Alice | 30 |
Blurred: │ Name │ Age │
├───────┼─────┤
│ Alice │ 30 │
With Primitive 2:
// Replace | at byte 0 with │
{ source_offset: 0, kind: { Text: "│" }, replacement: true }
// Content token "Name" at bytes 2-5
{ source_offset: 2, kind: { Text: "Name" } }
// Pad with virtual spaces
{ source_offset: null, kind: "Space" }
{ source_offset: null, kind: "Space" }
// Replace | at byte 8 with │
{ source_offset: 8, kind: { Text: "│" }, replacement: true }
Focused state (cursor in table): Show raw pipes and original spacing for editing.
Tab navigation: Plugin listens for Tab key (via pre_command hook or keybinding) when cursor is in a table, and moves cursor to the next cell.
Blurred state: - [ ] Task → ☐ Task, - [x] Done → ☑ Done
- [ ] with ☐ using replacement tokens- [x] with ☑ using replacement tokensInteraction: Click on the checkbox region toggles [ ] ↔ [x] via buffer edit:
editor.on("mouse_click", "onCheckboxClick");
globalThis.onCheckboxClick = function(data) {
// Check if click lands on a checkbox byte range
// If so, replace [ ] with [x] or vice versa
editor.replaceRange(bufferId, checkboxStart, checkboxEnd, newText);
};
Focused state: Show raw - [ ] syntax for manual editing.
Blurred state:
``` fence lines can be dimmed or concealedFocused state: Show ``` fences normally.
| Step | What | Rust Change? | Plugin Change? | Effort | Status |
|---|---|---|---|---|---|
| 1 | Conceal ranges API (addConceal/clearConcealNamespace) | Yes | Yes | M | DONE |
| 2 | Cursor-aware emphasis concealment | No | Yes | M | DONE |
| 3 | Link concealment with blue underline styling | No | Yes | M | DONE |
| 4 | Cursor positions in ViewTransformRequest hook | Yes (small) | Yes (small) | S | DONE |
| 5 | Table grid rendering (box-drawing chars via conceal replacement) | No | Yes | L | DONE |
| 6 | Visual line cursor movement fix for plugin transforms | Yes (small) | No | S | DONE |
| 7 | Fix: Render glitch on scroll (frame-skip between base and transformed tokens) | Yes | No | M | TODO |
| 8 | Fix: Cursor/overlay flicker while typing (off-by-one frame lag) | Yes | No | M | TODO |
| 9 | Fix: Mouse wheel scroll not working in compose mode | Yes | No | S | TODO |
| 10 | Table column alignment (auto-pad cells to equal column widths) | No | Yes | M | TODO |
| 11 | Clickable links via OSC 8 terminal hyperlink escape codes | Yes | Yes | M | TODO |
| 12 | Header # concealment | No | Yes | S | TODO |
| 13 | Task list checkbox interaction (click to toggle) | No | Yes | S | TODO |
| 14 | Code block fence concealment | No | Yes | S | TODO |
Steps 1-6 are implemented and usability tested. Steps 7-9 are bugs found during testing. Steps 10-14 are remaining features.
Recommendation: Start in the plugin (steps 1-6), then extract common patterns into core APIs (step 10).
Rationale: The plugin already has full control over token output. Omitting tokens effectively conceals them. The cursor backtrack behavior is imperfect but usable. Once the UX patterns stabilize, promote them to first-class editor APIs for better cursor handling.
When cursor enters a concealed block, the plugin must:
Problem: The cursor byte position was set based on the concealed view. After expanding, the same byte position may visually shift. The cursor should remain at the same source byte — which it will, since source_offset mapping is stable.
Smooth transition: The transition is instant (single render frame). No animation needed in a terminal.
Each cursor independently determines which block is "focused." A block is focused if ANY cursor is inside it. Multiple blocks can be focused simultaneously.
The view transform already runs on every render. Adding cursor awareness and token filtering adds O(n) work where n = tokens in viewport. For typical markdown documents (< 1000 visible tokens), this is negligible.
Table column width calculation requires scanning all rows — but only rows in the viewport (the transform only sees viewport tokens). For tables that extend beyond the viewport, column widths may shift as the user scrolls. This is acceptable for a first implementation; a future optimization could cache table structure.