Skip to content

Add VSCode built-in preview panel#134

Open
yzr278892 wants to merge 28 commits into
let-def:mainfrom
yzr278892:builtin-preview-core
Open

Add VSCode built-in preview panel#134
yzr278892 wants to merge 28 commits into
let-def:mainfrom
yzr278892:builtin-preview-core

Conversation

@yzr278892
Copy link
Copy Markdown

Motivation

First of all, I really admire this project. The near-zero-latency real-time rendering is incredibly smooth — that's why I use it and want to contribute.

My main use case is using VSCode on Windows connecting to WSL or a remote server. The SDL window works great locally, but in remote development scenarios it's inconvenient: windows end up on the wrong desktop, scaling is off, and it's hard to manage with multiple monitors. I wanted something like LaTeX Workshop's built-in viewer that opens inside VSCode while keeping most of the SDL window's interactive features.

I actually tried this a long time ago using PNG encoding — the encoding was slow, the data was huge, and the latency was terrible. It failed completely. When I noticed the codebase already had QOI support (qoi.h), I picked this up again.

Changes

C Backend: -webview mode

New CLI flag -webview that skips SDL window creation (only inits SDL_INIT_TIMER | SDL_INIT_EVENTS for the event loop). After MuPDF renders a page, it's QOI-encoded via qoi.h, written to /dev/shm, and the file path is emitted via stdout JSON: ["page", N, total, "/path/to/file.qoi", w, h, pw, ph].

Incremental updates: the new pixmap is compared pixel-by-pixel with the previous render. When the changed area is under 50%, only dirty rectangles are transmitted (page-diff message). In practice, most edits (adding a character, changing a formula) produce only 1-4 small rectangles, reducing data from ~1.5MB (full page) to tens of KB.

New commands added for full preview control: synctex-backward, set-page, go-home, go-end, set-output-size, set-fit-mode, reset-zoom, invert.

Extension: preview.ts + webview

New preview.ts manages the WebviewPanel lifecycle. texpresso's stdout is intercepted, QOI files are base64-encoded and forwarded to the webview via postMessage.

The webview is a pure TypeScript Canvas viewer with:

  • QOI decoder (~80 lines, hand-written from the spec)
  • Ctrl+wheel zoom (2.5% per step), Ctrl+drag pan, inertia scrolling
  • Dark mode (CSS filter), Chinese/English i18n
  • Bidirectional SyncTeX: click preview → source cursor, source cursor → preview scroll
  • LRU page cache, page input with i18n error dialog

Integration with the vscode extension

The companion PR is at DominikPeters/texpresso-vscode#24. -webview, -tmpdir, -resolution are new parameters — all existing SDL behavior is completely unchanged when they're omitted. The extension adds startBuiltinPreview / startExternalPreview as two independent entry points.

Technical Details

  • QOI encode ~2-3ms (@ 2.5x resolution), vs ~50-100ms for PNG
  • tmpfs temp file read/write ~1ms, no disk I/O
  • Engine subprocess step() polls with 10μs timeout; go-end/set-page continuously poll rather than breaking on the first false
  • Inline render + RELOAD_EVENT dedup: keystroke changes render immediately in the same iteration, not waiting for the next event loop dispatch
  • Forward SyncTeX sends ["synctex-scroll", x, y] with TeX-point coordinates; webview auto-pans to bring content into view

Backward Compatibility

  • Without -webview, all SDL behavior is unchanged
  • All existing protocols (S-expression and JSON) are unaffected
  • All existing VSCode commands and settings are preserved

Screenshots

Preview Panel

Options Menu

Editor with Preview

PR Summary (detailed changelog)

See texpresso-pr-summary.md in the repo root for a full comparison of all changes.

I recently restructured the code. Please review the final version. Thank you very much.

- New webview_output.h/c: render pages to QOI temp files, output JSON to stdout
- renderer.h/c: expose txp_renderer_render_to_pixmap() and txp_renderer_invert_pixmap()
- synctex-backward: click-to-source reverse SyncTeX
- set-page: jump to arbitrary page
- set-output-size: request render resolution
- go-home/go-end: jump to first/last page
- reset-zoom: reset zoom level
- set-fit-mode: set width/height/custom fit mode
- driver.h: add webview_mode, render_width/height, tmpdir to persistent_state
- driver.c: -webview/-tmpdir flag parsing, conditional SDL init (TIMER+EVENTS)
- main.c: webview mode guards, RELOAD_EVENT triggers webview_output_page,
  new command handlers (go-home, go-end, set-page, synctex-backward, etc.)
- Makefile: add webview_output.o
- unhandled(): return 1 instead of 0, logging warnings instead of
  aborting the entire page DVI rendering
- pdf_code catch: return 1 instead of 0 for unknown PDF operators

This is the root cause of TikZ rendering failures: when any \special
was unrecognized, dvi_exec_special returned false, which caused
dvi_interp.c:383 to abort rendering the entire page. With this fix,
unrecognized specials are merely skipped, allowing the rest of the
page (including most TikZ graphics) to render correctly.
- main.c: fix display_page(NULL) crash in webview mode by skipping
  display_page and calling webview_output_page directly
- driver.c: update usage() to document -webview and -tmpdir flags
- dvi_special.c: implement PDF v operator (first control point = current point)
- dvi_special.c: implement PDF y operator (last control point = current point)
- dvi_special.re2c.c: sync v/y operator changes to re2c source
- change SDL window creation from SDL_WINDOW_SHOWN to SDL_WINDOW_HIDDEN
  to avoid potential hang during window creation in WSL2/WSLg
- add explicit SDL_ShowWindow() call after renderer setup
- main.c: render page directly after engine advance in webview mode
  instead of relying on RELOAD_EVENT which may not fire in time
- webview_output.c: fix mkstemp template (XXXXXX must be at end, not
  followed by .qoi) and add debug logging at each step
- driver.c: use SDL_WINDOW_HIDDEN then SDL_ShowWindow to avoid
  potential blocking during window creation in WSL2/WSLg
- driver.h: add dark_mode field to persistent_state
- webview_output.h/c: add dark_mode parameter, default to light mode
- main.c: handle EDIT_INVERT by toggling dark_mode in webview mode,
  output dark-mode JSON for webview sync, trigger re-render on each
  engine advance (not just on page count change) for live editing
…, preloading

- Fix zoom drift by using exact inverse zoom factors and recomputing offsets
- Reduce scroll velocity and improve clamping to page bounds
- Fix loading indicator i18n (updates with locale change)
- Fix bidirectional SyncTeX precision (pass TeX points directly)
- Fix reset zoom to properly reset resolution via set-output-size
- Pass pageWidth/pageHeight from C backend for accurate coordinate mapping
- Fix preview auto-close to check all open documents, not just visible editors
- Fix page preloading for go-to-end and set-page (500 step advancement)
- Renderer now scales content to fit pixmap dimensions
…ed page preloading

- Revert SyncTeX backward to use scale_factor conversion (synctex_scan expects DVI units)
- Implement dirty rect incremental rendering: compare old/new pixmaps, send only changed regions
- Add time-based page preloading (up to 1s per go-to-end/set-page, max 5000 steps)
- Always render in webview mode after processing changes (not just when advance flag is set)
- Fix dirty rect logic: skip sending when no changes detected (n_rects == 0)
- Advance engine until it blocks for go-to-end and set-page (up to 20000 steps)
- Send page-error message to webview when requested page doesn't exist
- Only render when had_changes || advance (avoid redundant renders)
- Remove redundant render_page call for page dimension computation
- Compute page dimensions only when auto-detecting render size
- Add debug stderr logging for render attempts
Remove the inline webview render in the advance_engine block that caused
each keystroke to trigger two MuPDF renders (one inline, one via RELOAD_EVENT).
The RELOAD_EVENT handler is now the single render point.

Also lower the auto-detect resolution multiplier from 3x to 2x to reduce
data volume by ~55% for real-time editing feel.
Add inline render back for real-time editing feedback (no iteration delay),
with a webview_rendered_this_iteration flag to prevent the RELOAD_EVENT
handler from doing a redundant second render.

This gives immediate visual feedback on keystrokes like the SDL window,
while avoiding duplicate MuPDF rendering.
Modify need_advance() to keep advancing engine in webview mode until
terminated, ensuring all pages are discovered on file open. Track
steps_done in advance_engine() to prevent busy loops when engine is
blocked. Limit inline render to content changes only (not during
preload advancement).

This enables the cascading go-end behavior: each click jumps to the
last discovered page, and subsequent clicks discover more pages if
the engine was previously blocked.
…w pages

Change the event-loop continue condition in webview mode: only skip event
handling (SDL_WaitEvent) when new pages were actually discovered
(after_page_count > before_page_count). Otherwise fall through to
process pending RELOAD_EVENT and other SDL events.

This fixes the "stuck on loading" issue where the engine kept
advancing without ever processing the initial RELOAD_EVENT.
Revert need_advance, advance_engine steps_done, and event-loop branching.
Instead, add a simple preload loop before the main event loop that
advances the engine until blocked or terminated. This avoids the
event-scheduling complexity that caused the preview to get stuck.

The preload runs synchronously before entering the main loop, so
the initial RELOAD_EVENT is guaranteed to render page 0.
The engine's step() returns false when the TeX subprocess hasn't
produced output yet (10us poll timeout) — this is normal during
computation, not a signal that work is done. Changed preload,
go-end, and set-page to retry instead of breaking, with a stalled
counter (500k max) to prevent infinite loops if the engine hangs.
Remove the preload phase that ran synchronously before the main loop.
Instead, rely on the existing lazy discovery: page 0 renders immediately,
and go-end / set-page poll the engine to discover more pages on demand.

This gives fast initial preview and avoids unnecessary resource usage
for large documents.
500000 iterations (~5s at 10us/iter) blocks the UI thread for too long,
causing navigation buttons to hang. 50000 (~500ms) is enough for the
engine subprocess to produce output while keeping the UI responsive.
Output synctex-scroll message with TeX-point coordinates when forward
SyncTeX finds a target, so the webview can pan to show the corresponding
content.
Instead of hardcoding 2.5x in the C auto-detection, use a -resolution
command-line flag that defaults to 2.5. This allows the extension to
pass the user's configured defaultResolution setting.
Copilot AI review requested due to automatic review settings April 29, 2026 16:08
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new backend “webview mode” intended to support a VSCode built-in preview by rendering pages offscreen and emitting QOI image updates over the existing stdout JSON channel (instead of opening an SDL window).

Changes:

  • Introduces -webview, -tmpdir, and -resolution CLI flags and corresponding frontend state to run without an SDL window.
  • Adds webview_output to render MuPDF display lists to pixmaps, QOI-encode them, and emit page / page-diff JSON messages (including dirty-rect incremental updates).
  • Extends command handling for preview control (page navigation, output sizing, fit/zoom signals, SyncTeX backward/forward bridging) and adjusts DVI special handling.

Reviewed changes

Copilot reviewed 13 out of 16 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
texpresso-pr-summary.md Documents the intended VSCode preview architecture and protocol/messages.
src/frontend/webview_output.h Declares the webview output interface used by the main loop.
src/frontend/webview_output.c Implements QOI temp-file emission and dirty-rect diffing over stdout JSON.
src/frontend/renderer.h Exposes new offscreen render + invert APIs for webview rendering.
src/frontend/renderer.c Adds txp_renderer_render_to_pixmap and makes invert helper public.
src/frontend/main.c Adds webview-mode command dispatch, inline render path, and stdout messages.
src/frontend/editor.h Adds new editor command tags/fields for webview preview control.
src/frontend/editor.c Parses the new JSON commands (synctex-backward, set-page, etc.).
src/frontend/driver.h Extends persistent state with webview-related fields.
src/frontend/driver.c Parses new CLI flags; changes SDL init/window creation for webview mode.
src/frontend/Makefile Links the new webview_output.o.
src/dvi/dvi_special.re2c.c Changes how unhandled specials/operators are treated and logged.
src/dvi/dvi_special.c Mirrors the special/operator handling behavior changes in the non-re2c file.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/frontend/webview_output.c Outdated
Comment on lines +209 to +243
is_diff = true;

// Send page-diff message
fprintf(stdout, "[\"page-diff\",%d,%d,%d,%d,%d,%d,%d,[",
page, total_pages, w, h, page_width, page_height, n_rects);

for (int i = 0; i < n_rects; i++) {
dirty_rect_t *r = &rects[i];
int rw = r->w, rh = r->h;
unsigned char *rect_rgb = malloc(rw * rh * 3);
if (!rect_rgb) continue;
for (int ry = 0; ry < rh; ry++) {
memcpy(rect_rgb + ry * rw * 3,
rgb + ((r->y + ry) * w + r->x) * 3,
rw * 3);
}
qoi_desc rdesc = { .width = rw, .height = rh, .channels = 3, .colorspace = QOI_SRGB };
int rqoi_len = 0;
void *rqoi_data = qoi_encode(rect_rgb, &rdesc, &rqoi_len);
free(rect_rgb);
if (!rqoi_data) continue;

char rpath[PATH_MAX];
snprintf(rpath, sizeof(rpath), "%s/texpresso-XXXXXX", tmpdir);
int rfd = mkstemp(rpath);
if (rfd >= 0) {
write(rfd, rqoi_data, rqoi_len);
close(rfd);
if (i > 0) fprintf(stdout, ",");
fprintf(stdout, "[%d,%d,%d,%d,\"%s\"]", r->x, r->y, rw, rh, rpath);
}
free(rqoi_data);
}
fprintf(stdout, "]]\n");
fflush(stdout);
Comment on lines +231 to +255
char rpath[PATH_MAX];
snprintf(rpath, sizeof(rpath), "%s/texpresso-XXXXXX", tmpdir);
int rfd = mkstemp(rpath);
if (rfd >= 0) {
write(rfd, rqoi_data, rqoi_len);
close(rfd);
if (i > 0) fprintf(stdout, ",");
fprintf(stdout, "[%d,%d,%d,%d,\"%s\"]", r->x, r->y, rw, rh, rpath);
}
free(rqoi_data);
}
fprintf(stdout, "]]\n");
fflush(stdout);
}
// else: dirty_ratio >= threshold or n_rects < 0 — fall through to full page
}

