diff --git a/.gitignore b/.gitignore index e15a287034..7efeb2ee24 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ bun.lockb dist .amp/ .worktrees/ + +# macOS Finder metadata +.DS_Store diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs index 653401ce7e..2988ad5072 100644 --- a/crates/fresh-core/src/api.rs +++ b/crates/fresh-core/src/api.rs @@ -2572,6 +2572,38 @@ pub enum PluginCommand { namespace: String, }, + /// Place an inline raster image (kitty graphics protocol) anchored to a + /// buffer position. The core reserves `rows` virtual lines × `cols` + /// columns and renders kitty Unicode placeholders over them, so the image + /// scrolls with the text. No-op (the plugin keeps its text fallback) on + /// terminals without graphics support. A generic rendering primitive: + /// the core doesn't know what the image shows — plugins use it to + /// render any file's content (diagrams, pictures, plots, previews, …). + PlaceImage { + buffer_id: BufferId, + /// Content key for dedup/reuse across re-renders (e.g. a hash of the + /// image bytes). Re-placing the same key reuses the transmitted image. + key: String, + /// Absolute path to a PNG the terminal can read. + source: String, + /// Byte position whose line the image is anchored to. + position: usize, + /// Placement width / height in terminal cells. + cols: u16, + rows: u16, + /// true = above the anchored line, false = below. + above: bool, + /// Namespace for bulk removal via [`PluginCommand::ClearImages`]. + namespace: String, + }, + + /// Remove all images (and their reserved placeholder rows) placed under a + /// namespace, and free the terminal-side pixel data. + ClearImages { + buffer_id: BufferId, + namespace: String, + }, + /// Add a conceal range that hides or replaces a byte range during rendering. /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc. AddConceal { @@ -2599,6 +2631,17 @@ pub enum PluginCommand { end: usize, }, + /// Remove conceal ranges in a byte range, restricted to one namespace. + /// Lets a plugin rebuild its own conceals for a line without wiping + /// ranges other plugins placed there (same motivation as + /// `ClearOverlaysInRangeForNamespace`). + ClearConcealsInRangeForNamespace { + buffer_id: BufferId, + namespace: OverlayNamespace, + start: usize, + end: usize, + }, + /// Add a collapsed fold range. Hides the byte range /// `[start, end)` from rendering — the line containing `start - 1` /// (the fold's "header") stays visible while the lines covered by diff --git a/crates/fresh-core/src/graphics.rs b/crates/fresh-core/src/graphics.rs new file mode 100644 index 0000000000..e3591a2c99 --- /dev/null +++ b/crates/fresh-core/src/graphics.rs @@ -0,0 +1,129 @@ +//! Terminal raster-graphics capability detection. +//! +//! Shared by the editor (which gates inline-image placement on it) and the +//! plugin runtime (which exposes it to plugins via +//! `editor.getGraphicsCapability()`), so both sides always agree on what the +//! terminal supports. Detection is purely environment-based. + +/// Terminal raster-graphics capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicsCapability { + /// No known raster-graphics support; images fall back to text. + None, + /// Kitty graphics protocol (kitty, WezTerm, Ghostty, recent Konsole). + Kitty, +} + +impl GraphicsCapability { + /// Detect graphics capability from the environment. Override with the + /// `FRESH_GRAPHICS` env var (`kitty` / `none`). + pub fn detect() -> Self { + Self::detect_from(|name| std::env::var(name).ok()) + } + + /// Detection core, parameterized over an environment lookup so it can be + /// unit-tested without touching the process environment. + pub fn detect_from(get: impl Fn(&str) -> Option) -> Self { + if let Some(v) = get("FRESH_GRAPHICS") { + match v.to_lowercase().as_str() { + "kitty" | "on" | "1" | "true" => return GraphicsCapability::Kitty, + "none" | "off" | "0" | "false" => return GraphicsCapability::None, + _ => {} + } + } + + // kitty sets KITTY_WINDOW_ID for every window. + if get("KITTY_WINDOW_ID").is_some() { + return GraphicsCapability::Kitty; + } + + // WezTerm / Ghostty advertise themselves and both speak the kitty + // graphics protocol. + if let Some(tp) = get("TERM_PROGRAM") { + let t = tp.to_lowercase(); + if t.contains("wezterm") || t.contains("ghostty") { + return GraphicsCapability::Kitty; + } + } + if get("GHOSTTY_RESOURCES_DIR").is_some() || get("GHOSTTY_BIN_DIR").is_some() { + return GraphicsCapability::Kitty; + } + + if let Some(term) = get("TERM") { + let t = term.to_lowercase(); + if t.contains("kitty") || t.contains("ghostty") || t.contains("wezterm") { + return GraphicsCapability::Kitty; + } + } + + GraphicsCapability::None + } + + pub fn supports_images(self) -> bool { + matches!(self, GraphicsCapability::Kitty) + } + + /// Stable string form exposed to plugins (`"kitty"` / `"none"`). + pub fn as_str(self) -> &'static str { + match self { + GraphicsCapability::None => "none", + GraphicsCapability::Kitty => "kitty", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_respects_override() { + let kitty = GraphicsCapability::detect_from(|n| { + if n == "FRESH_GRAPHICS" { + Some("kitty".to_string()) + } else { + None + } + }); + assert_eq!(kitty, GraphicsCapability::Kitty); + + let off = GraphicsCapability::detect_from(|n| { + if n == "FRESH_GRAPHICS" { + Some("none".to_string()) + } else { + None + } + }); + assert_eq!(off, GraphicsCapability::None); + } + + #[test] + fn detect_recognizes_kitty_and_wezterm() { + let kitty = GraphicsCapability::detect_from(|n| { + if n == "KITTY_WINDOW_ID" { + Some("1".to_string()) + } else { + None + } + }); + assert_eq!(kitty, GraphicsCapability::Kitty); + + let wez = GraphicsCapability::detect_from(|n| { + if n == "TERM_PROGRAM" { + Some("WezTerm".to_string()) + } else { + None + } + }); + assert_eq!(wez, GraphicsCapability::Kitty); + + let plain = GraphicsCapability::detect_from(|_| None); + assert_eq!(plain, GraphicsCapability::None); + } + + #[test] + fn as_str_round_trips() { + assert_eq!(GraphicsCapability::Kitty.as_str(), "kitty"); + assert_eq!(GraphicsCapability::None.as_str(), "none"); + } +} diff --git a/crates/fresh-core/src/lib.rs b/crates/fresh-core/src/lib.rs index 40c67184cd..223f31e91d 100644 --- a/crates/fresh-core/src/lib.rs +++ b/crates/fresh-core/src/lib.rs @@ -125,6 +125,7 @@ impl std::fmt::Display for WindowTerminalId { pub mod config; pub mod file_explorer; pub mod file_uri; +pub mod graphics; pub mod menu; pub mod overlay; pub mod plugin_schemas; diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index f27fcbecca..708da5ab14 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -603,15 +603,39 @@ type FileExplorerDecoration = { */ priority: number; }; -type FileExplorerTooltip = { +type FileExplorerSlotEntry = { /** - * Tooltip title shown in the popup border. + * File or directory path to override. */ - title: string; + path: string; /** - * Body lines shown inside the popup. + * Optional leading-slot override. */ - lines: Array; + leading: FileExplorerLeadingSlot | null; + /** + * Explicitly suppress the compatibility leading slot for this path. + */ + suppressLeading: boolean; + /** + * Optional trailing-slot override. + */ + trailing: FileExplorerTrailingSlot | null; + /** + * Explicitly suppress the compatibility trailing slot for this path. + */ + suppressTrailing: boolean; + /** + * Optional filename colour override. + */ + nameColor: OverlayColorSpec | null; + /** + * Explicitly suppress compatibility filename colouring for this path. + */ + suppressNameColor: boolean; + /** + * Priority for display when multiple overrides exist (higher wins). + */ + priority: number; }; type FileExplorerLeadingSlot = { /** @@ -625,7 +649,7 @@ type FileExplorerLeadingSlot = { /** * Minimum display width reserved for the leading slot. */ - minWidth?: number; + minWidth: number; }; type FileExplorerTrailingSlot = { /** @@ -639,41 +663,17 @@ type FileExplorerTrailingSlot = { /** * Optional tooltip shown when hovering the trailing slot. */ - tooltip?: FileExplorerTooltip | null; + tooltip: FileExplorerTooltip | null; }; -type FileExplorerSlotEntry = { - /** - * File or directory path to override. - */ - path: string; - /** - * Optional leading-slot override. - */ - leading?: FileExplorerLeadingSlot | null; - /** - * Explicitly suppress the compatibility leading slot for this path. - */ - suppressLeading?: boolean; - /** - * Optional trailing-slot override. - */ - trailing?: FileExplorerTrailingSlot | null; - /** - * Explicitly suppress the compatibility trailing slot for this path. - */ - suppressTrailing?: boolean; - /** - * Optional filename colour override. - */ - nameColor?: OverlayColorSpec | null; +type FileExplorerTooltip = { /** - * Explicitly suppress compatibility filename colouring for this path. + * Tooltip title shown in the popup border. */ - suppressNameColor?: boolean; + title: string; /** - * Priority for display when multiple overrides exist (higher wins). + * Body lines shown inside the popup. */ - priority?: number; + lines: Array; }; type FormatterPackConfig = { /** @@ -2157,6 +2157,14 @@ interface EditorAPI { */ readFile(path: string): string | null; /** + * Read up to `max_len` bytes from a file starting at byte `offset`, + * returned as an array of byte values (0-255). Returns `null` if the + * file can't be opened or read. Intended for lightweight binary + * inspection — e.g. parsing an image header for its dimensions — not + * for bulk reads: `max_len` is capped at 1 MiB. + */ + readFileBytes(path: string, offset: number, maxLen: number): number[] | null; + /** * Write file contents */ writeFile(path: string, content: string): boolean; @@ -2502,6 +2510,11 @@ interface EditorAPI { */ clearConcealsInRange(bufferId: number, start: number, end: number): boolean; /** + * Clear conceal ranges overlapping a byte range, restricted to one + * namespace — other plugins' conceals in the range are untouched. + */ + clearConcealsInRangeForNamespace(bufferId: number, namespace: string, start: number, end: number): boolean; + /** * Add a collapsed fold range. Hides bytes [start, end) from * rendering — the line containing `start - 1` (the fold "header") * stays visible, while subsequent lines covered by the range are @@ -2567,10 +2580,9 @@ interface EditorAPI { */ clearFileExplorerDecorations(namespace: string): boolean; /** - * Set file explorer slot overrides for a namespace. Any omitted fields - * fall back to the editor's default file-explorer providers. + * Set file explorer slot overrides for a namespace */ - setFileExplorerSlots(namespace: string, slots: FileExplorerSlotEntry[]): boolean; + setFileExplorerSlots(namespace: string, slots: Record[]): boolean; /** * Clear file explorer slot overrides for a namespace */ @@ -2621,6 +2633,31 @@ interface EditorAPI { */ addVirtualLine(bufferId: number, position: number, text: string, options: Record, above: boolean, namespace: string, priority: number): boolean; /** + * Place an inline image (kitty graphics protocol) anchored to a buffer + * position. `source` is an absolute path to a PNG; `cols`/`rows` are the + * placement size in terminal cells. Returns true if the command was + * dispatched. On terminals without graphics support the core treats it + * as a no-op, so the caller should keep a visible text fallback. + */ + placeImage(bufferId: number, key: string, source: string, position: number, cols: number, rows: number, above: boolean, namespace: string): boolean; + /** + * Remove all images placed under `namespace` (and their reserved rows) + * and free the terminal-side image data. + */ + clearImages(bufferId: number, namespace: string): boolean; + /** + * The terminal's raster-graphics capability: `"kitty"` if inline images + * placed via `placeImage` will actually render (kitty graphics + * protocol — kitty, WezTerm, Ghostty, …), `"none"` otherwise. + * + * Check this before doing expensive rendering work (rasterizing + * diagrams, converting image formats): on `"none"` terminals + * `placeImage` is a no-op in the core, so skip the work and keep a text + * fallback instead. Overridable by the user with the `FRESH_GRAPHICS` + * env var (`kitty` / `none`). + */ + getGraphicsCapability(): string; + /** * Show a prompt and wait for user input (async) * Returns the user input or null if cancelled */ diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index af02d1acab..ce0b9ce161 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -58,6 +58,82 @@ function isComposingInAnySplit(bufferId: number): boolean { return info != null && info.is_composing_in_any_split; } +// --------------------------------------------------------------------------- +// Fenced-code-block context. +// +// The per-line pipeline (processLineConceals / processLineSoftBreaks / +// processTableAlignment) has no cross-line context, so without help it +// treats lines INSIDE ``` fences as markdown — e.g. a TypeScript union +// `| 'item_assigned'` grows table borders. We cache the byte ranges of all +// fenced blocks per buffer (rebuilt on enable and after edits) and skip +// markdown processing for any line inside one. +// --------------------------------------------------------------------------- +const fenceRangesByBuffer = new Map>(); + +// UTF-8 byte length of a JS string (buffer offsets are UTF-8 bytes). +function utf8ByteLen(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + const c = s.codePointAt(i) as number; + if (c > 0xffff) i++; // surrogate pair consumed two UTF-16 units + n += c <= 0x7f ? 1 : c <= 0x7ff ? 2 : c <= 0xffff ? 3 : 4; + } + return n; +} + +// Rebuilds by reading the WHOLE buffer: fences are cross-line state, so a +// partial scan can't tell whether an edit opened or closed one. There is no +// size cap — markdown files are typically small, and the scan is a single +// linear string pass. If huge generated markdown ever makes edits feel slow, +// bail above a byte threshold here (stale ranges degrade gracefully: lines +// just get markdown styling they shouldn't). +async function rebuildFenceRanges(bufferId: number): Promise { + try { + const len = editor.getBufferLength(bufferId); + const text = await editor.getBufferText(bufferId, 0, len); + if (typeof text !== "string") return; + const ranges: Array<{ start: number; end: number }> = []; + const lines = text.split("\n"); + let offset = 0; + let openStart: number | null = null; + let fenceChar = ""; + for (const line of lines) { + const lineStart = offset; + const lineEnd = lineStart + utf8ByteLen(line); + offset = lineEnd + 1; // '\n' + const trimmed = line.trim(); + if (openStart === null) { + const m = trimmed.match(/^(```+|~~~+)/); + if (m) { + openStart = lineStart; + fenceChar = m[1][0]; + } + } else if ( + trimmed.startsWith(fenceChar.repeat(3)) && + trimmed.split(fenceChar).join("").trim() === "" + ) { + ranges.push({ start: openStart, end: lineEnd }); + openStart = null; + } + } + if (openStart !== null) ranges.push({ start: openStart, end: offset }); + fenceRangesByBuffer.set(bufferId, ranges); + } catch (_e) { + /* keep the previous ranges on failure */ + } +} + +// True if `byte` falls inside a cached fenced code block (the opening and +// closing fence lines themselves count as inside). +function insideFence(bufferId: number, byte: number): boolean { + const ranges = fenceRangesByBuffer.get(bufferId); + if (!ranges) return false; + for (const r of ranges) { + if (byte >= r.start && byte <= r.end) return true; + } + return false; +} + // Helper: get cached table column widths from per-buffer-per-split view state function getTableWidths(bufferId: number): Map | undefined { const obj = editor.getViewState(bufferId, "table-widths") as Record | undefined; @@ -141,6 +217,115 @@ function isTableSeparatorContent(lineContent: string): boolean { return /^\|[-:\s|]+\|$/.test(lineContent.trim()); } +/** True if the `|` at char offset `i` is escaped (`\|`) — i.e. preceded by an + * odd run of backslashes. Escaped pipes are cell *content*, not column + * separators, and must not split the row. */ +function isEscapedPipe(line: string, i: number): boolean { + let bs = 0; + for (let j = i - 1; j >= 0 && line[j] === '\\'; j--) bs++; + return bs % 2 === 1; +} + +/** Char offsets of every *unescaped* `|` in `line` (the column separators). + * Escaped pipes (`\|`) are skipped — they render as a literal `|` inside a + * cell rather than ending it. */ +function tablePipePositions(line: string): number[] { + const pos: number[] = []; + for (let i = 0; i < line.length; i++) { + if (line[i] === '|' && !isEscapedPipe(line, i)) pos.push(i); + } + return pos; +} + +/** Strip a table row's outer border pipes, leaving the inner cell text. A + * trailing `|` that is escaped (`\|`) is cell content, not a border. */ +function tableRowInner(trimmed: string): string { + let inner = trimmed; + if (inner.startsWith('|')) inner = inner.slice(1); + if (inner.endsWith('|') && !isEscapedPipe(inner, inner.length - 1)) { + inner = inner.slice(0, -1); + } + return inner; +} + +/** Split a table row's inner text (outer pipes already stripped via + * `tableRowInner`) into cells on *unescaped* pipes, unescaping `\|` → `|` + * within each cell so the literal pipe renders as one character. */ +function splitTableCells(inner: string): string[] { + const cells: string[] = []; + let cur = ''; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '\\' && inner[i + 1] === '|') { + cur += '|'; + i++; + continue; + } + if (ch === '|') { + cells.push(cur); + cur = ''; + continue; + } + cur += ch; + } + cells.push(cur); + return cells; +} + +/** + * Wrap a markdown table data row into per-column visual-line fragments. + * + * `colWidths[i]` is the *total* allocated cell width (including the two + * inside-padding spaces), matching `buildTableBorderLine`. Each column's text + * is word-wrapped to `colWidths[i] - 2` cells. Returns the per-column fragment + * arrays plus `maxVisualLines` — the number of stacked visual rows this row + * occupies (the tallest column). Cells are read through `concealedText` (so + * emphasis/link markers are hidden) unless `raw` is set. + * + * This is the single source of truth shared by the first-visual-line conceal + * (`processLineConceals`) and the continuation virtual lines + * (`processTableBorders`), so the two never disagree about row height. + */ +function wrapTableRow( + lineContent: string, + colWidths: number[], + raw: boolean, +): { cellWrapped: string[][]; numCols: number; maxVisualLines: number } { + const cells = splitTableCells(tableRowInner(lineContent.trim())); + const numCols = Math.min(cells.length, colWidths.length); + const cellWrapped: string[][] = []; + let maxVisualLines = 1; + for (let ci = 0; ci < numCols; ci++) { + const cellText = (raw ? cells[ci] : concealedText(cells[ci])).trim(); + const wrapW = Math.max(1, colWidths[ci] - 2); + const wrapped = wrapText(cellText, wrapW); + cellWrapped.push(wrapped); + maxVisualLines = Math.max(maxVisualLines, wrapped.length); + } + return { cellWrapped, numCols, maxVisualLines }; +} + +/** + * Render visual row `vl` of a wrapped table row as `│ c0 │ c1 │ … │`, each + * column padded to its allocated width. Columns with no fragment at `vl` + * render as blank padding, so a short column sits empty while a tall prose + * column keeps wrapping — exactly how a rendered README table lays out. + */ +function buildTableRowVisualLine( + cellWrapped: string[][], + colWidths: number[], + numCols: number, + vl: number, +): string { + let line = '│'; + for (let ci = 0; ci < numCols; ci++) { + const wrapW = Math.max(1, colWidths[ci] - 2); + const frag = (cellWrapped[ci] && cellWrapped[ci][vl]) || ''; + line += ' ' + frag + ' '.repeat(Math.max(0, wrapW - displayWidth(frag))) + ' │'; + } + return line; +} + /** Re-emit the table border virtual lines for the given table-row group. * * Detects the group's first/last visible rows by consulting `widthMap` @@ -157,6 +342,7 @@ function processTableBorders( content: string; }>, widthMap: Map, + cursors: number[], ): void { // Use theme keys (resolved at render time so the borders follow theme // changes — same pattern as addOverlay's fg/bg options). @@ -177,7 +363,9 @@ function processTableBorders( editor.clearVirtualTextNamespace(bufferId, ns); const trimmed = line.content.trim(); - const isTableRow = trimmed.startsWith("|") || trimmed.endsWith("|"); + const isTableRow = + (trimmed.startsWith("|") || trimmed.endsWith("|")) && + !insideFence(bufferId, line.byte_start); if (!isTableRow) continue; const widthInfo = widthMap.get(line.line_number); @@ -230,9 +418,36 @@ function processTableBorders( ); } + // Wrapped-cell continuation lines: the row's first visual line is rendered + // in place (conceals in processLineConceals); visual lines 2..N stack below + // the row as virtual lines. Suppressed on the source-separator row and + // while the cursor is on the row (it shows raw source for editing). The + // ascending priority `vl` keeps them ordered, below the row but above the + // high-priority bottom border emitted next. + const cursorOnRow = cursors.some(c => c >= line.byte_start && c < line.byte_end); + if (!isSourceSep && !cursorOnRow) { + const { cellWrapped, numCols, maxVisualLines } = + wrapTableRow(line.content, allocated, false); + if (maxVisualLines > 1) { + const anchor = Math.max(line.byte_start, line.byte_end - 1); + for (let vl = 1; vl < maxVisualLines; vl++) { + editor.addVirtualLine( + bufferId, + anchor, + buildTableRowVisualLine(cellWrapped, allocated, numCols, vl), + borderOptions, + false, // below + ns, + vl, + ); + } + } + } + // Bottom border: only below the last known row of the table. // └─┴─┘ — closes the frame. Anchor at the END of the row's bytes - // (one before the trailing newline) and place "below". + // (one before the trailing newline) and place "below". Priority is high + // so it renders after any wrapped-cell continuation lines above it. if (!nextIsTable) { // byte_end points just past the newline; anchor at last byte of // the row content so the virtual line renders directly under it. @@ -244,7 +459,7 @@ function processTableBorders( borderOptions, false, // below ns, - 0, + 1000, ); } } @@ -562,7 +777,13 @@ function enableMarkdownCompose(bufferId: number): void { // Set layout hints for centered margins editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth ?? undefined }); - // Trigger a refresh so lines_changed hooks fire for visible content + // Two refreshes, deliberately: the synchronous one below paints compose + // mode immediately (no blank frame while the buffer read awaits); the one + // chained on rebuildFenceRanges repaints once the fence cache exists, fixing + // any lines the first pass styled as markdown that are actually inside a + // fence. Dropping the first refresh trades a visible delay for the flicker; + // dropping the second leaves fence interiors mis-styled until the next edit. + void rebuildFenceRanges(bufferId).then(() => editor.refreshLines(bufferId)); editor.refreshLines(bufferId); editor.debug(`Markdown compose enabled for buffer ${bufferId}`); } @@ -584,6 +805,7 @@ function disableMarkdownCompose(bufferId: number): void { editor.clearNamespace(bufferId, "md-emphasis"); editor.clearConcealNamespace(bufferId, "md-syntax"); editor.clearSoftBreakNamespace(bufferId, "md-wrap"); + fenceRangesByBuffer.delete(bufferId); editor.refreshLines(bufferId); editor.debug(`Markdown compose disabled for buffer ${bufferId}`); @@ -814,8 +1036,21 @@ function charToByte(lineContent: string, charOffset: number, lineByteStart: numb // conceals + overlays) and concealedText (to compute visible table widths). // --------------------------------------------------------------------------- +// Superscript form of a footnote label: numeric labels map to Unicode +// superscript digits (¹, ²³, …); non-numeric labels keep a compact caret +// form (^note) since most of the alphabet has no superscript codepoint. +const SUPERSCRIPT_DIGITS = "⁰¹²³⁴⁵⁶⁷⁸⁹"; +function superscriptLabel(label: string): string { + let out = ""; + for (const ch of label) { + if (ch < "0" || ch > "9") return "^" + label; + out += SUPERSCRIPT_DIGITS[ch.charCodeAt(0) - 48]; + } + return out; +} + interface InlineSpan { - type: 'code' | 'bold-italic' | 'bold' | 'italic' | 'strikethrough' | 'link' | 'entity'; + type: 'code' | 'bold-italic' | 'bold' | 'italic' | 'strikethrough' | 'link' | 'entity' | 'footnote'; matchStart: number; // char offset of full match start matchEnd: number; // char offset of full match end contentStart: number; // char offset of visible content start @@ -899,6 +1134,22 @@ function findInlineSpans(text: string): InlineSpan[] { }); } + // 3b. Footnote references: [^1] → superscript ¹. Definition lines + // (`[^1]: text`) are excluded by the (?!:) guard and handled per-line in + // processLineConceals. + const footnoteRe = /\[\^([^\]\s]+)\](?!:)/g; + while ((m = footnoteRe.exec(text)) !== null) { + if (inCodeSpan(m.index)) continue; + const ms = m.index; + const me = ms + m[0].length; + spans.push({ + type: 'footnote', + matchStart: ms, matchEnd: me, + contentStart: ms, contentEnd: me, + concealRanges: [{ start: ms, end: me, replacement: superscriptLabel(m[1]) }], + }); + } + // 4. HTML entities const namedEntityRe = /&(nbsp|amp|lt|gt|mdash|ndash|hellip|rsquo|lsquo|rdquo|ldquo|bull|middot|copy|reg|trade|times|divide|plusmn|deg|frac12|frac14|rarr|larr|harr|uarr|darr|euro|pound|yen|cent|sect|para|laquo|raquo|ensp|emsp|thinsp);/g; while ((m = namedEntityRe.exec(text)) !== null) { @@ -983,40 +1234,186 @@ function effectiveComposeWidth(viewportWidth: number): number { return Math.min(cw, viewportWidth); } +// A table's right edge must never land in the final terminal column — most +// terminals auto-wrap the cursor there, which pushes the closing border glyph +// onto its own screen row. One cell of slack keeps the frame intact. +const TABLE_RIGHT_MARGIN = 1; + /** - * W3C-inspired column width distribution. - * Constrains columns to fit within `available` width, distributing space - * proportionally to each column's natural (max) width. + * Cells available to a table's *columns* (i.e. excluding the `numCols + 1` + * vertical border glyphs), for a given viewport width. + * + * Tables are laid out like a rendered README table, not stretched edge-to-edge: + * the total frame is capped at the configured compose width (or `maxWidth` when + * none is set) and clamped to the viewport, minus a one-cell right margin. On a + * wide terminal this keeps a 4-column status table readable instead of fanning + * the prose column across 150+ cells; on a narrow one it shrinks to fit. + */ +function tableAvailableWidth(viewportWidth: number, numCols: number): number { + const cap = config.composeWidth != null ? config.composeWidth : config.maxWidth; + const frame = Math.min(cap, viewportWidth) - TABLE_RIGHT_MARGIN; + return frame - (numCols + 1); +} + +/** + * Column width distribution via water-filling ("max-content with fair + * shrinking"), the model browsers and pandoc use for `table-layout: auto`. + * + * The old approach distributed the available width *proportionally to each + * column's natural width*, which over-rewards a column that is already wide + * (a prose "Notes" column) and squeezes short label columns far below their + * natural width — so a single word like `workflowTransitions` gets chopped + * mid-word into `workflowTr`/`ansitions` even though a much wider neighbour + * could have absorbed all the shrinking. + * + * Water-filling instead finds the largest cap C such that + * sum(clamp(maxW[i], MIN_COL_W, C)) <= available. + * Columns narrower than C keep their natural width untouched (so short label + * columns never wrap), and only columns wider than C are clipped to C — the + * deficit comes entirely out of the table's widest prose columns, which wrap + * cleanly at word boundaries. */ function distributeColumnWidths(maxW: number[], available: number): number[] { const numCols = maxW.length; + if (numCols === 0) return []; const total = maxW.reduce((s, w) => s + w, 0); - if (total <= available) return maxW; + if (total <= available) return maxW.slice(); + // Not even room for every column's minimum: give everyone the floor. if (numCols * MIN_COL_W >= available) return maxW.map(() => MIN_COL_W); - const remaining = available - numCols * MIN_COL_W; - const excess = maxW.reduce((s, w) => s + Math.max(0, w - MIN_COL_W), 0); - return maxW.map(w => { - const extra = excess > 0 ? Math.floor(remaining * Math.max(0, w - MIN_COL_W) / excess) : 0; - return MIN_COL_W + extra; - }); + // Width consumed if no column is allowed past `cap` (and none below the + // floor). Monotonic non-decreasing in `cap`, so binary-searchable. + const widthAt = (cap: number) => + maxW.reduce((s, w) => s + Math.min(Math.max(w, MIN_COL_W), cap), 0); + + // Largest cap whose total still fits. widthAt(MIN_COL_W) = numCols*MIN_COL_W, + // already known to be < available, so `lo` starts valid. + let lo = MIN_COL_W; + let hi = Math.max(...maxW); + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (widthAt(mid) <= available) lo = mid; + else hi = mid - 1; + } + const cap = lo; + const widths = maxW.map(w => Math.min(Math.max(w, MIN_COL_W), cap)); + + // Integer flooring at the cap can leave a few cells unspent; hand them to + // the capped (widest) columns, widest-natural first, so the prose column + // reclaims every available cell instead of leaving a ragged right edge. + let leftover = available - widths.reduce((s, w) => s + w, 0); + const capped = maxW + .map((w, i) => ({ i, w })) + .filter(c => c.w > cap) + .sort((a, b) => b.w - a.w); + for (let k = 0; leftover > 0 && capped.length > 0; k++, leftover--) { + widths[capped[k % capped.length].i]++; + } + return widths; +} + +/** + * Terminal cell width of a single code point. Tables are padded to align in + * *cells*, not code units — an emoji like ✅ is one UTF-16 unit but occupies + * two cells, so `.length`-based padding pushes the right border out of line. + */ +function charDisplayWidth(cp: number): number { + // Zero-width: combining marks, ZWJ, zero-width space, variation selectors. + if ( + cp === 0x200b || cp === 0x200d || + (cp >= 0x0300 && cp <= 0x036f) || + (cp >= 0xfe00 && cp <= 0xfe0f) + ) return 0; + // Symbols in U+2300–U+27BF that default to emoji presentation (2 cells in + // most terminals): watches, weather, ✅ ❌ ❓ ❗ ⭕ etc. + if ( + (cp >= 0x231a && cp <= 0x231b) || (cp >= 0x23e9 && cp <= 0x23ec) || + cp === 0x23f0 || cp === 0x23f3 || (cp >= 0x25fd && cp <= 0x25fe) || + (cp >= 0x2614 && cp <= 0x2615) || (cp >= 0x2648 && cp <= 0x2653) || + cp === 0x267f || cp === 0x2693 || cp === 0x26a1 || + (cp >= 0x26aa && cp <= 0x26ab) || (cp >= 0x26bd && cp <= 0x26be) || + (cp >= 0x26c4 && cp <= 0x26c5) || cp === 0x26ce || cp === 0x26d4 || + cp === 0x26ea || (cp >= 0x26f2 && cp <= 0x26f3) || cp === 0x26f5 || + cp === 0x26fa || cp === 0x26fd || cp === 0x2705 || + (cp >= 0x270a && cp <= 0x270b) || cp === 0x2728 || cp === 0x274c || + cp === 0x274e || (cp >= 0x2753 && cp <= 0x2755) || cp === 0x2757 || + (cp >= 0x2795 && cp <= 0x2797) || cp === 0x27b0 || cp === 0x27bf || + (cp >= 0x2b1b && cp <= 0x2b1c) || cp === 0x2b50 || cp === 0x2b55 + ) return 2; + // East Asian Wide / Fullwidth and the main emoji planes. + if ( + (cp >= 0x1100 && cp <= 0x115f) || + (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || + (cp >= 0xac00 && cp <= 0xd7a3) || + (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe4f) || + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f1e6 && cp <= 0x1f1ff) || // regional indicators (flags) + (cp >= 0x1f300 && cp <= 0x1f64f) || + (cp >= 0x1f680 && cp <= 0x1f6ff) || + // Colored circles & squares (🔴🟠🟡🟢🔵🟣🟤🟥🟧🟨🟩🟦🟪🟫): a gap between + // the ranges above and below. 🟡 (U+1F7E1) is a common "Partial" status + // marker; without this it counts as 1 cell but renders as 2, shifting every + // border to its right by one. (The surrounding U+1F700–U+1F77F alchemical + // symbols are NOT emoji-presentation, so the range is deliberately tight.) + (cp >= 0x1f7e0 && cp <= 0x1f7eb) || + (cp >= 0x1f900 && cp <= 0x1faff) || + (cp >= 0x20000 && cp <= 0x3fffd) + ) return 2; + return 1; +} + +/** Display width of a string in terminal cells. */ +function displayWidth(text: string): number { + let w = 0; + let prevBase = 0; + for (const ch of text) { + const cp = ch.codePointAt(0)!; + if (cp === 0xfe0f) { + // Emoji presentation selector upgrades a narrow base char (e.g. ⚠︎→⚠️) + // to two cells. + if (prevBase === 1) { w += 1; prevBase = 2; } + continue; + } + const cw = charDisplayWidth(cp); + w += cw; + if (cw > 0) prevBase = cw; + } + return w; +} + +/** Longest prefix of `text` whose display width is at most `width`. */ +function truncateToWidth(text: string, width: number): string { + let w = 0; + let out = ''; + for (const ch of text) { + const cw = charDisplayWidth(ch.codePointAt(0)!); + if (w + cw > width) break; + out += ch; + w += cw; + } + return out; } /** - * Wrap text into lines of at most `width` characters, breaking at word boundaries. + * Wrap text into lines of at most `width` cells, breaking at word boundaries. */ function wrapText(text: string, width: number): string[] { - if (width <= 0 || text.length <= width) return [text]; + if (width <= 0 || displayWidth(text) <= width) return [text]; const lines: string[] = []; let pos = 0; while (pos < text.length) { - if (pos + width >= text.length) { - lines.push(text.slice(pos)); + const rest = text.slice(pos); + if (displayWidth(rest) <= width) { + lines.push(rest); break; } - let breakAt = text.lastIndexOf(' ', pos + width); + // Char index just past the last char that fits in `width` cells. + const fit = truncateToWidth(rest, width).length || 1; + let breakAt = text.lastIndexOf(' ', pos + fit); if (breakAt <= pos) { - breakAt = pos + width; + breakAt = pos + fit; lines.push(text.slice(pos, breakAt)); pos = breakAt; } else { @@ -1044,23 +1441,211 @@ function processLineConceals( // This ensures clear+add commands are sent together from the plugin thread // and processed atomically in the same process_commands() batch, avoiding // the one-frame glitch where conceals are cleared but not yet rebuilt. - editor.debug(`[mc] processLine clear+rebuild bytes=${byteStart}..${byteEnd} content="${lineContent.slice(0,40)}"`); - editor.clearConcealsInRange(bufferId, byteStart, byteEnd); + // Slice by code POINTS, not UTF-16 units: `.slice(0, 40)` can cut an astral + // char (e.g. an emoji like 🟡) between its surrogate halves, leaving a lone + // surrogate the host's string→UTF-8 conversion rejects — that would throw out + // of this debug line and abort composition for this line and every line after. + editor.debug(`[mc] processLine clear+rebuild bytes=${byteStart}..${byteEnd} content="${[...lineContent].slice(0, 40).join("")}"`); + // Namespace-scoped for the same reason as the overlay clear below: an + // unscoped clear also wiped other plugins' conceals on these lines (e.g. + // fresh-markdown-preview collapsing rendered mermaid blocks). + editor.clearConcealsInRangeForNamespace(bufferId, "md-syntax", byteStart, byteEnd); // Only clear our own emphasis overlays — clearing ALL overlays in the range // would also wipe editor-owned overlays like LSP diagnostics (issue #2146). editor.clearOverlaysInRangeForNamespace(bufferId, "md-emphasis", byteStart, byteEnd); - const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd); + // `byteEnd` points just past this line's trailing newline — i.e. it is the + // first byte of the *next* line. A cursor there belongs to the next line, so + // it must not reveal this line's concealed markers (the bug where a heading's + // `##` stays visible while the cursor sits on the blank line just below it). + // Exclude that boundary — UNLESS the line has no trailing newline (the last + // line of the buffer), where `byteEnd` is the true content end and a cursor + // at it is still editing this line. + const lineEndForCursor = lineContent.endsWith('\n') ? byteEnd - 1 : byteEnd; + const cursorOnLine = cursors.some(c => c >= byteStart && c <= lineEndForCursor); // Strict version: excludes the boundary at byteEnd so that the cursor // sitting at the start of the *next* line doesn't count as being on // *this* line. Used for table row auto-expose to avoid exposing the // previous row's emphasis markers. const cursorStrictlyOnLine = cursors.some(c => c >= byteStart && c < byteEnd); - // Skip lines inside code fences (we'd need multi-line context for this; - // for now, detect fence lines and code content lines) const trimmed = lineContent.trim(); - if (trimmed.startsWith('```')) return; // fence line itself + + // --- Fenced code blocks --- + // Fence marker lines: conceal the ``` markers + language tag (revealed + // while the cursor is on the line) so code blocks render as a clean well. + if (/^(```|~~~)/.test(trimmed)) { + if (!cursorOnLine) { + let effLen = lineContent.length; + if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; + if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; + if (effLen > 0) { + editor.addConceal( + bufferId, + "md-syntax", + byteStart, + charToByte(lineContent, effLen, byteStart), + null, + ); + } + } + return; + } + // Lines inside a fence are code, not markdown: no tables, no emphasis, + // no conceals. (Cross-line context comes from the cached fence ranges.) + if (insideFence(bufferId, byteStart)) return; + + // --- ATX headings --- + // Conceal the `#` markers (revealed while the cursor is on the line) and + // style the heading text by level. A terminal can't change font size, so + // levels are distinguished by color/weight/underline instead. + const headingMatch = lineContent.match(/^(\s{0,3})(#{1,6})\s+/); + if (headingMatch) { + const level = headingMatch[2].length; + const markerStart = charToByte(lineContent, headingMatch[1].length, byteStart); + const markerEnd = charToByte(lineContent, headingMatch[0].length, byteStart); + if (!cursorOnLine) { + editor.addConceal(bufferId, "md-syntax", markerStart, markerEnd, null); + } + let effLen = lineContent.length; + if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; + if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; + const textEnd = charToByte(lineContent, effLen, byteStart); + const headingStyles: Record[] = [ + { fg: "syntax.keyword", bold: true, underline: true }, // H1 + { fg: "syntax.function", bold: true, underline: true }, // H2 + { fg: "syntax.function", bold: true }, // H3 + { fg: "syntax.type", bold: true }, // H4 + { fg: "syntax.constant", bold: true }, // H5 + { fg: "syntax.constant", italic: true }, // H6 + ]; + if (textEnd > markerEnd) { + editor.addOverlay( + bufferId, + "md-emphasis", + markerEnd, + textEnd, + headingStyles[Math.min(level, 6) - 1], + ); + } + // Fall through: headings may still contain inline emphasis/code/links. + } + + // --- Footnote definitions: [^1]: text --- + // The `[^1]:` marker renders as the same superscript the in-text reference + // uses, and the definition text is dimmed, mirroring GitHub's footnotes + // section. Revealed while the cursor is on the line. + const footDefMatch = lineContent.match(/^(\s{0,3})\[\^([^\]\s]+)\]:( ?)/); + if (footDefMatch) { + if (!cursorOnLine) { + editor.addConceal( + bufferId, + "md-syntax", + charToByte(lineContent, footDefMatch[1].length, byteStart), + charToByte(lineContent, footDefMatch[0].length, byteStart), + superscriptLabel(footDefMatch[2]) + " ", + ); + } + let effLen = lineContent.length; + if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; + if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; + const defTextStart = charToByte(lineContent, footDefMatch[0].length, byteStart); + const defTextEnd = charToByte(lineContent, effLen, byteStart); + if (defTextEnd > defTextStart) { + editor.addOverlay(bufferId, "md-emphasis", defTextStart, defTextEnd, { + fg: "syntax.comment", + }); + } + // Fall through: definitions may still contain inline emphasis/code/links. + } + + // --- Block quotes --- + // `> text` (and nested `> > text`): each `>` marker renders as a vertical + // quote bar and the quoted text is dimmed, approximating how GitHub + // displays block quotes. The bar glyph is width-preserving (one cell per + // `>`), so soft-wrap budgets and hanging indents are unaffected. Markers + // are revealed while the cursor is on the line. + const quoteMatch = lineContent.match(/^(\s{0,3})(>(?:[ \t]?>)*)/); + if (quoteMatch) { + const runStart = quoteMatch[1].length; + const markerRun = quoteMatch[2]; + if (!cursorOnLine) { + for (let ci = 0; ci < markerRun.length; ci++) { + if (markerRun[ci] !== '>') continue; + const pos = runStart + ci; + editor.addConceal( + bufferId, + "md-syntax", + charToByte(lineContent, pos, byteStart), + charToByte(lineContent, pos + 1, byteStart), + "▌", + ); + } + } + let effLen = lineContent.length; + if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; + if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; + const textStart = charToByte(lineContent, runStart + markerRun.length, byteStart); + const textEnd = charToByte(lineContent, effLen, byteStart); + if (textEnd > textStart) { + editor.addOverlay(bufferId, "md-emphasis", textStart, textEnd, { + fg: "syntax.comment", + italic: true, + }); + } + // Fall through: quoted text may still contain inline emphasis/code/links. + } + + // --- Horizontal rules --- + // `---` / `***` / `___` render as a rule spanning the compose width + // (revealed while the cursor is on the line). + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + if (!cursorOnLine) { + let effLen = lineContent.length; + if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; + if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; + if (effLen > 0) { + const viewport = editor.getViewport(); + const ruleW = Math.max(3, effectiveComposeWidth(viewport ? viewport.width : 80) - 2); + editor.addConceal( + bufferId, + "md-syntax", + byteStart, + charToByte(lineContent, effLen, byteStart), + "─".repeat(ruleW), + ); + } + } + return; + } + + // --- List bullets and task checkboxes --- + // `- ` / `* ` / `+ ` bullets render as `•` (width-preserving), and task + // boxes `[ ]` / `[x]` render as ☐ / ☑. Both revealed while the cursor is + // on the line. Ordered-list numbers stay as-is — they're already readable. + const bulletMatch = lineContent.match(/^(\s*)([-*+])(\s+)/); + if (bulletMatch && !cursorOnLine) { + const bulletPos = bulletMatch[1].length; + editor.addConceal( + bufferId, + "md-syntax", + charToByte(lineContent, bulletPos, byteStart), + charToByte(lineContent, bulletPos + 1, byteStart), + "•", + ); + const boxMatch = lineContent.slice(bulletMatch[0].length).match(/^\[([ xX])\](?= |$)/); + if (boxMatch) { + const boxPos = bulletMatch[0].length; + editor.addConceal( + bufferId, + "md-syntax", + charToByte(lineContent, boxPos, byteStart), + charToByte(lineContent, boxPos + 3, byteStart), + boxMatch[1] === ' ' ? "☐" : "☑", + ); + } + // Fall through: list items may still contain inline emphasis/code/links. + } // --- Table row handling --- // Always apply table conceals even when cursor is on the line. @@ -1078,88 +1663,48 @@ function processLineConceals( const widthInfo = bufWidths && lineNumber !== undefined ? bufWidths.get(lineNumber) : undefined; const colWidths = widthInfo ? widthInfo.allocated : undefined; - // Split the line into cells to compute per-cell padding - let inner = trimmed; - if (inner.startsWith('|')) inner = inner.slice(1); - if (inner.endsWith('|')) inner = inner.slice(0, -1); - const cells = inner.split('|'); - - // Check if any data cell needs multi-line wrapping + // Split the line into cells to compute per-cell padding. Escaped pipes + // (`\|`) are cell content, so split only on the unescaped column borders. + const cells = splitTableCells(tableRowInner(trimmed)); + + // Pipe positions in the (untrimmed) source line — shared by the wrapped + // first-line path and the single-line path below. Unescaped pipes only: + // an escaped `\|` is rendered inline by the char loop, not as a border. + const pipePositions = tablePipePositions(lineContent); + + // Multi-line cell wrapping. When a column's text is wider than its + // allocated width the row spans several visual lines. The FIRST visual + // line is rendered in place here — each cell is concealed to its first + // wrapped fragment (padded to the column width) and each pipe → │ — while + // the continuation lines are emitted as virtual lines below the row by + // processTableBorders. Keeping every source row exactly one source line + // means alignment and borders are computed from generated text, like a + // rendered README table, instead of splitting the source with soft breaks + // (which can't align independent columns and corrupted neighbouring lines). let handledByWrapping = false; if (colWidths && !isSeparator && !cursorStrictlyOnLine) { - const numCols = Math.min(cells.length, colWidths.length); - const cellWrapped: string[][] = []; - let maxVisualLines = 1; - for (let ci = 0; ci < numCols; ci++) { - // When cursor is on the row, use raw text (emphasis markers revealed). - const cellText = cursorStrictlyOnLine ? cells[ci].trim() : concealedText(cells[ci]).trim(); - const wrapW = Math.max(1, colWidths[ci] - 2); // 1 leading + 1 trailing space margin - const wrapped = wrapText(cellText, wrapW); - cellWrapped.push(wrapped); - maxVisualLines = Math.max(maxVisualLines, wrapped.length); - } - // Cap to available source bytes (excluding trailing newline) - let effLen = lineContent.length; - if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--; - if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--; - maxVisualLines = Math.min(maxVisualLines, effLen); - - if (maxVisualLines > 1) { - // Build formatted visual line for each wrapped row - const visualLines: string[] = []; - for (let vl = 0; vl < maxVisualLines; vl++) { - let vline = '│'; - for (let ci = 0; ci < numCols; ci++) { - const wrapW = Math.max(1, colWidths[ci] - 2); - const wrapped = cellWrapped[ci] || []; - const text = vl < wrapped.length ? wrapped[vl] : ''; - vline += ' ' + text + ' '.repeat(Math.max(0, wrapW - text.length)) + ' │'; - } - visualLines.push(vline); - } - - // Divide source bytes into segments, one per visual line. - // Soft breaks at segment boundaries (added by processLineSoftBreaks) - // create the visual line breaks; conceals replace each segment. - // - // IMPORTANT: break positions MUST land on Space characters. - // Space tokens have individual source_offset values matching their - // byte positions, so soft breaks will reliably trigger. Non-space - // characters inside Text tokens share the token's START offset, - // so breaks at mid-token positions silently fail. - // The consumed space (replaced by Newline) must NOT be covered by - // any segment's conceal range, so segment N+1 starts at spacePos+1. - // Exclude trailing newline from segment range so the Newline token - // at the end of the source line is NOT concealed (preserves the - // line break between adjacent source rows). - let lineCharLen = lineContent.length; - if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\n') lineCharLen--; - if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\r') lineCharLen--; - const spacePositions: number[] = []; - for (let i = 1; i < lineCharLen; i++) { - if (lineContent[i] === ' ') spacePositions.push(i); + const { cellWrapped, numCols, maxVisualLines } = + wrapTableRow(lineContent, colWidths, false); + if (maxVisualLines > 1 && pipePositions.length >= numCols + 1) { + for (let ci = 0; ci < numCols; ci++) { + const wrapW = Math.max(1, colWidths[ci] - 2); + const frag = cellWrapped[ci][0] || ''; + const cellRender = + ' ' + frag + ' '.repeat(Math.max(0, wrapW - displayWidth(frag))) + ' '; + const cStart = charToByte(lineContent, pipePositions[ci] + 1, byteStart); + const cEnd = charToByte(lineContent, pipePositions[ci + 1], byteStart); + editor.addConceal(bufferId, "md-syntax", cStart, cEnd, cellRender); } - const breakChars = spacePositions.slice(0, maxVisualLines - 1); - // Trim visual lines if we couldn't find enough break positions - const actualVisualLines = breakChars.length + 1; - // Segments: first starts at 0, subsequent start AFTER the consumed space - const segStarts = [0, ...breakChars.map(c => c + 1)]; - const segEnds = [...breakChars, lineCharLen]; - for (let vl = 0; vl < actualVisualLines; vl++) { - const sByteS = charToByte(lineContent, segStarts[vl], byteStart); - const sByteE = charToByte(lineContent, segEnds[vl], byteStart); - editor.addConceal(bufferId, "md-syntax", sByteS, sByteE, visualLines[vl] || ''); + for (let pi = 0; pi < pipePositions.length; pi++) { + const pStart = charToByte(lineContent, pipePositions[pi], byteStart); + const pEnd = charToByte(lineContent, pipePositions[pi] + 1, byteStart); + editor.addConceal(bufferId, "md-syntax", pStart, pEnd, "│"); } handledByWrapping = true; } } if (!handledByWrapping) { - // Find pipe positions for byte-range computation of truncated cells - const pipePositions: number[] = []; - for (let i = 0; i < lineContent.length; i++) { - if (lineContent[i] === '|') pipePositions.push(i); - } // Precompute which cells will be truncated. Per-character conceals // that land inside a truncated cell must be suppressed — the cell- @@ -1171,7 +1716,7 @@ function processLineConceals( if (!cursorStrictlyOnLine && colWidths) { for (let ci = 0; ci < Math.min(cells.length, colWidths.length); ci++) { const cellText = concealedText(cells[ci]); - if (cellText.length > colWidths[ci]) { + if (displayWidth(cellText) > colWidths[ci]) { const prevPipe = pipePositions[ci]; const nextPipe = pipePositions[ci + 1]; if (prevPipe !== undefined && nextPipe !== undefined) { @@ -1184,6 +1729,25 @@ function processLineConceals( // Track which pipe index we're on (0 = leading pipe) let pipeIdx = 0; for (let i = 0; i < lineContent.length; i++) { + // Escaped pipe `\|`: cell content, not a column border. Render it as a + // literal `|` (the backslash is just the escape marker), and do NOT + // count it as a column separator. Skip the conceal when the cursor is + // on the row (leave the raw `\|` visible for editing, like other + // revealed markers) or when the enclosing cell is truncated (its + // cell-wide conceal already covers these bytes). + if (lineContent[i] === '|' && isEscapedPipe(lineContent, i)) { + if (!cursorStrictlyOnLine) { + const inTruncated = truncatedCellCharRanges.some( + r => (i - 1) >= r.start && (i - 1) < r.end, + ); + if (!inTruncated) { + const escStart = charToByte(lineContent, i - 1, byteStart); + const escEnd = charToByte(lineContent, i + 1, byteStart); + editor.addConceal(bufferId, "md-syntax", escStart, escEnd, "|"); + } + } + continue; + } if (lineContent[i] === '|') { const pipeByte = charToByte(lineContent, i, byteStart); const pipeByteEnd = charToByte(lineContent, i + 1, byteStart); @@ -1196,7 +1760,7 @@ function processLineConceals( const cellIdx = pipeIdx - 1; if (!cursorStrictlyOnLine && colWidths && pipeIdx > 0 && cellIdx < cells.length && cellIdx < colWidths.length) { const cellText = concealedText(cells[cellIdx]); - const cellWidth = cellText.length; + const cellWidth = displayWidth(cellText); const allocatedWidth = colWidths[cellIdx]; if (cellWidth > allocatedWidth) { @@ -1206,9 +1770,12 @@ function processLineConceals( const prevPipeCharPos = pipePositions[pipeIdx - 1]; const cellByteStart = charToByte(lineContent, prevPipeCharPos + 1, byteStart); const cellByteEnd = pipeByte; + // Width-aware truncation can land 1 cell short when it would + // split a double-width char; pad back up to the allocation. + const cut = truncateToWidth(cellText, allocatedWidth - 1) + '-'; const truncated = isSeparator ? '─'.repeat(allocatedWidth) - : cellText.slice(0, allocatedWidth - 1) + '-'; + : cut + ' '.repeat(Math.max(0, allocatedWidth - displayWidth(cut))); editor.addConceal(bufferId, "md-syntax", cellByteStart, cellByteEnd, truncated); truncatedByteRanges.push({start: cellByteStart, end: cellByteEnd}); } else { @@ -1220,8 +1787,8 @@ function processLineConceals( } if (isSeparator) { - const pipeIndex = lineContent.substring(0, i + 1).split('|').length - 1; - const totalPipes = lineContent.split('|').length - 1; + const pipeIndex = pipeIdx + 1; + const totalPipes = pipePositions.length; let replacement = '┼'; if (pipeIndex === 1) replacement = '├'; else if (pipeIndex === totalPipes) replacement = '┤'; @@ -1230,7 +1797,9 @@ function processLineConceals( editor.addConceal(bufferId, "md-syntax", pipeByte, pipeByteEnd, padding + "│"); } pipeIdx++; - } else if (isSeparator && lineContent[i] === '-') { + } else if (isSeparator && (lineContent[i] === '-' || lineContent[i] === ':')) { + // Alignment colons (`:---:`) render as part of the rule line too — + // leaving them visible bleeds `:----:` through the concealed row. // Skip per-character conceals that land inside a truncated cell; // the cell-wide truncate conceal already handles the rendering. const inTruncated = truncatedCellCharRanges.some(r => i >= r.start && i < r.end); @@ -1292,6 +1861,9 @@ function processLineConceals( url: span.linkUrl, }); break; + case 'footnote': + editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { fg: "syntax.link" }); + break; // entities: no overlay } @@ -1337,6 +1909,10 @@ function processLineSoftBreaks( // Clear existing soft breaks for this line range editor.clearSoftBreaksInRange(bufferId, byteStart, byteEnd); + // Code lines never wrap and must not be misread as markdown (a `|`-leading + // code line would otherwise get table-cell soft breaks). + if (insideFence(bufferId, byteStart)) return; + const viewport = editor.getViewport(); if (!viewport) return; const width = effectiveComposeWidth(viewport.width); @@ -1361,50 +1937,9 @@ function processLineSoftBreaks( } } - // Table row wrapping: add soft breaks for multi-line cells - if (block.type === 'table-row' && lineNumber !== undefined) { - const trimmedLine = lineContent.trim(); - const isSep = /^\|[-:\s|]+\|$/.test(trimmedLine); - if (!isSep) { - const bufWidths = getTableWidths(bufferId); - const widthInfo = bufWidths ? bufWidths.get(lineNumber) : undefined; - const colWidths = widthInfo ? widthInfo.allocated : undefined; - if (colWidths) { - let innerLine = trimmedLine; - if (innerLine.startsWith('|')) innerLine = innerLine.slice(1); - if (innerLine.endsWith('|')) innerLine = innerLine.slice(0, -1); - const tableCells = innerLine.split('|'); - let maxVisualLines = 1; - const numCols = Math.min(tableCells.length, colWidths.length); - const cursorOnTableLine = cursors.some(c => c >= byteStart && c < byteEnd); - for (let ci = 0; ci < numCols; ci++) { - const cellText = cursorOnTableLine ? tableCells[ci].trim() : concealedText(tableCells[ci]).trim(); - const wrapW = Math.max(1, colWidths[ci] - 2); - const wrapped = wrapText(cellText, wrapW); - maxVisualLines = Math.max(maxVisualLines, wrapped.length); - } - // Exclude trailing newline (same as processLineConceals) - let effLineLen = lineContent.length; - if (effLineLen > 0 && lineContent[effLineLen - 1] === '\n') effLineLen--; - if (effLineLen > 0 && lineContent[effLineLen - 1] === '\r') effLineLen--; - maxVisualLines = Math.min(maxVisualLines, effLineLen); - - if (maxVisualLines > 1) { - // Must match the break positions from processLineConceals: - // pick Space chars (they have individual source_offsets that match). - const spacePositions: number[] = []; - for (let i = 1; i < effLineLen; i++) { - if (lineContent[i] === ' ') spacePositions.push(i); - } - const breakChars = spacePositions.slice(0, maxVisualLines - 1); - for (const charPos of breakChars) { - const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, charPos)); - editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, 0); - } - } - } - } - } + // Table rows never use soft breaks: a wrapped cell's overflow is rendered as + // virtual continuation lines (processTableBorders), not by splitting the + // single source line. (table-row is in `noWrap`, so we'd return below anyway.) if (noWrap) return; @@ -1496,7 +2031,9 @@ function processTableAlignment( for (const line of lines) { const trimmed = line.content.trim(); - const isTableRow = trimmed.startsWith('|') || trimmed.endsWith('|'); + const isTableRow = + (trimmed.startsWith('|') || trimmed.endsWith('|')) && + !insideFence(bufferId, line.byte_start); if (isTableRow && line.line_number === lastLineNum + 1) { currentGroup.push(line); } else if (isTableRow) { @@ -1516,11 +2053,9 @@ function processTableAlignment( for (const line of group) { const trimmed = line.content.trim(); - // Strip outer pipes and split on inner pipes - let inner = trimmed; - if (inner.startsWith('|')) inner = inner.slice(1); - if (inner.endsWith('|')) inner = inner.slice(0, -1); - const cells = inner.split('|'); + // Strip outer pipes and split on unescaped inner pipes (so `\|` stays + // cell content and doesn't inflate the column count / widths). + const cells = splitTableCells(tableRowInner(trimmed)); allCells.push(cells); } @@ -1539,7 +2074,7 @@ function processTableAlignment( // a row. Concealed rows simply get extra padding. const isSep = /^[-:\s]+$/.test(row[col]); if (!isSep) { - maxW = Math.max(maxW, row[col].length); + maxW = Math.max(maxW, displayWidth(row[col])); } } } @@ -1582,9 +2117,8 @@ function processTableAlignment( // large configured width overflows when the editor area shrinks // (e.g. when the File Explorer sidebar opens). const viewport = editor.getViewport(); - const composeW = effectiveComposeWidth(viewport ? viewport.width : 80); const numCols = merged.length; - const available = composeW - (numCols + 1); // subtract pipe/box-drawing characters + const available = tableAvailableWidth(viewport ? viewport.width : 80, numCols); const allocated = distributeColumnWidths(merged, available); // Check if adjacent cached lines had narrower allocated widths — if so, @@ -1677,9 +2211,17 @@ editor.on("lines_changed", (data) => { // already converged) so this doesn't loop. const tableWidthsGrew = processTableAlignment(data.buffer_id, data.lines); + // Process each line independently. A throw on one line (e.g. an unexpected + // character sequence) must NOT abort the loop — otherwise that line AND every + // line after it in the batch would be left uncomposed, rendering as raw + // markdown from the failure point onward. Isolate per line and keep going. for (const line of data.lines) { - processLineConceals(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number); - processLineSoftBreaks(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number); + try { + processLineConceals(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number); + processLineSoftBreaks(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number); + } catch (e) { + editor.debug(`[mc] line ${line.line_number} failed to compose: ${e}`); + } } // Add/refresh table border virtual lines (top/bottom + inter-row separators). @@ -1688,7 +2230,7 @@ editor.on("lines_changed", (data) => { // line up with the cell pipes the conceals produce. const widthMapForBorders = getTableWidths(data.buffer_id); if (widthMapForBorders) { - processTableBorders(data.buffer_id, data.lines, widthMapForBorders); + processTableBorders(data.buffer_id, data.lines, widthMapForBorders, cursors); } if (tableWidthsGrew) { @@ -1698,10 +2240,14 @@ editor.on("lines_changed", (data) => { editor.on("after_insert", (data) => { if (!isComposingInAnySplit(data.buffer_id)) return; editor.debug(`[mc] after_insert: pos=${data.position} text="${data.text.replace(/\n/g,'\\n')}" affected=${data.affected_start}..${data.affected_end}`); + // Keep fence ranges current so typed/removed fences change classification. + // The constant cursor_moved refreshes pick the new ranges up next frame. + void rebuildFenceRanges(data.buffer_id); }); editor.on("after_delete", (data) => { if (!isComposingInAnySplit(data.buffer_id)) return; editor.debug(`[mc] after_delete: start=${data.start} end=${data.end} deleted="${data.deleted_text.replace(/\n/g,'\\n')}" affected_start=${data.affected_start} deleted_len=${data.deleted_len}`); + void rebuildFenceRanges(data.buffer_id); }); editor.on("cursor_moved", (data) => { if (!isComposingInAnySplit(data.buffer_id)) return; @@ -1728,14 +2274,13 @@ editor.on("viewport_changed", (data) => { // Recompute allocated table column widths for new viewport width const bufWidths = getTableWidths(data.buffer_id); if (bufWidths) { - const composeW = effectiveComposeWidth(data.width); const seen = new Set(); // Track by JSON key to deduplicate shared TableWidthInfo for (const [lineNum, info] of bufWidths) { const key = info.maxW.join(","); if (seen.has(key)) continue; seen.add(key); const numCols = info.maxW.length; - const available = composeW - (numCols + 1); + const available = tableAvailableWidth(data.width, numCols); info.allocated = distributeColumnWidths(info.maxW, available); } setTableWidths(data.buffer_id, bufWidths); diff --git a/crates/fresh-editor/src/app/editor_init.rs b/crates/fresh-editor/src/app/editor_init.rs index c03e27c353..2ca439e3ed 100644 --- a/crates/fresh-editor/src/app/editor_init.rs +++ b/crates/fresh-editor/src/app/editor_init.rs @@ -223,6 +223,9 @@ impl Editor { recovery_service: parts.recovery_service, time_source: parts.time_source, color_capability: parts.color_capability, + image_manager: crate::services::graphics::ImageManager::new( + crate::services::graphics::GraphicsCapability::detect(), + ), update_checker: parts.update_checker, key_translator: parts.key_translator, diff --git a/crates/fresh-editor/src/app/lifecycle.rs b/crates/fresh-editor/src/app/lifecycle.rs index ccc1043aeb..06cc333d18 100644 --- a/crates/fresh-editor/src/app/lifecycle.rs +++ b/crates/fresh-editor/src/app/lifecycle.rs @@ -86,6 +86,31 @@ impl Editor { std::mem::take(&mut self.pending_escape_sequences) } + /// Terminal raster-graphics capability (kitty graphics protocol, …). + pub fn graphics_capability(&self) -> crate::services::graphics::GraphicsCapability { + self.image_manager.capability() + } + + /// Mutable access to the inline-image registry (used by the plugin + /// `placeImage` / `clearImages` commands to register and forget images). + pub fn image_manager_mut(&mut self) -> &mut crate::services::graphics::ImageManager { + &mut self.image_manager + } + + /// Drain queued image transmit/delete sequences for the kitty graphics + /// protocol. The caller writes these to the terminal after a frame. + /// Always empty in session mode or on terminals without graphics support. + pub fn take_graphics_escape_sequences(&mut self) -> Vec { + if self.session_mode { + // Still drain (and discard) so the queues can't grow unboundedly + // when a headless session runs inside a graphics-capable + // environment. + let _ = self.image_manager.take_escape_sequences(); + return Vec::new(); + } + self.image_manager.take_escape_sequences() + } + /// Take pending clipboard data queued in session mode, clearing the request pub fn take_pending_clipboard( &mut self, diff --git a/crates/fresh-editor/src/app/mod.rs b/crates/fresh-editor/src/app/mod.rs index 208f8e5d53..dd9b68c115 100644 --- a/crates/fresh-editor/src/app/mod.rs +++ b/crates/fresh-editor/src/app/mod.rs @@ -946,6 +946,11 @@ pub struct Editor { /// Terminal color capability (true color, 256, or 16 colors) color_capability: crate::view::color_support::ColorCapability, + /// Terminal raster-graphics support and the registry of inline images + /// (kitty graphics protocol). Owns pending transmit/delete escape + /// sequences flushed to the terminal after each frame. + image_manager: crate::services::graphics::ImageManager, + /// Hunks for the Review Diff tool // `review_hunks` moved onto `Window`. diff --git a/crates/fresh-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index 1d0d9d2898..26b41e88dc 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -478,6 +478,94 @@ impl Editor { } } + /// Handle PlaceImage — reserve `rows`×`cols` placeholder cells anchored to + /// `position` and register the image for kitty transmission. No-op on + /// terminals without graphics support (the caller keeps a text fallback). + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_place_image( + &mut self, + buffer_id: BufferId, + key: String, + source: String, + position: usize, + cols: u16, + rows: u16, + above: bool, + namespace: String, + ) { + use crate::services::graphics::{max_placement_cells, ImageCellSpec}; + use crate::view::virtual_text::{VirtualTextNamespace, VirtualTextPosition}; + use ratatui::style::Style; + use std::path::PathBuf; + + if !self.graphics_capability().supports_images() { + return; + } + + let max = max_placement_cells() as u16; + let cols = cols.clamp(1, max); + let rows = rows.clamp(1, max); + + // Register first (mutable borrow of the image manager); the buffer + // borrow below is sequential so the two never overlap. + let id = + self.image_manager_mut() + .register(&key, &namespace, PathBuf::from(source), cols, rows); + + if let Some(state) = self + .windows + .get_mut(&self.active_window) + .expect("active window present") + .buffer_state_mut(buffer_id) + { + let placement = if above { + VirtualTextPosition::LineAbove + } else { + VirtualTextPosition::LineBelow + }; + let blank: String = " ".repeat(cols as usize); + // Rows are added top-to-bottom; `query_lines_in_range` breaks + // priority ties by insertion order, which keeps them contiguous + // and ordered at render time. + for image_row in 0..rows { + state.virtual_texts.add_image_line( + &mut state.marker_list, + position, + blank.clone(), + Style::default(), + placement, + VirtualTextNamespace::from_string(namespace.clone()), + 0, + ImageCellSpec { + id, + image_row, + cols, + }, + ); + } + } + } + + /// Handle ClearImages — drop a namespace's reserved placeholder rows and + /// free the terminal-side image data. + pub(super) fn handle_clear_images(&mut self, buffer_id: BufferId, namespace: String) { + use crate::view::virtual_text::VirtualTextNamespace; + + self.image_manager_mut().forget_namespace(&namespace); + + if let Some(state) = self + .windows + .get_mut(&self.active_window) + .expect("active window present") + .buffer_state_mut(buffer_id) + { + let ns = VirtualTextNamespace::from_string(namespace); + state + .virtual_texts + .clear_namespace(&mut state.marker_list, &ns); + } + } + // ==================== Conceal Commands ==================== /// Handle AddConceal command - add a conceal range that hides or replaces bytes @@ -542,6 +630,28 @@ impl Editor { } } + /// Handle ClearConcealsInRangeForNamespace command + pub(super) fn handle_clear_conceals_in_range_for_namespace( + &mut self, + buffer_id: BufferId, + namespace: OverlayNamespace, + start: usize, + end: usize, + ) { + if let Some(state) = self + .windows + .get_mut(&self.active_window) + .expect("active window present") + .buffer_state_mut(buffer_id) + { + state.conceals.remove_in_range_for_namespace( + &namespace, + &(start..end), + &mut state.marker_list, + ); + } + } + // ==================== Fold Commands ==================== /// Handle AddFold command — register a collapsed fold range for the diff --git a/crates/fresh-editor/src/app/plugin_dispatch.rs b/crates/fresh-editor/src/app/plugin_dispatch.rs index cca962676d..32fc18aa2e 100644 --- a/crates/fresh-editor/src/app/plugin_dispatch.rs +++ b/crates/fresh-editor/src/app/plugin_dispatch.rs @@ -381,6 +381,28 @@ impl Editor { self.handle_clear_virtual_text_namespace(buffer_id, namespace); } + PluginCommand::PlaceImage { + buffer_id, + key, + source, + position, + cols, + rows, + above, + namespace, + } => { + self.handle_place_image( + buffer_id, key, source, position, cols, rows, above, namespace, + ); + } + + PluginCommand::ClearImages { + buffer_id, + namespace, + } => { + self.handle_clear_images(buffer_id, namespace); + } + // ==================== Conceal Commands ==================== PluginCommand::AddConceal { buffer_id, @@ -404,6 +426,14 @@ impl Editor { } => { self.handle_clear_conceals_in_range(buffer_id, start, end); } + PluginCommand::ClearConcealsInRangeForNamespace { + buffer_id, + namespace, + start, + end, + } => { + self.handle_clear_conceals_in_range_for_namespace(buffer_id, namespace, start, end); + } PluginCommand::AddFold { buffer_id, diff --git a/crates/fresh-editor/src/app/types/layout.rs b/crates/fresh-editor/src/app/types/layout.rs index 8411f048fb..e0928528de 100644 --- a/crates/fresh-editor/src/app/types/layout.rs +++ b/crates/fresh-editor/src/app/types/layout.rs @@ -21,6 +21,10 @@ pub struct ViewLineMapping { /// the cursor on a position whose `line_end_byte` was inherited /// from the previous source row. pub is_plugin_virtual: bool, + /// Set when this visual row is one row of a placed inline image. The + /// render post-pass overwrites the row's content cells with kitty + /// Unicode placeholders referencing the image. `None` otherwise. + pub image_placeholder: Option, } impl ViewLineMapping { diff --git a/crates/fresh-editor/src/main.rs b/crates/fresh-editor/src/main.rs index 8535e0ac4d..a1abf79108 100644 --- a/crates/fresh-editor/src/main.rs +++ b/crates/fresh-editor/src/main.rs @@ -4313,6 +4313,13 @@ where let mut last_render = Instant::now(); let mut needs_render = true; let mut pending_event: Option = None; + // Synchronized-update markers (DEC mode 2026) are emitted around each frame + // to avoid tearing. Inside tmux they are sent through to the outer terminal + // unwrapped, and tmux's handling drops individual cell-clear updates from a + // frame diff — leaving stale glyphs (e.g. markdown table border `│`) on + // blank lines until a full redraw. tmux already batches its own pane + // refreshes, so the markers buy nothing there. Skip them when inside tmux. + let synchronized_update = std::env::var_os("TMUX").is_none(); // Time of the last real input event, used to start the wave-animation // screensaver after the configured idle period. Read from the editor's // injected time source so tests can drive idle time deterministically. @@ -4409,15 +4416,35 @@ where { let _span = tracing::info_span!("terminal_draw").entered(); use crossterm::ExecutableCommand; - stdout().execute(crossterm::terminal::BeginSynchronizedUpdate)?; + if synchronized_update { + stdout().execute(crossterm::terminal::BeginSynchronizedUpdate)?; + } terminal.draw(|frame| editor.render(frame))?; - stdout().execute(crossterm::terminal::EndSynchronizedUpdate)?; + if synchronized_update { + stdout().execute(crossterm::terminal::EndSynchronizedUpdate)?; + } } tracing::info!(target: "paste_timing", "render: {}ms (paste_pending={})", r0.elapsed().as_millis(), was_paste_pending); last_render = Instant::now(); needs_render = false; } + // Flush any pending inline-image protocol bytes (kitty graphics) to + // the terminal. The placeholder cells that reference these images are + // emitted as part of the normal frame above; transmitting the pixel + // data afterwards is fine — the terminal displays the image as soon + // as it arrives. No-op in session mode / on terminals without + // graphics support. + { + let graphics = editor.take_graphics_escape_sequences(); + if !graphics.is_empty() { + use std::io::Write; + let mut out = stdout(); + let _ = out.write_all(&graphics); + let _ = out.flush(); + } + } + let event = if let Some(e) = pending_event.take() { Some(e) } else { diff --git a/crates/fresh-editor/src/services/graphics.rs b/crates/fresh-editor/src/services/graphics.rs new file mode 100644 index 0000000000..a9a84d5945 --- /dev/null +++ b/crates/fresh-editor/src/services/graphics.rs @@ -0,0 +1,473 @@ +//! Kitty terminal graphics protocol support for rendering inline images +//! inside the editor. The editor core knows nothing about *what* is being +//! rendered — plugins decide (via `placeImage` / `clearImages`) what any +//! file's content should look like; this module only owns the mechanics. +//! +//! # Integration model +//! +//! Images are displayed using kitty's *Unicode placeholder* mechanism +//! rather than absolute cursor-positioned placements. Core transmits the +//! image once (keyed by a 24-bit image id) and the buffer reserves rows of +//! *placeholder cells*: each cell is `U+10EEEE` followed by combining +//! row/column diacritics, and the cell's foreground color carries the image +//! id. Because the image follows its placeholder cells, it scrolls and +//! repaints naturally with the surrounding text — unlike a cursor-anchored +//! placement, which would have to be deleted and re-emitted every frame and +//! would fight the cell-diff renderer. +//! +//! The protocol bytes are queued here and flushed to stdout by the main +//! loop after each frame (see [`ImageManager::take_escape_sequences`]); the +//! placeholder cells themselves are produced by [`placeholder_row`] and +//! placed into the buffer as virtual lines. + +use base64::Engine as _; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Unicode placeholder code point used by the kitty graphics protocol. +pub const IMAGE_PLACEHOLDER: char = '\u{10EEEE}'; + +/// Largest image id we hand out. Kept inside 24 bits so the id round-trips +/// losslessly through a cell's truecolor foreground (R<<16 | G<<8 | B). +pub const MAX_IMAGE_ID: u32 = 0x00FF_FFFF; + +/// Combining diacritics that encode row/column numbers for Unicode +/// placeholders. Derived (per the kitty spec) from Unicode 6.0.0 combining +/// class 230 marks without decomposition mappings. Index `i` represents +/// row/column number `i`, so the table also bounds the maximum number of +/// rows/columns a single image placement may span (297). +static ROWCOLUMN_DIACRITICS: &[u32] = &[ + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, + 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, + 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, + 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, + 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB, + 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, + 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, + 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, + 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, + 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, + 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, + 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7, + 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, + 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, + 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, + 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, + 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, + 0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, + 0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +]; + +/// Maximum number of cells (rows or columns) one image placement can span. +pub fn max_placement_cells() -> usize { + ROWCOLUMN_DIACRITICS.len() +} + +/// The diacritic for a given row/column index, or `None` if out of range. +pub fn rowcolumn_diacritic(index: usize) -> Option { + ROWCOLUMN_DIACRITICS + .get(index) + .and_then(|&cp| char::from_u32(cp)) +} + +/// Foreground RGB that encodes a 24-bit image id for placeholder cells. +pub fn fg_for_id(id: u32) -> (u8, u8, u8) { + ( + ((id >> 16) & 0xFF) as u8, + ((id >> 8) & 0xFF) as u8, + (id & 0xFF) as u8, + ) +} + +/// Build one row of placeholder cells for image `id`, image-row `row`, +/// spanning `cols` columns. Each grapheme cell is +/// `U+10EEEE` + row-diacritic + column-diacritic; the caller must style the +/// whole line with `fg = fg_for_id(id)`. Returns `None` if `row`/`cols` +/// exceed [`max_placement_cells`]. +pub fn placeholder_row(row: usize, cols: usize) -> Option { + let row_d = rowcolumn_diacritic(row)?; + if cols == 0 || cols > max_placement_cells() { + return None; + } + let mut s = String::with_capacity(cols * IMAGE_PLACEHOLDER.len_utf8() * 2); + for col in 0..cols { + let col_d = rowcolumn_diacritic(col)?; + s.push(IMAGE_PLACEHOLDER); + s.push(row_d); + s.push(col_d); + } + Some(s) +} + +/// Describes one row of a placed image: which image (`id`), which image-row +/// this buffer line represents (`image_row`), and how many columns wide the +/// placement is (`cols`). Carried on the reserved virtual line and consumed +/// by the render post-pass, which writes the placeholder graphemes directly +/// into the terminal cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageCellSpec { + pub id: u32, + pub image_row: u16, + pub cols: u16, +} + +impl ImageCellSpec { + /// The truecolor foreground that encodes this image's id. + pub fn fg(&self) -> (u8, u8, u8) { + fg_for_id(self.id) + } + + /// The placeholder grapheme for column `col` of this row: + /// `U+10EEEE` + row diacritic + column diacritic. `None` if `col` or the + /// row index is beyond the diacritic table. + pub fn cell_symbol(&self, col: usize) -> Option { + let row_d = rowcolumn_diacritic(self.image_row as usize)?; + let col_d = rowcolumn_diacritic(col)?; + let mut s = String::with_capacity(IMAGE_PLACEHOLDER.len_utf8() + 6); + s.push(IMAGE_PLACEHOLDER); + s.push(row_d); + s.push(col_d); + Some(s) + } +} + +/// Terminal raster-graphics capability — shared with the plugin runtime via +/// `fresh-core` so plugins querying `editor.getGraphicsCapability()` always +/// agree with the editor's own gating. +pub use fresh_core::graphics::GraphicsCapability; + +#[derive(Debug, Clone)] +struct Registered { + key: String, + namespace: String, + cols: u16, + rows: u16, + path: PathBuf, +} + +/// Owns image ids (deduplicated by content key) and the queue of +/// transmit/delete escape sequences flushed to the terminal after a frame. +pub struct ImageManager { + capability: GraphicsCapability, + /// When true, each emitted kitty escape is wrapped in tmux's DCS + /// passthrough so it survives the multiplexer (see [`wrap_tmux`]). + tmux_passthrough: bool, + next_id: u32, + by_key: HashMap, + by_namespace: HashMap>, + images: HashMap, + pending_transmit: Vec, + pending_delete: Vec, +} + +/// Wrap a single terminal escape sequence in tmux's DCS passthrough so tmux +/// forwards it to the outer terminal verbatim instead of discarding it. Each +/// `ESC` (0x1b) inside the payload must be doubled; the whole thing is framed +/// by `ESC P tmux ; … ESC \`. Requires `set -g allow-passthrough on` in tmux. +fn wrap_tmux(inner: &[u8]) -> Vec { + let mut out = Vec::with_capacity(inner.len() + 16); + out.extend_from_slice(b"\x1bPtmux;"); + for &b in inner { + if b == 0x1b { + out.push(0x1b); + } + out.push(b); + } + out.extend_from_slice(b"\x1b\\"); + out +} + +impl ImageManager { + /// Create an image manager. Tmux passthrough wrapping is detected from + /// the environment (`$TMUX` set ⇒ on); tests that assert byte-exact + /// escape output pin it explicitly via [`set_tmux_passthrough`]. + pub fn new(capability: GraphicsCapability) -> Self { + ImageManager { + capability, + tmux_passthrough: std::env::var_os("TMUX").is_some(), + next_id: 1, + by_key: HashMap::new(), + by_namespace: HashMap::new(), + images: HashMap::new(), + pending_transmit: Vec::new(), + pending_delete: Vec::new(), + } + } + + /// Force tmux passthrough wrapping on/off (overrides env detection). + pub fn set_tmux_passthrough(&mut self, on: bool) { + self.tmux_passthrough = on; + } + + pub fn capability(&self) -> GraphicsCapability { + self.capability + } + + fn alloc_id(&mut self) -> u32 { + let id = self.next_id; + self.next_id = if self.next_id >= MAX_IMAGE_ID { + 1 + } else { + self.next_id + 1 + }; + id + } + + /// Register (or look up) an image by content `key`. `cols`/`rows` are the + /// placement size in cells. Returns the image id used to encode the + /// placeholder foreground. Queues a transmit if newly registered or if + /// the path/size changed; reuses the id and skips work otherwise. + pub fn register( + &mut self, + key: &str, + namespace: &str, + path: PathBuf, + cols: u16, + rows: u16, + ) -> u32 { + if let Some(&id) = self.by_key.get(key) { + let unchanged = self + .images + .get(&id) + .map(|img| img.path == path && img.cols == cols && img.rows == rows) + .unwrap_or(false); + if unchanged { + return id; + } + // Content changed under the same key: drop the old data and + // re-transmit under the same id so existing placeholders update. + self.pending_delete.push(id); + self.images.insert( + id, + Registered { + key: key.to_string(), + namespace: namespace.to_string(), + cols, + rows, + path, + }, + ); + self.pending_transmit.push(id); + return id; + } + + let id = self.alloc_id(); + self.by_key.insert(key.to_string(), id); + self.by_namespace + .entry(namespace.to_string()) + .or_default() + .push(id); + self.images.insert( + id, + Registered { + key: key.to_string(), + namespace: namespace.to_string(), + cols, + rows, + path, + }, + ); + self.pending_transmit.push(id); + id + } + + /// Forget an image (e.g. its placeholders were cleared) and queue a + /// delete so the terminal frees the pixel data. + pub fn forget(&mut self, key: &str) { + if let Some(id) = self.by_key.remove(key) { + if let Some(img) = self.images.remove(&id) { + if let Some(ids) = self.by_namespace.get_mut(&img.namespace) { + ids.retain(|&i| i != id); + } + } + self.pending_delete.push(id); + } + } + + /// Forget every image registered under `namespace` (used by + /// `clearImages`) and queue deletes so the terminal frees the data. + pub fn forget_namespace(&mut self, namespace: &str) { + let Some(ids) = self.by_namespace.remove(namespace) else { + return; + }; + for id in ids { + if let Some(img) = self.images.remove(&id) { + self.by_key.remove(&img.key); + } + self.pending_delete.push(id); + } + } + + /// Forget every registered image (e.g. on shutdown / buffer teardown). + pub fn forget_all(&mut self) { + let ids: Vec = self.by_key.values().copied().collect(); + self.by_key.clear(); + self.by_namespace.clear(); + self.images.clear(); + self.pending_delete.extend(ids); + } + + /// Drain queued transmit/delete operations into terminal escape bytes. + /// Returns empty when graphics are unsupported (queues are still cleared + /// so they don't accumulate). + pub fn take_escape_sequences(&mut self) -> Vec { + if self.capability == GraphicsCapability::None { + self.pending_transmit.clear(); + self.pending_delete.clear(); + return Vec::new(); + } + + let mut out: Vec = Vec::new(); + // Append one complete escape sequence, wrapping it in tmux passthrough + // when running inside tmux so the multiplexer forwards it intact. + let mut emit = |seq: &str| { + if self.tmux_passthrough { + out.extend_from_slice(&wrap_tmux(seq.as_bytes())); + } else { + out.extend_from_slice(seq.as_bytes()); + } + }; + + for id in self.pending_delete.drain(..) { + // a=d, d=I: delete the image *and* its placements, freeing data. + emit(&format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")); + } + + let transmits: Vec = self.pending_transmit.drain(..).collect(); + for id in transmits { + if let Some(img) = self.images.get(&id) { + // Transmit medium `t=f` (file path), format `f=100` (PNG); + // `a=T` transmits and creates a placement; `U=1` marks it a + // virtual placement for Unicode placeholders; `c`/`r` give the + // placement size in cells; `q=2` suppresses acknowledgements. + let path_b64 = base64::engine::general_purpose::STANDARD + .encode(img.path.to_string_lossy().as_bytes()); + emit(&format!( + "\x1b_Ga=T,U=1,i={id},f=100,t=f,c={cols},r={rows},q=2;{path_b64}\x1b\\", + cols = img.cols, + rows = img.rows, + )); + } + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diacritic_table_has_297_entries() { + assert_eq!(max_placement_cells(), 297); + assert_eq!(rowcolumn_diacritic(0), char::from_u32(0x0305)); + assert_eq!(rowcolumn_diacritic(296), char::from_u32(0x1D244)); + assert_eq!(rowcolumn_diacritic(297), None); + } + + #[test] + fn fg_for_id_round_trips_24_bit() { + let id = 0x12_34_56; + let (r, g, b) = fg_for_id(id); + assert_eq!((r, g, b), (0x12, 0x34, 0x56)); + assert_eq!(((r as u32) << 16) | ((g as u32) << 8) | b as u32, id); + } + + #[test] + fn placeholder_row_builds_one_grapheme_per_column() { + let row = placeholder_row(0, 3).unwrap(); + // Each cell is placeholder + 2 combining diacritics = 3 scalars. + let chars: Vec = row.chars().collect(); + assert_eq!(chars.len(), 9); + assert_eq!(chars[0], IMAGE_PLACEHOLDER); + assert_eq!(chars[3], IMAGE_PLACEHOLDER); + assert_eq!(chars[6], IMAGE_PLACEHOLDER); + // First cell: row 0, col 0 diacritics. + assert_eq!(chars[1], rowcolumn_diacritic(0).unwrap()); + assert_eq!(chars[2], rowcolumn_diacritic(0).unwrap()); + // Third cell: row 0, col 2 diacritics. + assert_eq!(chars[7], rowcolumn_diacritic(0).unwrap()); + assert_eq!(chars[8], rowcolumn_diacritic(2).unwrap()); + } + + #[test] + fn placeholder_row_rejects_out_of_range() { + assert!(placeholder_row(0, 0).is_none()); + assert!(placeholder_row(0, max_placement_cells() + 1).is_none()); + assert!(placeholder_row(max_placement_cells(), 1).is_none()); + } + + #[test] + fn transmit_and_delete_escape_sequences() { + let mut mgr = ImageManager::new(GraphicsCapability::Kitty); + mgr.set_tmux_passthrough(false); // assert raw bytes, independent of $TMUX + let id = mgr.register("a", "ns", PathBuf::from("/tmp/x.png"), 10, 4); + assert!(id >= 1 && id <= MAX_IMAGE_ID); + + let seq = String::from_utf8(mgr.take_escape_sequences()).unwrap(); + assert!(seq.contains(&format!("i={id}"))); + assert!(seq.contains("a=T")); + assert!(seq.contains("U=1")); + assert!(seq.contains("c=10,r=4")); + assert!(seq.starts_with("\x1b_G")); + assert!(seq.ends_with("\x1b\\")); + + // Re-registering identical content is a no-op (id reused, nothing queued). + let id2 = mgr.register("a", "ns", PathBuf::from("/tmp/x.png"), 10, 4); + assert_eq!(id, id2); + assert!(mgr.take_escape_sequences().is_empty()); + + // Forgetting queues a delete for that id. + mgr.forget("a"); + let del = String::from_utf8(mgr.take_escape_sequences()).unwrap(); + assert!(del.contains(&format!("a=d,d=I,i={id}"))); + } + + #[test] + fn forget_namespace_deletes_all_in_namespace() { + let mut mgr = ImageManager::new(GraphicsCapability::Kitty); + mgr.set_tmux_passthrough(false); // assert raw bytes, independent of $TMUX + let a = mgr.register("a", "doc1", PathBuf::from("/tmp/a.png"), 4, 2); + let b = mgr.register("b", "doc1", PathBuf::from("/tmp/b.png"), 4, 2); + let c = mgr.register("c", "doc2", PathBuf::from("/tmp/c.png"), 4, 2); + let _ = mgr.take_escape_sequences(); + + mgr.forget_namespace("doc1"); + let del = String::from_utf8(mgr.take_escape_sequences()).unwrap(); + assert!(del.contains(&format!("i={a}"))); + assert!(del.contains(&format!("i={b}"))); + assert!(!del.contains(&format!("a=d,d=I,i={c}"))); + + // doc2 image survives and is still deduped by key. + let c2 = mgr.register("c", "doc2", PathBuf::from("/tmp/c.png"), 4, 2); + assert_eq!(c, c2); + } + + #[test] + fn tmux_passthrough_wraps_each_sequence() { + let mut mgr = ImageManager::new(GraphicsCapability::Kitty); + mgr.set_tmux_passthrough(true); + mgr.register("a", "ns", PathBuf::from("/tmp/x.png"), 10, 4); + + let seq = mgr.take_escape_sequences(); + // Framed by tmux DCS passthrough … + assert!(seq.starts_with(b"\x1bPtmux;")); + assert!(seq.ends_with(b"\x1b\\")); + // … and the inner kitty APC's ESCs are doubled (ESC ESC _ G). + assert!(seq.windows(4).any(|w| w == [0x1b, 0x1b, b'_', b'G'])); + } + + #[test] + fn unsupported_capability_emits_nothing() { + let mut mgr = ImageManager::new(GraphicsCapability::None); + mgr.register("a", "ns", PathBuf::from("/tmp/x.png"), 10, 4); + assert!(mgr.take_escape_sequences().is_empty()); + } +} diff --git a/crates/fresh-editor/src/services/mod.rs b/crates/fresh-editor/src/services/mod.rs index 62d930d39d..8d53301f4a 100644 --- a/crates/fresh-editor/src/services/mod.rs +++ b/crates/fresh-editor/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod file_watcher; pub mod fs; #[cfg(target_os = "linux")] pub mod gpm; +pub mod graphics; /// Outbound HTTP(S); the only place `ureq`/TLS is used (gated by `http`). pub mod http; pub mod live_grep_state; diff --git a/crates/fresh-editor/src/view/conceal.rs b/crates/fresh-editor/src/view/conceal.rs index f346a40fe9..bce93a14e3 100644 --- a/crates/fresh-editor/src/view/conceal.rs +++ b/crates/fresh-editor/src/view/conceal.rs @@ -170,6 +170,52 @@ impl ConcealManager { self.version = self.version.wrapping_add(1); } + /// Like [`remove_in_range`], but only removes ranges belonging to + /// `namespace`. Lets one plugin rebuild its conceals for a line without + /// destroying another plugin's ranges there (same motivation as + /// `clear_overlays_in_range_for_namespace`, issue #2146). + pub fn remove_in_range_for_namespace( + &mut self, + namespace: &OverlayNamespace, + range: &Range, + marker_list: &mut MarkerList, + ) { + if range.start >= range.end { + return; + } + let hits = marker_list.query_range(range.start, range.end); + if hits.is_empty() { + return; + } + let mut candidates: Vec = hits + .iter() + .filter_map(|(mid, _, _)| self.marker_to_idx.get(mid).copied()) + .collect(); + candidates.sort_unstable(); + candidates.dedup(); + + let mut to_remove: Vec = candidates + .into_iter() + .filter(|&idx| { + let r = &self.ranges[idx]; + if &r.namespace != namespace { + return false; + } + let start = marker_list.get_position(r.start_marker).unwrap_or(0); + let end = marker_list.get_position(r.end_marker).unwrap_or(0); + start < range.end && range.start < end + }) + .collect(); + if to_remove.is_empty() { + return; + } + to_remove.sort_unstable_by(|a, b| b.cmp(a)); + for idx in to_remove { + self.swap_remove_at(idx, marker_list); + } + self.version = self.version.wrapping_add(1); + } + /// Clear all conceal ranges and their markers pub fn clear(&mut self, marker_list: &mut MarkerList) { let had_any = !self.ranges.is_empty(); diff --git a/crates/fresh-editor/src/view/line_wrap_cache.rs b/crates/fresh-editor/src/view/line_wrap_cache.rs index 726150e92d..5a6430e0ed 100644 --- a/crates/fresh-editor/src/view/line_wrap_cache.rs +++ b/crates/fresh-editor/src/view/line_wrap_cache.rs @@ -309,6 +309,7 @@ pub fn layout_for_plain_text( ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }); } lines @@ -556,6 +557,7 @@ pub fn compute_line_layout( ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }); } result @@ -612,6 +614,7 @@ pub fn placeholder_layout_for_row_count(n: u32) -> Vec { ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }) .collect() } @@ -818,6 +821,7 @@ mod tests { ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }) .collect() } diff --git a/crates/fresh-editor/src/view/ui/split_rendering/mod.rs b/crates/fresh-editor/src/view/ui/split_rendering/mod.rs index 0a5e53650e..64faa7709b 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/mod.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/mod.rs @@ -520,6 +520,7 @@ mod tests { ends_with_newline: true, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }]; let indicators = fold_indicators_for_viewport(&state, &folds, &view_lines); diff --git a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs index 5b7e3480a4..a373669eb5 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_buffer.rs @@ -12,7 +12,8 @@ use super::super::layout::{ calculate_compose_layout, calculate_view_anchor, calculate_viewport_end, ComposeLayout, }; use super::super::post_pass::{ - apply_background_to_lines, render_column_guides, render_cursor_column_bg, render_ruler_bg, + apply_background_to_lines, apply_image_placeholders, render_column_guides, + render_cursor_column_bg, render_ruler_bg, }; use super::super::view_data::build_view_data; use super::contexts::SelectionContext; @@ -531,6 +532,17 @@ pub(crate) fn draw_buffer_in_split( ); } + // Overwrite reserved image rows with kitty Unicode placeholder cells so + // the terminal paints the transmitted image over them. These rows only + // carry a spec on graphics-capable terminals (the `placeImage` handler + // inserts a text fallback otherwise), so this is safe to run always. + apply_image_placeholders( + frame, + &layout_output.render_output.view_line_mappings, + render_area, + gutter_width, + ); + if let Some((screen_x, screen_y)) = cursor_screen_pos { // Record the hardware cursor position instead of committing it to // the frame now. `render.rs` decides at the end of the render pass diff --git a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/mod.rs b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/mod.rs index 0323faf8fa..9c7bf2be7c 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/mod.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/mod.rs @@ -218,6 +218,7 @@ pub(crate) fn render_view_lines(input: LineRenderInput<'_>) -> LineRenderOutput ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }) } else { break; @@ -906,5 +907,6 @@ fn build_view_line_mapping( char_source_bytes: content_map, line_end_byte, is_plugin_virtual, + image_placeholder: view_line.image_placeholder, } } diff --git a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/trailing.rs b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/trailing.rs index 842f12a97b..2dd043ef35 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/trailing.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/trailing.rs @@ -138,6 +138,7 @@ fn render_implicit_line_into( visual_to_char: Vec::new(), line_end_byte: ctx.state.buffer.len(), is_plugin_virtual: false, + image_placeholder: None, }); // NOTE: We intentionally do NOT update last_line_end here; the @@ -231,6 +232,7 @@ fn ensure_trailing_mapping(ctx: &PostRowContext<'_>, acc: &mut PostRowAccumulato visual_to_char: Vec::new(), line_end_byte: ctx.state.buffer.len(), is_plugin_virtual: false, + image_placeholder: None, }); } } diff --git a/crates/fresh-editor/src/view/ui/split_rendering/post_pass.rs b/crates/fresh-editor/src/view/ui/split_rendering/post_pass.rs index 0104c274fa..515892ffa1 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/post_pass.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/post_pass.rs @@ -46,6 +46,52 @@ pub(super) fn render_column_guides( } } +/// Overwrite the content cells of any image-placeholder rows with kitty +/// Unicode placeholder graphemes (`U+10EEEE` + row/column diacritics), with +/// each cell's foreground encoding the image id. The terminal then paints the +/// transmitted image over this rectangle of placeholder cells. Rows without +/// an image placeholder are left untouched. +/// +/// This is the "post-render buffer injection" path: the placeholder grapheme +/// (base char + two combining marks) is written as a single ratatui cell +/// symbol, side-stepping the per-scalar cell pipeline that would otherwise +/// split the combining marks into their own columns. +pub(super) fn apply_image_placeholders( + frame: &mut Frame, + view_line_mappings: &[ViewLineMapping], + render_area: Rect, + gutter_width: usize, +) { + let content_x = render_area.x + gutter_width as u16; + let max_x = render_area.x + render_area.width; + let max_y = render_area.y + render_area.height; + let buf = frame.buffer_mut(); + + for (screen_row, mapping) in view_line_mappings.iter().enumerate() { + let Some(spec) = mapping.image_placeholder else { + continue; + }; + let y = render_area.y + screen_row as u16; + if y >= max_y { + break; + } + let (r, g, b) = spec.fg(); + let fg = Color::Rgb(r, g, b); + for col in 0..spec.cols as usize { + let x = content_x + col as u16; + if x >= max_x { + break; + } + let Some(symbol) = spec.cell_symbol(col) else { + break; + }; + let cell = &mut buf[(x, y)]; + cell.set_symbol(&symbol); + cell.set_fg(fg); + } + } +} + /// Tint the background of a single column (the cursor's column) to make it /// easier to track vertical alignment. `column_x` is relative to /// `render_area.x` (i.e. the same coordinate as `cursor` from diff --git a/crates/fresh-editor/src/view/ui/split_rendering/style.rs b/crates/fresh-editor/src/view/ui/split_rendering/style.rs index 55c88d59d2..418bdd0953 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/style.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/style.rs @@ -162,7 +162,16 @@ pub(super) fn create_wrapped_virtual_lines( wrap_width: Option, gutter_glyph: Option<(String, Color)>, text_overlays: &[fresh_core::api::VirtualLineTextOverlay], + image: Option, ) -> Vec { + // Image placeholder rows are never wrapped: the row reserves an exact + // cell width and the post-pass overwrites those cells with placeholders. + if let Some(spec) = image { + let token_style = token_style_from_ratatui(style); + let mut line = build_virtual_view_line(text, &token_style, gutter_glyph, 0, text_overlays); + line.image_placeholder = Some(spec); + return vec![line]; + } // `TokenColor` faithfully captures every `ratatui::Color` variant // (RGB, named ANSI, indexed, `Reset`) so themes like `terminal` — // which use named ANSI colors for the diff backgrounds — survive @@ -262,6 +271,7 @@ fn build_virtual_view_line( ends_with_newline: true, virtual_gutter_glyph: gutter_glyph, virtual_line_style: Some(token_style.clone()), + image_placeholder: None, } } @@ -271,7 +281,8 @@ mod tests { #[test] fn create_wrapped_virtual_lines_no_wrap_returns_one_line() { - let lines = create_wrapped_virtual_lines("hello world", Style::default(), None, None, &[]); + let lines = + create_wrapped_virtual_lines("hello world", Style::default(), None, None, &[], None); assert_eq!(lines.len(), 1); assert_eq!(lines[0].text, "hello world"); assert_eq!(lines[0].line_start, LineStart::AfterInjectedNewline); @@ -279,7 +290,7 @@ mod tests { #[test] fn create_wrapped_virtual_lines_empty_input_yields_single_empty_row() { - let lines = create_wrapped_virtual_lines("", Style::default(), Some(20), None, &[]); + let lines = create_wrapped_virtual_lines("", Style::default(), Some(20), None, &[], None); assert_eq!(lines.len(), 1); assert!(lines[0].text.is_empty()); assert_eq!(lines[0].line_start, LineStart::AfterInjectedNewline); @@ -289,7 +300,8 @@ mod tests { fn create_wrapped_virtual_lines_splits_no_boundary_at_hard_cap() { // No word boundary anywhere — must hard-cap at width. let text: String = std::iter::repeat('X').take(50).collect(); - let lines = create_wrapped_virtual_lines(&text, Style::default(), Some(20), None, &[]); + let lines = + create_wrapped_virtual_lines(&text, Style::default(), Some(20), None, &[], None); assert_eq!(lines.len(), 3); assert_eq!(lines[0].text.chars().count(), 20); assert_eq!(lines[1].text.chars().count(), 20); @@ -312,6 +324,7 @@ mod tests { Some(18), None, &[], + None, ); assert!(lines.len() >= 2); // Concatenating the segment texts must round-trip the input. diff --git a/crates/fresh-editor/src/view/ui/split_rendering/transforms.rs b/crates/fresh-editor/src/view/ui/split_rendering/transforms.rs index da7a1e1c92..fcab7e528e 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/transforms.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/transforms.rs @@ -944,6 +944,7 @@ pub(super) fn inject_virtual_lines( wrap_width, glyph, &vtext.text_overlays, + vtext.image, )); } } @@ -969,6 +970,7 @@ pub(super) fn inject_virtual_lines( wrap_width, glyph, &vtext.text_overlays, + vtext.image, )); } } diff --git a/crates/fresh-editor/src/view/ui/view_pipeline.rs b/crates/fresh-editor/src/view/ui/view_pipeline.rs index 084b11fa60..93645325e1 100644 --- a/crates/fresh-editor/src/view/ui/view_pipeline.rs +++ b/crates/fresh-editor/src/view/ui/view_pipeline.rs @@ -71,6 +71,10 @@ pub struct ViewLine { /// (it can't recover the bg from `char_styles.first()` when there /// are no chars). `None` for source lines. pub virtual_line_style: Option, + /// Set when this virtual line is one row of a placed inline image. The + /// render post-pass overwrites this row's cells with kitty Unicode + /// placeholders. `None` for ordinary lines. + pub image_placeholder: Option, } impl ViewLine { @@ -295,6 +299,7 @@ impl<'a> Iterator for ViewLineIterator<'a> { ends_with_newline: false, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }); } return None; @@ -610,6 +615,7 @@ impl<'a> Iterator for ViewLineIterator<'a> { ends_with_newline, virtual_gutter_glyph: None, virtual_line_style: None, + image_placeholder: None, }) } } diff --git a/crates/fresh-editor/src/view/virtual_text.rs b/crates/fresh-editor/src/view/virtual_text.rs index 05cf591c1d..596b907933 100644 --- a/crates/fresh-editor/src/view/virtual_text.rs +++ b/crates/fresh-editor/src/view/virtual_text.rs @@ -113,6 +113,11 @@ pub struct VirtualText { /// Offsets are byte offsets within `text`. Used e.g. by live-diff /// to bold + underline removed words on deletion virtual lines. pub text_overlays: Vec, + /// When set, this virtual line is one row of a placed inline image. + /// The render post-pass overwrites its cells with kitty Unicode + /// placeholders instead of rendering `text`; `text` holds `cols` spaces + /// so the row still reserves width on terminals without graphics. + pub image: Option, } impl VirtualText { @@ -138,8 +143,9 @@ impl VirtualText { } } -/// Unique identifier for a virtual text entry -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Unique identifier for a virtual text entry. Ids come from a monotonic +/// counter, so ordering by id is ordering by insertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct VirtualTextId(pub u64); /// Manages virtual text entries for a buffer @@ -234,6 +240,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -285,6 +292,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -326,6 +334,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -373,6 +382,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); @@ -466,6 +476,56 @@ impl VirtualTextManager { gutter_glyph, gutter_color, text_overlays, + image: None, + }, + ); + self.bump_version(); + + id + } + + /// Add a virtual line that reserves one row of a placed inline image. + /// `text` should be `cols` spaces (the fallback shown when the terminal + /// has no graphics support); `spec` carries the image id, image-row, and + /// width the render post-pass uses to emit kitty Unicode placeholders. + #[allow(clippy::too_many_arguments)] + pub fn add_image_line( + &mut self, + marker_list: &mut MarkerList, + position: usize, + text: String, + style: Style, + placement: VirtualTextPosition, + namespace: VirtualTextNamespace, + priority: i32, + spec: crate::services::graphics::ImageCellSpec, + ) -> VirtualTextId { + debug_assert!( + placement.is_line(), + "add_image_line requires LineAbove or LineBelow" + ); + + let marker_id = marker_list.create(position, false); + + let id = VirtualTextId(self.next_id); + self.next_id += 1; + + self.texts.insert( + id, + VirtualText { + marker_id, + text, + style, + fg_theme_key: None, + bg_theme_key: None, + position: placement, + priority, + string_id: None, + namespace: Some(namespace), + gutter_glyph: None, + gutter_color: None, + text_overlays: Vec::new(), + image: Some(spec), }, ); self.bump_version(); @@ -623,23 +683,32 @@ impl VirtualTextManager { start: usize, end: usize, ) -> Vec<(usize, &VirtualText)> { - let mut results: Vec<(usize, &VirtualText)> = self + let mut results: Vec<(usize, VirtualTextId, &VirtualText)> = self .texts - .values() - .filter_map(|vtext| { + .iter() + .filter_map(|(id, vtext)| { let pos = marker_list.get_position(vtext.marker_id)?; if pos >= start && pos < end { - Some((pos, vtext)) + Some((pos, *id, vtext)) } else { None } }) .collect(); - // Sort by position, then by priority - results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority))); + // Sort by position, then priority, then insertion order — the map's + // iteration order is arbitrary, so equal (position, priority) entries + // need the id tiebreak to render deterministically. + results.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.2.priority.cmp(&b.2.priority)) + .then_with(|| a.1.cmp(&b.1)) + }); results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Build a lookup map for efficient per-character access during rendering @@ -704,31 +773,41 @@ impl VirtualTextManager { /// Query only virtual LINES (LineAbove/LineBelow) in a byte range /// /// Used by the render pipeline to inject header/footer lines. - /// Returns (byte_position, &VirtualText) pairs sorted by position then priority. + /// Returns (byte_position, &VirtualText) pairs sorted by position, then + /// priority, then insertion order. The insertion-order tiebreak matters: + /// entries live in a HashMap whose iteration order is arbitrary, so + /// without it, equal-priority lines at one anchor (e.g. the N reserved + /// rows of one placed image) would come back shuffled. pub fn query_lines_in_range( &self, marker_list: &MarkerList, start: usize, end: usize, ) -> Vec<(usize, &VirtualText)> { - let mut results: Vec<(usize, &VirtualText)> = self + let mut results: Vec<(usize, VirtualTextId, &VirtualText)> = self .texts - .values() - .filter(|vtext| vtext.position.is_line()) - .filter_map(|vtext| { + .iter() + .filter(|(_, vtext)| vtext.position.is_line()) + .filter_map(|(id, vtext)| { let pos = marker_list.get_position(vtext.marker_id)?; if pos >= start && pos < end { - Some((pos, vtext)) + Some((pos, *id, vtext)) } else { None } }) .collect(); - // Sort by position, then by priority - results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority))); + results.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.2.priority.cmp(&b.2.priority)) + .then_with(|| a.1.cmp(&b.1)) + }); results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range @@ -740,24 +819,32 @@ impl VirtualTextManager { start: usize, end: usize, ) -> Vec<(usize, &VirtualText)> { - let mut results: Vec<(usize, &VirtualText)> = self + let mut results: Vec<(usize, VirtualTextId, &VirtualText)> = self .texts - .values() - .filter(|vtext| vtext.position.is_inline()) - .filter_map(|vtext| { + .iter() + .filter(|(_, vtext)| vtext.position.is_inline()) + .filter_map(|(id, vtext)| { let pos = marker_list.get_position(vtext.marker_id)?; if pos >= start && pos < end { - Some((pos, vtext)) + Some((pos, *id, vtext)) } else { None } }) .collect(); - // Sort by position, then by priority - results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.priority.cmp(&b.1.priority))); + // Sort by position, then priority, then insertion order (see + // `query_range` for why the id tiebreak is load-bearing). + results.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.2.priority.cmp(&b.2.priority)) + .then_with(|| a.1.cmp(&b.1)) + }); results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position diff --git a/crates/fresh-editor/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index bd0e7e5277..53c458ce2c 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -1314,6 +1314,149 @@ Ampersand: & dash: — space: here numeric: © ); } +/// Test compose-mode rendering of block quotes, list bullets, task +/// checkboxes, and horizontal rules: the raw markers should be concealed +/// into their display glyphs (▌, •, ☐/☑, ─). +#[test] +fn test_compose_mode_blockquote_bullet_checkbox_hr_rendering() { + use crate::common::harness::{copy_plugin, copy_plugin_lib}; + use crate::common::tracing::init_tracing_from_env; + use crossterm::event::{KeyCode, KeyModifiers}; + + init_tracing_from_env(); + + let md_content = "\ +# Render Test + +> quoted text here + +- bullet item +- [ ] open task +- [x] done task + +*** + +after rule with a footnote[^1] + +[^1]: the footnote text +"; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + + let md_path = project_root.join("render_test.md"); + std::fs::write(&md_path, &md_content).unwrap(); + + let mut harness = + EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root) + .unwrap(); + + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + harness.assert_screen_contains("render_test.md"); + + // Enable compose mode + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + + // Wait for compose mode to settle: the quote marker should be concealed + // into the quote bar. The cursor sits on line 0 (the heading), away from + // all the lines under test, so conceals are active everywhere below. + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + s.contains("\u{258c}") && s.contains("quoted text here") + }) + .unwrap(); + + let screen = harness.screen_to_string(); + + // Block quote: `>` becomes a vertical bar, text remains. + assert!( + !screen.contains("> quoted"), + "Raw > marker should be concealed in compose mode.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{258c} quoted text here"), + "Quote bar \u{258c} should replace the > marker.\nScreen:\n{}", + screen, + ); + + // Bullets: `-` becomes `•`. + assert!( + !screen.contains("- bullet item"), + "Raw - bullet should be concealed in compose mode.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{2022} bullet item"), + "Bullet \u{2022} should replace the - marker.\nScreen:\n{}", + screen, + ); + + // Task checkboxes: `[ ]` → ☐, `[x]` → ☑. + assert!( + !screen.contains("[ ] open task") && !screen.contains("[x] done task"), + "Raw checkbox syntax should be concealed in compose mode.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{2610} open task"), + "Unchecked box \u{2610} should replace [ ].\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{2611} done task"), + "Checked box \u{2611} should replace [x].\nScreen:\n{}", + screen, + ); + + // Horizontal rule: `***` becomes a run of ─ characters. + assert!( + !screen.contains("***"), + "Raw *** rule should be concealed in compose mode.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"), + "Rendered \u{2500} rule should be visible for ***.\nScreen:\n{}", + screen, + ); + + // Footnotes: `[^1]` references render as superscript ¹, and the + // `[^1]:` definition marker collapses to the same superscript. + assert!( + !screen.contains("[^1]"), + "Raw [^1] footnote syntax should be concealed in compose mode.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("footnote\u{00b9}"), + "Superscript \u{00b9} should replace the [^1] reference.\nScreen:\n{}", + screen, + ); + assert!( + screen.contains("\u{00b9} the footnote text"), + "Definition marker should render as \u{00b9} before the footnote text.\nScreen:\n{}", + screen, + ); +} + /// Test that table columns are aligned (padded to equal widths) in compose mode. /// /// Given an uneven table, the box-drawing pipe positions should line up @@ -2446,6 +2589,138 @@ End. ); } +/// Regression test: an escaped pipe (`\|`) inside a table cell is *content*, +/// not a column separator. Before the fix the renderer split on every `|`, +/// so a row like `| a \| b \| c | ... |` fanned out into phantom columns +/// (visible as extra `│` borders past the table frame) and the stray `\` +/// stayed on screen. After the fix: +/// 1. Every visual line of the 3-column table has exactly 4 box separators. +/// 2. The escaped pipe renders as a literal `|` (the backslash is concealed). +#[test] +fn test_compose_mode_table_escaped_pipe() { + use crate::common::harness::{copy_plugin, copy_plugin_lib}; + use crate::common::tracing::init_tracing_from_env; + use crossterm::event::{KeyCode, KeyModifiers}; + + init_tracing_from_env(); + + // 3-column table. The middle data row uses escaped pipes in cell 2. + let md_content = "\ +# Escaped Pipe Test + +| Case | Value | Notes | +|---|---|---| +| Plain | ok | nothing special | +| Escaped pipe | a \\| b \\| c | pipe stays in one cell | + +End. +"; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + + let md_path = project_root.join("escaped_pipe_test.md"); + std::fs::write(&md_path, &md_content).unwrap(); + + let mut harness = + EditorTestHarness::with_config_and_working_dir(80, 30, Default::default(), project_root) + .unwrap(); + + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + harness.assert_screen_contains("escaped_pipe_test.md"); + + // Enable compose mode + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + s.contains("│") && s.contains("─") + }) + .unwrap(); + let mut prev = String::new(); + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + let stable = s == prev; + prev = s; + stable + }) + .unwrap(); + let screen = harness.screen_to_string(); + + eprintln!("=== Rendered escaped-pipe table ==="); + for (i, line) in screen.lines().enumerate() { + eprintln!("{:3}: {}", i, line); + } + eprintln!("=== End ==="); + + // Every visual line of this 3-column table must have exactly 4 box + // separators (leading │, two inner │, trailing │ — or ├/┼/┤ on the rule). + // The escaped row's `\|` must NOT add phantom separators. + let pipe_chars = ['│', '┼', '├', '┤']; + let table_lines: Vec<(usize, &str)> = screen + .lines() + .enumerate() + .filter(|(_, l)| { + let t = l.trim(); + t.contains('│') || t.contains('┼') || t.contains('├') + }) + .collect(); + + let mut failures = Vec::new(); + for (line_num, line_text) in &table_lines { + let pipe_count = line_text.chars().filter(|c| pipe_chars.contains(c)).count(); + if pipe_count != 4 { + failures.push(format!( + " line {}: {} box separators (expected 4): {:?}", + line_num, + pipe_count, + line_text.trim() + )); + } + } + assert!( + failures.is_empty(), + "Escaped pipe broke column separators (phantom columns?):\n{}\n\nFull table:\n{}", + failures.join("\n"), + table_lines + .iter() + .map(|(n, l)| format!(" {:3}: {}", n, l)) + .collect::>() + .join("\n"), + ); + + // The escaped pipe renders as a literal `|` inside the cell, and the + // backslash escape marker is concealed (no `\|` left on screen). + assert!( + screen.contains("a | b | c"), + "Escaped pipes should render as literal `|` in the cell.\nScreen:\n{}", + screen, + ); + assert!( + !screen.contains("\\|"), + "The backslash escape marker should be concealed.\nScreen:\n{}", + screen, + ); +} + /// Regression test: pressing Down arrow past the end of a table should /// advance the cursor to the next line below the table, NOT jump it /// back to the beginning of the document. @@ -4789,3 +5064,286 @@ fn test_compose_mode_table_width_clamped_when_sidebar_opens() { screen, ); } + +/// A wide, prose-heavy table renders as a clean README-style frame: every +/// border and cell row is the SAME width, the frame fits inside the viewport +/// (no border glyph wraps onto its own screen row), and the long prose column +/// wraps across virtual continuation lines instead of overflowing. Regression +/// guard for the table rewrite (virtual continuation lines + width cap + +/// right-margin) — the failure mode was lone `┐`/`┤`/`┘` glyphs on their own +/// row and Notes text bleeding past the frame. +#[test] +fn test_wide_table_renders_clean_frame() { + use crate::common::harness::{copy_plugin, copy_plugin_lib}; + use crossterm::event::{KeyCode, KeyModifiers}; + + let md = "\ +# Slice-by-slice status + +| # | Slice | Status | Notes / what needs to change | +|---|-------|--------|------------------------------| +| 01 | Author workflow | Implemented (exceeds) | workflows, _definition.ts, saveDraft+lintDraft; plus createFromStock, editor, rename, update, setPublished | +| 04 | Evaluate & materialize criteria | Partial / divergent | Predicate engine (criteria/predicates.ts, closed set), gated CaseContext. Missing the materialized table + evaluation/source/lifecycle + compare-then-patch. Gated by the divergence above | +| 08 | Criterion status history | Not implemented | No workflowCriterionStatus; depends on materialized criteria | +"; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + let md_path = project_root.join("wide_table.md"); + std::fs::write(&md_path, md).unwrap(); + + // Wide viewport, like the user's window — the case that used to overflow. + let viewport_width = 160usize; + let mut harness = EditorTestHarness::with_config_and_working_dir( + viewport_width as u16, + 40, + Default::default(), + project_root, + ) + .unwrap(); + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + + let mut prev = String::new(); + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + let stable = s == prev; + prev = s; + stable + }) + .unwrap(); + + let screen = harness.screen_to_string(); + let frame_rows: Vec<&str> = screen + .lines() + .map(|l| l.trim_end()) + .filter(|l| { + let t = l.trim_start(); + t.starts_with('┌') || t.starts_with('├') || t.starts_with('└') || t.starts_with('│') + }) + .collect(); + + // Sanity: we got a framed table (top, header sep, ≥3 row separators, bottom) + // plus many cell rows. + assert!( + frame_rows.len() >= 10, + "expected a multi-row framed table, got {} frame rows:\n{}", + frame_rows.len(), + screen + ); + + // No border glyph wrapped onto its own screen row — the old overflow bug + // left lone closing glyphs like `┐`/`┤`/`┘` (or a stray `│`) on a row. + for (i, line) in screen.lines().enumerate() { + let t = line.trim(); + assert!( + !matches!(t, "┐" | "┤" | "┘" | "┌" | "├" | "└" | "│"), + "row {} is a lone border glyph {:?} — the frame wrapped past the viewport:\n{}", + i, + t, + screen + ); + } + + // Every framed row has the SAME display width (uniform columns) and fits + // inside the viewport. + let width_of = |s: &str| s.chars().count(); + let frame_w = width_of(frame_rows[0]); + assert!( + frame_w <= viewport_width, + "frame width {} exceeds viewport {}:\n{}", + frame_w, + viewport_width, + screen + ); + for row in &frame_rows { + assert_eq!( + width_of(row), + frame_w, + "ragged frame: row {:?} is width {}, expected {}:\n{}", + row, + width_of(row), + frame_w, + screen + ); + } + + // The long Notes prose wrapped into the frame rather than escaping it: the + // tail of row 01's Notes appears INSIDE a bordered cell row. + assert!( + screen.lines().any(|l| { + let t = l.trim(); + t.starts_with('│') && t.contains("setPublished") + }), + "expected wrapped Notes continuation inside the frame:\n{}", + screen + ); +} + +/// A table taller than the viewport stays fully composed after scrolling to +/// the end: every visible table row is framed (`│ … │`), none are left as raw +/// `| .. |` source. Guards `markdown_compose`'s own scroll/line-delivery path. +#[test] +fn test_tall_table_scroll_all_rows_composed() { + use crate::common::harness::{copy_plugin, copy_plugin_lib}; + use crossterm::event::{KeyCode, KeyModifiers}; + + let mut md = + String::from("# Tall table\n\n| # | Slice | Status | Notes |\n|---|---|---|---|\n"); + for i in 1..=22 { + md.push_str(&format!( + "| {:02} | Item number {} here | Implemented or not implemented | Notes column with enough prose to wrap across a couple of lines for row {} of the long table |\n", + i, i, i + )); + } + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + let md_path = project_root.join("tall.md"); + std::fs::write(&md_path, &md).unwrap(); + + let mut harness = + EditorTestHarness::with_config_and_working_dir(120, 24, Default::default(), project_root) + .unwrap(); + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + + // Jump to end of file, let it settle. + harness + .send_key(KeyCode::End, KeyModifiers::CONTROL) + .unwrap(); + for _ in 0..6 { + harness.process_async_and_render().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(60)); + harness.advance_time(std::time::Duration::from_millis(60)); + } + + let screen = harness.screen_to_string(); + // The last row must be visible and framed (we scrolled to EOF). + assert!( + screen.lines().any(|l| l.trim_start().starts_with("│ 22 ")), + "row 22 not composed after scroll to EOF:\n{}", + screen + ); + // No visible line is a raw markdown table row (`| 07 | …`) — that's the + // un-composed failure mode. + for line in screen.lines() { + let t = line.trim_start(); + let raw_row = + t.starts_with("| ") && t.as_bytes().get(2).is_some_and(|c| c.is_ascii_digit()); + assert!( + !raw_row, + "found a raw (un-composed) table row after scroll:\n {:?}\n{}", + line.trim_end(), + screen + ); + } +} + +/// A wrapping table row containing an astral-plane emoji (🟡) must not abort +/// composition. Regression guard for the bug where `processLineConceals`' +/// debug line sliced an emoji mid-surrogate and threw, aborting the per-line +/// loop so that row AND every row after it rendered as raw `| .. |` markdown. +/// Fixed by a code-point-safe slice plus a per-line try/catch in the loop. +#[test] +fn test_emoji_row_does_not_abort_composition() { + use crate::common::harness::{copy_plugin, copy_plugin_lib}; + use crossterm::event::{KeyCode, KeyModifiers}; + + let md = "# Slice-by-slice status\n\n\ +| # | Slice | Status | Notes / what needs to change |\n\ +|---|---|---|---|\n\ +| 13 | Claim / release | \u{274c} **Not implemented** | No `workflowClaims` |\n\ +| 14 | Deadlines | \u{274c} **Not implemented** | Nothing |\n\ +| 15 | Outcomes, early exit & cancel | \u{1f7e1} **Partial** | Terminal `completed` + `returnToFirm` exist. Missing: discriminated `outcome`, early-exit-with-reason (declined/canceled), `ended` status |\n\ +| 16 | Outbox pub/sub | \u{274c} **Not implemented** | No `workflowEventLog`/`Subscriptions`/`Deliveries`, no `@convex-dev/workpool`, empty `convex.config.ts` |\n\ +| 17 | trigger.dev OCR round-trip | \u{274c} **Not implemented** | No `waiting` round-trip (doc extraction exists elsewhere) |\n"; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + let md_path = project_root.join("c.md"); + std::fs::write(&md_path, md).unwrap(); + + // Narrow width like the user's window with the File Explorer open. + let mut harness = + EditorTestHarness::with_config_and_working_dir(82, 30, Default::default(), project_root) + .unwrap(); + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + for _ in 0..6 { + harness.process_async_and_render().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(60)); + harness.advance_time(std::time::Duration::from_millis(60)); + } + + let screen = harness.screen_to_string(); + // The emoji row (15) and the rows after it (16, 17) must be composed into + // the framed table, not left as raw `| 16 | … |` source. Before the fix, + // row 15's emoji threw and everything from 15 onward rendered raw. + for line in screen.lines() { + let t = line.trim_start(); + let raw_row = + t.starts_with("| ") && t.as_bytes().get(2).is_some_and(|c| c.is_ascii_digit()); + assert!( + !raw_row, + "row rendered as raw markdown (composition aborted): {:?}\n{}", + line.trim_end(), + screen + ); + } + // Positively confirm the rows after the emoji row are framed. + assert!( + screen + .lines() + .any(|l| l.contains('│') && l.contains("Outbox pub/sub")), + "row 16 (after the emoji row) was not composed into the frame:\n{}", + screen + ); +} diff --git a/crates/fresh-editor/tests/e2e/markdown_compose_stray_pipe.rs b/crates/fresh-editor/tests/e2e/markdown_compose_stray_pipe.rs new file mode 100644 index 0000000000..287ac0a631 --- /dev/null +++ b/crates/fresh-editor/tests/e2e/markdown_compose_stray_pipe.rs @@ -0,0 +1,187 @@ +// Regression probe for "stray pipes when scrolling": a wrapping table renders +// continuation virtual lines, and scrolling could leave isolated `│` border +// glyphs hanging on otherwise-blank lines. This guards the *composed buffer* +// against logical stray-border regressions (e.g. from table cell-splitting or +// virtual-line changes). The live-only variant of this artifact was a +// tmux/synchronized-update terminal-output bug (fixed in main.rs) that the +// headless ratatui backend cannot exercise. Detector: see `stray_rows`. + +use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness}; +use crate::common::tracing::init_tracing_from_env; +use crossterm::event::{KeyCode, KeyModifiers}; + +const FIXTURE: &str = "\ +# Stray Pipe Probe + +Intro paragraph one. + +## Code + +```rust +fn main() { + let total: i32 = (1..=10).sum(); + println!(\"sum = {total}\"); +} +``` + +## Tables + +### Basic + +| Feature | Supported | Notes | +| ------- | :-------: | ----- | +| Headings | ✅ | h1-h6 | +| Tables | ✅ | alignment | +| Mermaid | ❓ | the point of this test | + +### Wrapping + +| Property | Value / Description | Status | +| -------- | ------------------- | :----: | +| Short | OK | ✅ | +| Long prose | This is an intentionally long sentence that should wrap across multiple lines within its cell so we can confirm soft wrapping behaves the way we expect. | ✅ | +| Long URL | https://example.com/some/really/long/path/that/keeps/going?param=one¶m2=two&token=abcdefghijklmnopqrstuvwxyz0123456789 | ⚠️ | +| Mixed | Call renderTableCell(content, wrap true, maxWidth 320) and then re-measure to verify the layout still fits the viewport at all widths. | ❓ | + +## After + +Trailing paragraph one. + +Trailing paragraph two. + +Trailing paragraph three. + +Trailing paragraph four. + +Trailing paragraph five. +"; + +const BOX_VERTICALS: [char; 4] = ['│', '┼', '├', '┤']; + +/// A "stray" row is one whose editor *content* region (everything to the right +/// of the optional File Explorer sidebar's two `│` borders) consists only of +/// whitespace plus one or more box-vertical glyphs — i.e. a border pipe left +/// hanging on an otherwise blank line. Splitting on `│` makes this robust +/// whether or not the sidebar is open: +/// - no sidebar, blank line: "" -> 1 part, not flagged +/// - no sidebar, real table row: has non-blank cell text, not flagged +/// - sidebar + blank content: ["", " tree ", " ", ""] -> flagged +/// - sidebar + real table row: a content part has cell text, not flagged +fn stray_rows(screen: &str) -> Vec<(usize, String)> { + screen + .lines() + .enumerate() + .filter(|(_, l)| { + // This test always runs with the File Explorer open, so each row's + // first two `│` are the sidebar's left and right borders. The + // editor content region is everything after the 2nd `│`. + let mut pipes = l.match_indices('│'); + let _left = pipes.next(); + let after_sidebar = match pipes.next() { + Some((idx, _)) => &l[idx + '│'.len_utf8()..], + None => return false, + }; + // A stray = a box-vertical in the content region whose only company + // is whitespace (no real cell text), i.e. a border pipe hanging on + // an otherwise blank line. A legit table row has cell text here. + if !after_sidebar.chars().any(|c| BOX_VERTICALS.contains(&c)) { + return false; + } + after_sidebar + .chars() + .all(|c| c.is_whitespace() || BOX_VERTICALS.contains(&c)) + }) + .map(|(i, l)| (i, l.to_string())) + .collect() +} + +#[test] +fn test_compose_mode_no_stray_pipes_on_scroll() { + init_tracing_from_env(); + + let temp_dir = tempfile::TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + std::fs::create_dir(&project_root).unwrap(); + let plugins_dir = project_root.join("plugins"); + std::fs::create_dir(&plugins_dir).unwrap(); + copy_plugin(&plugins_dir, "markdown_compose"); + copy_plugin_lib(&plugins_dir); + + let md_path = project_root.join("probe.md"); + std::fs::write(&md_path, FIXTURE).unwrap(); + + let mut harness = + EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root) + .unwrap(); + harness.open_file(&md_path).unwrap(); + harness.render().unwrap(); + + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.wait_for_prompt().unwrap(); + harness.type_text("Toggle Compose").unwrap(); + harness.wait_for_screen_contains("Toggle Compose").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.wait_for_prompt_closed().unwrap(); + harness + .wait_until_stable(|h| { + let s = h.screen_to_string(); + s.contains("│") && s.contains("─") + }) + .unwrap(); + + // Open the File Explorer sidebar — this offsets and narrows the compose + // content area, matching the live editor's restored layout where the + // stray-pipe artifact appears. + harness.editor_mut().toggle_file_explorer(); + harness + .wait_until_stable(|h| h.editor().file_explorer_visible()) + .unwrap(); + harness + .wait_until_stable(|h| h.screen_to_string().contains("File Explorer")) + .unwrap(); + + let (content_start, content_end) = harness.content_area_rows(); + let mid = ((content_start + content_end) / 2) as u16; + + let mut all_stray: Vec = Vec::new(); + + // Scroll down through the document a few rows at a time, then back up, + // dumping any stray rows seen at each step. + for step in 0..30 { + harness.mouse_scroll_down(40, mid).unwrap(); + let _ = harness.render(); + let screen = harness.screen_to_string(); + for (row, text) in stray_rows(&screen) { + all_stray.push(format!("down step {step} row {row}: {:?}", text)); + } + } + for step in 0..30 { + harness.mouse_scroll_up(40, mid).unwrap(); + let _ = harness.render(); + let screen = harness.screen_to_string(); + for (row, text) in stray_rows(&screen) { + all_stray.push(format!("up step {step} row {row}: {:?}", text)); + } + } + + if !all_stray.is_empty() { + eprintln!("=== STRAY PIPE ROWS DETECTED ({}) ===", all_stray.len()); + for s in &all_stray { + eprintln!("{s}"); + } + eprintln!("=== final screen ==="); + for (i, l) in harness.screen_to_string().lines().enumerate() { + eprintln!("{i:3}: {l}"); + } + } + + assert!( + all_stray.is_empty(), + "Found {} stray box-vertical glyph(s) on non-table rows while scrolling.", + all_stray.len() + ); +} diff --git a/crates/fresh-editor/tests/e2e/mod.rs b/crates/fresh-editor/tests/e2e/mod.rs index 47a4504398..b10a63234b 100644 --- a/crates/fresh-editor/tests/e2e/mod.rs +++ b/crates/fresh-editor/tests/e2e/mod.rs @@ -125,6 +125,7 @@ pub mod macros; pub mod markdown_compose; pub mod markdown_compose_diagnostics; pub mod markdown_compose_scroll_reach; +pub mod markdown_compose_stray_pipe; pub mod memory_scroll_leak; pub mod menu_bar; pub mod menu_cursor_bleed; diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index 98e14c7e87..ab66727f07 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -676,6 +676,10 @@ pub struct PluginTrackedState { pub overlay_namespaces: Vec<(BufferId, String)>, /// (buffer_id, namespace) pairs used for virtual lines pub virtual_line_namespaces: Vec<(BufferId, String)>, + /// (buffer_id, namespace) pairs used for inline images placed via + /// `placeImage`. Cleared on unload with ClearImages, which removes the + /// reserved placeholder rows and frees the terminal-side pixel data. + pub image_namespaces: Vec<(BufferId, String)>, /// (buffer_id, namespace) pairs used for line indicators pub line_indicator_namespaces: Vec<(BufferId, String)>, /// (buffer_id, virtual_text_id) pairs @@ -2234,6 +2238,30 @@ impl JsEditorApi { std::fs::read_to_string(&path).ok() } + /// Read up to `max_len` bytes from a file starting at byte `offset`, + /// returned as an array of byte values (0-255). Returns `null` if the + /// file can't be opened or read. Intended for lightweight binary + /// inspection — e.g. parsing an image header for its dimensions — not + /// for bulk reads: `max_len` is capped at 1 MiB. + #[plugin_api(ts_return = "number[] | null")] + pub fn read_file_bytes(&self, path: String, offset: u32, max_len: u32) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + const CAP: u32 = 1024 * 1024; + let mut f = std::fs::File::open(&path).ok()?; + f.seek(SeekFrom::Start(offset as u64)).ok()?; + let mut buf = vec![0u8; max_len.min(CAP) as usize]; + let mut filled = 0; + while filled < buf.len() { + match f.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(_) => return None, + } + } + buf.truncate(filled); + Some(buf) + } + /// Write file contents pub fn write_file(&self, path: String, content: String) -> bool { let p = Path::new(&path); @@ -3529,6 +3557,25 @@ impl JsEditorApi { .is_ok() } + /// Clear conceal ranges overlapping a byte range, restricted to one + /// namespace — other plugins' conceals in the range are untouched. + pub fn clear_conceals_in_range_for_namespace( + &self, + buffer_id: u32, + namespace: String, + start: u32, + end: u32, + ) -> bool { + self.command_sender + .send(PluginCommand::ClearConcealsInRangeForNamespace { + buffer_id: BufferId(buffer_id as usize), + namespace: OverlayNamespace::from_string(namespace), + start: start as usize, + end: end as usize, + }) + .is_ok() + } + // === Folds === /// Add a collapsed fold range. Hides bytes [start, end) from @@ -4110,6 +4157,72 @@ impl JsEditorApi { .is_ok()) } + /// Place an inline image (kitty graphics protocol) anchored to a buffer + /// position. `source` is an absolute path to a PNG; `cols`/`rows` are the + /// placement size in terminal cells. Returns true if the command was + /// dispatched. On terminals without graphics support the core treats it + /// as a no-op, so the caller should keep a visible text fallback. + #[allow(clippy::too_many_arguments)] + pub fn place_image( + &self, + buffer_id: u32, + key: String, + source: String, + position: u32, + cols: u32, + rows: u32, + above: bool, + namespace: String, + ) -> bool { + // Track the namespace so unload cleanup sends ClearImages, tearing + // down the reserved placeholder rows and freeing terminal pixel data. + self.plugin_tracked_state + .borrow_mut() + .entry(self.plugin_name.clone()) + .or_default() + .image_namespaces + .push((BufferId(buffer_id as usize), namespace.clone())); + + self.command_sender + .send(PluginCommand::PlaceImage { + buffer_id: BufferId(buffer_id as usize), + key, + source, + position: position as usize, + cols: cols as u16, + rows: rows as u16, + above, + namespace, + }) + .is_ok() + } + + /// Remove all images placed under `namespace` (and their reserved rows) + /// and free the terminal-side image data. + pub fn clear_images(&self, buffer_id: u32, namespace: String) -> bool { + self.command_sender + .send(PluginCommand::ClearImages { + buffer_id: BufferId(buffer_id as usize), + namespace, + }) + .is_ok() + } + + /// The terminal's raster-graphics capability: `"kitty"` if inline images + /// placed via `placeImage` will actually render (kitty graphics + /// protocol — kitty, WezTerm, Ghostty, …), `"none"` otherwise. + /// + /// Check this before doing expensive rendering work (rasterizing + /// diagrams, converting image formats): on `"none"` terminals + /// `placeImage` is a no-op in the core, so skip the work and keep a text + /// fallback instead. Overridable by the user with the `FRESH_GRAPHICS` + /// env var (`kitty` / `none`). + pub fn get_graphics_capability(&self) -> String { + fresh_core::graphics::GraphicsCapability::detect() + .as_str() + .to_string() + } + // === Prompts === /// Show a prompt and wait for user input (async) @@ -7374,6 +7487,19 @@ impl QuickJsBackend { // They will persist until the buffer is closed. This is acceptable for now // since most plugins re-create virtual lines on init anyway. + // Clear inline-image namespaces: removes the reserved placeholder + // rows and frees the terminal-side pixel data. + let mut seen_img_ns: std::collections::HashSet<(usize, String)> = + std::collections::HashSet::new(); + for (buf_id, ns) in &tracked.image_namespaces { + if seen_img_ns.insert((buf_id.0, ns.clone())) { + let _ = self.command_sender.send(PluginCommand::ClearImages { + buffer_id: *buf_id, + namespace: ns.clone(), + }); + } + } + // Clear line indicator namespaces let mut seen_li_ns: std::collections::HashSet<(usize, String)> = std::collections::HashSet::new(); diff --git a/crates/fresh-plugin-runtime/src/ts_export.rs b/crates/fresh-plugin-runtime/src/ts_export.rs index 5b19b6f31b..1abb57599e 100644 --- a/crates/fresh-plugin-runtime/src/ts_export.rs +++ b/crates/fresh-plugin-runtime/src/ts_export.rs @@ -1354,6 +1354,7 @@ mod tests { "addConceal", "clearConcealNamespace", "clearConcealsInRange", + "clearConcealsInRangeForNamespace", "addSoftBreak", "clearSoftBreakNamespace", "clearSoftBreaksInRange",