diff --git a/R/profile.R b/R/profile.R index 627450e2..4e441312 100644 --- a/R/profile.R +++ b/R/profile.R @@ -25,8 +25,10 @@ local({ }) if (requireNamespace("sess", quietly = TRUE)) { + plot_backend <- Sys.getenv("SESS_PLOT_BACKEND", "auto") sess::connect( use_rstudioapi = as.logical(Sys.getenv("SESS_RSTUDIOAPI", "TRUE")), - use_httpgd = as.logical(Sys.getenv("SESS_USE_HTTPGD", "TRUE")) + use_httpgd = (plot_backend %in% c("auto", "httpgd")), + use_jgd = (plot_backend %in% c("auto", "jgd")) ) } diff --git a/README.md b/README.md index d5908d7c..e35d73c5 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,15 @@ See the [sess package README](./sess/README.md) for more details on the protocol 4. Create an R file and start coding. -The following software or extensions are recommended to enhance the experience of using R in VS Code: +The following software are recommended to enhance the experience of using R in VS Code: -* [radian](https://github.com/randy3k/radian): A modern R console that corrects many limitations of the official R terminal and supports many features such as syntax highlighting and auto-completion. +* Interactive plot backends (install one for a better R plotting experience): + * [jgd](https://github.com/grantmcdermott/jgd): Lightweight JSON graphics device with native vscode-R integration. + * [httpgd](https://github.com/nx10/httpgd): SVG-based graphics device served via HTTP and WebSockets. -* [VSCode-R-Debugger](https://github.com/ManuelHentschel/VSCode-R-Debugger): A VS Code extension to support R debugging capabilities. +* [arf](https://github.com/eitsupi/arf): Modern R console with many features: syntax highlighting, fuzzy history search, multiline editing, vi/emacs keybindings, R version switching, etc. Successor to [radian](https://github.com/randy3k/radian) written in Rust. -* [httpgd](https://github.com/nx10/httpgd): An R package to provide a graphics device that asynchronously serves SVG graphics via HTTP and WebSockets. +* [VSCode-R-Debugger](https://github.com/ManuelHentschel/VSCode-R-Debugger): A VS Code extension to support R debugging capabilities. Go to the installation wiki pages ([Windows](https://github.com/REditorSupport/vscode-R/wiki/Installation:-Windows) | [macOS](https://github.com/REditorSupport/vscode-R/wiki/Installation:-macOS) | [Linux](https://github.com/REditorSupport/vscode-R/wiki/Installation:-Linux)) for more detailed instructions. @@ -65,7 +67,7 @@ Go to the installation wiki pages ([Windows](https://github.com/REditorSupport/v * [Data viewer](https://github.com/REditorSupport/vscode-R/wiki/Interactive-viewers#data-viewer): Viewing `data.frame` or `matrix` in a grid or a list structure in a treeview. -* [Plot viewer](https://github.com/REditorSupport/vscode-R/wiki/Plot-viewer): PNG file viewer and SVG plot viewer based on [httpgd](https://github.com/nx10/httpgd). +* [Plot viewer](https://github.com/REditorSupport/vscode-R/wiki/Plot-viewer): Interactive plot viewer with support for [jgd](https://github.com/grantmcdermott/jgd) and [httpgd](https://github.com/nx10/httpgd) backends, plus a standard PNG/SVG fallback. * [Webpage viewer](https://github.com/REditorSupport/vscode-R/wiki/Interactive-viewers#webpage-viewer): Viewing [htmlwidgets](https://www.htmlwidgets.org) such as interactive graphics and [visual profiling results](https://rstudio.github.io/profvis/). diff --git a/package.json b/package.json index c6b3e067..2f7e4d35 100644 --- a/package.json +++ b/package.json @@ -1841,7 +1841,44 @@ "r.plot.useHttpgd": { "type": "boolean", "default": false, - "markdownDescription": "Use the httpgd-based plot viewer instead of the base VSCode-R plot viewer.\n\nRequires the `httpgd` R package version 1.2.0 or later." + "markdownDescription": "Use the httpgd-based plot viewer instead of the base VSCode-R plot viewer.\n\nRequires the `httpgd` R package version 1.2.0 or later.\n\n**Deprecated:** Use `#r.plot.backend#` instead. When `#r.plot.backend#` is `auto`, setting this to `true` forces httpgd." + }, + "r.plot.backend": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "standard", + "httpgd", + "jgd" + ], + "markdownEnumDescriptions": [ + "Automatic: tries JGD first (if installed), then httpgd, then standard. Respects `#r.plot.useHttpgd#` if set.", + "Standard static plot viewer (PNG/SVG)", + "httpgd-based interactive plot viewer (requires `httpgd` R package)", + "JGD-based interactive plot viewer (requires `jgd` R package)" + ], + "markdownDescription": "Select the plot backend.\n\nWhen set to `auto`, the best available backend is used (JGD if installed, then httpgd, then standard). Setting `#r.plot.useHttpgd#` to `true` forces httpgd." + }, + "r.plot.jgd.historyLimit": { + "type": "number", + "default": 50, + "description": "Maximum number of plots retained in JGD history per session." + }, + "r.plot.jgd.exportWidth": { + "type": "number", + "default": 7, + "description": "Default export width in inches for JGD plots." + }, + "r.plot.jgd.exportHeight": { + "type": "number", + "default": 7, + "description": "Default export height in inches for JGD plots." + }, + "r.plot.jgd.exportDpi": { + "type": "number", + "default": 150, + "description": "Default export DPI for JGD plots." }, "r.plot.format": { "type": "string", diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION index e2966117..0dc94f0e 100644 --- a/sess/DESCRIPTION +++ b/sess/DESCRIPTION @@ -14,8 +14,9 @@ Imports: jsonlite, utils, methods, - rstudioapi, - svglite + rstudioapi Suggests: + jgd, + svglite, testthat (>= 3.0.0) Config/roxygen2/version: 8.0.0 diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 320d2210..4127f56b 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -2,8 +2,9 @@ #' #' @param use_rstudioapi Logical. Enable rstudioapi emulation. #' @param use_httpgd Logical. Enable httpgd plot device if available. +#' @param use_jgd Logical. Enable jgd plot device if available. #' @export -register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE) { +register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = FALSE) { # 1. Override View() to serve table data via paged RPC. show_dataview <- function(x, title = deparse(substitute(x))) { # make sure title is computed. @@ -112,8 +113,12 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE) { invisible(x) } - # 4. httpgd or Static Plot Hook - if (use_httpgd && requireNamespace("httpgd", quietly = TRUE)) { + # 4. Plot device: JGD > httpgd > Standard + if (use_jgd && nzchar(Sys.getenv("JGD_SOCKET")) && requireNamespace("jgd", quietly = TRUE)) { + options(device = function(...) { + jgd::jgd() + }) + } else if (use_httpgd && requireNamespace("httpgd", quietly = TRUE)) { options(device = function(...) { httpgd::hgd(silent = TRUE) notify_client("httpgd", list(url = httpgd::hgd_url())) diff --git a/sess/R/server.R b/sess/R/server.R index 5ecf0ec0..d3f23dfe 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -5,8 +5,9 @@ #' session JSON file written by the extension. #' @param use_rstudioapi Logical. Enable rstudioapi emulation. Defaults to TRUE. #' @param use_httpgd Logical. Use httpgd for plotting if available. Defaults to TRUE. +#' @param use_jgd Logical. Use jgd for plotting if available. Defaults to FALSE. #' @export -connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) { +connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = FALSE) { .sess_env$con <- NULL .sess_env$pending_responses <- list() .sess_env$read_buffer <- "" @@ -90,7 +91,8 @@ connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) if (is.na(use_rstudioapi)) use_rstudioapi <- TRUE if (is.na(use_httpgd)) use_httpgd <- TRUE - register_hooks(use_rstudioapi = use_rstudioapi, use_httpgd = use_httpgd) + if (is.na(use_jgd)) use_jgd <- FALSE + register_hooks(use_rstudioapi = use_rstudioapi, use_httpgd = use_httpgd, use_jgd = use_jgd) invisible(NULL) } diff --git a/sess/man/connect.Rd b/sess/man/connect.Rd index 801ef873..6dbfe233 100644 --- a/sess/man/connect.Rd +++ b/sess/man/connect.Rd @@ -4,7 +4,12 @@ \alias{connect} \title{Connect to the VS Code IPC server} \usage{ -connect(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) +connect( + pipe_path = NULL, + use_rstudioapi = TRUE, + use_httpgd = TRUE, + use_jgd = FALSE +) } \arguments{ \item{pipe_path}{Character. Path to the named pipe / Unix domain socket. @@ -14,6 +19,8 @@ session JSON file written by the extension.} \item{use_rstudioapi}{Logical. Enable rstudioapi emulation. Defaults to TRUE.} \item{use_httpgd}{Logical. Use httpgd for plotting if available. Defaults to TRUE.} + +\item{use_jgd}{Logical. Use jgd for plotting if available. Defaults to FALSE.} } \description{ Connect to the VS Code IPC server diff --git a/sess/man/dispatch_message.Rd b/sess/man/dispatch_message.Rd new file mode 100644 index 00000000..f64dd17d --- /dev/null +++ b/sess/man/dispatch_message.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{dispatch_message} +\alias{dispatch_message} +\title{Dispatch a single NDJSON line as a JSON-RPC message (internal)} +\usage{ +dispatch_message(line) +} +\description{ +Dispatch a single NDJSON line as a JSON-RPC message (internal) +} +\keyword{internal} diff --git a/sess/man/ipc_write.Rd b/sess/man/ipc_write.Rd new file mode 100644 index 00000000..b4bbbc1d --- /dev/null +++ b/sess/man/ipc_write.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dispatch.R +\name{ipc_write} +\alias{ipc_write} +\title{Write a JSON object to the IPC pipe as a NDJSON line (internal)} +\usage{ +ipc_write(data) +} +\description{ +Write a JSON object to the IPC pipe as a NDJSON line (internal) +} +\keyword{internal} diff --git a/sess/man/notify_client.Rd b/sess/man/notify_client.Rd index 9799e6c5..37e62864 100644 --- a/sess/man/notify_client.Rd +++ b/sess/man/notify_client.Rd @@ -12,5 +12,5 @@ notify_client(method, params = list()) \item{params}{A list containing the arguments for the command} } \description{ -Pushes an event instantly to the client extension via the active IPC pipe connection. +Notify the client via IPC pipe (JSON-RPC 2.0 Notification) } diff --git a/sess/man/poll_connection.Rd b/sess/man/poll_connection.Rd new file mode 100644 index 00000000..cd257fc3 --- /dev/null +++ b/sess/man/poll_connection.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{poll_connection} +\alias{poll_connection} +\title{Poll the IPC connection for incoming messages (internal)} +\usage{ +poll_connection() +} +\description{ +Runs as a recurring later callback; dispatches NDJSON messages from vscode. +} +\keyword{internal} diff --git a/sess/man/register_hooks.Rd b/sess/man/register_hooks.Rd index 3736802b..26235125 100644 --- a/sess/man/register_hooks.Rd +++ b/sess/man/register_hooks.Rd @@ -4,12 +4,14 @@ \alias{register_hooks} \title{Register hooks for the client IPC} \usage{ -register_hooks(use_rstudioapi = TRUE, use_httpgd = TRUE) +register_hooks(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = FALSE) } \arguments{ \item{use_rstudioapi}{Logical. Enable rstudioapi emulation.} \item{use_httpgd}{Logical. Enable httpgd plot device if available.} + +\item{use_jgd}{Logical. Enable jgd plot device if available.} } \description{ Register hooks for the client IPC diff --git a/sess/man/rpc_reply.Rd b/sess/man/rpc_reply.Rd new file mode 100644 index 00000000..a4b7fd70 --- /dev/null +++ b/sess/man/rpc_reply.Rd @@ -0,0 +1,12 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{rpc_reply} +\alias{rpc_reply} +\title{Send a JSON-RPC reply to a request (internal)} +\usage{ +rpc_reply(id, result = NULL, error = NULL) +} +\description{ +Send a JSON-RPC reply to a request (internal) +} +\keyword{internal} diff --git a/sess/man/rpc_send.Rd b/sess/man/rpc_send.Rd index a559ebb8..e09a517c 100644 --- a/sess/man/rpc_send.Rd +++ b/sess/man/rpc_send.Rd @@ -17,6 +17,6 @@ rpc_send(method, params = list(), request = FALSE) The result of the request if request=TRUE, otherwise TRUE if sent. } \description{ -This is the internal workhorse for both Notifications and Requests. +Send a message to the client via IPC pipe (JSON-RPC 2.0) } \keyword{internal} diff --git a/src/extension.ts b/src/extension.ts index d1eb4bb2..c4084b59 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -203,6 +203,14 @@ export async function activate(context: vscode.ExtensionContext): Promise { + (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); await session.shutdownSessionWatcher(); } diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index 0c48c7d1..9b6fb880 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -4,7 +4,16 @@ import { PlotViewer, PlotManager } from './types'; import { HttpgdManager, HttpgdViewer } from './httpgdViewer'; export { HttpgdManager }; import { StandardPlotViewer } from './standardViewer'; +import { JgdManager } from './jgdViewer'; import { extensionContext } from '../extension'; +import { config } from '../util'; + +export function resolveBackend(): 'auto' | 'standard' | 'httpgd' | 'jgd' { + const explicit = config().get('plot.backend', 'auto'); + if (explicit !== 'auto') return explicit as 'standard' | 'httpgd' | 'jgd'; + if (config().get('plot.useHttpgd', false)) return 'httpgd'; + return 'auto'; +} const commands = [ 'showViewers', @@ -29,23 +38,33 @@ const commands = [ export class CommonPlotManager implements PlotManager { public httpgdManager: HttpgdManager; public standardPlotViewer: StandardPlotViewer; + public jgdManager: JgdManager; constructor() { this.httpgdManager = new HttpgdManager(); this.standardPlotViewer = new StandardPlotViewer(); + this.jgdManager = new JgdManager(); } get viewers(): PlotViewer[] { const viewers: PlotViewer[] = [...this.httpgdManager.viewers]; + const jgdViewer = this.jgdManager.getViewer(); + if (jgdViewer) viewers.push(jgdViewer); viewers.push(this.standardPlotViewer); return viewers; } get activeViewer(): PlotViewer | undefined { + const backend = resolveBackend(); + if (backend === 'jgd' || backend === 'auto') { + return this.jgdManager.getViewer() || this.httpgdManager.getRecentViewer() || this.standardPlotViewer; + } return this.httpgdManager.getRecentViewer() || this.standardPlotViewer; } public initialize(): void { + this.jgdManager.initialize(extensionContext.extensionUri); + for (const cmd of commands) { const fullCommand = `r.plot.${cmd}`; extensionContext.subscriptions.push( @@ -54,6 +73,8 @@ export class CommonPlotManager implements PlotManager { }) ); } + + void vscode.commands.executeCommand('setContext', 'r.plot.backend', resolveBackend()); } public async showStandardPlot(): Promise { @@ -64,6 +85,14 @@ export class CommonPlotManager implements PlotManager { await this.httpgdManager.showViewer(url); } + public getJgdEnvVars(): Record { + return this.jgdManager.getEnvVars(); + } + + public dispose(): void { + this.jgdManager.stop(); + } + private async handleCommand(command: string, hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]): Promise { if (command === 'showViewers') { for (const viewer of this.viewers) { @@ -97,5 +126,11 @@ export class CommonPlotManager implements PlotManager { export function initializePlotManager(): PlotManager { const manager = new CommonPlotManager(); manager.initialize(); + + const backend = resolveBackend(); + if (backend === 'jgd' || backend === 'auto') { + manager.jgdManager.start(); + } + return manager; } diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts new file mode 100644 index 00000000..972da154 --- /dev/null +++ b/src/plotViewer/jgdPlotHistory.ts @@ -0,0 +1,196 @@ +import { EventEmitter } from 'events'; + +export interface PlotFrame { + version: number; + sessionId: string; + frameExt?: Record | null; + device: { + width: number; + height: number; + dpi: number; + bg: string | null; + }; + ops: any[]; + rIndex?: number; +} + +interface SessionHistory { + plots: PlotFrame[]; + currentIndex: number; + latestDeleted: boolean; +} + +export class PlotHistory { + private sessions: Map = new Map(); + private activeSessionId: string = ''; + private maxPlots: number; + private emitter = new EventEmitter(); + + constructor(maxPlots: number = 50) { + this.maxPlots = maxPlots; + } + + onDidChange(listener: () => void): { dispose(): void } { + this.emitter.on('change', listener); + return { + dispose: () => { + this.emitter.removeListener('change', listener); + }, + }; + } + + addPlot(sessionId: string, plot: PlotFrame) { + let session = this.sessions.get(sessionId); + if (!session) { + session = { plots: [], currentIndex: -1, latestDeleted: false }; + this.sessions.set(sessionId, session); + } + + session.latestDeleted = false; + session.plots.push(plot); + while (session.plots.length > this.maxPlots) { + session.plots.shift(); + } + session.currentIndex = session.plots.length - 1; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + } + + replaceCurrent(sessionId: string, plot: PlotFrame) { + const session = this.sessions.get(sessionId); + if (!session || session.plots.length === 0) { + return this.addPlot(sessionId, plot); + } + const old = session.plots[session.currentIndex]; + if (old?.rIndex !== undefined) plot.rIndex = old.rIndex; + session.plots[session.currentIndex] = plot; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + } + + appendOps(sessionId: string, plot: PlotFrame): boolean { + const session = this.sessions.get(sessionId); + if (session && session.latestDeleted) return false; + if (!session || session.plots.length === 0) { + this.addPlot(sessionId, plot); + return true; + } + const latest = session.plots[session.plots.length - 1]; + const newOps = plot.ops || []; + for (const op of newOps) { + latest.ops.push(op); + } + latest.device = plot.device; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + return true; + } + + replaceAtIndex(sessionId: string, rIndex: number, plot: PlotFrame): boolean { + const session = this.sessions.get(sessionId); + if (!session) return false; + const idx = session.plots.findIndex(p => p.rIndex === rIndex); + if (idx < 0) return false; + plot.rIndex = rIndex; + session.plots[idx] = plot; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + return true; + } + + replaceLatest(sessionId: string, plot: PlotFrame, expectedRIndex?: number): boolean { + const session = this.sessions.get(sessionId); + if (session && session.latestDeleted) return false; + if (!session || session.plots.length === 0) { + this.addPlot(sessionId, plot); + return true; + } + const old = session.plots[session.plots.length - 1]; + if (expectedRIndex !== undefined && old?.rIndex !== undefined && old.rIndex !== expectedRIndex) { + return false; + } + if (old?.rIndex !== undefined) { + plot.rIndex = old.rIndex; + } else if (expectedRIndex !== undefined) { + plot.rIndex = expectedRIndex; + } + session.plots[session.plots.length - 1] = plot; + this.activeSessionId = sessionId; + this.emitter.emit('change'); + return true; + } + + currentPlot(): PlotFrame | null { + const session = this.sessions.get(this.activeSessionId); + if (!session || session.currentIndex < 0) return null; + return session.plots[session.currentIndex] ?? null; + } + + navigatePrevious(): PlotFrame | null { + const session = this.sessions.get(this.activeSessionId); + if (!session || session.currentIndex <= 0) return null; + session.currentIndex--; + this.emitter.emit('change'); + return session.plots[session.currentIndex]; + } + + navigateNext(): PlotFrame | null { + const session = this.sessions.get(this.activeSessionId); + if (!session || session.currentIndex >= session.plots.length - 1) return null; + session.currentIndex++; + this.emitter.emit('change'); + return session.plots[session.currentIndex]; + } + + getActiveSessionId(): string { + return this.activeSessionId; + } + + currentIndex(): number { + const session = this.sessions.get(this.activeSessionId); + return session ? session.currentIndex + 1 : 0; + } + + currentRIndex(): number | undefined { + const plot = this.currentPlot(); + return plot?.rIndex; + } + + count(): number { + const session = this.sessions.get(this.activeSessionId); + return session ? session.plots.length : 0; + } + + clear() { + const session = this.sessions.get(this.activeSessionId); + if (session) { + session.plots = []; + session.currentIndex = -1; + session.latestDeleted = false; + } + this.emitter.emit('change'); + } + + isLatestDeleted(): boolean { + const session = this.sessions.get(this.activeSessionId); + return session ? session.latestDeleted : false; + } + + removeCurrent(): PlotFrame | null { + const session = this.sessions.get(this.activeSessionId); + if (!session || session.plots.length === 0) return null; + const wasLatest = (session.currentIndex === session.plots.length - 1); + session.plots.splice(session.currentIndex, 1); + if (wasLatest) session.latestDeleted = true; + if (session.plots.length === 0) { + session.currentIndex = -1; + this.emitter.emit('change'); + return null; + } + if (session.currentIndex >= session.plots.length) { + session.currentIndex = session.plots.length - 1; + } + this.emitter.emit('change'); + return session.plots[session.currentIndex]; + } +} diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts new file mode 100644 index 00000000..7e8a6271 --- /dev/null +++ b/src/plotViewer/jgdSocketServer.ts @@ -0,0 +1,303 @@ +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import { PlotHistory, PlotFrame } from './jgdPlotHistory'; + +const SERVER_NAME = 'jgd-vscode'; + +export type ConnectionChangeListener = (count: number) => void; + +export interface JgdMessage { + type: string; + plot?: PlotFrame & { sessionId?: string; frameExt?: Record | null }; + ext?: Record | null; + resizeReplay?: boolean; + plotIndex?: number; + plotNumber?: number; + incremental?: boolean; + newPage?: boolean; + id?: number; + kind?: string; + str?: string; + c?: number; + gc?: Record; +} + +interface RSession { + id: string; + socket: net.Socket; + buffer: string; + welcomeSent: boolean; + lastResizeW: number; + lastResizeH: number; + lastResizeHadPlotIndex: boolean; +} + +const isWindows = process.platform === 'win32'; + +export interface JgdMeasureText { + (request: JgdMessage): Promise; +} + +export interface JgdGetDimensions { + (): { width: number; height: number } | null; +} + +export class JgdSocketServer { + private server: net.Server | null = null; + private socketPath: string = ''; + private sessions: Map = new Map(); + private connectionListeners: ConnectionChangeListener[] = []; + private sessionCounter = 0; + private readyListeners: (() => void)[] = []; + + private resizeListener: ((w: number, h: number) => void) | null = null; + private measureTextFn: JgdMeasureText | null = null; + private getDimensionsFn: JgdGetDimensions | null = null; + private onFrameFn: ((sessionId: string, msg: JgdMessage) => void) | null = null; + private onDeviceClosedFn: ((sessionId: string) => void) | null = null; + + constructor(private history: PlotHistory) {} + + getSocketPath(): string { + return this.socketPath; + } + + getEnvVars(): Record { + return { JGD_SOCKET: this.getSocketPath() }; + } + + onReady(listener: () => void) { + this.readyListeners.push(listener); + } + + onConnectionChange(listener: ConnectionChangeListener) { + this.connectionListeners.push(listener); + } + + setResizeListener(listener: (w: number, h: number) => void) { + this.resizeListener = listener; + } + + setMeasureText(fn: JgdMeasureText) { + this.measureTextFn = fn; + } + + setGetDimensions(fn: JgdGetDimensions) { + this.getDimensionsFn = fn; + } + + setOnFrame(fn: (sessionId: string, msg: JgdMessage) => void) { + this.onFrameFn = fn; + } + + setOnDeviceClosed(fn: (sessionId: string) => void) { + this.onDeviceClosedFn = fn; + } + + handleResize(w: number, h: number) { + const idx = this.history.currentIndex(); + const total = this.history.count(); + if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted())) { + const rIndex = this.history.currentRIndex(); + if (rIndex !== undefined) { + const sessionId = this.history.getActiveSessionId(); + this.broadcastResize(w, h, rIndex, sessionId); + } else { + this.broadcastResize(w, h); + } + } else { + this.broadcastResize(w, h); + } + } + + private notifyConnectionChange() { + const count = this.sessions.size; + for (const l of this.connectionListeners) l(count); + } + + start() { + this.server = net.createServer((socket) => this.handleConnection(socket)); + + const token = crypto.randomBytes(8).toString('hex'); + if (isWindows) { + const pipeName = `jgd-${token}`; + const pipePath = `\\\\.\\pipe\\${pipeName}`; + this.socketPath = `npipe:////./pipe/${pipeName}`; + this.server.listen(pipePath, () => { + console.log('jgd: named pipe server listening at', pipePath); + this.notifyReady(); + }); + } else { + this.socketPath = path.join(os.tmpdir(), `jgd-${token}.sock`); + try { fs.unlinkSync(this.socketPath); } catch { /* ignore */ } + + this.server.listen(this.socketPath, () => { + console.log('jgd: socket server listening at', this.socketPath); + this.notifyReady(); + }); + } + + this.server.on('error', (err) => { + console.error('jgd socket server error:', err); + }); + } + + private notifyReady() { + for (const l of this.readyListeners) l(); + } + + stop() { + for (const session of this.sessions.values()) { + session.socket.destroy(); + } + this.sessions.clear(); + this.server?.close(); + if (!isWindows) { + try { fs.unlinkSync(this.socketPath); } catch { /* ignore */ } + } + } + + private handleConnection(socket: net.Socket) { + const sessionId = `session-${++this.sessionCounter}`; + const session: RSession = { + id: sessionId, socket, buffer: '', welcomeSent: false, + lastResizeW: 0, lastResizeH: 0, lastResizeHadPlotIndex: false + }; + this.sessions.set(sessionId, session); + this.notifyConnectionChange(); + + socket.on('data', (data) => { + session.buffer += data.toString(); + let newlineIdx: number; + while ((newlineIdx = session.buffer.indexOf('\n')) !== -1) { + const line = session.buffer.substring(0, newlineIdx); + session.buffer = session.buffer.substring(newlineIdx + 1); + if (line.length === 0) continue; + + if (!session.welcomeSent) { + session.welcomeSent = true; + const welcome = { + type: 'server_info', + serverName: SERVER_NAME, + protocolVersion: 1, + transport: isWindows ? 'npipe' : 'unix', + }; + socket.write(JSON.stringify(welcome) + '\n'); + + const dims = this.getDimensionsFn?.(); + if (dims) { + session.lastResizeW = dims.width; + session.lastResizeH = dims.height; + socket.write(JSON.stringify({ type: 'resize', width: dims.width, height: dims.height }) + '\n'); + } + } + + this.handleMessage(session, line); + } + }); + + socket.on('close', () => { + this.sessions.delete(sessionId); + this.notifyConnectionChange(); + }); + + socket.on('error', (err) => { + console.error(`jgd session ${sessionId} error:`, err.message); + this.sessions.delete(sessionId); + this.notifyConnectionChange(); + }); + } + + private handleMessage(session: RSession, line: string) { + try { + const msg = JSON.parse(line) as JgdMessage; + switch (msg.type) { + case 'frame': { + const plot = msg.plot; + if (plot) { + plot.sessionId = session.id; + plot.frameExt = msg.ext ?? null; + + const isResizeReplay = !!msg.resizeReplay; + const plotIndex = (typeof msg.plotIndex === 'number' && Number.isFinite(msg.plotIndex)) ? msg.plotIndex : undefined; + + let accepted = true; + if (isResizeReplay && plotIndex !== undefined) { + accepted = this.history.replaceAtIndex(session.id, plotIndex, plot as PlotFrame); + } else if (isResizeReplay) { + const plotNumber = (typeof msg.plotNumber === 'number' && Number.isFinite(msg.plotNumber)) ? msg.plotNumber : undefined; + accepted = this.history.replaceLatest(session.id, plot as PlotFrame, plotNumber); + } else if (msg.incremental) { + accepted = this.history.appendOps(session.id, plot as PlotFrame); + } else if (msg.newPage) { + if (typeof msg.plotNumber === 'number' && Number.isFinite(msg.plotNumber)) { + plot.rIndex = msg.plotNumber; + } + this.history.addPlot(session.id, plot as PlotFrame); + } else { + this.history.replaceLatest(session.id, plot as PlotFrame); + } + if (accepted) { + this.onFrameFn?.(session.id, msg); + } + } + break; + } + + case 'metrics_request': + if (this.measureTextFn) { + void this.measureTextFn(msg).then((response: unknown) => { + const resp = JSON.stringify(response) + '\n'; + session.socket.write(resp); + }); + } + break; + + case 'close': + console.log(`jgd: session ${session.id} device closed`); + this.onDeviceClosedFn?.(session.id); + break; + + default: + break; + } + } catch (e) { + console.error('jgd: failed to parse message:', e); + } + } + + sendToSession(sessionId: string, msg: object) { + const session = this.sessions.get(sessionId); + if (session) { + session.socket.write(JSON.stringify(msg) + '\n'); + } + } + + private broadcastResize(w: number, h: number, plotIndex?: number, sessionId?: string) { + if (plotIndex !== undefined) { + if (!sessionId) return; + const session = this.sessions.get(sessionId); + if (!session) return; + session.lastResizeW = w; + session.lastResizeH = h; + session.lastResizeHadPlotIndex = true; + const data = JSON.stringify({ type: 'resize', width: w, height: h, plotIndex }) + '\n'; + session.socket.write(data); + return; + } + + const data = JSON.stringify({ type: 'resize', width: w, height: h }) + '\n'; + for (const session of this.sessions.values()) { + if (session.lastResizeW === w && session.lastResizeH === h) { + if (!session.lastResizeHadPlotIndex) continue; + } + session.lastResizeHadPlotIndex = false; + session.lastResizeW = w; + session.lastResizeH = h; + session.socket.write(data); + } + } +} diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts new file mode 100644 index 00000000..948b4067 --- /dev/null +++ b/src/plotViewer/jgdViewer.ts @@ -0,0 +1,1293 @@ +import * as vscode from 'vscode'; +import { PlotViewer } from './types'; +import { PlotHistory, PlotFrame } from './jgdPlotHistory'; +import { JgdSocketServer, JgdMessage } from './jgdSocketServer'; +import { config } from '../util'; + +interface MetricsRequest { + id: number; + kind: string; + str?: string; + c?: number; + gc?: { + font?: { + size?: number; + family?: string; + face?: number; + }; + }; +} + +interface MetricsResponse { + type: 'metrics_response'; + id: number; + width: number; + ascent: number; + descent: number; +} + +interface MetricsCacheEntry { + width: number; + ascent: number; + descent: number; +} + +interface WebviewMetricsResponse { + type: 'metrics_response'; + id: number; + originalId: number; + width: number; + ascent: number; + descent: number; +} + +interface WebviewMetricsWarmupEntry { + key: string; + width: number; + ascent: number; + descent: number; +} + +interface WebviewMetricsWarmup { + type: 'metrics_warmup'; + entries: WebviewMetricsWarmupEntry[]; +} + +interface WebviewExportData { + type: 'export_data'; + format: string; + data: string; +} + +interface WebviewResize { + type: 'resize'; + width: number; + height: number; +} + +interface WebviewNavigate { + type: 'navigate'; + direction: string; +} + +interface WebviewRequestExport { + type: 'requestExport'; + format: 'png' | 'svg'; +} + +interface WebviewDeleteCurrent { + type: 'deleteCurrent'; +} + +type WebviewMessage = + | WebviewMetricsResponse + | WebviewMetricsWarmup + | WebviewExportData + | WebviewResize + | WebviewNavigate + | WebviewRequestExport + | WebviewDeleteCurrent; + +export class JgdManager { + public server: JgdSocketServer; + public history: PlotHistory; + private viewer: JgdViewer | null = null; + private extensionUri: vscode.Uri | null = null; + private historyChangeDisposable: { dispose(): void } | null = null; + + constructor() { + const maxPlots = config().get('plot.jgd.historyLimit', 50); + this.history = new PlotHistory(maxPlots); + this.server = new JgdSocketServer(this.history); + } + + initialize(extensionUri: vscode.Uri) { + this.extensionUri = extensionUri; + } + + start() { + this.server.setOnFrame((_sessionId, msg: JgdMessage) => { + const current = this.history.currentPlot(); + if (current) { + this.getOrCreateViewer().showPlot(current); + } else if (msg.plot) { + this.getOrCreateViewer().showPlot(msg.plot as PlotFrame); + } + }); + + this.server.setOnDeviceClosed((_sessionId) => { + this.viewer?.updateToolbar(); + }); + + this.server.setMeasureText((request: JgdMessage) => { + return this.getOrCreateViewer().measureText(request as unknown as MetricsRequest); + }); + + this.server.setGetDimensions(() => { + return this.viewer?.getPanelDimensions() ?? null; + }); + + this.server.start(); + + this.historyChangeDisposable = this.history.onDidChange(() => { + void vscode.commands.executeCommand('setContext', 'r.plot.canGoBack', + this.history.currentIndex() > 1); + void vscode.commands.executeCommand('setContext', 'r.plot.canGoForward', + this.history.currentIndex() < this.history.count()); + }); + } + + stop() { + this.server.stop(); + this.historyChangeDisposable?.dispose(); + this.viewer?.dispose(); + this.viewer = null; + } + + getViewer(): JgdViewer | null { + return this.viewer; + } + + getEnvVars(): Record { + return this.server.getEnvVars(); + } + + private getOrCreateViewer(): JgdViewer { + if (!this.viewer) { + this.viewer = new JgdViewer(this.extensionUri!, this.history, this.server); + } + return this.viewer; + } +} + +export class JgdViewer implements PlotViewer { + readonly id = 'jgd'; + private panel: vscode.WebviewPanel | null = null; + private pendingMetrics: Map void> = new Map(); + private metricsIdCounter = 0; + private metricsCache: Map = new Map(); + private panelWidth = 800; + private panelHeight = 600; + + constructor( + private extensionUri: vscode.Uri, + private history: PlotHistory, + private server: JgdSocketServer, + ) {} + + show(preserveFocus?: boolean): void { + if (this.panel) { + this.panel.reveal(undefined, preserveFocus); + } else { + this.createPanel(preserveFocus); + } + const plot = this.history.currentPlot(); + if (plot) this.sendPlotToWebview(plot); + } + + dispose(): void { + this.panel?.dispose(); + this.panel = null; + } + + async handleCommand(command: string, ...args: unknown[]): Promise { + switch (command) { + case 'showViewers': + this.show(true); + break; + case 'nextPlot': { + const plot = this.history.navigateNext(); + if (plot) { + this.sendPlotToWebview(plot); + this.updateToolbar(); + if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) { + this.server.handleResize(this.panelWidth, this.panelHeight); + } + } + break; + } + case 'prevPlot': { + const plot = this.history.navigatePrevious(); + if (plot) { + this.sendPlotToWebview(plot); + this.updateToolbar(); + if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) { + this.server.handleResize(this.panelWidth, this.panelHeight); + } + } + break; + } + case 'firstPlot': { + let plot = this.history.navigatePrevious(); + while (plot) { + const prev = this.history.navigatePrevious(); + if (!prev) break; + plot = prev; + } + if (plot) { + this.sendPlotToWebview(plot); + this.updateToolbar(); + this.server.handleResize(this.panelWidth, this.panelHeight); + } + break; + } + case 'lastPlot': { + let plot = this.history.navigateNext(); + while (plot) { + const next = this.history.navigateNext(); + if (!next) break; + plot = next; + } + if (plot) { + this.sendPlotToWebview(plot); + this.updateToolbar(); + this.server.handleResize(this.panelWidth, this.panelHeight); + } + break; + } + case 'exportPlot': { + if (!this.panel) return; + const format = (args[0] as string) || 'png'; + await this.handleExportRequest(format as 'png' | 'svg'); + break; + } + case 'closePlot': + case 'hidePlot': { + const plot = this.history.removeCurrent(); + if (plot) { + this.sendPlotToWebview(plot); + } else { + void this.panel?.webview.postMessage({ type: 'clear' }); + } + this.updateToolbar(); + break; + } + case 'resetPlots': + this.history.clear(); + void this.panel?.webview.postMessage({ type: 'clear' }); + this.updateToolbar(); + break; + // httpgd-specific commands — no-op for JGD + case 'toggleStyle': + case 'togglePreviewPlots': + case 'openUrl': + case 'openExternal': + case 'zoomIn': + case 'zoomOut': + case 'toggleFullWindow': + case 'showIndex': + break; + } + } + + showPlot(plot: PlotFrame) { + if (!this.panel) this.createPanel(true); + this.sendPlotToWebview(plot); + this.updateToolbar(); + } + + updateToolbar() { + void this.panel?.webview.postMessage({ + type: 'toolbar', + current: this.history.currentIndex(), + total: this.history.count() + }); + } + + getPanelDimensions(): { width: number; height: number } { + return { width: this.panelWidth, height: this.panelHeight }; + } + + private canonicalizeFamily(family: string | undefined): string { + if (!family || family === '' || family === 'sans') return 'sans-serif'; + if (family === 'serif' || family === 'Times') return 'serif'; + if (family === 'mono' || family === 'Courier') return 'monospace'; + return family; + } + + async measureText(request: MetricsRequest): Promise { + if (!this.panel) { + return { type: 'metrics_response', id: request.id, width: 0, ascent: 0, descent: 0 }; + } + + const gc = request.gc ?? {}; + const font = gc.font ?? {}; + const canonical = this.canonicalizeFamily(font.family); + const fontSize = font.size ?? 12; + const fontFace = font.face ?? 1; + const fontKey = `${fontSize}|${canonical}|${fontFace}`; + const cacheKey = `${request.kind}|${request.str ?? ''}|${request.c ?? 0}|${fontKey}`; + const cached = this.metricsCache.get(cacheKey); + if (cached) { + return { type: 'metrics_response', id: request.id, ...cached }; + } + + if (request.kind === 'strWidth' && request.str) { + const baseFontKey = `12|${canonical}|${fontFace}`; + const scale = fontSize / 12; + let total = 0; + let allCached = true; + for (const ch of request.str) { + const cp = ch.codePointAt(0)!; + const exactKey = `metricInfo||${cp}|${fontKey}`; + const exactCached = this.metricsCache.get(exactKey); + if (exactCached) { + total += exactCached.width; + } else { + const baseKey = `metricInfo||${cp}|${baseFontKey}`; + const baseCached = this.metricsCache.get(baseKey); + if (baseCached) { + total += baseCached.width * scale; + } else { + allCached = false; + break; + } + } + } + if (allCached) { + const result: MetricsCacheEntry = { width: total, ascent: 0, descent: 0 }; + this.metricsCache.set(cacheKey, result); + return { type: 'metrics_response', id: request.id, ...result }; + } + } + + if (request.kind === 'metricInfo' && request.c) { + const baseFontKey = `12|${canonical}|${fontFace}`; + const scale = fontSize / 12; + const baseKey = `metricInfo||${request.c}|${baseFontKey}`; + const baseCached = this.metricsCache.get(baseKey); + if (baseCached) { + const result: MetricsCacheEntry = { + width: baseCached.width * scale, + ascent: baseCached.ascent * scale, + descent: baseCached.descent * scale + }; + this.metricsCache.set(cacheKey, result); + return { type: 'metrics_response', id: request.id, ...result }; + } + } + + return this.roundTripMetrics(request, cacheKey); + } + + private roundTripMetrics(request: MetricsRequest, cacheKey: string): Promise { + return new Promise((resolve) => { + const id = ++this.metricsIdCounter; + this.pendingMetrics.set(id, (response: MetricsResponse) => { + this.metricsCache.set(cacheKey, { width: response.width, ascent: response.ascent, descent: response.descent }); + resolve(response); + }); + void this.panel!.webview.postMessage({ + type: 'metrics_request', + id, + originalId: request.id, + kind: request.kind, + str: request.str, + c: request.c, + gc: request.gc + }); + + setTimeout(() => { + if (this.pendingMetrics.has(id)) { + this.pendingMetrics.delete(id); + resolve({ type: 'metrics_response', id: request.id, width: 0, ascent: 0, descent: 0 }); + } + }, 500); + }); + } + + private createPanel(preserveFocus = false) { + const viewColumnConfig = config().get>('session.viewers.viewColumn') ?? {}; + const plotColumn = viewColumnConfig['plot'] ?? 'Two'; + let viewColumn = vscode.ViewColumn.Two; + if (plotColumn === 'Active') viewColumn = vscode.ViewColumn.Active; + else if (plotColumn === 'Beside') viewColumn = vscode.ViewColumn.Beside; + + this.panel = vscode.window.createWebviewPanel( + 'jgd.plotPane', + 'R Plot (JGD)', + { viewColumn, preserveFocus }, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + this.panel.webview.html = this.getWebviewHtml(); + + this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => { + switch (raw.type) { + case 'metrics_response': { + const resolver = this.pendingMetrics.get(raw.id); + if (resolver) { + this.pendingMetrics.delete(raw.id); + resolver({ + type: 'metrics_response', + id: raw.originalId, + width: raw.width, + ascent: raw.ascent, + descent: raw.descent + }); + } + break; + } + case 'metrics_warmup': { + if (raw.entries && Array.isArray(raw.entries)) { + for (const e of raw.entries) { + this.metricsCache.set(e.key, { width: e.width, ascent: e.ascent, descent: e.descent }); + } + } + break; + } + case 'export_data': { + void this.handleExportData(raw); + break; + } + case 'resize': { + this.panelWidth = raw.width; + this.panelHeight = raw.height; + this.server.handleResize(raw.width, raw.height); + break; + } + case 'navigate': { + if (raw.direction === 'previous') { + void this.handleCommand('prevPlot'); + } else if (raw.direction === 'next') { + void this.handleCommand('nextPlot'); + } + break; + } + case 'requestExport': { + void this.handleExportRequest(raw.format); + break; + } + case 'deleteCurrent': { + void this.handleCommand('closePlot'); + break; + } + } + }); + + this.panel.onDidDispose(() => { + this.panel = null; + this.metricsCache.clear(); + }); + } + + private async handleExportRequest(format: 'png' | 'svg') { + const defaultW = config().get('plot.jgd.exportWidth', 7); + const defaultH = config().get('plot.jgd.exportHeight', 7); + const defaultDpi = config().get('plot.jgd.exportDpi', 150); + const input = await vscode.window.showInputBox({ + title: 'Export Plot', + prompt: 'Width x height (inches) @ DPI', + value: `${defaultW} x ${defaultH} @ ${defaultDpi}`, + validateInput: (v) => { + const m = v.match(/^\s*([\d.]+)\s*[x×,]\s*([\d.]+)\s*(?:@\s*(\d+))?\s*$/i); + if (!m) return 'Enter as "7 x 5 @ 150" (inches @ DPI)'; + const w = parseFloat(m[1]), h = parseFloat(m[2]), dpi = parseInt(m[3] || '150'); + if (w < 0.5 || h < 0.5 || w > 50 || h > 50) return 'Dimensions must be 0.5–50 inches'; + if (dpi < 36 || dpi > 600) return 'DPI must be 36–600'; + return null; + } + }); + if (!input) return; + const m = input.match(/^\s*([\d.]+)\s*[x×,]\s*([\d.]+)\s*(?:@\s*(\d+))?\s*$/i)!; + const dpi = parseInt(m[3] || String(defaultDpi)); + const width = Math.round(parseFloat(m[1]) * dpi); + const height = Math.round(parseFloat(m[2]) * dpi); + void this.panel?.webview.postMessage({ type: 'export', format, width, height }); + } + + private sendPlotToWebview(plot: PlotFrame) { + void this.panel?.webview.postMessage({ type: 'render', plot }); + } + + private async handleExportData(msg: WebviewExportData) { + const filters: Record = { + png: ['PNG Image'], + svg: ['SVG Image'], + }; + const ext = msg.format; + const uri = await vscode.window.showSaveDialog({ + filters: { [filters[ext]?.[0] ?? ext]: [ext] }, + defaultUri: vscode.Uri.file(`plot.${ext}`) + }); + if (!uri) return; + + if (msg.data) { + const buf = Buffer.from(msg.data, 'base64'); + await vscode.workspace.fs.writeFile(uri, buf); + void vscode.window.showInformationMessage(`Plot exported to ${uri.fsPath}`); + } + } + + private getWebviewHtml(): string { + return ` + + + + + + + +
+ + + + No plots + +
+
+ +
+ + + +`; + } +} + +function getRendererScript(): string { + return ` +const vscode = acquireVsCodeApi(); +const canvas = document.getElementById('plot-canvas'); +const ctx = canvas.getContext('2d'); +const metricsCanvas = document.getElementById('metrics-canvas'); +const metricsCtx = metricsCanvas.getContext('2d'); + +let currentPlot = null; + +document.getElementById('btn-prev').addEventListener('click', () => { + vscode.postMessage({ type: 'navigate', direction: 'previous' }); +}); +document.getElementById('btn-next').addEventListener('click', () => { + vscode.postMessage({ type: 'navigate', direction: 'next' }); +}); +document.getElementById('export-select').addEventListener('change', (e) => { + const fmt = e.target.value; + if (fmt) { + vscode.postMessage({ type: 'requestExport', format: fmt }); + e.target.value = ''; + } +}); +document.getElementById('btn-delete').addEventListener('click', () => { + vscode.postMessage({ type: 'deleteCurrent' }); +}); + +const container = document.getElementById('canvas-container'); +let resizeTimer = null; +let lastSentW = 0; +let lastSentH = 0; +const resizeObserver = new ResizeObserver(() => { + if (currentPlot) replay(currentPlot); + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w !== lastSentW || h !== lastSentH) { + lastSentW = w; + lastSentH = h; + vscode.postMessage({ type: 'resize', width: w, height: h }); + } + }, 300); +}); +resizeObserver.observe(container); + +(function warmupMetrics() { + const fonts = [ + { size: 12, family: 'sans-serif', face: 1 }, + { size: 12, family: 'serif', face: 1 }, + { size: 12, family: 'monospace', face: 1 }, + { size: 12, family: 'sans-serif', face: 2 }, + { size: 10, family: 'sans-serif', face: 1 }, + { size: 14, family: 'sans-serif', face: 1 }, + ]; + const entries = []; + for (const f of fonts) { + let style = ''; + if (f.face === 2 || f.face === 4) style += 'bold '; + if (f.face === 3 || f.face === 4) style += 'italic '; + metricsCtx.font = style + f.size + 'px ' + f.family; + const fontKey = f.size + '|' + f.family + '|' + f.face; + for (let c = 32; c <= 126; c++) { + const ch = String.fromCodePoint(c); + const m = metricsCtx.measureText(ch); + entries.push({ + key: 'metricInfo||' + c + '|' + fontKey, + width: m.width, + ascent: m.actualBoundingBoxAscent || f.size * 0.75, + descent: m.actualBoundingBoxDescent || f.size * 0.25 + }); + } + } + vscode.postMessage({ type: 'metrics_warmup', entries }); +})(); + +window.addEventListener('message', (event) => { + const msg = event.data; + switch (msg.type) { + case 'render': + currentPlot = msg.plot; + replay(msg.plot); + break; + case 'clear': + currentPlot = null; + ctx.clearRect(0, 0, canvas.width, canvas.height); + break; + case 'toolbar': + document.getElementById('plot-info').textContent = + msg.total > 0 ? msg.current + ' / ' + msg.total : 'No plots'; + document.getElementById('btn-prev').disabled = msg.current <= 1; + document.getElementById('btn-next').disabled = msg.current >= msg.total; + document.getElementById('btn-delete').disabled = msg.total === 0; + break; + case 'metrics_request': + handleMetricsRequest(msg); + break; + case 'export': + handleExport(msg.format, msg.width, msg.height); + break; + } +}); + +function applyGc(ctx, gc) { + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.filter = 'none'; + if (!gc) return; + if (gc.col != null) ctx.strokeStyle = gc.col; + if (gc.fill != null) ctx.fillStyle = gc.fill; + ctx.lineWidth = gc.lwd || 1; + ctx.lineCap = gc.lend || 'round'; + ctx.lineJoin = gc.ljoin || 'round'; + ctx.miterLimit = gc.lmitre || 10; + if (gc.lty && gc.lty.length > 0) { + ctx.setLineDash(gc.lty); + } else { + ctx.setLineDash([]); + } + if (gc.font) { + const size = gc.font.size || 12; + const family = mapFontFamily(gc.font.family); + const face = gc.font.face || 1; + let style = ''; + if (face === 2 || face === 4) style += 'bold '; + if (face === 3 || face === 4) style += 'italic '; + ctx.font = style + size + 'px ' + family; + } + if (gc.ext) { + if (gc.ext.blendMode != null) ctx.globalCompositeOperation = gc.ext.blendMode; + if (gc.ext.opacity != null) ctx.globalAlpha = gc.ext.opacity; + if (gc.ext.shadow) { + if (gc.ext.shadow.blur != null) ctx.shadowBlur = gc.ext.shadow.blur; + if (gc.ext.shadow.color != null) ctx.shadowColor = gc.ext.shadow.color; + if (gc.ext.shadow.offsetX != null) ctx.shadowOffsetX = gc.ext.shadow.offsetX; + if (gc.ext.shadow.offsetY != null) ctx.shadowOffsetY = gc.ext.shadow.offsetY; + } + if (gc.ext.filter != null && isSafeCssFilter(gc.ext.filter)) ctx.filter = gc.ext.filter; + } +} + +function mapFontFamily(family) { + if (!family || family === '' || family === 'sans') return 'sans-serif'; + if (family === 'serif' || family === 'Times') return 'serif'; + if (family === 'mono' || family === 'Courier') return 'monospace'; + return family + ', sans-serif'; +} + +function makeRenderCtx() { + return { groupStack: [], currentClip: null }; +} + +function effectToFilter(effect) { + switch (effect.type) { + case 'blur': return 'blur(' + (effect.radius || 0) + 'px)'; + case 'brightness': return 'brightness(' + (effect.value || 1) + ')'; + case 'contrast': return 'contrast(' + (effect.value || 1) + ')'; + case 'grayscale': return 'grayscale(' + (effect.value || 1) + ')'; + case 'saturate': return 'saturate(' + (effect.value || 1) + ')'; + case 'sepia': return 'sepia(' + (effect.value || 1) + ')'; + case 'hue-rotate': return 'hue-rotate(' + (effect.angle || 0) + 'deg)'; + case 'invert': return 'invert(' + (effect.value || 1) + ')'; + default: return effect.filter || ''; + } +} + +function applyGlowEffect(ctx, effect) { + const w = ctx.canvas.width; + const h = ctx.canvas.height; + const origCanvas = document.createElement('canvas'); + origCanvas.width = w; + origCanvas.height = h; + const origCtx = origCanvas.getContext('2d'); + if (!origCtx) return; + origCtx.drawImage(ctx.canvas, 0, 0); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.filter = 'blur(' + (effect.radius || 3) + 'px) brightness(' + (effect.brightness || 1.5) + ')'; + ctx.drawImage(origCanvas, 0, 0); + ctx.filter = 'none'; + ctx.drawImage(origCanvas, 0, 0); + ctx.restore(); +} + +function applyPostEffects(ctx, effects) { + for (let i = 0; i < effects.length; i++) { + const effect = effects[i]; + if (effect.type === 'glow') { + applyGlowEffect(ctx, effect); + continue; + } + const filterStr = effectToFilter(effect); + if (!filterStr || !isSafeCssFilter(filterStr)) continue; + const w = ctx.canvas.width; + const h = ctx.canvas.height; + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = w; + tmpCanvas.height = h; + const tmpCtx = tmpCanvas.getContext('2d'); + if (!tmpCtx) continue; + tmpCtx.drawImage(ctx.canvas, 0, 0); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.filter = filterStr; + ctx.drawImage(tmpCanvas, 0, 0); + ctx.restore(); + } +} + +let replayGeneration = 0; +let replayChain = Promise.resolve(); + +async function replay(plot) { + const gen = ++replayGeneration; + const run = () => doReplay(plot, gen); + replayChain = replayChain.then(run, run); + await replayChain; +} + +async function doReplay(plot, gen) { + if (replayGeneration !== gen) return; + + const dpr = window.devicePixelRatio || 1; + const containerW = container.clientWidth; + const containerH = container.clientHeight; + + if (containerW <= 0 || containerH <= 0) return; + + const plotW = plot.device.width; + const plotH = plot.device.height; + const scaleX = containerW / plotW; + const scaleY = containerH / plotH; + const scale = Math.min(scaleX, scaleY); + + const drawW = plotW * scale; + const drawH = plotH * scale; + + canvas.width = drawW * dpr; + canvas.height = drawH * dpr; + canvas.style.width = drawW + 'px'; + canvas.style.height = drawH + 'px'; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr * scale, dpr * scale); + + ctx.save(); + try { + if (plot.device.bg) { + ctx.fillStyle = plot.device.bg; + ctx.fillRect(0, 0, plotW, plotH); + } else { + ctx.clearRect(0, 0, plotW, plotH); + } + + const ops = plot.ops; + const rc = makeRenderCtx(); + for (let i = 0; i < ops.length; i++) { + if (replayGeneration !== gen) return; + const currentCtx = rc.groupStack.length > 0 ? rc.groupStack[rc.groupStack.length - 1].ctx : ctx; + await renderOp(currentCtx, ops[i], plotH, rc); + if (replayGeneration !== gen) return; + } + + if (plot.frameExt && plot.frameExt.postEffects) { + ctx.restore(); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.filter = 'none'; + applyPostEffects(ctx, plot.frameExt.postEffects); + ctx.save(); + } + } finally { + ctx.restore(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + } +} + +async function renderOp(ctx, op, plotH, rc) { + switch (op.op) { + case 'line': { + applyGc(ctx, op.gc); + if (op.gc && op.gc.col != null) { + ctx.beginPath(); + ctx.moveTo(op.x1, op.y1); + ctx.lineTo(op.x2, op.y2); + ctx.stroke(); + } + break; + } + case 'polyline': { + applyGc(ctx, op.gc); + if (op.x.length < 2) break; + ctx.beginPath(); + ctx.moveTo(op.x[0], op.y[0]); + for (let i = 1; i < op.x.length; i++) { + ctx.lineTo(op.x[i], op.y[i]); + } + if (op.gc && op.gc.col != null) ctx.stroke(); + break; + } + case 'polygon': { + applyGc(ctx, op.gc); + ctx.beginPath(); + ctx.moveTo(op.x[0], op.y[0]); + for (let i = 1; i < op.x.length; i++) { + ctx.lineTo(op.x[i], op.y[i]); + } + ctx.closePath(); + if (op.gc && op.gc.fill != null) ctx.fill(); + if (op.gc && op.gc.col != null) ctx.stroke(); + break; + } + case 'rect': { + applyGc(ctx, op.gc); + const rx = Math.min(op.x0, op.x1); + const ry = Math.min(op.y0, op.y1); + const rw = Math.abs(op.x1 - op.x0); + const rh = Math.abs(op.y1 - op.y0); + if (op.gc && op.gc.fill != null) { + ctx.fillStyle = op.gc.fill; + ctx.fillRect(rx, ry, rw, rh); + } + if (op.gc && op.gc.col != null) { + ctx.strokeStyle = op.gc.col; + ctx.strokeRect(rx, ry, rw, rh); + } + break; + } + case 'circle': { + applyGc(ctx, op.gc); + ctx.beginPath(); + ctx.arc(op.x, op.y, op.r, 0, 2 * Math.PI); + if (op.gc && op.gc.fill != null) ctx.fill(); + if (op.gc && op.gc.col != null) ctx.stroke(); + break; + } + case 'text': { + applyGc(ctx, op.gc); + ctx.save(); + ctx.translate(op.x, op.y); + if (op.rot) ctx.rotate(-op.rot * Math.PI / 180); + ctx.textBaseline = 'alphabetic'; + let align = 'left'; + if (op.hadj === 0.5) align = 'center'; + else if (op.hadj === 1) align = 'right'; + ctx.textAlign = align; + if (op.gc && op.gc.col != null) { + ctx.fillStyle = op.gc.col; + ctx.fillText(op.str, 0, 0); + } + ctx.restore(); + break; + } + case 'clip': { + const clipRect = { x0: op.x0, y0: op.y0, x1: op.x1, y1: op.y1 }; + if (rc.groupStack.length > 0) { + rc.groupStack[rc.groupStack.length - 1].clip = clipRect; + } else { + rc.currentClip = clipRect; + } + ctx.restore(); + ctx.save(); + ctx.beginPath(); + ctx.rect(op.x0, op.y0, op.x1 - op.x0, op.y1 - op.y0); + ctx.clip(); + break; + } + case 'beginGroup': { + const groupCanvas = document.createElement('canvas'); + groupCanvas.width = ctx.canvas.width; + groupCanvas.height = ctx.canvas.height; + const groupCtx = groupCanvas.getContext('2d'); + if (!groupCtx) break; + groupCtx.setTransform(ctx.getTransform()); + groupCtx.save(); + let activeClip = rc.currentClip; + for (let gi = rc.groupStack.length - 1; gi >= 0; gi--) { + if (rc.groupStack[gi].clip) { activeClip = rc.groupStack[gi].clip; break; } + } + if (activeClip) { + groupCtx.beginPath(); + groupCtx.rect(activeClip.x0, activeClip.y0, + activeClip.x1 - activeClip.x0, + activeClip.y1 - activeClip.y0); + groupCtx.clip(); + } + rc.groupStack.push({ + parentCtx: ctx, + ctx: groupCtx, + canvas: groupCanvas, + ext: op.ext || null, + clip: null + }); + break; + } + case 'endGroup': { + if (rc.groupStack.length === 0) break; + const group = rc.groupStack.pop(); + const parentCtx = group.parentCtx; + parentCtx.save(); + if (group.ext) { + if (group.ext.filter != null && isSafeCssFilter(group.ext.filter)) parentCtx.filter = group.ext.filter; + if (group.ext.opacity != null) parentCtx.globalAlpha = group.ext.opacity; + if (group.ext.blendMode != null) parentCtx.globalCompositeOperation = group.ext.blendMode; + if (group.ext.shadow) { + if (group.ext.shadow.blur != null) parentCtx.shadowBlur = group.ext.shadow.blur; + if (group.ext.shadow.color != null) parentCtx.shadowColor = group.ext.shadow.color; + if (group.ext.shadow.offsetX != null) parentCtx.shadowOffsetX = group.ext.shadow.offsetX; + if (group.ext.shadow.offsetY != null) parentCtx.shadowOffsetY = group.ext.shadow.offsetY; + } + } + parentCtx.setTransform(1, 0, 0, 1, 0, 0); + parentCtx.drawImage(group.canvas, 0, 0); + parentCtx.restore(); + break; + } + case 'path': { + applyGc(ctx, op.gc); + ctx.beginPath(); + for (const subpath of op.subpaths) { + if (subpath.length === 0) continue; + ctx.moveTo(subpath[0][0], subpath[0][1]); + for (let i = 1; i < subpath.length; i++) { + ctx.lineTo(subpath[i][0], subpath[i][1]); + } + ctx.closePath(); + } + const rule = op.winding === 'evenodd' ? 'evenodd' : 'nonzero'; + if (op.gc && op.gc.fill != null) ctx.fill(rule); + if (op.gc && op.gc.col != null) ctx.stroke(); + break; + } + case 'raster': { + const img = new Image(); + img.src = op.data; + await img.decode(); + ctx.save(); + const dw = op.w; + const dh = op.h; + const aw = Math.abs(dw); + const ah = Math.abs(dh); + const dx = dw >= 0 ? op.x : op.x + dw; + const dy = op.y - ah; + if (op.rot) { + const cx = dx + aw / 2; + const cy = dy + ah / 2; + ctx.translate(cx, cy); + ctx.rotate(-op.rot * Math.PI / 180); + ctx.translate(-cx, -cy); + } + ctx.imageSmoothingEnabled = !!op.interpolate; + ctx.drawImage(img, dx, dy, aw, ah); + ctx.restore(); + break; + } + } +} + +function handleMetricsRequest(msg) { + const gc = msg.gc || {}; + const size = gc.font ? gc.font.size || 12 : 12; + const family = gc.font ? mapFontFamily(gc.font.family) : 'sans-serif'; + const face = gc.font ? gc.font.face || 1 : 1; + let style = ''; + if (face === 2 || face === 4) style += 'bold '; + if (face === 3 || face === 4) style += 'italic '; + metricsCtx.font = style + size + 'px ' + family; + + let width = 0, ascent = 0, descent = 0; + if (msg.kind === 'strWidth' && msg.str) { + const m = metricsCtx.measureText(msg.str); + width = m.width; + } else if (msg.kind === 'metricInfo') { + const ch = msg.c > 0 ? String.fromCodePoint(msg.c) : 'M'; + const m = metricsCtx.measureText(ch); + width = m.width; + ascent = m.actualBoundingBoxAscent || size * 0.75; + descent = m.actualBoundingBoxDescent || size * 0.25; + } + + vscode.postMessage({ + type: 'metrics_response', + id: msg.id, + originalId: msg.originalId, + width, ascent, descent + }); +} + +function handleExport(format, exportW, exportH) { + if (!currentPlot) return; + if (format === 'png') { + const offscreen = document.createElement('canvas'); + const plotW = currentPlot.device.width; + const plotH = currentPlot.device.height; + const scale = Math.min(exportW / plotW, exportH / plotH); + offscreen.width = plotW * scale; + offscreen.height = plotH * scale; + const offCtx = offscreen.getContext('2d'); + offCtx.scale(scale, scale); + if (currentPlot.device.bg) { + offCtx.fillStyle = currentPlot.device.bg; + offCtx.fillRect(0, 0, plotW, plotH); + } + (async () => { + const rc = makeRenderCtx(); + for (const op of currentPlot.ops) { + const curCtx = rc.groupStack.length > 0 ? rc.groupStack[rc.groupStack.length - 1].ctx : offCtx; + await renderOp(curCtx, op, plotH, rc); + } + let exportCanvas = offscreen; + if (currentPlot.frameExt && currentPlot.frameExt.postEffects) { + const postCanvas = document.createElement('canvas'); + postCanvas.width = offscreen.width; + postCanvas.height = offscreen.height; + const postCtx = postCanvas.getContext('2d'); + if (postCtx) { + postCtx.drawImage(offscreen, 0, 0); + applyPostEffects(postCtx, currentPlot.frameExt.postEffects); + exportCanvas = postCanvas; + } + } + exportCanvas.toBlob((blob) => { + if (!blob) return; + const reader = new FileReader(); + reader.onload = () => { + const base64 = btoa(String.fromCharCode(...new Uint8Array(reader.result))); + vscode.postMessage({ type: 'export_data', format: 'png', data: base64 }); + }; + reader.readAsArrayBuffer(blob); + }, 'image/png'); + })(); + } else if (format === 'svg') { + const svg = plotToSvg(currentPlot, exportW, exportH); + const base64 = btoa(unescape(encodeURIComponent(svg))); + vscode.postMessage({ type: 'export_data', format: 'svg', data: base64 }); + } +} + +function svgEsc(s) { return s.replace(/&/g,'&').replace(/[<]/g,'<').replace(/[>]/g,'>').replace(/"/g,'"'); } + +const cssFilterRe = /^(?:blur|brightness|contrast|drop-shadow|grayscale|hue-rotate|invert|opacity|saturate|sepia)\\s*\\([^()]*(?:\\([^)]*\\)[^()]*)*\\)(?:\\s+(?:blur|brightness|contrast|drop-shadow|grayscale|hue-rotate|invert|opacity|saturate|sepia)\\s*\\([^()]*(?:\\([^)]*\\)[^()]*)*\\))*$/; +function isSafeCssFilter(s) { + if (typeof s !== 'string') return false; + var trimmed = s.trim(); + return cssFilterRe.test(trimmed) && !/url\\s*\\(/i.test(trimmed); +} + +function svgTag(name, attrs, selfClose) { + return String.fromCharCode(60) + name + (attrs || '') + (selfClose ? '/>' : '>'); +} +function svgClose(name) { return String.fromCharCode(60) + '/' + name + '>'; } + +function svgGcStroke(gc) { + if (!gc || gc.col == null) return ' stroke="none"'; + let s = ' stroke="' + gc.col + '"'; + s += ' stroke-width="' + (gc.lwd || 1) + '"'; + s += ' stroke-linecap="' + (gc.lend || 'round') + '"'; + s += ' stroke-linejoin="' + (gc.ljoin || 'round') + '"'; + if (gc.lty && gc.lty.length > 0) s += ' stroke-dasharray="' + gc.lty.join(',') + '"'; + return s; +} + +function svgGcFill(gc) { + if (!gc || gc.fill == null) return ' fill="none"'; + return ' fill="' + gc.fill + '"'; +} + +function svgFont(gc) { + if (!gc || !gc.font) return { size: 12, family: 'sans-serif', style: '', weight: '' }; + const size = gc.font.size || 12; + const family = mapFontFamily(gc.font.family); + const face = gc.font.face || 1; + return { + size, + family, + weight: (face === 2 || face === 4) ? 'bold' : 'normal', + style: (face === 3 || face === 4) ? 'italic' : 'normal' + }; +} + +function plotToSvg(plot, exportW, exportH) { + const w = plot.device.width; + const h = plot.device.height; + const outW = exportW || w; + const outH = exportH || h; + let s = svgTag('svg', ' xmlns="http://www.w3.org/2000/svg" width="' + outW + '" height="' + outH + '" viewBox="0 0 ' + w + ' ' + h + '"') + '\\n'; + + if (plot.device.bg) { + s += svgTag('rect', ' width="' + w + '" height="' + h + '" fill="' + plot.device.bg + '"', true) + '\\n'; + } + + let clipId = 0; + const elementStack = []; + + for (const op of plot.ops) { + switch (op.op) { + case 'clip': { + while (elementStack.length > 0) { + const top = elementStack[elementStack.length - 1]; + if (top.kind === 'group') break; + elementStack.pop(); + s += svgClose('g') + '\\n'; + if (top.kind === 'clip') break; + } + clipId++; + const cw = op.x1 - op.x0, ch = op.y1 - op.y0; + const cx = Math.min(op.x0, op.x1), cy = Math.min(op.y0, op.y1); + const aw = Math.abs(cw), ah = Math.abs(ch); + s += svgTag('defs') + svgTag('clipPath', ' id="c' + clipId + '"') + svgTag('rect', ' x="' + cx + '" y="' + cy + '" width="' + aw + '" height="' + ah + '"', true) + svgClose('clipPath') + svgClose('defs') + '\\n'; + s += svgTag('g', ' clip-path="url(#c' + clipId + ')"') + '\\n'; + elementStack.push({kind: 'clip', attrs: ''}); + break; + } + case 'line': + s += svgTag('line', ' x1="' + op.x1 + '" y1="' + op.y1 + '" x2="' + op.x2 + '" y2="' + op.y2 + '"' + svgGcStroke(op.gc) + ' fill="none"', true) + '\\n'; + break; + case 'rect': { + const rx = Math.min(op.x0, op.x1), ry = Math.min(op.y0, op.y1); + const rw = Math.abs(op.x1 - op.x0), rh = Math.abs(op.y1 - op.y0); + s += svgTag('rect', ' x="' + rx + '" y="' + ry + '" width="' + rw + '" height="' + rh + '"' + svgGcFill(op.gc) + svgGcStroke(op.gc), true) + '\\n'; + break; + } + case 'circle': + s += svgTag('circle', ' cx="' + op.x + '" cy="' + op.y + '" r="' + op.r + '"' + svgGcFill(op.gc) + svgGcStroke(op.gc), true) + '\\n'; + break; + case 'polyline': { + if (op.x.length < 2) break; + let pts = ''; + for (let i = 0; i < op.x.length; i++) pts += op.x[i] + ',' + op.y[i] + ' '; + s += svgTag('polyline', ' points="' + pts.trim() + '"' + svgGcStroke(op.gc) + ' fill="none"', true) + '\\n'; + break; + } + case 'polygon': { + let pts = ''; + for (let i = 0; i < op.x.length; i++) pts += op.x[i] + ',' + op.y[i] + ' '; + s += svgTag('polygon', ' points="' + pts.trim() + '"' + svgGcFill(op.gc) + svgGcStroke(op.gc), true) + '\\n'; + break; + } + case 'path': { + let d = ''; + for (const sub of op.subpaths) { + if (sub.length === 0) continue; + d += 'M' + sub[0][0] + ' ' + sub[0][1]; + for (let i = 1; i < sub.length; i++) d += 'L' + sub[i][0] + ' ' + sub[i][1]; + d += 'Z'; + } + const rule = op.winding === 'evenodd' ? 'evenodd' : 'nonzero'; + s += svgTag('path', ' d="' + d + '" fill-rule="' + rule + '"' + svgGcFill(op.gc) + svgGcStroke(op.gc), true) + '\\n'; + break; + } + case 'text': { + const f = svgFont(op.gc); + let anchor = 'start'; + if (op.hadj === 0.5) anchor = 'middle'; + else if (op.hadj === 1) anchor = 'end'; + const col = (op.gc && op.gc.col != null) ? op.gc.col : 'black'; + let transform = 'translate(' + op.x + ',' + op.y + ')'; + if (op.rot) transform += ' rotate(' + (-op.rot) + ')'; + s += svgTag('text', ' transform="' + transform + '" font-family="' + f.family + '" font-size="' + f.size + '" font-weight="' + f.weight + '" font-style="' + f.style + '" text-anchor="' + anchor + '" fill="' + col + '"') + svgEsc(op.str) + svgClose('text') + '\\n'; + break; + } + case 'raster': { + const aw = Math.abs(op.w), ah = Math.abs(op.h); + const dx = op.w >= 0 ? op.x : op.x + op.w; + const dy = op.y - ah; + let transform = ''; + if (op.rot) { + const cx = dx + aw / 2, cy = dy + ah / 2; + transform = ' transform="rotate(' + (-op.rot) + ',' + cx + ',' + cy + ')"'; + } + s += svgTag('image', ' x="' + dx + '" y="' + dy + '" width="' + aw + '" height="' + ah + '" href="' + op.data + '"' + transform, true) + '\\n'; + break; + } + case 'beginGroup': { + let gAttrs = ''; + if (op.ext) { + if (op.ext.opacity != null) { + const rawOpacity = Number(op.ext.opacity); + if (Number.isFinite(rawOpacity)) { + const clampedOpacity = Math.max(0, Math.min(1, rawOpacity)); + gAttrs += ' opacity="' + clampedOpacity + '"'; + } + } + if (op.ext.filter != null && isSafeCssFilter(op.ext.filter)) gAttrs += ' style="filter:' + svgEsc(op.ext.filter) + ';"'; + } + s += svgTag('g', gAttrs) + '\\n'; + elementStack.push({kind: 'group', attrs: gAttrs}); + break; + } + case 'endGroup': + while (elementStack.length > 0 && elementStack[elementStack.length - 1].kind === 'clip') { + elementStack.pop(); + s += svgClose('g') + '\\n'; + } + if (elementStack.length > 0 && elementStack[elementStack.length - 1].kind === 'group') { + elementStack.pop(); + s += svgClose('g') + '\\n'; + } + break; + } + } + + while (elementStack.length > 0) { elementStack.pop(); s += svgClose('g') + '\\n'; } + s += svgClose('svg'); + return s; +} +`; +} diff --git a/src/rTerminal.ts b/src/rTerminal.ts index a9f75913..3a62d880 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -5,12 +5,13 @@ import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; -import { extensionContext } from './extension'; +import { extensionContext, globalPlotManager } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; import { cleanupSession } from './session'; import { config, delay, getRterm, getCurrentWorkspaceFolder } from './util'; +import { resolveBackend, CommonPlotManager } from './plotViewer'; import * as fs from 'fs'; import * as yaml from 'js-yaml'; @@ -194,13 +195,19 @@ export async function makeTerminalOptions(): Promise { const newRprofile = extensionContext.asAbsolutePath(path.join('R', 'profile.R')); if (config().get('sessionWatcher')) { const pipePath = await getGlobalPipePath(); + const backend = resolveBackend(); termOptions.env = { R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, R_PROFILE_USER: newRprofile, SESS_PIPE: pipePath, SESS_RSTUDIOAPI: config().get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE', - SESS_USE_HTTPGD: config().get('plot.useHttpgd') ? 'TRUE' : 'FALSE' + SESS_USE_HTTPGD: backend === 'httpgd' ? 'TRUE' : 'FALSE', + SESS_PLOT_BACKEND: backend, }; + if (backend === 'jgd' || backend === 'auto') { + const jgdVars = (globalPlotManager as CommonPlotManager)?.getJgdEnvVars() ?? {}; + Object.assign(termOptions.env, jgdVars); + } } return termOptions; } diff --git a/src/session.ts b/src/session.ts index 470f3254..f754e43f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,6 +14,7 @@ import * as rTerminal from './rTerminal'; import { purgeAddinPickerItems, RSEditOperation, RSRange } from './rstudioapi'; import { extensionContext, homeExtDir, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, tmpDir } from './extension'; +import { resolveBackend, CommonPlotManager } from './plotViewer'; import { showWebView } from './webViewer'; @@ -413,11 +414,18 @@ function getAttachSessionScriptPath(pipePath: string): string { } function buildAttachSessionScript(pipePath: string, sessPath: string, installSessScriptPath: string): string { + const backend = resolveBackend(); + const useHttpgd = backend === 'httpgd' || backend === 'auto' ? 'TRUE' : 'FALSE'; + const useJgd = backend === 'jgd' || backend === 'auto' ? 'TRUE' : 'FALSE'; + const jgdSocket = (backend === 'jgd' || backend === 'auto') + ? (globalPlotManager as CommonPlotManager)?.getJgdEnvVars()?.['JGD_SOCKET'] ?? '' + : ''; return [ 'local({', ` pipe_path <- ${asRStringLiteral(pipePath)}`, ` sess_src <- ${asRStringLiteral(sessPath)}`, ` install_sess_script <- ${asRStringLiteral(installSessScriptPath)}`, + ...(jgdSocket ? [` Sys.setenv(JGD_SOCKET = ${asRStringLiteral(jgdSocket)})`] : []), ' bundled_version <- tryCatch(read.dcf(file.path(sess_src, "DESCRIPTION"))[1, "Version"], error = function(e) NA_character_)', ' installed_version <- suppressWarnings(tryCatch(as.character(utils::packageVersion("sess")), error = function(e) NA_character_))', ' needs_install <- is.na(installed_version) || (!is.na(bundled_version) && utils::compareVersion(installed_version, bundled_version) < 0)', @@ -429,7 +437,7 @@ function buildAttachSessionScript(pipePath: string, sessPath: string, installSes ' on.exit(Sys.unsetenv(c("VSCODE_R_SESS_PKG_PATH", "VSCODE_R_SESS_REPO")), add = TRUE)', ' source(install_sess_script, local = TRUE)', ' }', - ' sess::connect(pipe_path = pipe_path)', + ` sess::connect(pipe_path = pipe_path, use_httpgd = ${useHttpgd}, use_jgd = ${useJgd})`, '})', '', ].join('\n'); diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts new file mode 100644 index 00000000..84cd4675 --- /dev/null +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -0,0 +1,363 @@ +import * as assert from 'assert'; +import { PlotHistory, PlotFrame } from '../../plotViewer/jgdPlotHistory'; + +function makePlot(label: string, width = 400, height = 300): PlotFrame { + return { + version: 1, + sessionId: '', + device: { width, height, dpi: 96, bg: label }, + ops: [{ op: 'rect', label }], + }; +} + +suite('JGD PlotHistory', () => { + let history: PlotHistory; + + setup(() => { + history = new PlotHistory(50); + }); + + suite('addPlot', () => { + test('adds a plot and sets it as current', () => { + history.addPlot('s1', makePlot('A')); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentIndex(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + }); + + test('appends multiple plots', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + assert.strictEqual(history.count(), 2); + assert.strictEqual(history.currentIndex(), 2); + assert.strictEqual(history.currentPlot()?.device.bg, 'B'); + }); + }); + + suite('navigation', () => { + setup(() => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.addPlot('s1', makePlot('C')); + }); + + test('navigatePrevious moves backward', () => { + const plot = history.navigatePrevious(); + assert.strictEqual(plot?.device.bg, 'B'); + assert.strictEqual(history.currentIndex(), 2); + }); + + test('navigateNext moves forward', () => { + history.navigatePrevious(); + const plot = history.navigateNext(); + assert.strictEqual(plot?.device.bg, 'C'); + assert.strictEqual(history.currentIndex(), 3); + }); + + test('navigatePrevious returns null at beginning', () => { + history.navigatePrevious(); + history.navigatePrevious(); + assert.strictEqual(history.navigatePrevious(), null); + assert.strictEqual(history.currentIndex(), 1); + }); + + test('navigateNext returns null at end', () => { + assert.strictEqual(history.navigateNext(), null); + assert.strictEqual(history.currentIndex(), 3); + }); + }); + + suite('removeCurrent', () => { + test('removes the only plot', () => { + history.addPlot('s1', makePlot('A')); + const remaining = history.removeCurrent(); + assert.strictEqual(remaining, null); + assert.strictEqual(history.count(), 0); + }); + + test('removes middle plot and stays in bounds', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.addPlot('s1', makePlot('C')); + history.navigatePrevious(); + const remaining = history.removeCurrent(); + assert.strictEqual(remaining?.device.bg, 'C'); + assert.strictEqual(history.count(), 2); + }); + + test('removes last plot and adjusts index', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + const remaining = history.removeCurrent(); + assert.strictEqual(remaining?.device.bg, 'A'); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentIndex(), 1); + }); + + test('returns null on empty history', () => { + assert.strictEqual(history.removeCurrent(), null); + }); + }); + + suite('clear', () => { + test('removes all plots', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.clear(); + assert.strictEqual(history.count(), 0); + assert.strictEqual(history.currentPlot(), null); + }); + }); + + suite('replaceCurrent', () => { + test('replaces the current plot in place', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.replaceCurrent('s1', makePlot('B2')); + assert.strictEqual(history.count(), 2); + assert.strictEqual(history.currentPlot()?.device.bg, 'B2'); + }); + + test('falls back to addPlot on empty session', () => { + history.replaceCurrent('s1', makePlot('A')); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + }); + + test('replaces at navigated position, not latest', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.navigatePrevious(); + history.replaceCurrent('s1', makePlot('A2')); + assert.strictEqual(history.currentPlot()?.device.bg, 'A2'); + history.navigateNext(); + assert.strictEqual(history.currentPlot()?.device.bg, 'B'); + }); + }); + + suite('replaceLatest', () => { + test('replaces the latest plot regardless of navigation', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.navigatePrevious(); + const accepted = history.replaceLatest('s1', makePlot('B2')); + assert.strictEqual(accepted, true); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + history.navigateNext(); + assert.strictEqual(history.currentPlot()?.device.bg, 'B2'); + }); + + test('falls back to addPlot on empty session', () => { + const accepted = history.replaceLatest('s1', makePlot('A')); + assert.strictEqual(accepted, true); + assert.strictEqual(history.count(), 1); + }); + }); + + suite('latestDeleted', () => { + test('replaceLatest is rejected after deleting latest plot', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.removeCurrent(); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + + const accepted = history.replaceLatest('s1', makePlot('stale')); + assert.strictEqual(accepted, false); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + }); + + test('deleting non-latest plot does not arm latestDeleted', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.navigatePrevious(); + history.removeCurrent(); + assert.strictEqual(history.count(), 1); + + const accepted = history.replaceLatest('s1', makePlot('B2')); + assert.strictEqual(accepted, true); + assert.strictEqual(history.currentPlot()?.device.bg, 'B2'); + }); + + test('addPlot resets latestDeleted', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.removeCurrent(); + + history.addPlot('s1', makePlot('C')); + const accepted = history.replaceLatest('s1', makePlot('C2')); + assert.strictEqual(accepted, true); + assert.strictEqual(history.currentPlot()?.device.bg, 'C2'); + }); + + test('clear resets latestDeleted', () => { + history.addPlot('s1', makePlot('A')); + history.removeCurrent(); + history.clear(); + + history.addPlot('s1', makePlot('B')); + const accepted = history.replaceLatest('s1', makePlot('B2')); + assert.strictEqual(accepted, true); + }); + + test('replaceLatest is rejected on empty session after deleting last plot', () => { + history.addPlot('s1', makePlot('A')); + history.removeCurrent(); + assert.strictEqual(history.count(), 0); + + const accepted = history.replaceLatest('s1', makePlot('stale')); + assert.strictEqual(accepted, false); + assert.strictEqual(history.count(), 0); + }); + }); + + suite('resize after delete (jgd#11)', () => { + test('must not replace remaining plot with stale resize frame', () => { + history.addPlot('s1', makePlot('RED')); + history.addPlot('s1', makePlot('BLUE')); + + history.removeCurrent(); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'RED'); + + const accepted = history.replaceLatest('s1', makePlot('BLUE', 800, 600)); + assert.strictEqual(accepted, false); + + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'RED'); + }); + + test('resize works normally when latest was not deleted', () => { + history.addPlot('s1', makePlot('RED')); + history.addPlot('s1', makePlot('BLUE')); + + const accepted = history.replaceLatest('s1', makePlot('BLUE', 800, 600)); + assert.strictEqual(accepted, true); + assert.strictEqual(history.count(), 2); + assert.strictEqual(history.currentPlot()?.device.bg, 'BLUE'); + assert.strictEqual(history.currentPlot()?.device.width, 800); + }); + }); + + suite('appendOps', () => { + test('appends ops to the latest plot', () => { + history.addPlot('s1', makePlot('A')); + const extra: PlotFrame = { + version: 1, sessionId: '', ops: [{ op: 'line', label: 'extra' }], + device: { width: 400, height: 300, dpi: 96, bg: 'A' }, + }; + history.appendOps('s1', extra); + assert.strictEqual(history.count(), 1); + const ops = history.currentPlot()!.ops as { op: string }[]; + assert.strictEqual(ops.length, 2); + assert.strictEqual(ops[0].op, 'rect'); + assert.strictEqual(ops[1].op, 'line'); + }); + + test('always targets latest plot, not navigated position', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.navigatePrevious(); + const extra: PlotFrame = { + version: 1, sessionId: '', ops: [{ op: 'line' }], + device: { width: 400, height: 300, dpi: 96, bg: 'B' }, + }; + history.appendOps('s1', extra); + assert.strictEqual(history.currentPlot()!.ops.length, 1); + history.navigateNext(); + assert.strictEqual(history.currentPlot()!.ops.length, 2); + }); + + test('is rejected when latestDeleted is true', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + history.removeCurrent(); + const extra: PlotFrame = { + version: 1, sessionId: '', ops: [{ op: 'line' }], + device: { width: 400, height: 300, dpi: 96, bg: 'A' }, + }; + history.appendOps('s1', extra); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()!.ops.length, 1); + }); + }); + + suite('replaceLatest expectedRIndex guard', () => { + test('accepts replacement when expectedRIndex matches', () => { + const plot1 = makePlot('A'); + plot1.rIndex = 0; + history.addPlot('s1', plot1); + const accepted = history.replaceLatest('s1', makePlot('A-resized', 800, 600), 0); + assert.strictEqual(accepted, true); + assert.strictEqual(history.currentPlot()?.device.width, 800); + }); + + test('rejects replacement when expectedRIndex does not match', () => { + const plot1 = makePlot('A'); + plot1.rIndex = 0; + history.addPlot('s1', plot1); + const plot2 = makePlot('B'); + plot2.rIndex = 1; + history.addPlot('s1', plot2); + const accepted = history.replaceLatest('s1', makePlot('A-resized', 800, 600), 0); + assert.strictEqual(accepted, false); + assert.strictEqual(history.currentPlot()?.device.bg, 'B'); + }); + }); + + suite('eviction', () => { + test('evicts oldest plots when maxPlots exceeded', () => { + const small = new PlotHistory(3); + small.addPlot('s1', makePlot('A')); + small.addPlot('s1', makePlot('B')); + small.addPlot('s1', makePlot('C')); + small.addPlot('s1', makePlot('D')); + assert.strictEqual(small.count(), 3); + small.navigatePrevious(); + small.navigatePrevious(); + assert.strictEqual(small.currentPlot()?.device.bg, 'B'); + }); + }); + + suite('multi-session', () => { + test('tracks plots independently per session', () => { + history.addPlot('s1', makePlot('S1-A')); + history.addPlot('s2', makePlot('S2-A')); + assert.strictEqual(history.currentPlot()?.device.bg, 'S2-A'); + assert.strictEqual(history.count(), 1); + + history.addPlot('s1', makePlot('S1-B')); + assert.strictEqual(history.currentPlot()?.device.bg, 'S1-B'); + assert.strictEqual(history.count(), 2); + }); + }); + + suite('events', () => { + test('emits change on addPlot', () => { + let fired = 0; + history.onDidChange(() => fired++); + history.addPlot('s1', makePlot('A')); + assert.strictEqual(fired, 1); + }); + + test('emits change on navigation', () => { + history.addPlot('s1', makePlot('A')); + history.addPlot('s1', makePlot('B')); + let fired = 0; + history.onDidChange(() => fired++); + history.navigatePrevious(); + history.navigateNext(); + assert.strictEqual(fired, 2); + }); + + test('does not emit change when replaceLatest is rejected', () => { + history.addPlot('s1', makePlot('A')); + history.removeCurrent(); + let fired = 0; + history.onDidChange(() => fired++); + history.replaceLatest('s1', makePlot('stale')); + assert.strictEqual(fired, 0); + }); + }); +}); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts new file mode 100644 index 00000000..55432a35 --- /dev/null +++ b/src/test/suite/jgdSocketServer.test.ts @@ -0,0 +1,273 @@ +import * as assert from 'assert'; +import * as net from 'net'; +import { PlotHistory, PlotFrame } from '../../plotViewer/jgdPlotHistory'; +import { JgdSocketServer, JgdMessage } from '../../plotViewer/jgdSocketServer'; + +let plotCounter = 0; +function makePlotMsg(label: string, width = 400, height = 300, extra: Record = {}): Record { + const msg: Record = { + type: 'frame', + plot: { + version: 1, + sessionId: '', + device: { width, height, dpi: 96, bg: label }, + ops: [{ op: 'rect', label }], + }, + ...extra, + }; + if (!extra.resizeReplay && !extra.incremental && msg.plotNumber === undefined) { + msg.plotNumber = plotCounter++; + if (msg.newPage === undefined) msg.newPage = true; + } + return msg; +} + +interface ClientHelper { + socket: net.Socket; + send: (msg: object) => void; + readLine: () => Promise; + close: () => void; +} + +function uriToConnectPath(uri: string): string { + const NPIPE_PREFIX = 'npipe:////./pipe/'; + if (uri.startsWith(NPIPE_PREFIX)) { + return `\\\\.\\pipe\\${uri.slice(NPIPE_PREFIX.length)}`; + } + return uri; +} + +function connectClient(socketUri: string): Promise { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + let buffer = ''; + const lineQueue: string[] = []; + let lineResolve: ((line: string) => void) | null = null; + + socket.on('data', (data) => { + buffer += data.toString(); + let idx: number; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, idx); + buffer = buffer.substring(idx + 1); + if (lineResolve) { + const r = lineResolve; + lineResolve = null; + r(line); + } else { + lineQueue.push(line); + } + } + }); + + socket.on('error', (err) => { + if (lineResolve) { + const r = lineResolve; + lineResolve = null; + r(''); + } + reject(err); + }); + + socket.connect(uriToConnectPath(socketUri), () => { + resolve({ + socket, + send: (msg: object) => socket.write(JSON.stringify(msg) + '\n'), + readLine: () => { + if (lineQueue.length > 0) return Promise.resolve(lineQueue.shift()!); + return new Promise((res) => { lineResolve = res; }); + }, + close: () => socket.destroy(), + }); + }); + }); +} + +function waitMs(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +suite('JGD SocketServer', () => { + let history: PlotHistory; + let server: JgdSocketServer; + let clients: ClientHelper[]; + let shownPlots: PlotFrame[]; + let measuredRequests: JgdMessage[]; + let closedSessions: string[]; + let dims: { width: number; height: number }; + + setup(async () => { + plotCounter = 0; + history = new PlotHistory(50); + server = new JgdSocketServer(history); + clients = []; + shownPlots = []; + measuredRequests = []; + closedSessions = []; + dims = { width: 800, height: 600 }; + + server.setOnFrame((_sessionId, msg) => { + const current = history.currentPlot(); + if (current) shownPlots.push(current); + else if (msg.plot) shownPlots.push(msg.plot as PlotFrame); + }); + + server.setMeasureText((request) => { + measuredRequests.push(request); + return Promise.resolve({ + type: 'metrics_response', + id: request.id, + width: 42, + ascent: 10, + descent: 3, + }); + }); + + server.setGetDimensions(() => dims); + + server.setOnDeviceClosed((sessionId) => { + closedSessions.push(sessionId); + }); + + server.start(); + await new Promise((resolve) => server.onReady(resolve)); + }); + + teardown(() => { + for (const c of clients) c.close(); + server.stop(); + }); + + async function connect(): Promise { + const client = await connectClient(server.getSocketPath()); + clients.push(client); + client.send({ type: 'hello' }); + await client.readLine(); // server_info + await client.readLine(); // initial resize + return client; + } + + suite('frame routing', () => { + test('routes normal frame to addPlot', async () => { + const client = await connect(); + + client.send(makePlotMsg('A')); + await waitMs(50); + + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A'); + assert.strictEqual(shownPlots.length, 1); + }); + + test('routes incremental frame via appendOps', async () => { + const client = await connect(); + + client.send(makePlotMsg('A')); + await waitMs(50); + + client.send({ + type: 'frame', + plot: { + version: 1, + sessionId: '', + device: { width: 400, height: 300, dpi: 96, bg: 'A' }, + ops: [{ op: 'line', label: 'extra' }], + }, + incremental: true, + }); + await waitMs(50); + + assert.strictEqual(history.count(), 1); + const ops = history.currentPlot()?.ops as { op: string }[] | undefined; + assert.strictEqual(ops?.length, 2); + assert.strictEqual(shownPlots.length, 2); + }); + + test('routes resizeReplay frame to replaceLatest', async () => { + const client = await connect(); + + client.send(makePlotMsg('A')); + await waitMs(50); + + client.send(makePlotMsg('A-resized', 1000, 700, { resizeReplay: true })); + await waitMs(50); + + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'A-resized'); + }); + }); + + suite('resize after delete (jgd#11)', () => { + test('resize after delete-latest uses plotIndex', async () => { + const client = await connect(); + + client.send(makePlotMsg('RED')); + await waitMs(50); + client.send(makePlotMsg('BLUE')); + await waitMs(50); + assert.strictEqual(history.count(), 2); + + history.removeCurrent(); + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'RED'); + + server.handleResize(1000, 700); + const resizeMsg = JSON.parse(await client.readLine()) as { type: string; plotIndex: number }; + assert.strictEqual(resizeMsg.type, 'resize'); + assert.strictEqual(resizeMsg.plotIndex, 0); + + client.send(makePlotMsg('RED-resized', 1000, 700, { resizeReplay: true, plotIndex: 0 })); + await waitMs(50); + + assert.strictEqual(history.count(), 1); + assert.strictEqual(history.currentPlot()?.device.bg, 'RED-resized'); + }); + }); + + suite('initial connection', () => { + test('sends current panel dimensions on connect', async () => { + dims = { width: 500, height: 400 }; + const client = await connectClient(server.getSocketPath()); + clients.push(client); + client.send({ type: 'hello' }); + const info = JSON.parse(await client.readLine()) as { type: string }; + assert.strictEqual(info.type, 'server_info'); + const msg = JSON.parse(await client.readLine()) as { type: string; width: number; height: number }; + assert.strictEqual(msg.type, 'resize'); + assert.strictEqual(msg.width, 500); + assert.strictEqual(msg.height, 400); + }); + }); + + suite('close message', () => { + test('forwards close with session id', async () => { + const client = await connect(); + + client.send({ type: 'close' }); + await waitMs(50); + + assert.strictEqual(closedSessions.length, 1); + assert.ok(closedSessions[0].match(/^session-/)); + }); + }); + + suite('metrics', () => { + test('forwards metrics_request and returns response', async () => { + const client = await connect(); + + client.send({ + type: 'metrics_request', + id: 7, + kind: 'strWidth', + str: 'hello', + gc: { font: { size: 12, family: 'sans' } }, + }); + + const resp = JSON.parse(await client.readLine()) as { type: string; id: number; width: number }; + assert.strictEqual(resp.type, 'metrics_response'); + assert.strictEqual(resp.id, 7); + assert.strictEqual(resp.width, 42); + assert.strictEqual(measuredRequests.length, 1); + }); + }); +}); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index af3bac1b..a5dfc2f5 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -23,9 +23,9 @@ suite('R Terminal', () => { }); test('makeTerminalOptions sets session watcher environment variables', async () => { - // Stub config to enable sessionWatcher + // Stub config to enable sessionWatcher with httpgd backend const configStub = { - get: (key: string) => { + get: (key: string, defaultValue?: unknown) => { if (key === 'sessionWatcher') { return true; } @@ -38,7 +38,7 @@ suite('R Terminal', () => { if (key === 'rterm.option') { return ['--no-save']; } - return undefined; + return defaultValue; } }; sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); @@ -52,6 +52,7 @@ suite('R Terminal', () => { assert.ok(options.env['SESS_PIPE']); assert.strictEqual(options.env['SESS_RSTUDIOAPI'], 'TRUE'); assert.strictEqual(options.env['SESS_USE_HTTPGD'], 'TRUE'); + assert.strictEqual(options.env['SESS_PLOT_BACKEND'], 'httpgd'); assert.ok(options.env['R_PROFILE_USER']); assert.ok(options.env['R_PROFILE_USER'].endsWith(path.join('R', 'profile.R'))); });