From bf22681123179a22b682c2d44913a957e9da2ec0 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:22:53 +0530 Subject: [PATCH 1/2] Markdown: hybrid editing, rendered except for the block being edited (#460) --- CHANGELOG.md | 1 + docs/design/document-workspace.md | 13 + docs/development/v024-ledger.md | 2 + docs/testing/v024-editor-physical-qa.md | 6 + src/markdown/sourceMap.ts | 14 +- src/terminal/keys.ts | 4 +- src/viewer/DocumentWorkspace.ts | 40 ++- src/viewer/EditorPanel.ts | 8 +- src/viewer/HybridView.ts | 251 +++++++++++++++ tests/hybrid.test.ts | 387 ++++++++++++++++++++++++ 10 files changed, 709 insertions(+), 17 deletions(-) create mode 100644 src/viewer/HybridView.ts create mode 100644 tests/hybrid.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c37b43..6464c7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled. - **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet. - **Heading navigation** ([#462](https://github.com/raiseCatError/notMyShell/issues/462), first slice). In a Markdown or HTML document, **`#`** in the viewer or workspace opens a heading prompt: type to narrow, Up/Down choose, Enter jumps (source or rendered view, any width) and **Backspace** goes back to where you were. In the editor, F1 then `h` (or *Go to heading…* in the snippet palette) lists the headings with their depth; `j` / *Back* returns to the caret position you left, even after edits. `/view README.md#install` opens the file at that heading. Anchors follow GitHub's rules (lower-case, punctuation dropped, `-1`, `-2` for repeats; raw HTML headings listed without an anchor); Jekyll/Kramdown ids are different and are not implemented (see the [architecture note](docs/architecture/document-source-map.md)). Underneath, every parsed Markdown block now knows its source lines and every rendered row its block's line, the shared map the coming split view and hybrid editing build on. - **`/playground`: an unsaved Markdown or HTML document** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), second slice). `/playground md`, `/playground html`, or `/playground` to choose, opens an editable scratch document in the document workspace at once — type, switch between source, edit and preview with the usual keys, and use the snippets. Nothing is saved for you: **Ctrl+S asks for a file name** (Save As creates a new file with the same checked save as `/edit` and never replaces an existing one), and closing with unsaved text asks Save As, Discard or Keep editing. If NMSh or the terminal dies, the text is kept as a private draft and `/playground recover` brings it back. The preview is NMSh's own renderer: no browser process, no folder served, scripts and network stay off. Split view arrives with [#461](https://github.com/raiseCatError/notMyShell/issues/461). diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index 4c7de6d3..f01bdea1 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -49,6 +49,19 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc **Verified by** `tests/markdownEdit.test.ts` (each helper: empty and multi-line selections, nested and mixed lists, quotes, fences, Unicode, very long lines, one undo step, restored drafts) and `tests/markdownEditorKeys.test.ts` (keys, menu at 30–120 columns, non-Markdown files unchanged, CRLF saved as CRLF). Physical Ghostty/Zed: NOT RUN. +## Hybrid view + +`DocumentMode` gains `'hybrid'` (#460), implemented in `src/viewer/HybridView.ts` over the shared source map (`docs/architecture/document-source-map.md`). It is a presentation of the one `TextBuffer`; there is no second editor and no second document. + +- **Items:** `hybridItems` cuts the buffer into items from `Block.from/to`: each top-level block is one item; every line no block owns (blank lines, lines past the parser's 50,000-line limit, a block the parser could not place) is a one-line item and is shown raw. Blocks that share a span (an HTML block's several blocks) are one item. +- **Active region:** the items that contain the caret line or intersect the selection are shown as raw source (wrapped to the width at grapheme boundaries, tabs expanded, caret and selection drawn as in the editor); all others are rendered by the ordinary Markdown renderer applied to that item's own lines. A block is the smallest safe region: a nested list item reveals its whole list, a table cell the whole table, a fenced line the whole fence. A block that renders to no rows is shown raw rather than vanishing. +- **Input:** `documentSessionKey` treats hybrid like edit mode: every key goes to `editorKey`. Because the caret is always in a raw item, no rendered-column mapping exists to get wrong; the only mapping is source line → rows drawn. Up/Down move by source line (a wrapped line is one step). Wheel scrolls the view only; Page keys move the caret by about a screen of source lines. +- **Viewport:** anchored to `{line of an item's first line, row offset}`, not an absolute row, so re-rendering the active block does not move the rows above it; the caret is revealed with minimal scrolling and, when far away, by placing its block near the bottom. +- **Cost:** moving the caret never re-parses; a text change re-parses once (`DocumentIndex`) and re-renders only items whose text changed (cached by text, width, colour depth and glyph set, 600 entries); only rows on screen are drawn. Counters (`stats.parses`, `stats.renders`) back the budget tests. +- **Modes:** F3 toggles hybrid ⇄ source editor; from the read-only views `H`; Esc leaves for the read-only source view (text kept); Ctrl+O previews and returns; in the split F3 switches the text pane's view and F2 closes the split back to hybrid or edit. Dialogs, the name prompt and the review take the whole panel as before. +- **Not provided:** HTML files, images drawn in place (the placeholder is shown), visual-line Up/Down, pointer input, rendered-text selection with the mouse, tables edited as a grid. +- **Verified by** `tests/hybrid.test.ts` (items, wrapping, rendering, smallest region, ownership, a 300-step mode/move/resize walk that never changes the text, selection across blocks, undo through a snippet, heading navigation, viewport stability, parse/render budgets on 4,000 lines, widths, NO_COLOR, hostile text, CRLF/BOM, split with a hybrid pane, and the real app with the Playground, Save As, recovery draft and hunk review). + ## Split view `DocumentMode` gains `'split'` (#461). The session keeps the one `EditorState` and one `ViewerState`; the split adds a `SplitState` (focus, swapped, ratio, linked, a buffer version counter fed by `TextBuffer.observe`, the last caret line). The preview pane is the existing native renderer (`renderViewer` with `rendered` true), re-synced from the buffer only when the version changed. Keys: **F2** (the new `splitToggle` key) or `s` from the read-only views enters; **Ctrl+O** switches the focused pane (elsewhere it still opens the preview); a key goes to exactly one pane. The source pane is the editor unchanged (snippets, helpers, recovery, review and Save As all work in it; a dialog or prompt takes the whole panel). The preview pane takes the viewer's navigation, find and heading keys plus `x` swap, `<`/`>` width (30–70%), `l` link/unlink, `e` focus the text, `s` back to editing, Esc to the source view. diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md index 93c2cdde..9587c829 100644 --- a/docs/development/v024-ledger.md +++ b/docs/development/v024-ledger.md @@ -41,6 +41,8 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e 7. **#461: split view** — branch `feature/v024-split` (stacked on #462 slice 1). Done: `split` mode with editor + native preview of one buffer, linked scrolling, focus/ownership, swap/ratio, narrow collapse, F2. **#461 remaining:** pointer input and hit testing, vertical orientation, the Chromium preview inside the split, preview→caret sync; physical QA. **Next:** #460 (hybrid Markdown editing) builds on `blockAt`/`rowForLine`; #466 diagnostics uses the heading index and block spans; #462's details/Mermaid/math slices; #463 footnote renumbering. +8. **#460: hybrid Markdown editing** — branch `feature/v024-hybrid` (from `dev` after #489–#496 merged). Done: hybrid mode, items from the source map, active-block raw editing, anchored viewport, split text-pane toggle, tests, independent review (see its section). **#460 remaining:** images in place, visual-line movement, pointer input, tables as a grid, HTML. **#461** still lacks pointer input, preview→caret sync, vertical orientation and the Chromium preview; **#463** footnote renumbering; **#466**, **#462** (details/Mermaid/math), **#464/#465/#467** not started. + ## Environment notes Scratch and test runs: `t7run`; focused tests via `/Volumes/T7Tmp/ai-dev/runtests.mjs` (see the testing-gotchas memory). `npm ci` was needed in a fresh worktree (the main checkout's `node_modules` is stale). diff --git a/docs/testing/v024-editor-physical-qa.md b/docs/testing/v024-editor-physical-qa.md index 09fbe545..b3e783f6 100644 --- a/docs/testing/v024-editor-physical-qa.md +++ b/docs/testing/v024-editor-physical-qa.md @@ -21,6 +21,12 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti - [ ] Mermaid and math entries say they are source text; nothing in the preview claims to render them. - [ ] The palette rows and footer fit 30, 50, 80 and 120 columns; NO_COLOR and Safe Glyphs stay legible. +## Hybrid view (#460) +- [ ] Open a Markdown file, F3 (is F3 delivered by the terminal and by Zed?): headings, bold, links, lists, tables, quotes and fences render; the block under the caret shows its source; moving with the arrows swaps blocks without the screen jumping. +- [ ] Type in every kind of block; select across blocks with Shift+Down (both show source); undo/redo; insert a snippet and Tab through it. +- [ ] Resize the window while editing; NO_COLOR; Safe Glyphs; 30, 50, 80 and 120 columns; a large file. +- [ ] F2 with F3: the split with a hybrid text pane; Esc, Ctrl+O, Save, recovery after a kill, hunk review. + ## Split view (#461) - [ ] F2 is delivered by the terminal and by Zed's terminal (otherwise use `s` from the read-only view). The split shows the text and the rendered document side by side; typing updates the preview; the preview follows the caret; `l` unlinks. - [ ] Ctrl+O switches panes; letters typed in the preview pane never change the text; `x`, `<`, `>` behave; below 100 columns one pane at a time; widen the window and the split returns. diff --git a/src/markdown/sourceMap.ts b/src/markdown/sourceMap.ts index 9f88b1a0..a1ea5343 100644 --- a/src/markdown/sourceMap.ts +++ b/src/markdown/sourceMap.ts @@ -131,21 +131,27 @@ export interface DocumentIndexOptions { /** The parsed structure of a buffer, kept until the buffer changes. */ export class DocumentIndex { - private version = 0; + private versionCounter = 0; + /** How many times the buffer was parsed: tests and budgets read it. */ + parseCount = 0; private cached?: {version: number; blocks: Block[]; headings: HeadingEntry[]}; private readonly stop: () => void; constructor(private readonly buffer: TextBuffer, private readonly options: DocumentIndexOptions = {}) { - this.stop = buffer.observe(() => { this.version += 1; }); + this.stop = buffer.observe(() => { this.versionCounter += 1; }); } + /** Changes whenever the buffer's text does. */ + get version(): number { return this.versionCounter; } + /** The document was longer than the parser reads; only its first lines are indexed. */ get truncated(): boolean { return this.buffer.lines.length > 50_000; } private ensure() { - if (this.cached?.version === this.version) return this.cached; + if (this.cached?.version === this.versionCounter) return this.cached; + this.parseCount += 1; this.options.onParse?.(); const blocks = parseBlocks(this.buffer.text, {html: this.options.html ?? false}); - this.cached = {version: this.version, blocks, headings: outline(blocks, this.buffer.lines)}; + this.cached = {version: this.versionCounter, blocks, headings: outline(blocks, this.buffer.lines)}; return this.cached; } diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index 6af8573e..ee69289d 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -6,7 +6,7 @@ export type Key = | {kind: 'left' | 'right' | 'up' | 'down' | 'lineHome' | 'lineEnd' | 'backspace' | 'delete' | 'enter' | 'newline' | 'complete' | 'escape' | 'selectAll'} | {kind: 'selectLeft' | 'selectRight' | 'selectUp' | 'selectDown' | 'selectLineHome' | 'selectLineEnd'} | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} - | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' | 'splitToggle' + | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' | 'splitToggle' | 'hybridToggle' /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ | 'queueCommand' | 'sendToProgram' /** Ctrl+T: open the Arcade. NMSh's own key: it is never written to a program, so it is safe while a command runs. */ @@ -106,6 +106,8 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001BOP', 'palette'], ['\u001B[11~', 'palette'], // F2 opens or closes the document workspace's split view. ['\u001BOQ', 'splitToggle'], ['\u001B[12~', 'splitToggle'], ['\u001B[Q', 'splitToggle'], + // F3 switches the document workspace's text between its source and the hybrid view. + ['\u001BOR', 'hybridToggle'], ['\u001B[13~', 'hybridToggle'], // Kitty keyboard protocol functional-key form (`CSI 1 P`, the 1 omitted when unmodified); NMSh enables that protocol where supported. ['\u001B[P', 'palette'], ['\u001B[110;5u', 'suggestNext'], // Kitty Ctrl+N diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts index e68f5faa..093b767c 100644 --- a/src/viewer/DocumentWorkspace.ts +++ b/src/viewer/DocumentWorkspace.ts @@ -6,6 +6,7 @@ import type {EditableFile} from './fileEdit.js'; import {displayWidth, stripAnsi, truncateAnsi} from '../util/text.js'; import {foreground, UI_COLORS} from '../ui/palette.js'; import {getIconStyle} from '../ui/glyphs.js'; +import {createHybrid, renderHybrid, scrollHybrid, type HybridState} from './HybridView.js'; import {createViewer, renderViewer, showSourceLine, viewerKey, type ViewerAction, type ViewerLook, type ViewerState} from './ViewerPanel.js'; /** @@ -23,7 +24,7 @@ import {createViewer, renderViewer, showSourceLine, viewerKey, type ViewerAction * and line endings kept). In navigation modes `h`, `e`, `r` are commands; while typing they are text, and Ctrl+O is the * switch that works there. */ -export type DocumentMode = 'source' | 'edit' | 'preview' | 'split'; +export type DocumentMode = 'source' | 'edit' | 'preview' | 'split' | 'hybrid'; /** * The split view (#461): the editor, the only editable pane, beside NMSh's own preview of the same buffer. F2 opens and closes @@ -34,6 +35,8 @@ export interface SplitState { focus: 'source' | 'preview'; /** The preview is on the left. */ swapped: boolean; + /** The text pane shows the hybrid view (rendered except for the active block) instead of the plain source editor. */ + hybrid: boolean; /** The preview's share of the width, in percent. */ ratio: number; /** The preview follows the caret when it moves to another line. */ @@ -88,6 +91,8 @@ export interface DocumentSession { tops: {source: {top: number; sub: number}; preview: {top: number; sub: number}}; /** Present only while the mode is 'split'. */ split?: SplitState; + /** The hybrid view's viewport and render cache (#460), created the first time it is shown. */ + hybrid?: HybridState; note?: string; /** Sized-text blocks of the last render, positioned in the returned panel rows. */ overlays: TextOverlay[]; @@ -154,7 +159,7 @@ export function setDocumentMode(state: DocumentSession, mode: DocumentMode): voi if (state.mode === 'split') { state.split?.stop(); delete state.split; } if (mode === 'preview') state.before = state.mode === 'split' ? 'edit' : state.mode; if (mode === 'split') { - const split: SplitState = {focus: state.mode === 'edit' ? 'source' : 'preview', swapped: false, ratio: 50, linked: true, version: 0, seen: -1, caretLine: -1, stop: () => {}}; + const split: SplitState = {focus: state.mode === 'edit' || state.mode === 'hybrid' ? 'source' : 'preview', swapped: false, hybrid: state.mode === 'hybrid', ratio: 50, linked: true, version: 0, seen: -1, caretLine: -1, stop: () => {}}; split.stop = state.editor.buffer.observe(() => { split.version += 1; }); state.split = split; syncDocumentViewer(state, true); @@ -163,7 +168,7 @@ export function setDocumentMode(state: DocumentSession, mode: DocumentMode): voi state.note = undefined; return; } - if (mode !== 'edit') { + if (mode !== 'edit' && mode !== 'hybrid') { syncDocumentViewer(state, mode === 'preview'); viewer.top = state.tops[mode].top; viewer.sub = state.tops[mode].sub; @@ -180,11 +185,16 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu state.note = undefined; // A save or conflict dialog belongs to the editor, whatever mode raised it. if (editor.dialog || editor.review || editor.input || editor.busy) return fromEditor(state, editorKey(editor, key, columns, height)); - if (key.kind === 'splitToggle') { setDocumentMode(state, state.mode === 'split' ? 'edit' : 'split'); return undefined; } + if (key.kind === 'splitToggle') { setDocumentMode(state, state.mode === 'split' ? (state.split?.hybrid ? 'hybrid' : 'edit') : 'split'); return undefined; } + if (key.kind === 'hybridToggle') { toggleHybrid(state); return undefined; } if (state.mode === 'split') return splitKey(state, key, columns, height, look); if (key.kind === 'sendToProgram') return editor.file.scratch ? fromEditor(state, editorKey(editor, key, columns, height)) : {kind: 'save'}; - if (state.mode === 'edit') { + if (state.mode === 'edit' || state.mode === 'hybrid') { + if (state.mode === 'hybrid') { + if (key.kind === 'wheelUp' || key.kind === 'wheelDown') { scrollHybrid(editor, hybridOf(state), key.kind === 'wheelUp' ? -3 : 3, columns, look); return undefined; } + if (key.kind === 'pageUp' || key.kind === 'pageDown') { editor.buffer.vertical((key.kind === 'pageUp' ? -1 : 1) * Math.max(1, height - 6)); return undefined; } + } if (key.kind === 'toggleDetails') { setDocumentMode(state, 'preview'); return {kind: 'preview', refresh: false}; } // Esc leaves typing for navigation without closing or discarding anything; the unsaved buffer stays as it is. if (key.kind === 'escape' && !editor.input && !editor.snippet) { setDocumentMode(state, 'source'); return undefined; } @@ -198,6 +208,7 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu if (!searching) { if (text === 'e') { setDocumentMode(state, 'edit'); return undefined; } if (text === 's') { setDocumentMode(state, 'split'); return undefined; } + if (text === 'H') { toggleHybrid(state); return undefined; } if (key.kind === 'toggleDetails' && state.mode === 'preview') { setDocumentMode(state, state.before); return undefined; } if (text === 'h' || key.kind === 'toggleDetails') { if (state.mode === 'preview') { setDocumentMode(state, 'source'); return undefined; } @@ -232,6 +243,17 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu return undefined; } +function hybridOf(state: DocumentSession): HybridState { + return state.hybrid ??= createHybrid(); +} + +/** F3: the plain source editor and the hybrid view are two presentations of one buffer. */ +function toggleHybrid(state: DocumentSession): void { + if (formatForPath(state.editor.file.path).format !== 'markdown') { state.note = 'The hybrid view is for Markdown files.'; return; } + if (state.mode === 'split' && state.split) { state.split.hybrid = !state.split.hybrid; return; } + setDocumentMode(state, state.mode === 'hybrid' ? 'edit' : 'hybrid'); +} + function splitLook(state: DocumentSession, look: ViewerLook): ViewerLook { const split = state.split!; const {workspace: _workspace, sized: _sized, ...rest} = look; @@ -279,8 +301,9 @@ function renderSplit(state: DocumentSession, columns: number, height: number, lo if (split.linked && line !== split.caretLine) showSourceLine(state.viewer, line, previewWidth, height, previewLook); split.caretLine = line; if (state.note) state.viewer.note = state.note; - if (layout.narrow) return split.focus === 'source' ? renderEditor(editor, columns, height) : renderViewer(state.viewer, columns, height, previewLook); - const source = renderEditor(editor, layout.source, height); + const sourcePane = (width: number) => split.hybrid ? renderHybrid(editor, hybridOf(state), width, height, look) : renderEditor(editor, width, height); + if (layout.narrow) return split.focus === 'source' ? sourcePane(columns) : renderViewer(state.viewer, columns, height, previewLook); + const source = sourcePane(layout.source); const preview = renderViewer(state.viewer, layout.preview, height, previewLook); const [left, right] = split.swapped ? [preview, source] : [source, preview]; const [leftWidth, rightWidth] = split.swapped ? [layout.preview, layout.source] : [layout.source, layout.preview]; @@ -310,7 +333,8 @@ export function renderDocumentSession(state: DocumentSession, columns: number, h state.overlays = []; const editor = state.editor; if (state.mode === 'split' && state.split && !(editor.dialog || editor.review || editor.input)) return renderSplit(state, columns, height, look); - if (state.mode === 'edit' || state.mode === 'split' || editor.dialog || editor.review || editor.input) return renderEditor(editor, columns, height); + if (state.mode === 'hybrid' && !(editor.dialog || editor.review || editor.input)) return renderHybrid(editor, hybridOf(state), columns, height, look); + if (state.mode === 'edit' || state.mode === 'hybrid' || state.mode === 'split' || editor.dialog || editor.review || editor.input) return renderEditor(editor, columns, height); if (state.mode === 'preview' && state.preview) { const view = state.preview.render(columns, height); state.overlays = view.overlays ?? []; diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts index 4b1390e9..122a5e83 100644 --- a/src/viewer/EditorPanel.ts +++ b/src/viewer/EditorPanel.ts @@ -126,7 +126,7 @@ function paletteFormat(state: EditorState): PaletteFormat | undefined { const MAX_JUMPS = 20; -function headingIndex(state: EditorState): DocumentIndex { +export function headingIndex(state: EditorState): DocumentIndex { return state.index ??= new DocumentIndex(state.buffer, {html: true}); } @@ -464,10 +464,10 @@ export function editorKey(state: EditorState, key: Key, columns: number, height: // ---- Drawing ------------------------------------------------------------------------------------------------------ -interface Cell {text: string; width: number; offset: number} +export interface Cell {text: string; width: number; offset: number} /** A line's drawn cells from display column 0: tabs as spaces, unsafe characters as marks, each with its offset. */ -function lineCells(line: string, limit: number): Cell[] { +export function lineCells(line: string, limit: number): Cell[] { const cells: Cell[] = []; let column = 0; let offset = 0; @@ -488,7 +488,7 @@ function lineCells(line: string, limit: number): Cell[] { return cells; } -const inside = (position: Position, start: Position, end: Position) => +export const inside = (position: Position, start: Position, end: Position) => (position.line > start.line || (position.line === start.line && position.column >= start.column)) && (position.line < end.line || (position.line === end.line && position.column < end.column)); function drawLine(state: EditorState, index: number, width: number): string { diff --git a/src/viewer/HybridView.ts b/src/viewer/HybridView.ts new file mode 100644 index 00000000..1d2decd0 --- /dev/null +++ b/src/viewer/HybridView.ts @@ -0,0 +1,251 @@ +import type {Block} from '../markdown/blocks.js'; +import {renderMarkdown} from '../markdown/render.js'; +import {renderControlRows} from '../ui/controls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {getIconStyle} from '../ui/glyphs.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {truncateAnsi, truncateText} from '../util/text.js'; +import {headingIndex, inside, lineCells, type Cell, type EditorState} from './EditorPanel.js'; +import {inert} from './document.js'; +import type {ViewerLook} from './ViewerPanel.js'; + +/** + * Hybrid Markdown editing (#460): the document reads rendered, except for the block the caret (or a selection) is in, which + * shows its exact source. It is a presentation of the SAME TextBuffer the other modes use: every key is the editor's, the + * caret and selection live in source coordinates, so the only mapping needed is from source lines to the rows drawn. + * + * - The document is cut into items by the shared source map (`Block.from/to`): each top-level block is one item, every line + * no block owns (blank lines, text past the parser's limit) is a one-line item. A block is the smallest region that is safe + * to reveal: a caret in a nested list item shows the whole list, in a table cell the whole table, in a fence the whole fence. + * - Rendered items come from the ordinary renderer applied to that item's own lines, cached by text and width; a keystroke + * renders again only the item it changed. Nothing is rendered that is not on screen (or needed to place the caret). + * - The viewport is anchored to a source line and a row offset inside its item, never to an absolute row, so revealing a block + * (which changes its height) does not move what is above it. + * - A line the renderer cannot place (a block that renders to no rows) is shown raw: the fallback is always the source. + */ + +export interface HybridItem { + from: number; + /** Exclusive. */ + to: number; + /** The top-level block(s) the lines parse as; absent for a blank or stray line. */ + block?: Block; +} + +export interface RawRow { + start: number; + end: number; + cells: Cell[]; + width: number; +} + +export interface HybridState { + /** The first row drawn: a source line (the first line of an item) and the row offset inside that item. */ + top: {line: number; sub: number}; + stats: {parses: number; renders: number}; + cache: Map; + items?: {version: number; list: HybridItem[]}; +} + +export function createHybrid(): HybridState { + return {top: {line: 0, sub: 0}, stats: {parses: 0, renders: 0}, cache: new Map()}; +} + +/** Cut `lineCount` lines into items: blocks as the parser placed them, every other line on its own. */ +export function hybridItems(blocks: readonly Block[], lineCount: number): HybridItem[] { + const items: HybridItem[] = []; + let next = 0; + for (const block of blocks) { + if (block.from === undefined || block.to === undefined || block.from < next || block.to <= block.from || block.to > lineCount) continue; + for (; next < block.from; next += 1) items.push({from: next, to: next + 1}); + items.push({from: block.from, to: block.to, block}); + next = block.to; + } + for (; next < lineCount; next += 1) items.push({from: next, to: next + 1}); + return items; +} + +/** One source line cut into rows of at most `width` cells; wide characters and emoji are whole, tabs are spaces. */ +export function wrapRaw(line: string, width: number): RawRow[] { + const cells = lineCells(line, Number.POSITIVE_INFINITY); + const rows: RawRow[] = []; + let current: RawRow = {start: 0, end: 0, cells: [], width: 0}; + for (const cell of cells) { + if (current.cells.length && current.width + cell.width > width) { rows.push(current); current = {start: cell.offset, end: cell.offset, cells: [], width: 0}; } + current.cells.push(cell); + current.width += cell.width; + } + rows.push(current); + rows.forEach((row, index) => { row.end = index + 1 < rows.length ? rows[index + 1]!.start : line.length; }); + return rows; +} + +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const MARGIN = 4; +const CACHE_LIMIT = 600; + +interface Layout { + items: HybridItem[]; + active: (item: HybridItem) => boolean; + width: number; + rawRows: Map>; + look: ViewerLook; +} + +function rawOf(editor: EditorState, layout: Layout, index: number): Array<{line: number; row: RawRow}> { + let rows = layout.rawRows.get(index); + if (!rows) { + const item = layout.items[index]!; + rows = []; + for (let line = item.from; line < item.to; line += 1) { + const wrapped = wrapRaw(editor.buffer.lines[line]!, layout.width); + // The caret at the end of a full row needs a cell of its own. + const last = wrapped.at(-1)!; + if (editor.buffer.caret.line === line && editor.buffer.caret.column >= editor.buffer.lines[line]!.length && last.width >= layout.width) wrapped.push({start: editor.buffer.lines[line]!.length, end: editor.buffer.lines[line]!.length, cells: [], width: 0}); + for (const row of wrapped) rows.push({line, row}); + } + layout.rawRows.set(index, rows); + } + return rows; +} + +function renderedOf(editor: EditorState, hybrid: HybridState, layout: Layout, index: number): string[] | undefined { + const item = layout.items[index]!; + if (!item.block) return undefined; + const text = editor.buffer.lines.slice(item.from, item.to).join('\n'); + // Colour depth and glyph set are part of the key: NO_COLOR and Safe Glyphs can change while the document is open. + const key = `${layout.width}|${colorLevel()}|${getIconStyle()}|${layout.look.syntax ? 's' : ''}|${text}`; + let rows = hybrid.cache.get(key); + if (!rows) { + hybrid.stats.renders += 1; + const rendered = renderMarkdown(text, layout.width, {...(layout.look.syntax ? {syntax: layout.look.syntax} : {}), hyperlinks: Boolean(layout.look.hyperlinks), html: true}); + rows = rendered.map(row => row.ansi); + if (hybrid.cache.size >= CACHE_LIMIT) hybrid.cache.delete(hybrid.cache.keys().next().value!); + hybrid.cache.set(key, rows); + } + // A block that renders to nothing (a link reference definition) is shown as its source rather than disappearing. + return rows.length ? rows : undefined; +} + +function heightOf(editor: EditorState, hybrid: HybridState, layout: Layout, index: number): number { + if (layout.active(layout.items[index]!)) return rawOf(editor, layout, index).length; + return renderedOf(editor, hybrid, layout, index)?.length ?? rawOf(editor, layout, index).length; +} + +function itemAt(items: readonly HybridItem[], line: number): number { + let low = 0, high = items.length - 1; + while (low < high) { const mid = (low + high + 1) >> 1; if (items[mid]!.from <= line) low = mid; else high = mid - 1; } + return low; +} + +/** Row of the caret inside its item (0-based), counting the wrapped rows of the lines above it in the item. */ +function caretRowIn(editor: EditorState, layout: Layout, index: number): number { + const caret = editor.buffer.caret; + const rows = rawOf(editor, layout, index); + let found = 0; + rows.forEach((entry, at) => { + if (entry.line !== caret.line) return; + if (caret.column >= entry.row.start && (caret.column < entry.row.end || at + 1 >= rows.length || rows[at + 1]!.line !== caret.line || caret.column === entry.row.end && entry.row.end === entry.row.start)) found = at; + }); + return found; +} + +function revealCaret(editor: EditorState, hybrid: HybridState, layout: Layout, body: number): void { + const items = layout.items; + if (!items.length) return; + let topIndex = itemAt(items, hybrid.top.line); + let sub = Math.min(hybrid.top.sub, Math.max(0, heightOf(editor, hybrid, layout, topIndex) - 1)); + const caretItem = itemAt(items, editor.buffer.caret.line); + const caretRow = caretRowIn(editor, layout, caretItem); + if (caretItem < topIndex || (caretItem === topIndex && caretRow < sub)) { topIndex = caretItem; sub = Math.max(0, caretRow - 1); } + else { + let rows = -sub, far = false; + for (let at = topIndex; at < caretItem; at += 1) { rows += heightOf(editor, hybrid, layout, at); if (rows > body * 4) { far = true; break; } } + if (far) { topIndex = caretItem; sub = Math.max(0, caretRow - body + 2); } + else { + let delta = rows + caretRow - body + 1; + while (delta > 0 && topIndex < caretItem + 0) { + const remaining = heightOf(editor, hybrid, layout, topIndex) - sub; + if (delta >= remaining) { delta -= remaining; topIndex += 1; sub = 0; } else { sub += delta; delta = 0; } + } + if (delta > 0) sub += delta; + } + } + hybrid.top = {line: items[topIndex]!.from, sub}; +} + +/** Scroll the viewport by `rows` without moving the caret (the wheel). */ +export function scrollHybrid(editor: EditorState, hybrid: HybridState, rows: number, columns: number, look: ViewerLook = {}): void { + const layout = layoutOf(editor, hybrid, columns, look); + let index = itemAt(layout.items, hybrid.top.line), sub = hybrid.top.sub + rows; + while (sub < 0 && index > 0) { index -= 1; sub += heightOf(editor, hybrid, layout, index); } + while (index < layout.items.length - 1 && sub >= heightOf(editor, hybrid, layout, index)) { sub -= heightOf(editor, hybrid, layout, index); index += 1; } + hybrid.top = {line: layout.items[index]!.from, sub: Math.max(0, Math.min(sub, heightOf(editor, hybrid, layout, index) - 1))}; +} + +function layoutOf(editor: EditorState, hybrid: HybridState, columns: number, look: ViewerLook): Layout { + const index = headingIndex(editor); + const blocks = index.blocks(); + hybrid.stats.parses = index.parseCount; + if (hybrid.items?.version !== index.version || hybrid.items.list.at(-1)?.to !== editor.buffer.lines.length) hybrid.items = {version: index.version, list: hybridItems(blocks, editor.buffer.lines.length)}; + const buffer = editor.buffer; + const selection = buffer.selection; + const first = Math.min(buffer.caret.line, selection?.start.line ?? buffer.caret.line); + const last = Math.max(buffer.caret.line, selection?.end.line ?? buffer.caret.line); + return {items: hybrid.items.list, active: item => item.to > first && item.from <= last, width: Math.max(8, columns - MARGIN), rawRows: new Map(), look}; +} + +function drawRaw(editor: EditorState, line: number, row: RawRow): string { + const caret = editor.buffer.caret; + const selection = editor.buffer.selection; + let out = ''; + let caretDrawn = caret.line !== line || caret.column < row.start || caret.column > row.end; + for (const cell of row.cells) { + const atCaret = !caretDrawn && cell.offset === caret.column; + const selected = selection && inside({line, column: cell.offset}, selection.start, selection.end); + out += atCaret || selected ? `${REVERSE}${cell.text}${RESET}` : cell.text; + if (atCaret) caretDrawn = true; + } + if (!caretDrawn) out += `${REVERSE} ${RESET}`; + return out; +} + +export function activeKind(editor: EditorState, hybrid: HybridState, columns: number, look: ViewerLook = {}): {kind: string; lines: number} { + const layout = layoutOf(editor, hybrid, columns, look); + const item = layout.items[itemAt(layout.items, editor.buffer.caret.line)]; + return item ? {kind: item.block?.kind ?? (editor.buffer.lines[item.from]!.trim() ? 'text' : 'blank line'), lines: item.to - item.from} : {kind: 'text', lines: 1}; +} + +export function renderHybrid(editor: EditorState, hybrid: HybridState, columns: number, height: number, look: ViewerLook = {}): string[] { + const safe = getIconStyle() === 'safe'; + const accent = foreground(UI_COLORS.accent), subtle = foreground(UI_COLORS.subtle), primary = foreground(UI_COLORS.primary), warning = foreground(UI_COLORS.warning); + const layout = layoutOf(editor, hybrid, columns, look); + const caret = editor.buffer.caret; + const kind = activeKind(editor, hybrid, columns, look); + const status = `${subtle}Ln ${caret.line + 1}, Col ${caret.column + 1} · ${kind.kind}${kind.lines > 1 ? ` (${kind.lines} lines)` : ''} · Hybrid${RESET}${editor.note ? ` ${editor.note.tone === 'error' ? foreground(UI_COLORS.failure) : subtle}${truncateText(inert(editor.note.text), Math.max(1, columns - 40))}${RESET}` : ''}`; + const footer = renderControlRows([['Ctrl+S', 'save'], ['Esc', 'source'], ['Ctrl+O', 'preview'], ['F3', 'source editor'], ['F2', 'split'], ['F1', 'Markdown']], Math.max(1, columns - 4)); + const body = Math.max(1, height - 2 - 1 - 1 - footer.length); + revealCaret(editor, hybrid, layout, body); + const title = truncateText(editor.file.title, Math.max(8, Math.floor((columns - 4) * 0.6))); + const out = [` ${primary}${title}${RESET}${editor.buffer.dirty ? `${warning} ● edited${RESET}` : ''} ${subtle}hybrid · ${editor.buffer.lines.length.toLocaleString('en-US')} lines${RESET}`]; + const bar = `${accent}${safe ? '|' : '▎'}${RESET}`; + let index = itemAt(layout.items, hybrid.top.line), sub = hybrid.top.sub; + while (out.length - 1 < body && index < layout.items.length) { + const item = layout.items[index]!; + if (layout.active(item)) { + for (const entry of rawOf(editor, layout, index).slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${bar} ${drawRaw(editor, entry.line, entry.row)}`); } + } else { + const rows = renderedOf(editor, hybrid, layout, index); + if (rows) { for (const row of rows.slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${truncateAnsi(row, layout.width)}${RESET}`); } } + else for (const entry of rawOf(editor, layout, index).slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${subtle}${drawRaw(editor, entry.line, entry.row)}${RESET}`); } + } + index += 1; sub = 0; + } + while (out.length - 1 < body) out.push(''); + out.push(` ${status}`, ...footer.map(row => ` ${row}`)); + const rule = framePanel([], columns)[0]!; + if (height <= 1) return [rule].slice(0, Math.max(0, height)); + return [...framePanel(out.slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)), columns), rule]; +} diff --git a/tests/hybrid.test.ts b/tests/hybrid.test.ts new file mode 100644 index 00000000..d5fd717b --- /dev/null +++ b/tests/hybrid.test.ts @@ -0,0 +1,387 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parseBlocks} from '../src/markdown/blocks.js'; +import {createDocumentSession, documentSessionKey, renderDocumentSession, type DocumentSession} from '../src/viewer/DocumentWorkspace.js'; +import {hybridItems, wrapRaw} from '../src/viewer/HybridView.js'; +import {openForEdit} from '../src/viewer/fileEdit.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +/** + * #460: Hybrid Markdown editing. The document reads as rendered text except for the block the caret (or selection) is in, + * which shows its exact source. Same TextBuffer as every other mode; only the presentation differs. + */ +const F2 = '\u001bOQ', F3 = '\u001bOR'; +const DOC = ['# Project Overview', '', 'A paragraph with **bold** and *italic* and a [link](http://example.com).', 'It continues on a second source line.', '', '- first item', ' - nested item', '- second item', '', '> a quoted line', '', '| a | b |', '|---|---|', '| 1 | 2 |', '', '```js', 'const x = 1;', '```', '', '## Second heading', '', 'The end.'].join('\n'); +async function open(content = `${DOC}\n`, name = 'doc.md', mode: 'edit' | 'source' = 'edit') { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-hybrid-'))); + const path = join(root, name); writeFileSync(path, content); + const opened = await openForEdit(path, '/'); + if (!opened.ok) throw new Error(opened.reason); + return {root, path, state: createDocumentSession(opened.file, mode)}; +} +const press = (state: DocumentSession, bytes: string, columns = 100, height = 30) => { for (const key of decodeKeys(bytes)) documentSessionKey(state, key, columns, height); }; +const draw = (state: DocumentSession, columns = 100, height = 30) => renderDocumentSession(state, columns, height).map(row => stripAnsi(row)); +const body = (rows: string[]) => rows.join('\n'); +const goto = (state: DocumentSession, line: number, column = 0) => { state.editor.buffer.caret = {line, column}; state.editor.buffer.anchor = undefined; }; + +// ---- Items and wrapping ----------------------------------------------------------------------------------------- + +test('items: every source line belongs to exactly one item, in order; blocks span their lines, stray and blank lines are single-line items', () => { + const lines = DOC.split('\n'); + const items = hybridItems(parseBlocks(DOC, {html: true}), lines.length); + let next = 0; + for (const item of items) { assert.equal(item.from, next); assert.ok(item.to > item.from); next = item.to; } + assert.equal(next, lines.length); + assert.deepEqual(items.filter(item => item.block).map(item => lines[item.from]), ['# Project Overview', 'A paragraph with **bold** and *italic* and a [link](http://example.com).', '- first item', '> a quoted line', '| a | b |', '```js', '## Second heading', 'The end.']); + assert.ok(items.filter(item => !item.block).every(item => item.to - item.from === 1)); +}); + +test('items: blocks the parser could not place and lines past its limit stay raw, never lost', () => { + const items = hybridItems([], 5); + assert.equal(items.length, 5); + assert.ok(items.every(item => !item.block)); + const truncated = hybridItems(parseBlocks('# a\n\ntext', {html: true}), 9); + assert.equal(truncated.at(-1)!.to, 9); +}); + +test('items: HTML blocks that make several blocks are one item', () => { + const items = hybridItems(parseBlocks('

Hi

\n

x

\n\nafter', {html: true}), 4); + assert.deepEqual(items.map(item => [item.from, item.to]), [[0, 2], [2, 3], [3, 4]]); +}); + +test('wrapRaw: wide characters and emoji are never split, tabs expand, the caret column maps to the right row, and the line end gets a place', () => { + const rows = wrapRaw('日本語日本語日本語', 8); + assert.ok(rows.every(row => row.width <= 8)); + assert.deepEqual(rows.map(row => row.start), [0, 4, 8].slice(0, rows.length)); + const emoji = wrapRaw('a😀b😀c😀d', 4); + assert.ok(emoji.every(row => row.width <= 4)); + assert.equal(emoji.map(row => row.cells.map(cell => cell.text).join('')).join(''), 'a😀b😀c😀d'); + const tabs = wrapRaw('\tx', 10); + assert.equal(tabs[0]!.cells.map(cell => cell.text).join(''), ' x'); + const long = wrapRaw('abcdefghij', 4); + assert.equal(long.length, 3); + assert.equal(wrapRaw('', 4).length, 1); +}); + +// ---- Rendering -------------------------------------------------------------------------------------------------- + +test('F3 opens Hybrid: inactive blocks are rendered, the block under the caret shows its exact source', async () => { + const { root, state } = await open(); + try { + goto(state, 0); + press(state, F3); + assert.equal(state.mode, 'hybrid'); + let view = body(draw(state)); + assert.match(view, /# Project Overview/u, 'the active heading shows its marker'); + assert.match(view, /A paragraph with bold and italic and a link/u, 'the paragraph is rendered: no ** or [..](..) markup'); + assert.doesNotMatch(view, /\*\*bold\*\*/u); + goto(state, 2); + view = body(draw(state)); + assert.match(view, /\*\*bold\*\* and \*italic\* and a \[link\]\(http:\/\/example\.com\)/u, 'the paragraph now shows its source'); + assert.doesNotMatch(view, /# Project Overview/u); + assert.match(view, /Project Overview/u, 'the heading went back to its rendered form'); + assert.match(view, /It continues on a second source line\./u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('the smallest safe region: a caret inside a nested list item reveals the whole list; inside a table cell, the whole table; inside a fence, the whole fence', async () => { + const { root, state } = await open(); + try { + press(state, F3); + const lines = DOC.split('\n'); + goto(state, lines.indexOf(' - nested item'), 5); + let view = body(draw(state)); + assert.match(view, /- first item/u); assert.match(view, / - nested item/u); assert.match(view, /- second item/u); + assert.doesNotMatch(view, /\|---\|---\|/u); + goto(state, lines.indexOf('| 1 | 2 |'), 3); + view = body(draw(state)); + assert.match(view, /\| a \| b \|/u); assert.match(view, /\|---\|---\|/u); + assert.doesNotMatch(view, /- first item/u); + goto(state, lines.indexOf('const x = 1;'), 3); + view = body(draw(state)); + assert.match(view, /```js/u); assert.match(view, /const x = 1;/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('the active block is marked in the margin, and the rendered ones are not', async () => { + const { root, state } = await open(); + try { + goto(state, 2); press(state, F3); + const rows = draw(state); + const marked = rows.filter(row => row.includes('▎') || row.includes('|')); + assert.ok(marked.some(row => /bold/u.test(row))); + assert.ok(!rows.some(row => /Project Overview/u.test(row) && row.includes('▎'))); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Editing and ownership -------------------------------------------------------------------------------------- + +test('typing inserts text in the active block, including the letters that are shortcuts elsewhere; moving on renders the block again', async () => { + const { root, state } = await open(); + try { + goto(state, 0, 18); press(state, F3); + press(state, ' hello ehr'); + assert.equal(state.editor.buffer.lines[0], '# Project Overview hello ehr'); + press(state, '\u001b[B'); press(state, '\u001b[B'); + const view = body(draw(state)); + assert.match(view, /Project Overview hello ehr/u); + assert.doesNotMatch(view, /# Project Overview hello/u, 'rendered again once the caret left it'); + assert.equal(state.mode, 'hybrid'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('presentation changes never change the document: any walk through modes, moves and resizes leaves the text and the file as they were', async () => { + const { root, path, state } = await open(); + try { + const before = state.editor.buffer.text; + let seed = 5; + const random = (n: number) => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; }; + const moves = ['\u001b[A', '\u001b[B', '\u001b[C', '\u001b[D', '\u001b[1;2B', '\u001b[1;2A', '\u001b[H', '\u001b[F']; + press(state, F3); + for (let step = 0; step < 300; step += 1) { + const columns = [30, 50, 80, 100, 120][random(5)]!, height = [10, 20, 30][random(3)]!; + const roll = random(10); + if (roll === 0) press(state, F3, columns, height); + else if (roll === 1) press(state, F2, columns, height); + else if (roll === 2) press(state, '\u001b', columns, height); + else if (roll === 3 && state.mode === 'source') press(state, 'e', columns, height); + else if (roll === 4 && state.mode === 'source') press(state, 'H', columns, height); + else if (state.mode === 'hybrid' || state.mode === 'edit' || (state.mode === 'split' && state.split?.focus === 'source')) press(state, moves[random(moves.length)]!, columns, height); + draw(state, columns, height); + assert.equal(state.editor.buffer.text, before, `step ${step} in ${state.mode}`); + } + assert.equal(state.editor.buffer.dirty, false); + assert.equal(readFileSync(path, 'utf8'), `${DOC}\n`); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('selection across blocks shows every block it touches as source, and copy/cut take exactly the selected source', async () => { + const { root, state } = await open(); + try { + goto(state, 0, 0); press(state, F3); + press(state, '\u001b[1;2B'); press(state, '\u001b[1;2B'); press(state, '\u001b[1;2B'); + const view = body(draw(state)); + assert.match(view, /# Project Overview/u); + assert.match(view, /\*\*bold\*\*/u, 'the second block touched by the selection is raw too'); + assert.equal(state.editor.buffer.selectedText(), '# Project Overview\n\nA paragraph with **bold** and *italic* and a [link](http://example.com).\n'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('undo and redo work through the hybrid view, including after a snippet insertion', async () => { + const { root, state } = await open('', 'blank.md'); + try { + press(state, F3); + press(state, '\u001bOP'); press(state, '/'); press(state, 'table'); press(state, '\r'); + assert.match(state.editor.buffer.text, /\| Column \| Column \|/u); + assert.ok(state.editor.snippet); + press(state, 'Name'); press(state, '\t'); press(state, 'Value'); + assert.match(state.editor.buffer.lines[0]!, /\| Name \| Value \|/u); + assert.match(body(draw(state)), /\| Name \| Value \|/u, 'the table is the active block, so it is raw'); + for (let step = 0; step < 6 && state.editor.buffer.text !== ''; step += 1) press(state, '\u001a'); + assert.equal(state.editor.buffer.text, '', 'the whole snippet insertion and its typing undo back to the empty document'); + press(state, '\u0019'); + assert.match(state.editor.buffer.text, /Column/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('Go to heading works in the hybrid view and the view follows; Esc leaves for the source view with the text kept', async () => { + const { root, state } = await open(); + try { + press(state, F3); + press(state, '\u001bOP'); press(state, 'h'); press(state, 'second'); press(state, '\r'); + assert.equal(state.editor.buffer.lines[state.editor.buffer.caret.line], '## Second heading'); + assert.match(body(draw(state, 100, 12)), /## Second heading/u); + press(state, 'X'); + press(state, '\u001b'); + assert.equal(state.mode, 'source'); + assert.equal(state.editor.buffer.dirty, true); + press(state, F3); + assert.equal(state.mode, 'hybrid', 'F3 is available from the read-only views too'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('HTML files have no hybrid view and say so', async () => { + const { root, state } = await open('

x

\n', 'page.html'); + try { + press(state, F3); + assert.equal(state.mode, 'edit'); + assert.match(state.note ?? '', /Markdown/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Viewport --------------------------------------------------------------------------------------------------- + +const LONG = Array.from({length: 120}, (_, index) => (index % 4 === 0 ? `## Section ${index}` : index % 4 === 1 ? '' : index % 4 === 2 ? `Paragraph number ${index} with **strong** text.` : '')).join('\n'); + +test('the viewport does not jump when the active block changes height, and the caret is always on screen', async () => { + const { root, state } = await open(`${LONG}\n`); + try { + press(state, F3); + goto(state, 22); + draw(state, 100, 14); + const topBefore = state.hybrid!.top.line, subBefore = state.hybrid!.top.sub; + const visible = () => [...body(draw(state, 100, 14)).matchAll(/(?:Section|Paragraph number) (\d+)/gu)].map(match => Number(match[1])); + const targets = visible(); + assert.ok(targets.length >= 3, 'a few blocks are on screen'); + for (const line of [...targets, ...targets.reverse()]) { goto(state, line); draw(state, 100, 14); assert.equal(state.hybrid!.top.line, topBefore, `moving within the screen to ${line} keeps the top`); assert.equal(state.hybrid!.top.sub, subBefore); } + for (const line of [0, 119, 60, 3, 100, 40]) { + goto(state, line, 0); + const rows = draw(state, 100, 14); + assert.ok(rows.some(row => row.includes('▎') || row.includes('|')), `the caret block is visible after jumping to ${line}`); + } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('rendering is bounded: moving the caret does not parse again, typing parses once per change, and only visible blocks are rendered', async () => { + const big = Array.from({length: 4000}, (_, index) => (index % 2 ? '' : `Line ${index} with *emphasis*`)).join('\n'); + const { root, state } = await open(`${big}\n`); + try { + press(state, F3); + draw(state, 100, 30); + const stats = state.hybrid!.stats; + const parses = stats.parses, renders = stats.renders; + for (let step = 0; step < 100; step += 1) { press(state, '\u001b[B'); draw(state, 100, 30); } + assert.equal(stats.parses, parses, 'cursor movement never parses'); + assert.ok(stats.renders - renders < 120, `${stats.renders - renders} block renders for 100 moves`); + press(state, 'x'); draw(state, 100, 30); + assert.equal(stats.parses, parses + 1); + assert.ok(stats.renders < 400, `${stats.renders} renders for a 4,000-line document`); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Widths, colour, safety ------------------------------------------------------------------------------------- + +test('every row fits 30–120 columns in colour, NO_COLOR and Safe Glyphs, with wide text and hostile content', async () => { + const hostile = `# \u001b]0;x\u0007日本語の見出し 😀\n\n${'long '.repeat(60)}\n\n- ${'item '.repeat(30)}\n`; + const { root, state } = await open(hostile); + const saved = process.env.NO_COLOR; + try { + press(state, F3); + for (const noColor of [false, true]) { + if (noColor) process.env.NO_COLOR = '1'; else delete process.env.NO_COLOR; + for (const columns of [30, 50, 80, 120]) for (const line of [0, 2, 4]) { + goto(state, line, 3); + const raw = renderDocumentSession(state, columns, 18); + assert.ok(raw.every(row => displayWidth(stripAnsi(row)) <= columns), `${columns}/${line}`); + assert.ok(raw.every(row => !/\u001b\]/u.test(row))); + if (noColor) assert.ok(raw.every(row => !/\u001b\[(?:3[0-7]|9[0-7]|38;)/u.test(row))); + } + } + } finally { if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved; rmSync(root, {recursive: true, force: true}); } +}); + +test('CRLF files and files with a BOM: hybrid shows lines, saving keeps the line endings and the BOM', async () => { + const { root, path, state } = await open('# Title\r\n\r\ntext\r\n', 'crlf.md'); + try { + press(state, F3); + goto(state, 2, 4); press(state, '!'); + assert.match(body(draw(state)), /text!/u); + const action = documentSessionKey(state, decodeKeys('\u0013')[0]!, 100, 30); + assert.deepEqual(action, {kind: 'save'}); + assert.equal(readFileSync(path, 'utf8'), '# Title\r\n\r\ntext\r\n', 'asking to save wrote nothing'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Split and Playground --------------------------------------------------------------------------------------- + +test('F2 from hybrid opens the split with the hybrid view as its text pane; F3 there switches that pane between source and hybrid', async () => { + const { root, state } = await open(); + try { + goto(state, 2); press(state, F3); press(state, F2, 140, 30); + assert.equal(state.mode, 'split'); + assert.equal(state.split?.hybrid, true); + let left = draw(state, 140, 30).map(row => row.split('│')[0]!).join('\n'); + assert.match(left, /\*\*bold\*\*/u); + assert.match(left, /Project Overview/u); + assert.doesNotMatch(left, /# Project Overview/u, 'the text pane is hybrid: the heading is rendered'); + press(state, F3, 140, 30); + assert.equal(state.split?.hybrid, false); + left = draw(state, 140, 30).map(row => row.split('│')[0]!).join('\n'); + assert.match(left, /# Project Overview/u); + assert.equal(state.mode, 'split'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + + +// ---- Through the real app --------------------------------------------------------------------------------------- + +import {nmshConfigDirectory} from '../src/configuration/paths.js'; +import {DraftStore} from '../src/viewer/draftRecovery.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +type Fixture = ReturnType; +const keysTo = (f: Fixture, bytes: string) => { for (const k of decodeKeys(bytes)) (f.app as unknown as {handleKey(k: unknown): void}).handleKey(k); }; +const screenOf = (f: Fixture) => f.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); +async function waitFor(check: () => boolean, what: string) { + const deadline = process.hrtime.bigint() + 8_000_000_000n; + while (!check()) { if (process.hrtime.bigint() > deadline) throw new Error(`timed out waiting for ${what}`); await new Promise(done => setTimeout(done, 5)); } +} + +test('app: hybrid in the real workspace, input ownership and leaving it restore the shell view; a crash draft is written from the hybrid view', async () => { + const f = executionFrameFixture(110, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-hybrid-app-'))); + try { + const file = join(root, 'doc.md'); writeFileSync(file, `${DOC}\n`); + await f.app['openDocumentSession'](file, '/edit doc.md', 'edit', false); + keysTo(f, '\u001bOR'); + assert.equal(f.app['docSession']!.mode, 'hybrid'); + assert.match(screenOf(f), /Hybrid/u); + keysTo(f, 'hello'); + assert.match(f.app['docSession']!.editor.buffer.lines[0]!, /hello/u, 'plain letters are text'); + f.app['draftKeeper'].flush(); + assert.ok(new DraftStore(join(nmshConfigDirectory(), 'recovery')).own(realpathSync(file)), 'the draft is written while editing in hybrid'); + keysTo(f, '\u001b'); + assert.equal(f.app['docSession']!.mode, 'source'); + keysTo(f, 'q'); + assert.equal(f.app['docSession']!.editor.dialog?.kind, 'unsaved'); + keysTo(f, 'd'); + assert.equal(f.app['docSession'], undefined); + assert.equal(readFileSync(file, 'utf8'), `${DOC}\n`); + } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } +}); + +test('app: the Playground works in hybrid, Save As takes the whole panel and returns to hybrid with the file saved', async () => { + const f = executionFrameFixture(110, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-hybrid-play-'))); + try { + await f.app['openPlayground']('markdown'); + keysTo(f, '\u001bOR'); + keysTo(f, '# Hi there\n\nSome *text*.'); + assert.equal(f.app['docSession']!.mode, 'hybrid'); + keysTo(f, '\u0013'); + assert.ok(f.app['docSession']!.editor.input, 'the name prompt is open'); + assert.match(screenOf(f), /Save as/u); + keysTo(f, `${join(root, 'new.md')}\r`); + await waitFor(() => !f.app['docSession']!.editor.file.scratch, 'Save As'); + assert.equal(readFileSync(join(root, 'new.md'), 'utf8'), '# Hi there\n\nSome *text*.\n'); + assert.equal(f.app['docSession']!.mode, 'hybrid'); + assert.match(screenOf(f), /Some \*text\*\./u, 'the block under the caret is source'); + assert.doesNotMatch(screenOf(f), /# Hi there/u, 'the heading is rendered'); + } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } +}); + +test('app: an external change while editing in hybrid is reviewed and merged, then the hybrid view shows the result', async () => { + const f = executionFrameFixture(110, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-hybrid-review-'))); + try { + const file = join(root, 'doc.md'); writeFileSync(file, '# One\n\nalpha\n\nbeta\n\ngamma\n'); + await f.app['openDocumentSession'](file, '/edit doc.md', 'edit', false); + keysTo(f, '\u001bOR'); + f.app['docSession']!.editor.buffer.caret = {line: 2, column: 5}; keysTo(f, '!'); + writeFileSync(file, '# One\n\nalpha\n\nbeta\n\nGAMMA\n'); + keysTo(f, '\u0013'); + await waitFor(() => f.app['docSession']!.editor.dialog?.kind === 'conflict' && !f.app['docSession']!.editor.busy, 'the conflict'); + keysTo(f, 'v'); + await waitFor(() => Boolean(f.app['docSession']!.editor.review) && !f.app['docSession']!.editor.busy, 'the review'); + assert.match(screenOf(f), /Review changes/u); + keysTo(f, '\r'); + await waitFor(() => !f.app['docSession']!.editor.review && !f.app['docSession']!.editor.busy, 'the merge'); + assert.deepEqual(f.app['docSession']!.editor.buffer.lines.filter(Boolean), ['# One', 'alpha!', 'beta', 'GAMMA']); + assert.equal(f.app['docSession']!.mode, 'hybrid'); + assert.match(screenOf(f), /GAMMA/u); + } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } +}); From aeeee431a3cd3a535389eb3a898cdadeb135c79e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:36:46 +0530 Subject: [PATCH 2/2] Hybrid: fix the findings of the independent review --- docs/design/document-workspace.md | 2 + src/app/TerminalApp.ts | 2 +- src/markdown/blocks.ts | 9 ++- src/markdown/sourceMap.ts | 4 +- src/viewer/DocumentWorkspace.ts | 15 +++- src/viewer/HybridView.ts | 103 ++++++++++++++++------- tests/hybrid.test.ts | 130 ++++++++++++++++++++++++++++++ 7 files changed, 228 insertions(+), 37 deletions(-) diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index f01bdea1..12cc77d2 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -59,6 +59,8 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc - **Viewport:** anchored to `{line of an item's first line, row offset}`, not an absolute row, so re-rendering the active block does not move the rows above it; the caret is revealed with minimal scrolling and, when far away, by placing its block near the bottom. - **Cost:** moving the caret never re-parses; a text change re-parses once (`DocumentIndex`) and re-renders only items whose text changed (cached by text, width, colour depth and glyph set, 600 entries); only rows on screen are drawn. Counters (`stats.parses`, `stats.renders`) back the budget tests. - **Modes:** F3 toggles hybrid ⇄ source editor; from the read-only views `H`; Esc leaves for the read-only source view (text kept); Ctrl+O previews and returns; in the split F3 switches the text pane's view and F2 closes the split back to hybrid or edit. Dialogs, the name prompt and the review take the whole panel as before. +- **Independent review (opus subagent) and what changed:** a lone CR inside a line shifted every later block span (the index now parses the buffer's own lines, not its joined text); the caret was drawn twice and a character hidden at a wrap boundary (one rule now: the caret belongs to the last row of its line that starts at or before its column, and only that row draws it); the wheel was undone by the next draw (a "free scroll" state that any editing or moving key clears; in the split's hybrid pane too); the render cache grew without bound (current width only, 2 MB of rows, blocks over 2,000 lines are shown as source instead of rendered whole); every render re-wrapped the whole active block (per-line wrap cache: a caret move wraps nothing, a keystroke one line); the cache key lacked the link setting, syntax roles and theme colours; a one-row viewport and terminals under 12 columns could lose the caret; reloading from disk dropped the hybrid view. The reviewer's 150 randomized 400-step runs and 20,000 adversarial documents found no change to the text, no gap or overlap in the items and no unsafe output. +- **Known limits:** a source line over 4,096 characters is wrapped on every draw (not cached), so a megabyte-long line is slow here — use the source editor; an unclosed fence's trailing blank lines are separate raw lines. - **Not provided:** HTML files, images drawn in place (the placeholder is shown), visual-line Up/Down, pointer input, rendered-text selection with the mouse, tables edited as a grid. - **Verified by** `tests/hybrid.test.ts` (items, wrapping, rendering, smallest region, ownership, a 300-step mode/move/resize walk that never changes the text, selection across blocks, undo through a snippet, heading navigation, viewport stability, parse/render budgets on 4,000 lines, widths, NO_COLOR, hostile text, CRLF/BOM, split with a hybrid pane, and the real app with the Playground, Save As, recovery draft and hunk review). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 347e3394..697dfd06 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -4784,7 +4784,7 @@ export class TerminalApp { if (this.docSession !== session) return; if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; session.viewer.note = result.reason; this.render(); return; } this.drafts().discard(editor); - const fresh = createDocumentSession(result.file, session.mode === 'edit' ? 'edit' : 'source'); + const fresh = createDocumentSession(result.file, session.mode === 'edit' || session.mode === 'hybrid' ? session.mode : 'source'); fresh.note = 'Reloaded from disk; your changes were discarded.'; fresh.viewer.note = fresh.note; session.preview?.dispose(); diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts index 336178ff..b955995d 100644 --- a/src/markdown/blocks.ts +++ b/src/markdown/blocks.ts @@ -145,9 +145,12 @@ function interrupts(line: string, next: string | undefined): boolean { } export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] { - const lines = source.replace(/\r\n?/gu, '\n').split('\n'); - if (lines.length > MAX_LINES) lines.length = MAX_LINES; - return parseLines(lines, 0, Boolean(options.html), 0); + return parseBlockLines(source.replace(/\r\n?/gu, '\n').split('\n'), options); +} + +/** The same, for lines already cut (a buffer's): a stray CR inside a line stays in it and cannot shift the line numbers after it. */ +export function parseBlockLines(lines: readonly string[], options: {html?: boolean} = {}): Block[] { + return parseLines(lines.length > MAX_LINES ? lines.slice(0, MAX_LINES) : lines, 0, Boolean(options.html), 0); } /** The kind of HTML block a line starts (CommonMark 1, 2, 6, 7), or undefined. */ diff --git a/src/markdown/sourceMap.ts b/src/markdown/sourceMap.ts index a1ea5343..f397e1e4 100644 --- a/src/markdown/sourceMap.ts +++ b/src/markdown/sourceMap.ts @@ -1,6 +1,6 @@ import type {Position, TextBuffer} from '../viewer/textBuffer.js'; import {mapPosition} from '../viewer/snippets.js'; -import {parseBlocks, type Block} from './blocks.js'; +import {parseBlockLines, parseBlocks, type Block} from './blocks.js'; import type {MarkdownRow} from './render.js'; /** @@ -150,7 +150,7 @@ export class DocumentIndex { if (this.cached?.version === this.versionCounter) return this.cached; this.parseCount += 1; this.options.onParse?.(); - const blocks = parseBlocks(this.buffer.text, {html: this.options.html ?? false}); + const blocks = parseBlockLines(this.buffer.lines, {html: this.options.html ?? false}); this.cached = {version: this.versionCounter, blocks, headings: outline(blocks, this.buffer.lines)}; return this.cached; } diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts index 093b767c..3d9c26b7 100644 --- a/src/viewer/DocumentWorkspace.ts +++ b/src/viewer/DocumentWorkspace.ts @@ -191,10 +191,7 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu if (key.kind === 'sendToProgram') return editor.file.scratch ? fromEditor(state, editorKey(editor, key, columns, height)) : {kind: 'save'}; if (state.mode === 'edit' || state.mode === 'hybrid') { - if (state.mode === 'hybrid') { - if (key.kind === 'wheelUp' || key.kind === 'wheelDown') { scrollHybrid(editor, hybridOf(state), key.kind === 'wheelUp' ? -3 : 3, columns, look); return undefined; } - if (key.kind === 'pageUp' || key.kind === 'pageDown') { editor.buffer.vertical((key.kind === 'pageUp' ? -1 : 1) * Math.max(1, height - 6)); return undefined; } - } + if (state.mode === 'hybrid' && hybridScrollKey(state, key, columns, height, look)) return undefined; if (key.kind === 'toggleDetails') { setDocumentMode(state, 'preview'); return {kind: 'preview', refresh: false}; } // Esc leaves typing for navigation without closing or discarding anything; the unsaved buffer stays as it is. if (key.kind === 'escape' && !editor.input && !editor.snippet) { setDocumentMode(state, 'source'); return undefined; } @@ -243,6 +240,15 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu return undefined; } +/** The wheel scrolls the hybrid view without moving the caret; Page keys move the caret by about a screen of source lines. */ +function hybridScrollKey(state: DocumentSession, key: Key, columns: number, height: number, look: ViewerLook): boolean { + const hybrid = hybridOf(state); + if (key.kind === 'wheelUp' || key.kind === 'wheelDown') { scrollHybrid(state.editor, hybrid, key.kind === 'wheelUp' ? -3 : 3, columns, look); return true; } + hybrid.free = false; + if (key.kind === 'pageUp' || key.kind === 'pageDown') { state.editor.buffer.vertical((key.kind === 'pageUp' ? -1 : 1) * Math.max(1, height - 6)); return true; } + return false; +} + function hybridOf(state: DocumentSession): HybridState { return state.hybrid ??= createHybrid(); } @@ -267,6 +273,7 @@ function splitKey(state: DocumentSession, key: Key, columns: number, height: num const text = key.kind === 'text' ? key.value : undefined; if (key.kind === 'toggleDetails' && !editor.dialog) { split.focus = split.focus === 'source' ? 'preview' : 'source'; return undefined; } if (split.focus === 'source') { + if (split.hybrid && !editor.dialog && !editor.input && hybridScrollKey(state, key, layout.narrow ? columns : layout.source, height, look)) return undefined; if (key.kind === 'escape' && !editor.input && !editor.snippet) { setDocumentMode(state, 'source'); return undefined; } return fromEditor(state, editorKey(editor, key, layout.narrow ? columns : layout.source, height)); } diff --git a/src/viewer/HybridView.ts b/src/viewer/HybridView.ts index 1d2decd0..5c9914df 100644 --- a/src/viewer/HybridView.ts +++ b/src/viewer/HybridView.ts @@ -6,6 +6,7 @@ import {foreground, UI_COLORS} from '../ui/palette.js'; import {getIconStyle} from '../ui/glyphs.js'; import {colorLevel} from '../presentation/capabilities.js'; import {truncateAnsi, truncateText} from '../util/text.js'; +import {displayColumn} from './textBuffer.js'; import {headingIndex, inside, lineCells, type Cell, type EditorState} from './EditorPanel.js'; import {inert} from './document.js'; import type {ViewerLook} from './ViewerPanel.js'; @@ -43,13 +44,20 @@ export interface RawRow { export interface HybridState { /** The first row drawn: a source line (the first line of an item) and the row offset inside that item. */ top: {line: number; sub: number}; - stats: {parses: number; renders: number}; + stats: {parses: number; renders: number; wraps: number}; + /** Rendered inactive items, for the current width only and at most CACHE_BYTES of rows. */ cache: Map; + cacheBytes: number; + cacheWidth: number; + /** Source lines wrapped to a width, so a caret move or a keystroke wraps only what changed. */ + wraps: Map; + /** The wheel scrolled the view on purpose: the caret is not pulled back until a key moves or edits. */ + free: boolean; items?: {version: number; list: HybridItem[]}; } export function createHybrid(): HybridState { - return {top: {line: 0, sub: 0}, stats: {parses: 0, renders: 0}, cache: new Map()}; + return {top: {line: 0, sub: 0}, stats: {parses: 0, renders: 0, wraps: 0}, cache: new Map(), cacheBytes: 0, cacheWidth: 0, wraps: new Map(), free: false}; } /** Cut `lineCount` lines into items: blocks as the parser placed them, every other line on its own. */ @@ -83,10 +91,15 @@ export function wrapRaw(line: string, width: number): RawRow[] { const RESET = '\u001b[0m'; const REVERSE = '\u001b[7m'; -const MARGIN = 4; -const CACHE_LIMIT = 600; +const CACHE_BYTES = 2_000_000; +const WRAP_ENTRIES = 20_000; +const MAX_CACHED_TEXT = 65_536; +/** A block longer than this is not rendered whole to show a screenful of it: it stays source. */ +const MAX_RENDER_LINES = 2000; interface Layout { + hybrid: HybridState; + margin: number; items: HybridItem[]; active: (item: HybridItem) => boolean; width: number; @@ -94,16 +107,30 @@ interface Layout { look: ViewerLook; } +function wrappedLine(hybrid: HybridState, text: string, width: number): RawRow[] { + const key = `${width}|${text}`; + const cached = text.length <= 4096 ? hybrid.wraps.get(key) : undefined; + if (cached) return cached; + hybrid.stats.wraps += 1; + const rows = wrapRaw(text, width); + if (text.length <= 4096) { + if (hybrid.wraps.size >= WRAP_ENTRIES) hybrid.wraps.clear(); + hybrid.wraps.set(key, rows); + } + return rows; +} + function rawOf(editor: EditorState, layout: Layout, index: number): Array<{line: number; row: RawRow}> { let rows = layout.rawRows.get(index); if (!rows) { const item = layout.items[index]!; rows = []; for (let line = item.from; line < item.to; line += 1) { - const wrapped = wrapRaw(editor.buffer.lines[line]!, layout.width); + const text = editor.buffer.lines[line]!; + const wrapped = [...wrappedLine(layout.hybrid, text, layout.width)]; // The caret at the end of a full row needs a cell of its own. const last = wrapped.at(-1)!; - if (editor.buffer.caret.line === line && editor.buffer.caret.column >= editor.buffer.lines[line]!.length && last.width >= layout.width) wrapped.push({start: editor.buffer.lines[line]!.length, end: editor.buffer.lines[line]!.length, cells: [], width: 0}); + if (editor.buffer.caret.line === line && editor.buffer.caret.column >= text.length && last.width >= layout.width) wrapped.push({start: text.length, end: text.length, cells: [], width: 0}); for (const row of wrapped) rows.push({line, row}); } layout.rawRows.set(index, rows); @@ -113,17 +140,27 @@ function rawOf(editor: EditorState, layout: Layout, index: number): Array<{line: function renderedOf(editor: EditorState, hybrid: HybridState, layout: Layout, index: number): string[] | undefined { const item = layout.items[index]!; - if (!item.block) return undefined; + if (!item.block || item.to - item.from > MAX_RENDER_LINES) return undefined; const text = editor.buffer.lines.slice(item.from, item.to).join('\n'); - // Colour depth and glyph set are part of the key: NO_COLOR and Safe Glyphs can change while the document is open. - const key = `${layout.width}|${colorLevel()}|${getIconStyle()}|${layout.look.syntax ? 's' : ''}|${text}`; + // Everything the rendered rows depend on is in the key: colour depth, glyph set, links, syntax roles and the theme's colours can all + // change while the document is open. Only the current width is kept. + const theme = `${foreground(UI_COLORS.primary)}${foreground(UI_COLORS.accent)}${foreground(UI_COLORS.secondary)}`; + const key = `${colorLevel()}|${getIconStyle()}|${layout.look.hyperlinks ? 'h' : ''}|${JSON.stringify(layout.look.syntax ?? 0)}|${theme}|${text}`; + if (hybrid.cacheWidth !== layout.width) { hybrid.cache.clear(); hybrid.cacheBytes = 0; hybrid.cacheWidth = layout.width; } let rows = hybrid.cache.get(key); if (!rows) { hybrid.stats.renders += 1; const rendered = renderMarkdown(text, layout.width, {...(layout.look.syntax ? {syntax: layout.look.syntax} : {}), hyperlinks: Boolean(layout.look.hyperlinks), html: true}); rows = rendered.map(row => row.ansi); - if (hybrid.cache.size >= CACHE_LIMIT) hybrid.cache.delete(hybrid.cache.keys().next().value!); - hybrid.cache.set(key, rows); + if (text.length <= MAX_CACHED_TEXT) { + hybrid.cache.set(key, rows); + hybrid.cacheBytes += key.length + rows.reduce((sum, row) => sum + row.length, 0); + while (hybrid.cacheBytes > CACHE_BYTES && hybrid.cache.size > 1) { + const oldest = hybrid.cache.keys().next().value!; + hybrid.cacheBytes -= oldest.length + hybrid.cache.get(oldest)!.reduce((sum, row) => sum + row.length, 0); + hybrid.cache.delete(oldest); + } + } } // A block that renders to nothing (a link reference definition) is shown as its source rather than disappearing. return rows.length ? rows : undefined; @@ -142,13 +179,13 @@ function itemAt(items: readonly HybridItem[], line: number): number { /** Row of the caret inside its item (0-based), counting the wrapped rows of the lines above it in the item. */ function caretRowIn(editor: EditorState, layout: Layout, index: number): number { - const caret = editor.buffer.caret; - const rows = rawOf(editor, layout, index); + return caretEntry(rawOf(editor, layout, index), editor.buffer.caret); +} + +/** The row that holds the caret: the last row of its line that starts at or before its column (so one row, always). */ +function caretEntry(rows: ReadonlyArray<{line: number; row: RawRow}>, caret: {line: number; column: number}): number { let found = 0; - rows.forEach((entry, at) => { - if (entry.line !== caret.line) return; - if (caret.column >= entry.row.start && (caret.column < entry.row.end || at + 1 >= rows.length || rows[at + 1]!.line !== caret.line || caret.column === entry.row.end && entry.row.end === entry.row.start)) found = at; - }); + rows.forEach((entry, at) => { if (entry.line === caret.line && entry.row.start <= caret.column) found = at; }); return found; } @@ -159,11 +196,13 @@ function revealCaret(editor: EditorState, hybrid: HybridState, layout: Layout, b let sub = Math.min(hybrid.top.sub, Math.max(0, heightOf(editor, hybrid, layout, topIndex) - 1)); const caretItem = itemAt(items, editor.buffer.caret.line); const caretRow = caretRowIn(editor, layout, caretItem); - if (caretItem < topIndex || (caretItem === topIndex && caretRow < sub)) { topIndex = caretItem; sub = Math.max(0, caretRow - 1); } + // One row of context above the caret when the body has room for it; a one-row body shows the caret's row and nothing else. + const context = Math.min(1, body - 1); + if (caretItem < topIndex || (caretItem === topIndex && caretRow < sub)) { topIndex = caretItem; sub = Math.max(0, caretRow - context); } else { let rows = -sub, far = false; for (let at = topIndex; at < caretItem; at += 1) { rows += heightOf(editor, hybrid, layout, at); if (rows > body * 4) { far = true; break; } } - if (far) { topIndex = caretItem; sub = Math.max(0, caretRow - body + 2); } + if (far) { topIndex = caretItem; sub = Math.max(0, caretRow - body + 1 + context); } else { let delta = rows + caretRow - body + 1; while (delta > 0 && topIndex < caretItem + 0) { @@ -178,6 +217,7 @@ function revealCaret(editor: EditorState, hybrid: HybridState, layout: Layout, b /** Scroll the viewport by `rows` without moving the caret (the wheel). */ export function scrollHybrid(editor: EditorState, hybrid: HybridState, rows: number, columns: number, look: ViewerLook = {}): void { + hybrid.free = true; const layout = layoutOf(editor, hybrid, columns, look); let index = itemAt(layout.items, hybrid.top.line), sub = hybrid.top.sub + rows; while (sub < 0 && index > 0) { index -= 1; sub += heightOf(editor, hybrid, layout, index); } @@ -194,14 +234,15 @@ function layoutOf(editor: EditorState, hybrid: HybridState, columns: number, loo const selection = buffer.selection; const first = Math.min(buffer.caret.line, selection?.start.line ?? buffer.caret.line); const last = Math.max(buffer.caret.line, selection?.end.line ?? buffer.caret.line); - return {items: hybrid.items.list, active: item => item.to > first && item.from <= last, width: Math.max(8, columns - MARGIN), rawRows: new Map(), look}; + const margin = columns >= 16 ? 4 : 2; + return {hybrid, margin, items: hybrid.items.list, active: item => item.to > first && item.from <= last, width: Math.max(2, columns - margin), rawRows: new Map(), look}; } -function drawRaw(editor: EditorState, line: number, row: RawRow): string { +function drawRaw(editor: EditorState, line: number, row: RawRow, caretHere: boolean): string { const caret = editor.buffer.caret; const selection = editor.buffer.selection; let out = ''; - let caretDrawn = caret.line !== line || caret.column < row.start || caret.column > row.end; + let caretDrawn = !caretHere; for (const cell of row.cells) { const atCaret = !caretDrawn && cell.offset === caret.column; const selected = selection && inside({line, column: cell.offset}, selection.start, selection.end); @@ -224,22 +265,30 @@ export function renderHybrid(editor: EditorState, hybrid: HybridState, columns: const layout = layoutOf(editor, hybrid, columns, look); const caret = editor.buffer.caret; const kind = activeKind(editor, hybrid, columns, look); - const status = `${subtle}Ln ${caret.line + 1}, Col ${caret.column + 1} · ${kind.kind}${kind.lines > 1 ? ` (${kind.lines} lines)` : ''} · Hybrid${RESET}${editor.note ? ` ${editor.note.tone === 'error' ? foreground(UI_COLORS.failure) : subtle}${truncateText(inert(editor.note.text), Math.max(1, columns - 40))}${RESET}` : ''}`; + const status = `${subtle}Ln ${caret.line + 1}, Col ${displayColumn(editor.buffer.lines[caret.line]!, caret.column) + 1} · ${kind.kind}${kind.lines > 1 ? ` (${kind.lines} lines)` : ''} · Hybrid${RESET}${editor.note ? ` ${editor.note.tone === 'error' ? foreground(UI_COLORS.failure) : subtle}${truncateText(inert(editor.note.text), Math.max(1, columns - 40))}${RESET}` : ''}`; const footer = renderControlRows([['Ctrl+S', 'save'], ['Esc', 'source'], ['Ctrl+O', 'preview'], ['F3', 'source editor'], ['F2', 'split'], ['F1', 'Markdown']], Math.max(1, columns - 4)); const body = Math.max(1, height - 2 - 1 - 1 - footer.length); - revealCaret(editor, hybrid, layout, body); + if (!hybrid.free) revealCaret(editor, hybrid, layout, body); const title = truncateText(editor.file.title, Math.max(8, Math.floor((columns - 4) * 0.6))); const out = [` ${primary}${title}${RESET}${editor.buffer.dirty ? `${warning} ● edited${RESET}` : ''} ${subtle}hybrid · ${editor.buffer.lines.length.toLocaleString('en-US')} lines${RESET}`]; const bar = `${accent}${safe ? '|' : '▎'}${RESET}`; + const lead = layout.margin === 4 ? ' ' : ''; + const plain = ' '.repeat(layout.margin); let index = itemAt(layout.items, hybrid.top.line), sub = hybrid.top.sub; while (out.length - 1 < body && index < layout.items.length) { const item = layout.items[index]!; + const holdsCaret = item.from <= caret.line && caret.line < item.to; if (layout.active(item)) { - for (const entry of rawOf(editor, layout, index).slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${bar} ${drawRaw(editor, entry.line, entry.row)}`); } + const entries = rawOf(editor, layout, index); + const caretAt = holdsCaret ? caretEntry(entries, caret) : -1; + for (let at = sub; at < entries.length; at += 1) { if (out.length - 1 >= body) break; out.push(`${lead}${bar} ${drawRaw(editor, entries[at]!.line, entries[at]!.row, at === caretAt)}`); } } else { const rows = renderedOf(editor, hybrid, layout, index); - if (rows) { for (const row of rows.slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${truncateAnsi(row, layout.width)}${RESET}`); } } - else for (const entry of rawOf(editor, layout, index).slice(sub)) { if (out.length - 1 >= body) break; out.push(` ${subtle}${drawRaw(editor, entry.line, entry.row)}${RESET}`); } + if (rows) { for (const row of rows.slice(sub)) { if (out.length - 1 >= body) break; out.push(`${plain}${truncateAnsi(row, layout.width)}${RESET}`); } } + else { + const entries = rawOf(editor, layout, index); + for (let at = sub; at < entries.length; at += 1) { if (out.length - 1 >= body) break; out.push(`${plain}${subtle}${drawRaw(editor, entries[at]!.line, entries[at]!.row, false)}${RESET}`); } + } } index += 1; sub = 0; } diff --git a/tests/hybrid.test.ts b/tests/hybrid.test.ts index d5fd717b..bab3bdb9 100644 --- a/tests/hybrid.test.ts +++ b/tests/hybrid.test.ts @@ -385,3 +385,133 @@ test('app: an external change while editing in hybrid is reviewed and merged, th assert.match(screenOf(f), /GAMMA/u); } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } }); + +// ---- Findings of the independent review ------------------------------------------------------------------------- + +const REVERSE = '\u001b[7m'; +const rawDraw = (state: DocumentSession, columns: number, height: number, look = {}) => renderDocumentSession(state, columns, height, look); + +test('review 1: a lone CR inside a line does not shift the source map; blocks and the outline stay on their own lines', async () => { + const { root, state } = await open('one\rtwo\n\n# Heading\n\ntext here\n', 'cr.md'); + try { + press(state, F3); + const index = state.editor.index ?? undefined; + goto(state, 4); + const view = body(draw(state)); + assert.match(view, /Heading/u); + assert.doesNotMatch(view, /# Heading/u, 'the heading is a rendered block, not a stray raw line'); + press(state, '\u001bOP'); press(state, 'h'); + const headings = state.editor.index!.headings(); + assert.deepEqual(headings.map(heading => [heading.text, heading.line]), [['Heading', 2]]); + void index; + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 2: at a wrap boundary the caret is drawn once and no character is replaced', async () => { + const { root, state } = await open('abcdefghijklmnop\n', 'wrap.md'); + try { + press(state, F3); + for (const column of [7, 8, 9, 15, 16]) { + goto(state, 0, column); + const rows = rawDraw(state, 12, 12); + const active = rows.map(row => stripAnsi(row)).filter(row => row.includes('▎') || row.includes('|')); + assert.ok(!active.join('').includes('…'), `column ${column}: no truncation mark\n${active.join('\n')}`); + assert.equal(rows.join('').split(REVERSE).length - 1, 1, `column ${column}: exactly one caret`); + assert.equal(active.join('').replace(/[^a-p]/gu, ''), 'abcdefghijklmnop', `column ${column}: every character is shown once`); + } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 3: the wheel scrolls the view and the next draw leaves it there; typing brings the caret back', async () => { + const { root, state } = await open(`${LONG}\n`); + try { + press(state, F3); goto(state, 0); + draw(state, 100, 14); + for (let step = 0; step < 5; step += 1) documentSessionKey(state, {kind: 'wheelDown'}, 100, 14); + draw(state, 100, 14); + const scrolled = state.hybrid!.top.line; + assert.ok(scrolled > 0, 'the view moved'); + draw(state, 100, 14); + assert.equal(state.hybrid!.top.line, scrolled, 'drawing again does not undo the scroll'); + press(state, 'x', 100, 14); + draw(state, 100, 14); + assert.equal(state.hybrid!.top.line, 0, 'a key that edits shows the caret again'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 3b: in the split with a hybrid text pane the wheel and page keys move the hybrid view, not an invisible editor viewport', async () => { + const { root, state } = await open(`${LONG}\n`); + try { + press(state, F3, 140, 14); press(state, F2, 140, 14); + draw(state, 140, 14); + documentSessionKey(state, {kind: 'wheelDown'}, 140, 14); documentSessionKey(state, {kind: 'wheelDown'}, 140, 14); + draw(state, 140, 14); + assert.ok(state.hybrid!.top.line > 0); + const before = state.editor.buffer.caret.line; + documentSessionKey(state, {kind: 'pageDown'}, 140, 14); + assert.ok(state.editor.buffer.caret.line > before); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 4: the render cache is bounded in bytes and keeps only the current width', async () => { + const list = Array.from({length: 3000}, (_, index) => `- item ${index} with some words to render`).join('\n'); + const { root, state } = await open(`# Top\n\n${list}\n`); + try { + goto(state, 0); press(state, F3); + for (let columns = 12; columns <= 200; columns += 4) { draw(state, columns, 24); } + assert.ok(state.hybrid!.cache.size <= 8, `${state.hybrid!.cache.size} cached renders after sweeping 48 widths`); + assert.ok(state.hybrid!.cacheBytes < 4_000_000, `${state.hybrid!.cacheBytes} bytes cached`); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 5: a huge active block is wrapped once; moving the caret and typing wrap only what changed', async () => { + const list = Array.from({length: 5000}, (_, index) => `- item ${index} with some words to wrap`).join('\n'); + const { root, state } = await open(`${list}\n`); + try { + goto(state, 2500); press(state, F3); + draw(state, 100, 30); + const wraps = state.hybrid!.stats.wraps; + for (let step = 0; step < 20; step += 1) { press(state, '\u001b[B'); draw(state, 100, 30); } + assert.equal(state.hybrid!.stats.wraps, wraps, 'caret moves wrap nothing new'); + press(state, 'x'); draw(state, 100, 30); + assert.ok(state.hybrid!.stats.wraps - wraps <= 2, `typing re-wrapped ${state.hybrid!.stats.wraps - wraps} lines`); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 6: changing the link or colour setting shows in the next draw', async () => { + const { root, state } = await open('# Top\n\nSee [the site](https://example.com/page) now.\n\nlast\n'); + try { + goto(state, 4); press(state, F3); + assert.ok(!rawDraw(state, 100, 20, {}).join('').includes('\u001b]8;')); + assert.ok(rawDraw(state, 100, 20, {hyperlinks: true}).join('').includes('\u001b]8;'), 'links become clickable when the host supports them'); + assert.ok(!rawDraw(state, 100, 20, {}).join('').includes('\u001b]8;')); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 7 and 8: the caret is on screen with a one-row body and in terminals 6–11 columns wide', async () => { + const list = Array.from({length: 30}, (_, index) => `- item ${index}`).join('\n'); + const { root, state } = await open(`${list}\n`); + try { + press(state, F3); + for (const height of [4, 5, 6, 7, 8, 9, 12]) for (const columns of [20, 40, 100]) { + for (const line of [25, 24, 0, 29, 12, 13, 12]) { goto(state, line, 2); const rows = rawDraw(state, columns, height); assert.ok(rows.join('').includes(REVERSE), `caret visible at ${columns}x${height}, line ${line}`); } + goto(state, 25, 2); press(state, '\u001b[A', columns, height); + assert.ok(rawDraw(state, columns, height).join('').includes(REVERSE), `after Up at ${columns}x${height}`); + } + for (const columns of [6, 7, 8, 9, 10, 11]) { goto(state, 3, 6); assert.ok(rawDraw(state, columns, 12).join('').includes(REVERSE), `${columns} columns`); } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('review 9: reloading from disk while in hybrid keeps the hybrid view', async () => { + const f = executionFrameFixture(110, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-hybrid-reload-'))); + try { + const file = join(root, 'doc.md'); writeFileSync(file, '# A\n\ntext\n'); + await f.app['openDocumentSession'](file, '/edit doc.md', 'edit', false); + keysTo(f, '\u001bOR'); + const session = f.app['docSession']!; + f.app['runDocumentAction'](session, {kind: 'reload'}); + await waitFor(() => f.app['docSession'] !== session && !f.app['docSession']!.editor.busy, 'the reload'); + assert.equal(f.app['docSession']!.mode, 'hybrid'); + } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } +});