From a6d9905f42ec52cf83da432edce5a6f1f3864d4e Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 09:16:00 -0500 Subject: [PATCH 01/14] feat: Add inline image rendering and fenced code block highlighting This introduces inline image support in compose mode, leveraging the Kitty graphics protocol for terminals that support it (Kitty, WezTerm, Ghostty, Konsole). Plugins can now place raster images (PNG, JPG, etc.) and dynamically rendered diagrams (Mermaid via `mmdc`) directly into buffer content, anchored to text positions. The editor manages image transmission, cleanup, and renders placeholder cells that scroll with text. The `markdown_compose` plugin uses this to render image links and Mermaid diagrams. A new `editor.getGraphicsCapability()` API allows plugins to detect terminal support and skip expensive rendering work when images won't be displayed. Additionally, compose mode now provides lightweight, language-agnostic syntax highlighting and background tinting for fenced code blocks. --- crates/fresh-core/src/api.rs | 32 ++ crates/fresh-core/src/graphics.rs | 129 +++++ crates/fresh-core/src/lib.rs | 1 + crates/fresh-editor/plugins/lib/fresh.d.ts | 110 ++-- .../fresh-editor/plugins/markdown_compose.ts | 533 ++++++++++++++++++ crates/fresh-editor/src/app/editor_init.rs | 3 + crates/fresh-editor/src/app/lifecycle.rs | 25 + crates/fresh-editor/src/app/mod.rs | 5 + .../fresh-editor/src/app/plugin_commands.rs | 85 +++ .../fresh-editor/src/app/plugin_dispatch.rs | 22 + crates/fresh-editor/src/app/types/layout.rs | 4 + crates/fresh-editor/src/main.rs | 16 + crates/fresh-editor/src/services/graphics.rs | 425 ++++++++++++++ crates/fresh-editor/src/services/mod.rs | 1 + .../fresh-editor/src/view/line_wrap_cache.rs | 4 + .../src/view/ui/split_rendering/mod.rs | 1 + .../orchestration/render_buffer.rs | 14 +- .../orchestration/render_line/mod.rs | 2 + .../orchestration/render_line/trailing.rs | 2 + .../src/view/ui/split_rendering/post_pass.rs | 46 ++ .../src/view/ui/split_rendering/style.rs | 17 +- .../src/view/ui/split_rendering/transforms.rs | 2 + .../fresh-editor/src/view/ui/view_pipeline.rs | 6 + crates/fresh-editor/src/view/virtual_text.rs | 59 ++ .../src/backend/quickjs_backend.rs | 107 ++++ 25 files changed, 1608 insertions(+), 43 deletions(-) create mode 100644 crates/fresh-core/src/graphics.rs create mode 100644 crates/fresh-editor/src/services/graphics.rs diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs index 653401ce7e..3d89ea27cb 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 { 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..ccdefa8225 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; @@ -2567,10 +2575,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 +2628,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..96dfacf963 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -564,6 +564,10 @@ function enableMarkdownCompose(bufferId: number): void { // Trigger a refresh so lines_changed hooks fire for visible content editor.refreshLines(bufferId); + // Render mermaid diagrams / embedded images (async, best-effort). + void refreshComposeImages(bufferId); + // Highlight fenced code blocks (async, best-effort). + void refreshCodeHighlight(bufferId); editor.debug(`Markdown compose enabled for buffer ${bufferId}`); } @@ -584,12 +588,531 @@ function disableMarkdownCompose(bufferId: number): void { editor.clearNamespace(bufferId, "md-emphasis"); editor.clearConcealNamespace(bufferId, "md-syntax"); editor.clearSoftBreakNamespace(bufferId, "md-wrap"); + editor.clearNamespace(bufferId, CODE_NAMESPACE); + editor.clearImages(bufferId, IMAGE_NAMESPACE); editor.refreshLines(bufferId); editor.debug(`Markdown compose disabled for buffer ${bufferId}`); } } +// ── Inline image rendering (kitty graphics protocol) ──────────────────── +// Mermaid diagrams and embedded image links are rendered as real pictures +// via the generic `editor.placeImage` API on terminals that speak the kitty +// graphics protocol (kitty, WezTerm, Ghostty, recent Konsole). On terminals +// without graphics support we skip the rendering work entirely (see +// GRAPHICS_SUPPORTED) and the raw markdown stays visible as the fallback. +// Mermaid additionally requires the `mmdc` CLI +// (`npm i -g @mermaid-js/mermaid-cli`); non-PNG raster formats are converted +// opportunistically via `sips` (macOS) or ImageMagick when available. +const IMAGE_NAMESPACE = "md-compose-image"; +const RASTER_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff"]); +// Tri-state cache for `mmdc` availability (undefined = not yet probed). +let mmdcAvailable: boolean | undefined = undefined; + +// Whether `placeImage` can actually render here. Detected once at load — +// the terminal's protocol support can't change mid-session. When false, all +// image work (mmdc, format conversion) is skipped, not just the placement. +const GRAPHICS_SUPPORTED = editor.getGraphicsCapability() !== "none"; + +// Feature flags, surfaced in Settings → Plugin Settings. Read live via +// `pluginFlag` so toggling them takes effect without reloading the plugin. +editor.defineConfigBoolean("render_inline_images", { + default: true, + description: + "Compose mode: render image links and mermaid diagrams as inline pictures (graphics-capable terminals only).", +}); +editor.defineConfigBoolean("highlight_code_blocks", { + default: true, + description: + "Compose mode: approximate syntax highlighting and background tint for fenced code blocks.", +}); +function pluginFlag(name: string, fallback: boolean): boolean { + try { + const cfg = editor.getPluginConfig() as Record | null; + const v = cfg ? cfg[name] : undefined; + return typeof v === "boolean" ? v : fallback; + } catch (_e) { + return fallback; + } +} + +// Per-buffer generation counters: every refresh bumps its buffer's counter +// and bails after each await if superseded, so overlapping refreshes +// (typing during a slow mermaid render, rapid saves) can't interleave a +// stale clear-and-repaint over a newer one. Also used to debounce. +const imageRefreshGen = new Map(); +const codeHlGen = new Map(); + +function imageHash(s: string): string { + let h = 5381; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + } + return h.toString(16); +} + +function imageCacheDir(): string { + const dir = editor.pathJoin(editor.getTempDir(), "fresh-md-images"); + try { + editor.createDir(dir); + } catch (_e) { + /* already exists */ + } + return dir; +} + +async function runProcess( + cmd: string, + args: string[], + cwd?: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const r = cwd + ? await editor.spawnProcess(cmd, args, cwd) + : await editor.spawnProcess(cmd, args); + return { code: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; + } catch (_e) { + return { code: -1, stdout: "", stderr: "" }; + } +} + +// Resolve a markdown image URL to a local absolute path, or null for remote +// URLs / data URIs we can't render. +function resolveImagePath(url: string, fileDir: string): string | null { + if (/^[a-z]+:\/\//i.test(url) || url.startsWith("data:")) return null; + const clean = url.split("#")[0].split("?")[0].trim(); + if (!clean) return null; + return editor.pathIsAbsolute(clean) ? clean : editor.pathJoin(fileDir, clean); +} + +// Ensure a PNG the terminal can read: PNGs pass through; other raster +// formats are converted via `sips` (macOS) or ImageMagick (`magick` / +// `convert`), whichever is available. Returns null if the file is missing +// or no converter succeeds — the text fallback stays visible. +async function ensurePng(absPath: string): Promise { + if (!editor.fileExists(absPath)) return null; + const ext = editor.pathExtname(absPath).replace(/^\./, "").toLowerCase(); + if (ext === "png") return absPath; + if (!RASTER_EXT.has(ext)) return null; + const out = editor.pathJoin(imageCacheDir(), imageHash(absPath) + ".png"); + if (editor.fileExists(out)) return out; + const converters: [string, string[]][] = [ + ["sips", ["-s", "format", "png", absPath, "-o", out]], + ["magick", [absPath, out]], + ["convert", [absPath, out]], + ]; + for (const [cmd, args] of converters) { + const r = await runProcess(cmd, args); + if (r.code === 0 && editor.fileExists(out)) return out; + } + return null; +} + +// Pixel dimensions of a PNG, parsed directly from the IHDR chunk (works on +// every platform — no external tools). Returns null if the file isn't a +// well-formed PNG. +function pngDims(path: string): { w: number; h: number } | null { + // 8-byte signature, then IHDR: length(4) + "IHDR"(4) + width(4) + height(4). + const b = editor.readFileBytes(path, 0, 24); + if (!b || b.length < 24) return null; + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < 8; i++) { + if (b[i] !== sig[i]) return null; + } + if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null; + const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0; + const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0; + return w > 0 && h > 0 ? { w, h } : null; +} + +async function ensureMmdc(): Promise { + if (mmdcAvailable !== undefined) return mmdcAvailable; + const r = await runProcess("mmdc", ["--version"]); + mmdcAvailable = r.code === 0; + return mmdcAvailable; +} + +// Rasterize Mermaid source to a PNG (cached by content hash). Returns null if +// `mmdc` is unavailable or rendering fails — caller keeps the text fallback. +async function renderMermaid(code: string): Promise { + if (!(await ensureMmdc())) return null; + const dir = imageCacheDir(); + const key = imageHash("mermaid:" + code); + const out = editor.pathJoin(dir, key + ".png"); + if (editor.fileExists(out)) return out; + const inPath = editor.pathJoin(dir, key + ".mmd"); + if (!editor.writeFile(inPath, code)) return null; + const r = await runProcess("mmdc", ["-i", inPath, "-o", out, "-b", "transparent"]); + return r.code === 0 && editor.fileExists(out) ? out : null; +} + +// Choose a placement size in terminal cells that preserves the image aspect +// ratio, assuming a cell is roughly twice as tall as it is wide (~10px/cell +// horizontally). Width is bounded by the compose width AND the image's +// native pixel width, so small images (icons, badges) aren't blown up. +function cellsForImage(w: number, h: number): { cols: number; rows: number } { + const cfgW = config.composeWidth && config.composeWidth > 0 ? config.composeWidth : 80; + const nativeCols = Math.ceil(Math.max(1, w) / 10); + const cols = Math.max(4, Math.min(cfgW, 100, nativeCols)); + let rows = Math.round(cols * 0.5 * (h / Math.max(1, w))); + rows = Math.max(1, Math.min(rows, 40)); + return { cols, rows }; +} + +// Re-place all images for a composing buffer: clears prior placements, then +// renders image links and mermaid fences. Safe to call repeatedly; fully +// guarded so a failure never disrupts compose-mode text rendering. Bails +// when superseded by a newer refresh (generation check after every await). +async function refreshComposeImages(bufferId: number): Promise { + // Skip all rendering work when the terminal can't show the result or the + // user turned the feature off — the raw markdown stays as the fallback. + if (!GRAPHICS_SUPPORTED) return; + if (!pluginFlag("render_inline_images", true)) { + // Drop any placements from before the flag was switched off. + editor.clearImages(bufferId, IMAGE_NAMESPACE); + return; + } + + const gen = (imageRefreshGen.get(bufferId) ?? 0) + 1; + imageRefreshGen.set(bufferId, gen); + const stale = (): boolean => imageRefreshGen.get(bufferId) !== gen; + + try { + if (!isComposingInAnySplit(bufferId)) return; + const info = editor.getBufferInfo(bufferId); + if (!info || !isMarkdownFile(info.path)) return; + const fileDir = editor.pathDirname(info.path); + + const len = editor.getBufferLength(bufferId); + const text = await editor.getBufferText(bufferId, 0, len); + if (typeof text !== "string" || stale()) return; + + editor.clearImages(bufferId, IMAGE_NAMESPACE); + + const blocks = parseMarkdownBlocks(text); + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + if (b.type === "image") { + const m = b.content.match(/!\[[^\]]*\]\(([^)]+)\)/); + if (!m) continue; + const abs = resolveImagePath(m[1], fileDir); + if (!abs) continue; + const png = await ensurePng(abs); + if (stale()) return; + if (!png) continue; + const dims = pngDims(png); + if (!dims) continue; + const { cols, rows } = cellsForImage(dims.w, dims.h); + editor.placeImage( + bufferId, + imageHash(png + cols + "x" + rows), + png, + b.startByte, + cols, + rows, + false, + IMAGE_NAMESPACE, + ); + } else if ( + b.type === "code-fence" && + /^```\s*mermaid\b/i.test(b.content.trim()) + ) { + const codeLines: string[] = []; + let j = i + 1; + for (; j < blocks.length; j++) { + if (blocks[j].type === "code-content") { + codeLines.push(blocks[j].content); + } else { + break; + } + } + // Anchor the diagram below the closing fence (if present); advance + // past it so the closing fence isn't re-scanned. + const closing = blocks[j]; + const anchorByte = + closing && closing.type === "code-fence" ? closing.startByte : b.startByte; + i = j; + const code = codeLines.join("\n").trim(); + if (!code) continue; + const png = await renderMermaid(code); + if (stale()) return; + if (!png) continue; + const dims = pngDims(png); + if (!dims) continue; + const { cols, rows } = cellsForImage(dims.w, dims.h); + editor.placeImage( + bufferId, + imageHash("mmd:" + code + cols + "x" + rows), + png, + anchorByte, + cols, + rows, + false, + IMAGE_NAMESPACE, + ); + } + } + } catch (e) { + editor.debug(`[mc] refreshComposeImages error: ${e}`); + } +} + +// ── Fenced code-block syntax highlighting ─────────────────────────────── +// Fresh disables tree-sitter language injection, so code inside markdown +// fences isn't highlighted by its declared language. This is a lightweight, +// language-agnostic tokenizer (comments / strings / numbers / common +// keywords) that paints `syntax.*` theme-key overlays over fenced code, plus +// a subtle code-block background. It is a preview aid, not a full grammar. +const CODE_NAMESPACE = "md-code"; +// Skip whole-buffer re-highlight above this size to keep per-keystroke cost +// bounded; the block-level work itself only touches code-fence bytes. +const CODE_HL_MAX_BYTES = 512 * 1024; + +const CODE_KEYWORDS = new Set([ + // declarations / control flow shared across many languages + "abstract", "as", "async", "await", "break", "case", "catch", "class", + "const", "continue", "def", "default", "defer", "del", "do", "elif", "else", + "end", "enum", "except", "export", "extends", "extern", "final", "finally", + "fn", "for", "from", "func", "function", "global", "go", "goto", "if", + "impl", "implements", "import", "in", "include", "instanceof", "interface", + "is", "lambda", "let", "loop", "match", "mod", "module", "mut", "namespace", + "new", "of", "operator", "package", "pass", "private", "protected", "pub", + "public", "raise", "readonly", "ref", "return", "self", "static", "struct", + "super", "switch", "template", "then", "this", "throw", "throws", "trait", + "try", "type", "typedef", "typeof", "union", "unless", "until", "use", + "using", "var", "virtual", "void", "where", "while", "with", "yield", + // common type keywords + "bool", "boolean", "byte", "char", "double", "float", "int", "long", "short", + "string", "str", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "usize", +]); +const CODE_CONSTS = new Set([ + "true", "false", "null", "nil", "none", "None", "True", "False", "undefined", + "NaN", "Infinity", "nullptr", "NULL", +]); + +interface LangSpec { + line: string[]; // line-comment starters + block?: [string, string]; // block-comment open/close + triple?: boolean; // python-style triple-quoted strings + strings: string[]; // single-char string delimiters + template?: boolean; // backtick template strings +} + +function codeLangSpec(lang: string): LangSpec { + const cFamily: LangSpec = { line: ["//"], block: ["/*", "*/"], strings: ['"', "'"] }; + switch (lang.toLowerCase()) { + case "js": case "javascript": case "jsx": case "ts": case "typescript": case "tsx": + return { line: ["//"], block: ["/*", "*/"], strings: ['"', "'"], template: true }; + case "py": case "python": + return { line: ["#"], strings: ['"', "'"], triple: true }; + case "rb": case "ruby": case "sh": case "bash": case "shell": case "zsh": + case "yaml": case "yml": case "toml": case "r": case "perl": case "pl": + case "makefile": case "dockerfile": case "conf": case "ini": case "": + return { line: ["#"], strings: ['"', "'"] }; + case "sql": case "haskell": case "hs": case "elm": + return { line: ["--"], strings: ['"', "'"] }; + case "lua": + return { line: ["--"], block: ["--[[", "]]"], strings: ['"', "'"] }; + case "html": case "xml": case "svg": case "vue": + return { line: [], block: [""], strings: ['"', "'"] }; + case "clojure": case "clj": case "lisp": case "scheme": case "el": + return { line: [";"], strings: ['"'] }; + default: + return cFamily; + } +} + +// Tokenize the content lines of one fenced block and paint overlays. State +// (block comment / triple string) is carried across lines within the block. +function highlightCodeLines( + bufferId: number, + lines: { content: string; startByte: number }[], + spec: LangSpec, +): void { + let inBlock = false; + let inTriple: string | null = null; + + for (const { content, startByte } of lines) { + const n = content.length; + let i = 0; + const add = (cs: number, ce: number, key: string): void => { + if (ce <= cs) return; + const bs = charToByte(content, cs, startByte); + const be = charToByte(content, ce, startByte); + editor.addOverlay(bufferId, CODE_NAMESPACE, bs, be, { fg: key }); + }; + + if (inBlock && spec.block) { + const close = content.indexOf(spec.block[1]); + if (close === -1) { add(0, n, "syntax.comment"); continue; } + add(0, close + spec.block[1].length, "syntax.comment"); + i = close + spec.block[1].length; + inBlock = false; + } + if (inTriple) { + const close = content.indexOf(inTriple); + if (close === -1) { add(0, n, "syntax.string"); continue; } + add(0, close + inTriple.length, "syntax.string"); + i = close + inTriple.length; + inTriple = null; + } + + while (i < n) { + const rest = content.slice(i); + + let matchedLine = false; + for (const lc of spec.line) { + if (rest.startsWith(lc)) { + add(i, n, "syntax.comment"); + i = n; + matchedLine = true; + break; + } + } + if (matchedLine) break; + + if (spec.block && rest.startsWith(spec.block[0])) { + const close = content.indexOf(spec.block[1], i + spec.block[0].length); + if (close === -1) { + add(i, n, "syntax.comment"); + inBlock = true; + i = n; + } else { + add(i, close + spec.block[1].length, "syntax.comment"); + i = close + spec.block[1].length; + } + continue; + } + + if (spec.triple && (rest.startsWith('"""') || rest.startsWith("'''"))) { + const delim = rest.slice(0, 3); + const close = content.indexOf(delim, i + 3); + if (close === -1) { + add(i, n, "syntax.string"); + inTriple = delim; + i = n; + } else { + add(i, close + 3, "syntax.string"); + i = close + 3; + } + continue; + } + + const ch = content[i]; + + if (spec.strings.indexOf(ch) !== -1 || (spec.template === true && ch === "`")) { + let j = i + 1; + while (j < n) { + if (content[j] === "\\") { j += 2; continue; } + if (content[j] === ch) { j++; break; } + j++; + } + add(i, j, "syntax.string"); + i = j; + continue; + } + + if (ch >= "0" && ch <= "9") { + const m = rest.match(/^(0x[0-9a-fA-F]+|\d[\d_]*(\.\d+)?([eE][+-]?\d+)?)/); + if (m) { + add(i, i + m[0].length, "syntax.constant"); + i += m[0].length; + continue; + } + } + + if (/[A-Za-z_$]/.test(ch)) { + const m = rest.match(/^[A-Za-z_$][A-Za-z0-9_$]*/); + const word = m ? m[0] : ch; + if (CODE_KEYWORDS.has(word)) add(i, i + word.length, "syntax.keyword"); + else if (CODE_CONSTS.has(word)) add(i, i + word.length, "syntax.constant"); + i += word.length; + continue; + } + + i++; + } + } +} + +// Re-highlight all fenced code blocks in a composing buffer, debounced by +// `delayMs` (rapid keystrokes collapse into one trailing run — each call +// supersedes pending ones via the generation counter). Clears the previous +// overlays only once it's committed to repainting, so superseded runs leave +// the existing highlight untouched. Fully guarded so a failure never breaks +// rendering. +async function refreshCodeHighlight(bufferId: number, delayMs = 0): Promise { + if (!pluginFlag("highlight_code_blocks", true)) { + // Drop any overlays from before the flag was switched off. + editor.clearNamespace(bufferId, CODE_NAMESPACE); + return; + } + + const gen = (codeHlGen.get(bufferId) ?? 0) + 1; + codeHlGen.set(bufferId, gen); + const stale = (): boolean => codeHlGen.get(bufferId) !== gen; + + try { + if (delayMs > 0) { + await editor.delay(delayMs); + if (stale()) return; + } + if (!isComposingInAnySplit(bufferId)) return; + + const len = editor.getBufferLength(bufferId); + if (len <= 0 || len > CODE_HL_MAX_BYTES) { + editor.clearNamespace(bufferId, CODE_NAMESPACE); + return; + } + const text = await editor.getBufferText(bufferId, 0, len); + if (typeof text !== "string" || stale()) return; + + editor.clearNamespace(bufferId, CODE_NAMESPACE); + + const blocks = parseMarkdownBlocks(text); + for (let i = 0; i < blocks.length; i++) { + const open = blocks[i]; + if (open.type !== "code-fence") continue; + + // Language follows the opening fence markers (``` or ~~~). + const lang = open.content.trim().replace(/^(```+|~~~+)/, "").trim().split(/\s+/)[0] || ""; + const spec = codeLangSpec(lang); + + // Subtle code-block background on the opening fence line. + editor.addOverlay(bufferId, CODE_NAMESPACE, open.startByte, open.endByte, { + bg: "editor.line_number_bg", + extend_to_line_end: true, + }); + + // Collect content lines until the closing fence. + const contentLines: { content: string; startByte: number }[] = []; + let j = i + 1; + for (; j < blocks.length; j++) { + const b = blocks[j]; + if (b.type !== "code-content") break; + contentLines.push({ content: b.content, startByte: b.startByte }); + editor.addOverlay(bufferId, CODE_NAMESPACE, b.startByte, b.endByte, { + bg: "editor.line_number_bg", + extend_to_line_end: true, + }); + } + // Background on the closing fence line too (if present). + if (j < blocks.length && blocks[j].type === "code-fence") { + editor.addOverlay(bufferId, CODE_NAMESPACE, blocks[j].startByte, blocks[j].endByte, { + bg: "editor.line_number_bg", + extend_to_line_end: true, + }); + } + + highlightCodeLines(bufferId, contentLines, spec); + i = j; // resume after the closing fence + } + } catch (e) { + editor.debug(`[mc] refreshCodeHighlight error: ${e}`); + } +} + // Toggle markdown compose mode for current buffer function markdownToggleCompose() : void { const bufferId = editor.getActiveBufferId(); @@ -1695,13 +2218,23 @@ editor.on("lines_changed", (data) => { editor.refreshLines(data.buffer_id); } }); +editor.on("after_file_save", (data) => { + if (!isComposingInAnySplit(data.buffer_id)) return; + // Re-render diagrams/images and re-highlight code after a save. + void refreshComposeImages(data.buffer_id); + void refreshCodeHighlight(data.buffer_id); +}); 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}`); + // Live-update fenced code highlighting, debounced so rapid typing costs + // one trailing re-highlight instead of one per keystroke. + void refreshCodeHighlight(data.buffer_id, 200); }); 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 refreshCodeHighlight(data.buffer_id, 200); }); editor.on("cursor_moved", (data) => { if (!isComposingInAnySplit(data.buffer_id)) return; 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..edf318842d 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -478,6 +478,91 @@ 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); + 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 diff --git a/crates/fresh-editor/src/app/plugin_dispatch.rs b/crates/fresh-editor/src/app/plugin_dispatch.rs index cca962676d..30ec530a94 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, 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..9a902c7b1b 100644 --- a/crates/fresh-editor/src/main.rs +++ b/crates/fresh-editor/src/main.rs @@ -4418,6 +4418,22 @@ where 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..122ad79862 --- /dev/null +++ b/crates/fresh-editor/src/services/graphics.rs @@ -0,0 +1,425 @@ +//! 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, + next_id: u32, + by_key: HashMap, + by_namespace: HashMap>, + images: HashMap, + pending_transmit: Vec, + pending_delete: Vec, +} + +impl ImageManager { + pub fn new(capability: GraphicsCapability) -> Self { + ImageManager { + capability, + next_id: 1, + by_key: HashMap::new(), + by_namespace: HashMap::new(), + images: HashMap::new(), + pending_transmit: Vec::new(), + pending_delete: Vec::new(), + } + } + + 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(); + + for id in self.pending_delete.drain(..) { + // a=d, d=I: delete the image *and* its placements, freeing data. + out.extend_from_slice(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\").as_bytes()); + } + + 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()); + out.extend_from_slice( + 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, + ) + .as_bytes(), + ); + } + } + + 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); + 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); + 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 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..98cfc83353 100644 --- a/crates/fresh-editor/src/services/mod.rs +++ b/crates/fresh-editor/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod counters; pub mod env_provider; pub mod file_watcher; pub mod fs; +pub mod graphics; #[cfg(target_os = "linux")] pub mod gpm; /// Outbound HTTP(S); the only place `ureq`/TLS is used (gated by `http`). 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..867835683e 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,7 @@ 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 +289,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 +299,7 @@ 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 +322,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..97916881cf 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 { @@ -234,6 +239,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -285,6 +291,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -326,6 +333,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); self.bump_version(); @@ -373,6 +381,7 @@ impl VirtualTextManager { gutter_glyph: None, gutter_color: None, text_overlays: Vec::new(), + image: None, }, ); @@ -466,6 +475,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(); diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index 98e14c7e87..4a5d3d9195 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); @@ -4110,6 +4138,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 +7468,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(); From 76074c4854f2ab120e3c5229c0116b5a5728ff39 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 09:38:54 -0500 Subject: [PATCH 02/14] =?UTF-8?q?refactor:=20keep=20markdown=5Fcompose=20s?= =?UTF-8?q?tock=20=E2=80=94=20preview=20features=20move=20to=20an=20extern?= =?UTF-8?q?al=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch now exposes only generic, content-agnostic core APIs (placeImage/clearImages, getGraphicsCapability, readFileBytes, the kitty Unicode-placeholder render path). Markdown-specific rendering (inline images, mermaid, fenced-code highlighting) lives in the standalone fresh-markdown-preview plugin, built entirely on those APIs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fresh-editor/plugins/markdown_compose.ts | 533 ------------------ 1 file changed, 533 deletions(-) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index 96dfacf963..af02d1acab 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -564,10 +564,6 @@ function enableMarkdownCompose(bufferId: number): void { // Trigger a refresh so lines_changed hooks fire for visible content editor.refreshLines(bufferId); - // Render mermaid diagrams / embedded images (async, best-effort). - void refreshComposeImages(bufferId); - // Highlight fenced code blocks (async, best-effort). - void refreshCodeHighlight(bufferId); editor.debug(`Markdown compose enabled for buffer ${bufferId}`); } @@ -588,531 +584,12 @@ function disableMarkdownCompose(bufferId: number): void { editor.clearNamespace(bufferId, "md-emphasis"); editor.clearConcealNamespace(bufferId, "md-syntax"); editor.clearSoftBreakNamespace(bufferId, "md-wrap"); - editor.clearNamespace(bufferId, CODE_NAMESPACE); - editor.clearImages(bufferId, IMAGE_NAMESPACE); editor.refreshLines(bufferId); editor.debug(`Markdown compose disabled for buffer ${bufferId}`); } } -// ── Inline image rendering (kitty graphics protocol) ──────────────────── -// Mermaid diagrams and embedded image links are rendered as real pictures -// via the generic `editor.placeImage` API on terminals that speak the kitty -// graphics protocol (kitty, WezTerm, Ghostty, recent Konsole). On terminals -// without graphics support we skip the rendering work entirely (see -// GRAPHICS_SUPPORTED) and the raw markdown stays visible as the fallback. -// Mermaid additionally requires the `mmdc` CLI -// (`npm i -g @mermaid-js/mermaid-cli`); non-PNG raster formats are converted -// opportunistically via `sips` (macOS) or ImageMagick when available. -const IMAGE_NAMESPACE = "md-compose-image"; -const RASTER_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff"]); -// Tri-state cache for `mmdc` availability (undefined = not yet probed). -let mmdcAvailable: boolean | undefined = undefined; - -// Whether `placeImage` can actually render here. Detected once at load — -// the terminal's protocol support can't change mid-session. When false, all -// image work (mmdc, format conversion) is skipped, not just the placement. -const GRAPHICS_SUPPORTED = editor.getGraphicsCapability() !== "none"; - -// Feature flags, surfaced in Settings → Plugin Settings. Read live via -// `pluginFlag` so toggling them takes effect without reloading the plugin. -editor.defineConfigBoolean("render_inline_images", { - default: true, - description: - "Compose mode: render image links and mermaid diagrams as inline pictures (graphics-capable terminals only).", -}); -editor.defineConfigBoolean("highlight_code_blocks", { - default: true, - description: - "Compose mode: approximate syntax highlighting and background tint for fenced code blocks.", -}); -function pluginFlag(name: string, fallback: boolean): boolean { - try { - const cfg = editor.getPluginConfig() as Record | null; - const v = cfg ? cfg[name] : undefined; - return typeof v === "boolean" ? v : fallback; - } catch (_e) { - return fallback; - } -} - -// Per-buffer generation counters: every refresh bumps its buffer's counter -// and bails after each await if superseded, so overlapping refreshes -// (typing during a slow mermaid render, rapid saves) can't interleave a -// stale clear-and-repaint over a newer one. Also used to debounce. -const imageRefreshGen = new Map(); -const codeHlGen = new Map(); - -function imageHash(s: string): string { - let h = 5381; - for (let i = 0; i < s.length; i++) { - h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; - } - return h.toString(16); -} - -function imageCacheDir(): string { - const dir = editor.pathJoin(editor.getTempDir(), "fresh-md-images"); - try { - editor.createDir(dir); - } catch (_e) { - /* already exists */ - } - return dir; -} - -async function runProcess( - cmd: string, - args: string[], - cwd?: string, -): Promise<{ code: number; stdout: string; stderr: string }> { - try { - const r = cwd - ? await editor.spawnProcess(cmd, args, cwd) - : await editor.spawnProcess(cmd, args); - return { code: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; - } catch (_e) { - return { code: -1, stdout: "", stderr: "" }; - } -} - -// Resolve a markdown image URL to a local absolute path, or null for remote -// URLs / data URIs we can't render. -function resolveImagePath(url: string, fileDir: string): string | null { - if (/^[a-z]+:\/\//i.test(url) || url.startsWith("data:")) return null; - const clean = url.split("#")[0].split("?")[0].trim(); - if (!clean) return null; - return editor.pathIsAbsolute(clean) ? clean : editor.pathJoin(fileDir, clean); -} - -// Ensure a PNG the terminal can read: PNGs pass through; other raster -// formats are converted via `sips` (macOS) or ImageMagick (`magick` / -// `convert`), whichever is available. Returns null if the file is missing -// or no converter succeeds — the text fallback stays visible. -async function ensurePng(absPath: string): Promise { - if (!editor.fileExists(absPath)) return null; - const ext = editor.pathExtname(absPath).replace(/^\./, "").toLowerCase(); - if (ext === "png") return absPath; - if (!RASTER_EXT.has(ext)) return null; - const out = editor.pathJoin(imageCacheDir(), imageHash(absPath) + ".png"); - if (editor.fileExists(out)) return out; - const converters: [string, string[]][] = [ - ["sips", ["-s", "format", "png", absPath, "-o", out]], - ["magick", [absPath, out]], - ["convert", [absPath, out]], - ]; - for (const [cmd, args] of converters) { - const r = await runProcess(cmd, args); - if (r.code === 0 && editor.fileExists(out)) return out; - } - return null; -} - -// Pixel dimensions of a PNG, parsed directly from the IHDR chunk (works on -// every platform — no external tools). Returns null if the file isn't a -// well-formed PNG. -function pngDims(path: string): { w: number; h: number } | null { - // 8-byte signature, then IHDR: length(4) + "IHDR"(4) + width(4) + height(4). - const b = editor.readFileBytes(path, 0, 24); - if (!b || b.length < 24) return null; - const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - for (let i = 0; i < 8; i++) { - if (b[i] !== sig[i]) return null; - } - if (b[12] !== 0x49 || b[13] !== 0x48 || b[14] !== 0x44 || b[15] !== 0x52) return null; - const w = ((b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19]) >>> 0; - const h = ((b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]) >>> 0; - return w > 0 && h > 0 ? { w, h } : null; -} - -async function ensureMmdc(): Promise { - if (mmdcAvailable !== undefined) return mmdcAvailable; - const r = await runProcess("mmdc", ["--version"]); - mmdcAvailable = r.code === 0; - return mmdcAvailable; -} - -// Rasterize Mermaid source to a PNG (cached by content hash). Returns null if -// `mmdc` is unavailable or rendering fails — caller keeps the text fallback. -async function renderMermaid(code: string): Promise { - if (!(await ensureMmdc())) return null; - const dir = imageCacheDir(); - const key = imageHash("mermaid:" + code); - const out = editor.pathJoin(dir, key + ".png"); - if (editor.fileExists(out)) return out; - const inPath = editor.pathJoin(dir, key + ".mmd"); - if (!editor.writeFile(inPath, code)) return null; - const r = await runProcess("mmdc", ["-i", inPath, "-o", out, "-b", "transparent"]); - return r.code === 0 && editor.fileExists(out) ? out : null; -} - -// Choose a placement size in terminal cells that preserves the image aspect -// ratio, assuming a cell is roughly twice as tall as it is wide (~10px/cell -// horizontally). Width is bounded by the compose width AND the image's -// native pixel width, so small images (icons, badges) aren't blown up. -function cellsForImage(w: number, h: number): { cols: number; rows: number } { - const cfgW = config.composeWidth && config.composeWidth > 0 ? config.composeWidth : 80; - const nativeCols = Math.ceil(Math.max(1, w) / 10); - const cols = Math.max(4, Math.min(cfgW, 100, nativeCols)); - let rows = Math.round(cols * 0.5 * (h / Math.max(1, w))); - rows = Math.max(1, Math.min(rows, 40)); - return { cols, rows }; -} - -// Re-place all images for a composing buffer: clears prior placements, then -// renders image links and mermaid fences. Safe to call repeatedly; fully -// guarded so a failure never disrupts compose-mode text rendering. Bails -// when superseded by a newer refresh (generation check after every await). -async function refreshComposeImages(bufferId: number): Promise { - // Skip all rendering work when the terminal can't show the result or the - // user turned the feature off — the raw markdown stays as the fallback. - if (!GRAPHICS_SUPPORTED) return; - if (!pluginFlag("render_inline_images", true)) { - // Drop any placements from before the flag was switched off. - editor.clearImages(bufferId, IMAGE_NAMESPACE); - return; - } - - const gen = (imageRefreshGen.get(bufferId) ?? 0) + 1; - imageRefreshGen.set(bufferId, gen); - const stale = (): boolean => imageRefreshGen.get(bufferId) !== gen; - - try { - if (!isComposingInAnySplit(bufferId)) return; - const info = editor.getBufferInfo(bufferId); - if (!info || !isMarkdownFile(info.path)) return; - const fileDir = editor.pathDirname(info.path); - - const len = editor.getBufferLength(bufferId); - const text = await editor.getBufferText(bufferId, 0, len); - if (typeof text !== "string" || stale()) return; - - editor.clearImages(bufferId, IMAGE_NAMESPACE); - - const blocks = parseMarkdownBlocks(text); - for (let i = 0; i < blocks.length; i++) { - const b = blocks[i]; - if (b.type === "image") { - const m = b.content.match(/!\[[^\]]*\]\(([^)]+)\)/); - if (!m) continue; - const abs = resolveImagePath(m[1], fileDir); - if (!abs) continue; - const png = await ensurePng(abs); - if (stale()) return; - if (!png) continue; - const dims = pngDims(png); - if (!dims) continue; - const { cols, rows } = cellsForImage(dims.w, dims.h); - editor.placeImage( - bufferId, - imageHash(png + cols + "x" + rows), - png, - b.startByte, - cols, - rows, - false, - IMAGE_NAMESPACE, - ); - } else if ( - b.type === "code-fence" && - /^```\s*mermaid\b/i.test(b.content.trim()) - ) { - const codeLines: string[] = []; - let j = i + 1; - for (; j < blocks.length; j++) { - if (blocks[j].type === "code-content") { - codeLines.push(blocks[j].content); - } else { - break; - } - } - // Anchor the diagram below the closing fence (if present); advance - // past it so the closing fence isn't re-scanned. - const closing = blocks[j]; - const anchorByte = - closing && closing.type === "code-fence" ? closing.startByte : b.startByte; - i = j; - const code = codeLines.join("\n").trim(); - if (!code) continue; - const png = await renderMermaid(code); - if (stale()) return; - if (!png) continue; - const dims = pngDims(png); - if (!dims) continue; - const { cols, rows } = cellsForImage(dims.w, dims.h); - editor.placeImage( - bufferId, - imageHash("mmd:" + code + cols + "x" + rows), - png, - anchorByte, - cols, - rows, - false, - IMAGE_NAMESPACE, - ); - } - } - } catch (e) { - editor.debug(`[mc] refreshComposeImages error: ${e}`); - } -} - -// ── Fenced code-block syntax highlighting ─────────────────────────────── -// Fresh disables tree-sitter language injection, so code inside markdown -// fences isn't highlighted by its declared language. This is a lightweight, -// language-agnostic tokenizer (comments / strings / numbers / common -// keywords) that paints `syntax.*` theme-key overlays over fenced code, plus -// a subtle code-block background. It is a preview aid, not a full grammar. -const CODE_NAMESPACE = "md-code"; -// Skip whole-buffer re-highlight above this size to keep per-keystroke cost -// bounded; the block-level work itself only touches code-fence bytes. -const CODE_HL_MAX_BYTES = 512 * 1024; - -const CODE_KEYWORDS = new Set([ - // declarations / control flow shared across many languages - "abstract", "as", "async", "await", "break", "case", "catch", "class", - "const", "continue", "def", "default", "defer", "del", "do", "elif", "else", - "end", "enum", "except", "export", "extends", "extern", "final", "finally", - "fn", "for", "from", "func", "function", "global", "go", "goto", "if", - "impl", "implements", "import", "in", "include", "instanceof", "interface", - "is", "lambda", "let", "loop", "match", "mod", "module", "mut", "namespace", - "new", "of", "operator", "package", "pass", "private", "protected", "pub", - "public", "raise", "readonly", "ref", "return", "self", "static", "struct", - "super", "switch", "template", "then", "this", "throw", "throws", "trait", - "try", "type", "typedef", "typeof", "union", "unless", "until", "use", - "using", "var", "virtual", "void", "where", "while", "with", "yield", - // common type keywords - "bool", "boolean", "byte", "char", "double", "float", "int", "long", "short", - "string", "str", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "usize", -]); -const CODE_CONSTS = new Set([ - "true", "false", "null", "nil", "none", "None", "True", "False", "undefined", - "NaN", "Infinity", "nullptr", "NULL", -]); - -interface LangSpec { - line: string[]; // line-comment starters - block?: [string, string]; // block-comment open/close - triple?: boolean; // python-style triple-quoted strings - strings: string[]; // single-char string delimiters - template?: boolean; // backtick template strings -} - -function codeLangSpec(lang: string): LangSpec { - const cFamily: LangSpec = { line: ["//"], block: ["/*", "*/"], strings: ['"', "'"] }; - switch (lang.toLowerCase()) { - case "js": case "javascript": case "jsx": case "ts": case "typescript": case "tsx": - return { line: ["//"], block: ["/*", "*/"], strings: ['"', "'"], template: true }; - case "py": case "python": - return { line: ["#"], strings: ['"', "'"], triple: true }; - case "rb": case "ruby": case "sh": case "bash": case "shell": case "zsh": - case "yaml": case "yml": case "toml": case "r": case "perl": case "pl": - case "makefile": case "dockerfile": case "conf": case "ini": case "": - return { line: ["#"], strings: ['"', "'"] }; - case "sql": case "haskell": case "hs": case "elm": - return { line: ["--"], strings: ['"', "'"] }; - case "lua": - return { line: ["--"], block: ["--[[", "]]"], strings: ['"', "'"] }; - case "html": case "xml": case "svg": case "vue": - return { line: [], block: [""], strings: ['"', "'"] }; - case "clojure": case "clj": case "lisp": case "scheme": case "el": - return { line: [";"], strings: ['"'] }; - default: - return cFamily; - } -} - -// Tokenize the content lines of one fenced block and paint overlays. State -// (block comment / triple string) is carried across lines within the block. -function highlightCodeLines( - bufferId: number, - lines: { content: string; startByte: number }[], - spec: LangSpec, -): void { - let inBlock = false; - let inTriple: string | null = null; - - for (const { content, startByte } of lines) { - const n = content.length; - let i = 0; - const add = (cs: number, ce: number, key: string): void => { - if (ce <= cs) return; - const bs = charToByte(content, cs, startByte); - const be = charToByte(content, ce, startByte); - editor.addOverlay(bufferId, CODE_NAMESPACE, bs, be, { fg: key }); - }; - - if (inBlock && spec.block) { - const close = content.indexOf(spec.block[1]); - if (close === -1) { add(0, n, "syntax.comment"); continue; } - add(0, close + spec.block[1].length, "syntax.comment"); - i = close + spec.block[1].length; - inBlock = false; - } - if (inTriple) { - const close = content.indexOf(inTriple); - if (close === -1) { add(0, n, "syntax.string"); continue; } - add(0, close + inTriple.length, "syntax.string"); - i = close + inTriple.length; - inTriple = null; - } - - while (i < n) { - const rest = content.slice(i); - - let matchedLine = false; - for (const lc of spec.line) { - if (rest.startsWith(lc)) { - add(i, n, "syntax.comment"); - i = n; - matchedLine = true; - break; - } - } - if (matchedLine) break; - - if (spec.block && rest.startsWith(spec.block[0])) { - const close = content.indexOf(spec.block[1], i + spec.block[0].length); - if (close === -1) { - add(i, n, "syntax.comment"); - inBlock = true; - i = n; - } else { - add(i, close + spec.block[1].length, "syntax.comment"); - i = close + spec.block[1].length; - } - continue; - } - - if (spec.triple && (rest.startsWith('"""') || rest.startsWith("'''"))) { - const delim = rest.slice(0, 3); - const close = content.indexOf(delim, i + 3); - if (close === -1) { - add(i, n, "syntax.string"); - inTriple = delim; - i = n; - } else { - add(i, close + 3, "syntax.string"); - i = close + 3; - } - continue; - } - - const ch = content[i]; - - if (spec.strings.indexOf(ch) !== -1 || (spec.template === true && ch === "`")) { - let j = i + 1; - while (j < n) { - if (content[j] === "\\") { j += 2; continue; } - if (content[j] === ch) { j++; break; } - j++; - } - add(i, j, "syntax.string"); - i = j; - continue; - } - - if (ch >= "0" && ch <= "9") { - const m = rest.match(/^(0x[0-9a-fA-F]+|\d[\d_]*(\.\d+)?([eE][+-]?\d+)?)/); - if (m) { - add(i, i + m[0].length, "syntax.constant"); - i += m[0].length; - continue; - } - } - - if (/[A-Za-z_$]/.test(ch)) { - const m = rest.match(/^[A-Za-z_$][A-Za-z0-9_$]*/); - const word = m ? m[0] : ch; - if (CODE_KEYWORDS.has(word)) add(i, i + word.length, "syntax.keyword"); - else if (CODE_CONSTS.has(word)) add(i, i + word.length, "syntax.constant"); - i += word.length; - continue; - } - - i++; - } - } -} - -// Re-highlight all fenced code blocks in a composing buffer, debounced by -// `delayMs` (rapid keystrokes collapse into one trailing run — each call -// supersedes pending ones via the generation counter). Clears the previous -// overlays only once it's committed to repainting, so superseded runs leave -// the existing highlight untouched. Fully guarded so a failure never breaks -// rendering. -async function refreshCodeHighlight(bufferId: number, delayMs = 0): Promise { - if (!pluginFlag("highlight_code_blocks", true)) { - // Drop any overlays from before the flag was switched off. - editor.clearNamespace(bufferId, CODE_NAMESPACE); - return; - } - - const gen = (codeHlGen.get(bufferId) ?? 0) + 1; - codeHlGen.set(bufferId, gen); - const stale = (): boolean => codeHlGen.get(bufferId) !== gen; - - try { - if (delayMs > 0) { - await editor.delay(delayMs); - if (stale()) return; - } - if (!isComposingInAnySplit(bufferId)) return; - - const len = editor.getBufferLength(bufferId); - if (len <= 0 || len > CODE_HL_MAX_BYTES) { - editor.clearNamespace(bufferId, CODE_NAMESPACE); - return; - } - const text = await editor.getBufferText(bufferId, 0, len); - if (typeof text !== "string" || stale()) return; - - editor.clearNamespace(bufferId, CODE_NAMESPACE); - - const blocks = parseMarkdownBlocks(text); - for (let i = 0; i < blocks.length; i++) { - const open = blocks[i]; - if (open.type !== "code-fence") continue; - - // Language follows the opening fence markers (``` or ~~~). - const lang = open.content.trim().replace(/^(```+|~~~+)/, "").trim().split(/\s+/)[0] || ""; - const spec = codeLangSpec(lang); - - // Subtle code-block background on the opening fence line. - editor.addOverlay(bufferId, CODE_NAMESPACE, open.startByte, open.endByte, { - bg: "editor.line_number_bg", - extend_to_line_end: true, - }); - - // Collect content lines until the closing fence. - const contentLines: { content: string; startByte: number }[] = []; - let j = i + 1; - for (; j < blocks.length; j++) { - const b = blocks[j]; - if (b.type !== "code-content") break; - contentLines.push({ content: b.content, startByte: b.startByte }); - editor.addOverlay(bufferId, CODE_NAMESPACE, b.startByte, b.endByte, { - bg: "editor.line_number_bg", - extend_to_line_end: true, - }); - } - // Background on the closing fence line too (if present). - if (j < blocks.length && blocks[j].type === "code-fence") { - editor.addOverlay(bufferId, CODE_NAMESPACE, blocks[j].startByte, blocks[j].endByte, { - bg: "editor.line_number_bg", - extend_to_line_end: true, - }); - } - - highlightCodeLines(bufferId, contentLines, spec); - i = j; // resume after the closing fence - } - } catch (e) { - editor.debug(`[mc] refreshCodeHighlight error: ${e}`); - } -} - // Toggle markdown compose mode for current buffer function markdownToggleCompose() : void { const bufferId = editor.getActiveBufferId(); @@ -2218,23 +1695,13 @@ editor.on("lines_changed", (data) => { editor.refreshLines(data.buffer_id); } }); -editor.on("after_file_save", (data) => { - if (!isComposingInAnySplit(data.buffer_id)) return; - // Re-render diagrams/images and re-highlight code after a save. - void refreshComposeImages(data.buffer_id); - void refreshCodeHighlight(data.buffer_id); -}); 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}`); - // Live-update fenced code highlighting, debounced so rapid typing costs - // one trailing re-highlight instead of one per keystroke. - void refreshCodeHighlight(data.buffer_id, 200); }); 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 refreshCodeHighlight(data.buffer_id, 200); }); editor.on("cursor_moved", (data) => { if (!isComposingInAnySplit(data.buffer_id)) return; From 054e87ca12d7a84dd4b0c778ad10fb2cb4454f1f Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 10:24:17 -0500 Subject: [PATCH 03/14] fix(markdown_compose): fence-aware rendering, styled headings, clean code wells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache fenced-code-block byte ranges per buffer and skip all markdown processing (tables, emphasis, conceals, table soft-breaks) for lines inside them. Fixes |-leading code lines (e.g. TS union types) growing table borders inside ``` fences. - Conceal ATX heading markers (revealed on cursor line) and style heading text by level (bold/underline/color — terminals can't change font size). - Conceal fence marker lines (```lang / closing ```) when the cursor is elsewhere, so code blocks render as a clean well. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fresh-editor/plugins/markdown_compose.ts | 155 +++++++++++++++++- 1 file changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index af02d1acab..2f1514e019 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -58,6 +58,76 @@ 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; +} + +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; @@ -177,7 +247,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); @@ -562,7 +634,10 @@ 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 + // Build the fenced-code-block range cache first so the initial render + // already skips markdown processing inside fences, then trigger a refresh + // so lines_changed hooks fire for visible content. + void rebuildFenceRanges(bufferId).then(() => editor.refreshLines(bufferId)); editor.refreshLines(bufferId); editor.debug(`Markdown compose enabled for buffer ${bufferId}`); } @@ -584,6 +659,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}`); @@ -1057,10 +1133,67 @@ function processLineConceals( // 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. + } // --- Table row handling --- // Always apply table conceals even when cursor is on the line. @@ -1337,6 +1470,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); @@ -1496,7 +1633,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) { @@ -1698,10 +1837,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; From a0530112cea0fc99e7281a6a8b73220d5b517dfe Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 10:45:35 -0500 Subject: [PATCH 04/14] feat(graphics): wrap kitty escapes in tmux passthrough when inside tmux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw kitty graphics APC sequences are silently stripped by tmux, so inline images never reached the outer terminal. When $TMUX is set, wrap each transmit/delete in tmux DCS passthrough (ESC P tmux ; … ESC \, doubling inner ESCs). Requires 'set -g allow-passthrough on'. Unicode-placeholder cells already ride through as normal text, so only the data transmit needs wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/fresh-editor/src/app/editor_init.rs | 2 +- crates/fresh-editor/src/services/graphics.rs | 75 +++++++++++++++++--- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/fresh-editor/src/app/editor_init.rs b/crates/fresh-editor/src/app/editor_init.rs index 2ca439e3ed..e21279b67b 100644 --- a/crates/fresh-editor/src/app/editor_init.rs +++ b/crates/fresh-editor/src/app/editor_init.rs @@ -223,7 +223,7 @@ impl Editor { recovery_service: parts.recovery_service, time_source: parts.time_source, color_capability: parts.color_capability, - image_manager: crate::services::graphics::ImageManager::new( + image_manager: crate::services::graphics::ImageManager::new_from_env( crate::services::graphics::GraphicsCapability::detect(), ), update_checker: parts.update_checker, diff --git a/crates/fresh-editor/src/services/graphics.rs b/crates/fresh-editor/src/services/graphics.rs index 122ad79862..776efeb42c 100644 --- a/crates/fresh-editor/src/services/graphics.rs +++ b/crates/fresh-editor/src/services/graphics.rs @@ -158,6 +158,9 @@ struct Registered { /// 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>, @@ -166,10 +169,28 @@ pub struct ImageManager { 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 { pub fn new(capability: GraphicsCapability) -> Self { ImageManager { capability, + tmux_passthrough: false, next_id: 1, by_key: HashMap::new(), by_namespace: HashMap::new(), @@ -179,6 +200,20 @@ impl ImageManager { } } + /// Like [`new`], but detects the runtime environment: enables tmux + /// passthrough wrapping when running inside tmux (`$TMUX` set). Used by + /// the editor; tests use [`new`] for byte-exact (unwrapped) output. + pub fn new_from_env(capability: GraphicsCapability) -> Self { + let mut mgr = Self::new(capability); + mgr.tmux_passthrough = std::env::var_os("TMUX").is_some(); + mgr + } + + /// 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 } @@ -298,10 +333,19 @@ impl ImageManager { } 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. - out.extend_from_slice(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\").as_bytes()); + emit(&format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")); } let transmits: Vec = self.pending_transmit.drain(..).collect(); @@ -313,14 +357,11 @@ impl ImageManager { // placement size in cells; `q=2` suppresses acknowledgements. let path_b64 = base64::engine::general_purpose::STANDARD .encode(img.path.to_string_lossy().as_bytes()); - out.extend_from_slice( - 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, - ) - .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, + )); } } @@ -416,6 +457,22 @@ mod tests { 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); From aec9a8d6272e765f30d7fabe95538dbe31eefd48 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 14:13:08 -0500 Subject: [PATCH 05/14] fix(view): render same-priority virtual lines in insertion order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VirtualTextManager stores entries in a HashMap, and the range queries sorted only by (position, priority) — entries tying on both came back in arbitrary iteration order. For placed inline images, whose N reserved rows all share one anchor and priority 0, the rows rendered shuffled: diagrams displayed as torn horizontal bands. Tiebreak by VirtualTextId (a monotonic counter, so id order is insertion order) in query_range, query_lines_in_range, and query_inline_in_range. This fixes image row order without reserving the priority space, and makes every plugin's equal-priority virtual texts deterministic. Also commit .gitignore for macOS .DS_Store files. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + .../fresh-editor/src/app/plugin_commands.rs | 3 + crates/fresh-editor/src/view/virtual_text.rs | 71 ++++++++++++------- 3 files changed, 51 insertions(+), 26 deletions(-) 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-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index edf318842d..fb14575222 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -524,6 +524,9 @@ impl Editor { 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, diff --git a/crates/fresh-editor/src/view/virtual_text.rs b/crates/fresh-editor/src/view/virtual_text.rs index 97916881cf..4faf4a77ce 100644 --- a/crates/fresh-editor/src/view/virtual_text.rs +++ b/crates/fresh-editor/src/view/virtual_text.rs @@ -143,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 @@ -682,23 +683,29 @@ 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 + results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() } /// Build a lookup map for efficient per-character access during rendering @@ -763,31 +770,38 @@ 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 + results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() } /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range @@ -799,24 +813,29 @@ 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 + results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() } /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position From 2c84e25f63d7ee93ea9d0407da3c9421c02b83cc Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 14:13:27 -0500 Subject: [PATCH 06/14] feat(conceal): namespace-scoped range clear; markdown_compose uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown_compose rebuilds each line's conceals by clearing the line's byte range first — but clearConcealsInRange drops conceals from EVERY namespace, so it silently destroyed other plugins' conceals on those lines (e.g. an external preview plugin collapsing rendered diagram source). Same failure class as the unscoped overlay clear fixed in issue #2146; conceals never got the namespaced variant. Add ConcealManager::remove_in_range_for_namespace and plumb it through as clearConcealsInRangeForNamespace (PluginCommand, dispatch, QuickJS binding, fresh.d.ts, ts export list). markdown_compose now clears only its own md-syntax conceals per line. Also document two non-obvious choices in markdown_compose: the double refreshLines on enable (instant paint + corrected repaint once the fence cache exists) and the uncapped whole-buffer read in rebuildFenceRanges (fences are cross-line state; stale ranges degrade gracefully if a cap is ever added). Co-Authored-By: Claude Fable 5 --- crates/fresh-core/src/api.rs | 11 +++++ crates/fresh-editor/plugins/lib/fresh.d.ts | 5 ++ .../fresh-editor/plugins/markdown_compose.ts | 20 ++++++-- .../fresh-editor/src/app/plugin_commands.rs | 22 +++++++++ .../fresh-editor/src/app/plugin_dispatch.rs | 8 ++++ crates/fresh-editor/src/view/conceal.rs | 46 +++++++++++++++++++ .../src/backend/quickjs_backend.rs | 19 ++++++++ crates/fresh-plugin-runtime/src/ts_export.rs | 1 + 8 files changed, 128 insertions(+), 4 deletions(-) diff --git a/crates/fresh-core/src/api.rs b/crates/fresh-core/src/api.rs index 3d89ea27cb..2988ad5072 100644 --- a/crates/fresh-core/src/api.rs +++ b/crates/fresh-core/src/api.rs @@ -2631,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-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index ccdefa8225..708da5ab14 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -2510,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 diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index 2f1514e019..5d1830765e 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -81,6 +81,12 @@ function utf8ByteLen(s: string): number { 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); @@ -634,9 +640,12 @@ function enableMarkdownCompose(bufferId: number): void { // Set layout hints for centered margins editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth ?? undefined }); - // Build the fenced-code-block range cache first so the initial render - // already skips markdown processing inside fences, then 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}`); @@ -1121,7 +1130,10 @@ function processLineConceals( // 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); + // 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); diff --git a/crates/fresh-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index fb14575222..5ee22a9f34 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -630,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 30ec530a94..32fc18aa2e 100644 --- a/crates/fresh-editor/src/app/plugin_dispatch.rs +++ b/crates/fresh-editor/src/app/plugin_dispatch.rs @@ -426,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/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-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index 4a5d3d9195..ab66727f07 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -3557,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 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", From a9b44fafd1a0f966d084a57763a569d7d6678be9 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 14:13:40 -0500 Subject: [PATCH 07/14] refactor(graphics): fold tmux detection into ImageManager::new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new_from_env existed only so tests could construct a manager with passthrough deterministically off — a confusing split for one call site. Detection ($TMUX set => wrap escapes) now lives in new() itself, and the byte-exact tests pin set_tmux_passthrough(false) explicitly, which states their assumption instead of relying on which constructor was called. Co-Authored-By: Claude Fable 5 --- crates/fresh-editor/src/app/editor_init.rs | 2 +- crates/fresh-editor/src/services/graphics.rs | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/fresh-editor/src/app/editor_init.rs b/crates/fresh-editor/src/app/editor_init.rs index e21279b67b..2ca439e3ed 100644 --- a/crates/fresh-editor/src/app/editor_init.rs +++ b/crates/fresh-editor/src/app/editor_init.rs @@ -223,7 +223,7 @@ impl Editor { recovery_service: parts.recovery_service, time_source: parts.time_source, color_capability: parts.color_capability, - image_manager: crate::services::graphics::ImageManager::new_from_env( + image_manager: crate::services::graphics::ImageManager::new( crate::services::graphics::GraphicsCapability::detect(), ), update_checker: parts.update_checker, diff --git a/crates/fresh-editor/src/services/graphics.rs b/crates/fresh-editor/src/services/graphics.rs index 776efeb42c..372b301a6f 100644 --- a/crates/fresh-editor/src/services/graphics.rs +++ b/crates/fresh-editor/src/services/graphics.rs @@ -187,10 +187,13 @@ fn wrap_tmux(inner: &[u8]) -> Vec { } 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: false, + tmux_passthrough: std::env::var_os("TMUX").is_some(), next_id: 1, by_key: HashMap::new(), by_namespace: HashMap::new(), @@ -200,15 +203,6 @@ impl ImageManager { } } - /// Like [`new`], but detects the runtime environment: enables tmux - /// passthrough wrapping when running inside tmux (`$TMUX` set). Used by - /// the editor; tests use [`new`] for byte-exact (unwrapped) output. - pub fn new_from_env(capability: GraphicsCapability) -> Self { - let mut mgr = Self::new(capability); - mgr.tmux_passthrough = std::env::var_os("TMUX").is_some(); - mgr - } - /// Force tmux passthrough wrapping on/off (overrides env detection). pub fn set_tmux_passthrough(&mut self, on: bool) { self.tmux_passthrough = on; @@ -416,6 +410,7 @@ mod tests { #[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); @@ -441,6 +436,7 @@ mod tests { #[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); From cdd68afedcbcba53939ece3995f1f68c139ec764 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 15:14:50 -0500 Subject: [PATCH 08/14] style: cargo fmt Co-Authored-By: Claude Fable 5 --- .../fresh-editor/src/app/plugin_commands.rs | 6 +- crates/fresh-editor/src/services/graphics.rs | 57 +++++++++---------- crates/fresh-editor/src/services/mod.rs | 2 +- .../src/view/ui/split_rendering/style.rs | 6 +- crates/fresh-editor/src/view/virtual_text.rs | 15 ++++- 5 files changed, 46 insertions(+), 40 deletions(-) diff --git a/crates/fresh-editor/src/app/plugin_commands.rs b/crates/fresh-editor/src/app/plugin_commands.rs index 5ee22a9f34..26b41e88dc 100644 --- a/crates/fresh-editor/src/app/plugin_commands.rs +++ b/crates/fresh-editor/src/app/plugin_commands.rs @@ -508,9 +508,9 @@ impl Editor { // 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); + let id = + self.image_manager_mut() + .register(&key, &namespace, PathBuf::from(source), cols, rows); if let Some(state) = self .windows diff --git a/crates/fresh-editor/src/services/graphics.rs b/crates/fresh-editor/src/services/graphics.rs index 372b301a6f..a9a84d5945 100644 --- a/crates/fresh-editor/src/services/graphics.rs +++ b/crates/fresh-editor/src/services/graphics.rs @@ -37,34 +37,31 @@ pub const MAX_IMAGE_ID: u32 = 0x00FF_FFFF; /// 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, + 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. @@ -464,9 +461,7 @@ mod tests { 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'])); + assert!(seq.windows(4).any(|w| w == [0x1b, 0x1b, b'_', b'G'])); } #[test] diff --git a/crates/fresh-editor/src/services/mod.rs b/crates/fresh-editor/src/services/mod.rs index 98cfc83353..8d53301f4a 100644 --- a/crates/fresh-editor/src/services/mod.rs +++ b/crates/fresh-editor/src/services/mod.rs @@ -11,9 +11,9 @@ pub mod counters; pub mod env_provider; pub mod file_watcher; pub mod fs; -pub mod graphics; #[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/ui/split_rendering/style.rs b/crates/fresh-editor/src/view/ui/split_rendering/style.rs index 867835683e..418bdd0953 100644 --- a/crates/fresh-editor/src/view/ui/split_rendering/style.rs +++ b/crates/fresh-editor/src/view/ui/split_rendering/style.rs @@ -281,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, &[], 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); @@ -299,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, &[], 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); diff --git a/crates/fresh-editor/src/view/virtual_text.rs b/crates/fresh-editor/src/view/virtual_text.rs index 4faf4a77ce..596b907933 100644 --- a/crates/fresh-editor/src/view/virtual_text.rs +++ b/crates/fresh-editor/src/view/virtual_text.rs @@ -705,7 +705,10 @@ impl VirtualTextManager { .then_with(|| a.1.cmp(&b.1)) }); - results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() + results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Build a lookup map for efficient per-character access during rendering @@ -801,7 +804,10 @@ impl VirtualTextManager { .then_with(|| a.1.cmp(&b.1)) }); - results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() + results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Query only INLINE virtual texts (BeforeChar/AfterChar) in a byte range @@ -835,7 +841,10 @@ impl VirtualTextManager { .then_with(|| a.1.cmp(&b.1)) }); - results.into_iter().map(|(pos, _, vtext)| (pos, vtext)).collect() + results + .into_iter() + .map(|(pos, _, vtext)| (pos, vtext)) + .collect() } /// Build a lookup map for virtual LINES, keyed by the line's anchor byte position From 5400ff1ba453fe25fe947a5dbe6e45ca1f211c7a Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 15:14:50 -0500 Subject: [PATCH 09/14] feat(markdown_compose): render block quotes, bullets, checkboxes, and rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block quotes render as a dimmed vertical bar (one width-preserving bar glyph per '>', nesting included), list bullets as '•', task boxes as ☐ / ☑, and horizontal rules as a '─' line spanning the compose width. All markers reveal while the cursor is on the line, matching the existing heading/fence behavior, and fall through to inline emphasis processing. Co-Authored-By: Claude Fable 5 --- .../fresh-editor/plugins/markdown_compose.ts | 88 +++++++++++++ .../tests/e2e/markdown_compose.rs | 123 ++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index 5d1830765e..e3811fd2e6 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -1207,6 +1207,94 @@ function processLineConceals( // Fall through: headings 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. // Tables are structural: pipes → box-drawing, cells padded for alignment. diff --git a/crates/fresh-editor/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index bd0e7e5277..cc58d1819e 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -1314,6 +1314,129 @@ 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 +"; + + 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, + ); +} + /// 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 From 70941e07ca2eb4514b8808f24ff2ec25b770faee Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 12 Jun 2026 15:25:24 -0500 Subject: [PATCH 10/14] feat(markdown_compose): render footnote references and definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-text references ([^1]) conceal to Unicode superscript digits (¹) in link color; non-numeric labels keep a compact ^label form. Definition lines ([^1]: text) collapse the marker to the same superscript and dim the definition text, mirroring GitHub's footnotes section. Both reveal while the cursor is on the line. Going through findInlineSpans means table widths and soft-wrap budgets account for the concealed syntax automatically. Co-Authored-By: Claude Fable 5 --- .../fresh-editor/plugins/markdown_compose.ts | 62 ++++++++++++++++++- .../tests/e2e/markdown_compose.rs | 22 ++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index e3811fd2e6..d38eededa0 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -899,8 +899,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 @@ -984,6 +997,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) { @@ -1207,6 +1236,34 @@ function processLineConceals( // 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 @@ -1525,6 +1582,9 @@ function processLineConceals( url: span.linkUrl, }); break; + case 'footnote': + editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { fg: "syntax.link" }); + break; // entities: no overlay } diff --git a/crates/fresh-editor/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index cc58d1819e..002387b564 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -1336,7 +1336,9 @@ fn test_compose_mode_blockquote_bullet_checkbox_hr_rendering() { *** -after rule +after rule with a footnote[^1] + +[^1]: the footnote text "; let temp_dir = tempfile::TempDir::new().unwrap(); @@ -1435,6 +1437,24 @@ after rule "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. From 7c2a1105b4892211655bc9397f5dfbd2cd88a160 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Sat, 13 Jun 2026 05:42:50 -0500 Subject: [PATCH 11/14] fix(markdown_compose): measure table cell widths in display cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table column widths, cell padding, truncation, and wrapping all measured cell content with String.length (UTF-16 code units), so wide glyphs threw off alignment: an emoji like ✅ is one code unit but two terminal cells, which over-padded every cell containing one and pushed the right border past the table outline. Add a wcwidth-style displayWidth covering East Asian wide ranges, the emoji planes, emoji-presentation symbols (U+2300–U+2BFF, where ✅/❓ live), zero-width joiners/combining marks, and the U+FE0F variation selector, and route all width math through it. Also conceal alignment colons in separator rows so ':---:' no longer bleeds through the rendered rule. Co-Authored-By: Claude Fable 5 --- .../fresh-editor/plugins/markdown_compose.ts | 109 ++++++++++++++++-- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index d38eededa0..750b1ef80b 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -1117,20 +1117,100 @@ function distributeColumnWidths(maxW: number[], available: number): number[] { } /** - * Wrap text into lines of at most `width` characters, breaking at word boundaries. + * 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 >= 0x1f300 && cp <= 0x1f64f) || + (cp >= 0x1f680 && cp <= 0x1f6ff) || + (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` 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 { @@ -1403,7 +1483,7 @@ function processLineConceals( 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)) + ' │'; + vline += ' ' + text + ' '.repeat(Math.max(0, wrapW - displayWidth(text))) + ' │'; } visualLines.push(vline); } @@ -1461,7 +1541,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) { @@ -1486,7 +1566,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) { @@ -1496,9 +1576,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 { @@ -1520,7 +1603,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); @@ -1838,7 +1923,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])); } } } From 1bb7a3d2df3cbd3f8e32a9913d4d83864a3835ac Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Tue, 16 Jun 2026 13:03:10 -0500 Subject: [PATCH 12/14] feat(markdown_compose): word-wrap long table cells into aligned rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wide tables previously either truncated each cell to one line or split the single source line with soft breaks at whatever spaces happened to line up — which couldn't keep independent columns aligned, bled cell text across row boundaries, and corrupted neighbouring lines. Render tables the way GitHub does instead: each column word-wraps to its width and the row spans as many visual lines as its tallest cell. Mechanism: a source row's first visual line is rendered in place (cells concealed to their first wrapped fragment, pipes -> box-drawing); the overflow lines are emitted as virtual continuation lines below the row, the same primitive the borders already use. Every source row stays one source line, so cursor mapping and editing are unaffected and alignment is computed from generated text rather than fighting the byte layout. Also in table layout: - Distribute column widths by water-filling (max-content with fair shrinking) instead of proportionally. The old split starved short label columns to feed an already-wide prose column, chopping single words like `workflowTransitions` mid-word. Now narrow columns keep their natural width and only the genuinely wide prose column shrinks. - Cap the frame at the configured compose width (or `maxWidth` when unset) and leave a one-cell right margin, so a table on a wide terminal reads like a README instead of stretching edge-to-edge, and its right border never lands in the final column (which auto-wraps). Two correctness fixes surfaced by real documents: - Never let one line abort the whole batch. The per-line debug log sliced lineContent at 40 UTF-16 units, which can cut an astral char (e.g. the 🟡 status emoji at U+1F7E1) between its surrogate halves; the resulting lone surrogate failed the host's string->UTF-8 conversion and threw, aborting the lines_changed loop so that line AND every line after it rendered as raw markdown. Slice by code points, and wrap each line's processing in try/catch so a single bad line can no longer cascade. - Count colored circles/squares (U+1F7E0..U+1F7EB, e.g. 🟡) and regional indicators as two cells in displayWidth — they sat in a gap between the existing emoji ranges, so a 🟡 was measured as one cell but drawn as two, shifting every border to its right by one. - Stop revealing a line's concealed markers when the cursor sits at the start of the *next* line: byteEnd points just past the trailing newline, so a cursor there counted as "on" the line and left a heading's `##` visible while editing the blank line below it. Adds e2e tests: clean wide-table frame, full composition of a table taller than the viewport after scrolling, and a wrapping row with an astral emoji not aborting composition. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fresh-editor/plugins/markdown_compose.ts | 356 +++++++++++------- .../tests/e2e/markdown_compose.rs | 257 +++++++++++++ 2 files changed, 481 insertions(+), 132 deletions(-) diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index 750b1ef80b..7bbc5a0d82 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -217,6 +217,63 @@ function isTableSeparatorContent(lineContent: string): boolean { return /^\|[-:\s|]+\|$/.test(lineContent.trim()); } +/** + * 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 } { + let inner = lineContent.trim(); + if (inner.startsWith('|')) inner = inner.slice(1); + if (inner.endsWith('|')) inner = inner.slice(0, -1); + const cells = inner.split('|'); + 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` @@ -233,6 +290,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). @@ -308,9 +366,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. @@ -322,7 +407,7 @@ function processTableBorders( borderOptions, false, // below ns, - 0, + 1000, ); } } @@ -1097,23 +1182,82 @@ 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; } /** @@ -1153,8 +1297,15 @@ function charDisplayWidth(cp: number): number { (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; @@ -1238,7 +1389,11 @@ 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)}"`); + // 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). @@ -1247,7 +1402,15 @@ function processLineConceals( // 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 @@ -1454,82 +1617,46 @@ function processLineConceals( if (inner.endsWith('|')) inner = inner.slice(0, -1); const cells = inner.split('|'); - // Check if any data cell needs multi-line wrapping + // Pipe positions in the (untrimmed) source line — shared by the wrapped + // first-line path and the single-line path below. + const pipePositions: number[] = []; + for (let i = 0; i < lineContent.length; i++) { + if (lineContent[i] === '|') pipePositions.push(i); + } + + // 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 - displayWidth(text))) + ' │'; - } - 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- @@ -1743,50 +1870,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; @@ -1966,9 +2052,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, @@ -2061,9 +2146,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). @@ -2072,7 +2165,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) { @@ -2116,14 +2209,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/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index 002387b564..202a93542b 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -4932,3 +4932,260 @@ 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 + ); +} From c51c49a9168dbf929dd065873a35febb26a7646c Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Tue, 16 Jun 2026 13:18:59 -0500 Subject: [PATCH 13/14] style: cargo fmt Format the table-rendering e2e tests added in the previous commit: rustfmt breaks the long `harness.send_key(...).unwrap()` chains and over-width string/constructor lines that CI's `cargo fmt --all --check` flagged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/e2e/markdown_compose.rs | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/crates/fresh-editor/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index 202a93542b..9abd4ae980 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -4977,11 +4977,15 @@ fn test_wide_table_renders_clean_frame() { harness.open_file(&md_path).unwrap(); harness.render().unwrap(); - harness.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL).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 + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); harness.wait_for_prompt_closed().unwrap(); let mut prev = String::new(); @@ -5069,7 +5073,8 @@ 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"); + 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", @@ -5088,19 +5093,30 @@ fn test_tall_table_scroll_all_rows_composed() { std::fs::write(&md_path, &md).unwrap(); let mut harness = - EditorTestHarness::with_config_and_working_dir(120, 24, Default::default(), project_root).unwrap(); + 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 + .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 + .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)); } + 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). @@ -5113,8 +5129,8 @@ fn test_tall_table_scroll_all_rows_composed() { // 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()); + 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{}", @@ -5124,7 +5140,6 @@ fn test_tall_table_scroll_all_rows_composed() { } } - /// 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 @@ -5156,16 +5171,25 @@ fn test_emoji_row_does_not_abort_composition() { // 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(); + 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 + .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 + .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)); } + 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 @@ -5173,8 +5197,8 @@ fn test_emoji_row_does_not_abort_composition() { // 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()); + 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{}", @@ -5184,7 +5208,9 @@ fn test_emoji_row_does_not_abort_composition() { } // Positively confirm the rows after the emoji row are framed. assert!( - screen.lines().any(|l| l.contains('│') && l.contains("Outbox pub/sub")), + 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 ); From dec0386dee0794e4751b456d4e0f4793dc8aa4f8 Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Thu, 18 Jun 2026 13:17:42 -0500 Subject: [PATCH 14/14] fix(markdown_compose): render escaped table pipes; drop tmux stray borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compose-mode markdown rendering fixes: - Escaped pipes (`\|`) inside table cells were split as column separators, fanning a row into phantom columns and skewing column widths. Split table rows on unescaped pipes only and render `\|` as a literal `|`. Adds escape-aware helpers (isEscapedPipe / tablePipePositions / tableRowInner / splitTableCells) used by every table cell-split and width path. - Stray table-border `│` glyphs were left hanging on blank lines when scrolling inside tmux. The per-frame synchronized-update markers (DEC mode 2026) were sent to the outer terminal unwrapped; tmux's handling drops individual cell-clear updates from a frame diff, leaving stale glyphs until a full redraw. Skip the markers when inside tmux (TMUX set) — tmux batches its own pane refreshes, so they buy nothing there. Tests: escaped-pipe table regression test, plus a scroll probe that guards the composed buffer against logical stray-border regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fresh-editor/plugins/markdown_compose.ts | 107 ++++++++-- crates/fresh-editor/src/main.rs | 15 +- .../tests/e2e/markdown_compose.rs | 132 +++++++++++++ .../tests/e2e/markdown_compose_stray_pipe.rs | 187 ++++++++++++++++++ crates/fresh-editor/tests/e2e/mod.rs | 1 + 5 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 crates/fresh-editor/tests/e2e/markdown_compose_stray_pipe.rs diff --git a/crates/fresh-editor/plugins/markdown_compose.ts b/crates/fresh-editor/plugins/markdown_compose.ts index 7bbc5a0d82..ce0b9ce161 100644 --- a/crates/fresh-editor/plugins/markdown_compose.ts +++ b/crates/fresh-editor/plugins/markdown_compose.ts @@ -217,6 +217,61 @@ 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. * @@ -236,10 +291,7 @@ function wrapTableRow( colWidths: number[], raw: boolean, ): { cellWrapped: string[][]; numCols: number; maxVisualLines: number } { - let inner = lineContent.trim(); - if (inner.startsWith('|')) inner = inner.slice(1); - if (inner.endsWith('|')) inner = inner.slice(0, -1); - const cells = inner.split('|'); + const cells = splitTableCells(tableRowInner(lineContent.trim())); const numCols = Math.min(cells.length, colWidths.length); const cellWrapped: string[][] = []; let maxVisualLines = 1; @@ -1611,18 +1663,14 @@ 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('|'); + // 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. - const pipePositions: number[] = []; - for (let i = 0; i < lineContent.length; i++) { - if (lineContent[i] === '|') pipePositions.push(i); - } + // 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 @@ -1681,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); @@ -1720,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 = '┤'; @@ -1986,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); } diff --git a/crates/fresh-editor/src/main.rs b/crates/fresh-editor/src/main.rs index 9a902c7b1b..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,9 +4416,13 @@ 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(); diff --git a/crates/fresh-editor/tests/e2e/markdown_compose.rs b/crates/fresh-editor/tests/e2e/markdown_compose.rs index 9abd4ae980..53c458ce2c 100644 --- a/crates/fresh-editor/tests/e2e/markdown_compose.rs +++ b/crates/fresh-editor/tests/e2e/markdown_compose.rs @@ -2589,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. 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;