Add VSCode built-in preview panel#134
Open
yzr278892 wants to merge 28 commits into
Open
Conversation
- 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.
There was a problem hiding this comment.
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-resolutionCLI flags and corresponding frontend state to run without an SDL window. - Adds
webview_outputto render MuPDF display lists to pixmaps, QOI-encode them, and emitpage/page-diffJSON 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 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 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); |
| 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 on lines
+348
to
+349
| char window_title[128] = "TeXpresso "; | ||
| strcat(window_title, doc_name); |
Comment on lines
72
to
73
| if (!ignored) | ||
| fprintf(stderr, "unhandled %s: \"%.*s\"\n", kind, (int)(lim - cur), cur); |
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 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.
Author
|
Thanks for the review. I've addressed the Copilot suggestions:
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
-webviewmodeNew CLI flag
-webviewthat skips SDL window creation (only initsSDL_INIT_TIMER | SDL_INIT_EVENTSfor the event loop). After MuPDF renders a page, it's QOI-encoded viaqoi.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-diffmessage). 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+ webviewNew
preview.tsmanages 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:
Integration with the vscode extension
The companion PR is at DominikPeters/texpresso-vscode#24.
-webview,-tmpdir,-resolutionare new parameters — all existing SDL behavior is completely unchanged when they're omitted. The extension addsstartBuiltinPreview/startExternalPreviewas two independent entry points.Technical Details
step()polls with 10μs timeout;go-end/set-pagecontinuously poll rather than breaking on the firstfalse["synctex-scroll", x, y]with TeX-point coordinates; webview auto-pans to bring content into viewBackward Compatibility
-webview, all SDL behavior is unchangedScreenshots
PR Summary (detailed changelog)
See
texpresso-pr-summary.mdin the repo root for a full comparison of all changes.I recently restructured the code. Please review the final version. Thank you very much.