if (send_update && !is_diff) {
// Full page output
char tmppath[PATH_MAX];
write_qoi_file(tmpdir, rgb, w, h, tmppath, sizeof(tmppath));
if (tmppath[0]) {
fprintf(stdout, "[\"page\",%d,%d,\"%s\",%d,%d,%d,%d]\n",
page, total_pages, tmppath, w, h, page_width, page_height);
fflush(stdout);
Comment thread src/frontend/main.c Outdated
Comment on lines +1605 to +1607
if (ps->webview_mode)
fprintf(stderr, "[main] RELOAD_EVENT: page_count=%d, ui->page=%d, webview=%d\n",
page_count, ui->page, ps->webview_mode);
Comment thread src/frontend/driver.c Outdated
fprintf(stderr,
"Usage: texpresso [-I path]* [-json] [-lines] [-texlive] [-tectonic] "
"[-test-initialize] [-stream] root_file.tex\n");
"[-test-initialize] [-stream] [-webview] [-tmpdir path] root_file.tex\n");
Comment thread src/frontend/driver.c Outdated
Comment on lines +348 to +349
char window_title[128] = "TeXpresso ";
strcat(window_title, doc_name);
Comment thread src/dvi/dvi_special.re2c.c Outdated
Comment on lines 72 to 73
if (!ignored)
fprintf(stderr, "unhandled %s: \"%.*s\"\n", kind, (int)(lim - cur), cur);
Comment thread src/dvi/dvi_special.c Outdated
Comment on lines +57 to +59
if (!ignored)
fprintf(stderr, "unhandled %s: \"%.*s\"\n", kind, (int)(lim - cur), cur);
return 0;
return 1; // Don't abort page rendering for unrecognized specials
Comment on lines +4 to +10
#include "driver.h"

void webview_output_page(fz_context *ctx, txp_engine *eng,
int page, int total_pages,
int img_width, int img_height,
int page_width, int page_height,
const char *tmpdir, bool dark_mode);
Comment on lines +77 to +88
int total_pixels = w * h;
int dirty_pixels = 0;
int rect_count = 0;

// For each row, find min/max changed column
int row_min_x[4096]; // max page height ~4000
int row_max_x[4096];
int dirty_start = -1;

if (h > 4096) h = 4096; // safety

for (int y = 0; y < h; y++) {
Comment thread src/frontend/webview_output.c Outdated
Comment on lines +52 to +60
ssize_t written = write(fd, qoi_data, qoi_len);
close(fd);
free(qoi_data);

if (written != qoi_len) {
fprintf(stderr, "[webview] ERROR: write returned %zd, expected %d\n", written, qoi_len);
unlink(path_out);
path_out[0] = '\0';
return;
- webview_output.c: handle partial writes with write_all(), fix page-diff
  rect count mismatch by building emitted list first, JSON-escape file
  paths, handle h>4096 in compute_dirty_rects by falling back to full page
- webview_output.h: add missing #include "engine.h"
- driver.c: add -resolution to usage synopsis, fix strcat buffer overflow
  with snprintf for window_title
- main.c: remove noisy RELOAD_EVENT debug logs
- dvi_special.c / dvi_special.re2c.c: suppress unhandled() stderr logs
  to avoid log flooding on unrecognized specials
Restore dvi_special.c and dvi_special.re2c.c to pre-TikZ state
(commit 0fc3d86). The TikZ changes (unhandled() return 1, pdf:literal
handler, PDF operators) belong on the tikz branch, not in the webview
preview PR.
@yzr278892
Copy link
Copy Markdown
Author

Thanks for the review. I've addressed the Copilot suggestions:

  • page-diff rect count mismatch: now builds the emitted rect list first, then outputs JSON — declared count always matches actual content.
  • Partial writes: added write_all() helper that loops until all bytes are written, used for both full-page QOI and dirty rect QOI files.
  • Missing include: added #include "engine.h" to webview_output.h.
  • Buffer overflow: replaced strcat with snprintf for the SDL window title.
  • Usage synopsis: added the missing -resolution flag.
  • Debug log noise: removed per-frame RELOAD_EVENT debug logs from stderr.

I also noticed some TikZ-related DVI specials changes had accidentally leaked into this branch — those have been reverted. The webview preview branch now contains only preview-related changes.

Please take another look when you have a chance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants