From fcc61d45762ae79b6293324228f1baa0b12de877 Mon Sep 17 00:00:00 2001 From: Tim Mok Date: Fri, 23 Feb 2024 13:35:23 -0500 Subject: [PATCH 01/10] basic ability to save plots --- .../browser/components/actionBars.tsx | 10 ++ .../modalDialogs/savePlotModalDialog.css | 23 ++++ .../modalDialogs/savePlotModalDialog.tsx | 102 ++++++++++++++++++ .../browser/positronPlotsService.ts | 62 ++++++++++- .../positronPlots/common/positronPlots.ts | 5 + 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css create mode 100644 src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx index 4962456155a..088f2fe6283 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx @@ -24,6 +24,7 @@ import { ZoomPlotMenuButton } from 'vs/workbench/contrib/positronPlots/browser/c import { PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; import { StaticPlotClient } from 'vs/workbench/services/positronPlots/common/staticPlotClient'; import { INotificationService } from 'vs/platform/notification/common/notification'; +// import { showSavePlotModalDialog } from 'vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog'; // Constants. const kPaddingLeft = 14; @@ -77,6 +78,11 @@ export const ActionBars = (props: PropsWithChildren) => { const enableZoomPlot = hasPlots && positronPlotsContext.positronPlotInstances[positronPlotsContext.selectedInstanceIndex] instanceof StaticPlotClient; + const enableSavingPlots = hasPlots && + (positronPlotsContext.positronPlotInstances[positronPlotsContext.selectedInstanceIndex] + instanceof PlotClientInstance || + positronPlotsContext.positronPlotInstances[positronPlotsContext.selectedInstanceIndex] + instanceof StaticPlotClient); useEffect(() => { // Empty for now. @@ -106,6 +112,9 @@ export const ActionBars = (props: PropsWithChildren) => { const zoomPlotHandler = (zoomLevel: number) => { props.zoomHandler(zoomLevel); }; + const savePlotHandler = async () => { + positronPlotsContext.positronPlotsService.savePlot(); + }; // Render. return ( @@ -117,6 +126,7 @@ export const ActionBars = (props: PropsWithChildren) => { {enableZoomPlot && } + {enableSavingPlots && } {enableSizingPolicy && } {enableSizingPolicy && => { + + return new Promise((resolve) => { + const positronModalDialogReactRenderer = new PositronModalDialogReactRenderer(layoutService.mainContainer); + const ModalDialog = () => { + const [path, setPath] = React.useState(''); + + const acceptHandler = async () => { + positronModalDialogReactRenderer.destroy(); + + resolve(undefined); + }; + + const cancelHandler = () => { + positronModalDialogReactRenderer.destroy(); + resolve(undefined); + }; + + const saveHandler = async () => { + const path = await showSaveFilePicker({ + types: [ + { + description: 'PNG', + accept: { + 'image/png': ['.png'] + } + }, + { + description: 'SVG', + accept: { + 'image/svg+xml': ['.svg'] + } + }, + { + description: 'JPEG', + accept: { + 'image/jpeg': ['.jpeg'] + } + } + ], + }); + path.getFile().then(file => { + setPath(file.name); + console.log(file.name); + }); + }; + + return ( + + + + + + + + + + +
+ + + + +
+
+
+ + +
+
+
+ +
+ ); + }; + + positronModalDialogReactRenderer.render(); + }); +}; diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts index 2c3da48c05a..62ea448eded 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts @@ -6,6 +6,8 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IPositronPlotMetadata, PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; import { ILanguageRuntimeMessageOutput, RuntimeOutputKind } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; import { ILanguageRuntimeSession, IRuntimeSessionService, RuntimeClientType } from 'vs/workbench/services/runtimeSession/common/runtimeSessionService'; +import { HTMLFileSystemProvider } from 'vs/platform/files/browser/htmlFileSystemProvider'; +import { IFileService } from 'vs/platform/files/common/files'; import { HistoryPolicy, IPositronPlotClient, IPositronPlotsService, POSITRON_PLOTS_VIEW_ID } from 'vs/workbench/services/positronPlots/common/positronPlots'; import { Emitter, Event } from 'vs/base/common/event'; import { StaticPlotClient } from 'vs/workbench/services/positronPlots/common/staticPlotClient'; @@ -21,6 +23,9 @@ import { PlotSizingPolicyCustom } from 'vs/workbench/services/positronPlots/comm import { WebviewPlotClient } from 'vs/workbench/contrib/positronPlots/browser/webviewPlotClient'; import { IPositronNotebookOutputWebviewService } from 'vs/workbench/contrib/positronOutputWebview/browser/notebookOutputWebviewService'; import { IPositronIPyWidgetsService } from 'vs/workbench/services/positronIPyWidgets/common/positronIPyWidgetsService'; +import { Schemas } from 'vs/base/common/network'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { decodeBase64 } from 'vs/base/common/buffer'; /** The maximum number of recent executions to store. */ const MaxRecentExecutions = 10; @@ -92,7 +97,9 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe @IStorageService private _storageService: IStorageService, @IViewsService private _viewsService: IViewsService, @IPositronNotebookOutputWebviewService private _notebookOutputWebviewService: IPositronNotebookOutputWebviewService, - @IPositronIPyWidgetsService private _positronIPyWidgetsService: IPositronIPyWidgetsService) { + @IPositronIPyWidgetsService private _positronIPyWidgetsService: IPositronIPyWidgetsService, + @IFileService private readonly _fileService: IFileService, + @IFileDialogService private readonly _fileDialogService: IFileDialogService) { super(); // Register for language runtime service startups @@ -658,6 +665,59 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe this._onDidReplacePlots.fire(this._plots); } + savePlot(): void { + if (this._selectedPlotId) { + const plot = this._plots.find(plot => plot.id === this._selectedPlotId); + if (plot) { + // TODO: if it's a static plot, save the image to disk + // if it's a dynamic plot, present options dialog + this._fileDialogService.showSaveDialog({ + title: 'Save Plot', + filters: + [ + { + extensions: ['png'], + name: 'PNG', + }, + { + extensions: ['jpeg'], + name: 'JPEG', + } + ], + }).then(result => { + if (result) { + const htmlFileSystemProvider = this._fileService.getProvider(Schemas.file) as HTMLFileSystemProvider; + let uri = ''; + + if (plot instanceof StaticPlotClient) { + const staticPlot = plot as StaticPlotClient; + uri = staticPlot.uri; + } else if (plot instanceof PlotClientInstance) { + const plotClient = plot as PlotClientInstance; + uri = plotClient.lastRender?.uri || ''; + } else { + return; + } + + const regex = /^data:.+\/(.+);base64,(.*)$/; + const matches = uri.match(regex); + + if (!matches || matches.length !== 3) { + return; + } + + const data = matches[2]; + + htmlFileSystemProvider.writeFile(result, decodeBase64(data).buffer, { create: true, overwrite: true, unlock: true, atomic: false }) + .then(() => { + }); + } + }); + } + } + } + + /** * Generates a storage key for a plot. * diff --git a/src/vs/workbench/services/positronPlots/common/positronPlots.ts b/src/vs/workbench/services/positronPlots/common/positronPlots.ts index d5c3e033a14..7690ae7bee4 100644 --- a/src/vs/workbench/services/positronPlots/common/positronPlots.ts +++ b/src/vs/workbench/services/positronPlots/common/positronPlots.ts @@ -150,6 +150,11 @@ export interface IPositronPlotsService { */ selectHistoryPolicy(policy: HistoryPolicy): void; + /** + * Saves the plot with the specified FileSystemHandle. + */ + savePlot(): void; + /** * Placeholder for service initialization. */ From 0638711244f3a636ebe7b2b697f2d34f0916fbf6 Mon Sep 17 00:00:00 2001 From: Tim Mok Date: Tue, 12 Mar 2024 14:18:42 -0400 Subject: [PATCH 02/10] Preview before saving dynamic plots --- .../browser/components/actionBars.tsx | 1 - .../modalDialogs/savePlotModalDialog.css | 50 +++++- .../modalDialogs/savePlotModalDialog.tsx | 170 ++++++++++++------ .../browser/positronPlotsService.ts | 101 ++++++----- .../common/languageRuntimePlotClient.ts | 71 +++++++- .../common/positronPlotComm.ts | 20 +++ 6 files changed, 304 insertions(+), 109 deletions(-) diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx index 088f2fe6283..6ad179f498d 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/actionBars.tsx @@ -24,7 +24,6 @@ import { ZoomPlotMenuButton } from 'vs/workbench/contrib/positronPlots/browser/c import { PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; import { StaticPlotClient } from 'vs/workbench/services/positronPlots/common/staticPlotClient'; import { INotificationService } from 'vs/platform/notification/common/notification'; -// import { showSavePlotModalDialog } from 'vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog'; // Constants. const kPaddingLeft = 14; diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css index 1f61741e1b6..5c9d0e9e73a 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css @@ -2,18 +2,60 @@ * Copyright (C) 2024 Posit Software, PBC. All rights reserved. *--------------------------------------------------------------------------------------------*/ -table { +.plot-preview-input { border-collapse: collapse; border-spacing: 0; + width: 100%; + padding: 3px; + display: flex; + flex-direction: column; + row-gap: 5px; } -td { - padding: 3px; +.horizontal-input { + display: flex; + flex-direction: row; + column-gap: 5px; +} + +.plot-preview-container { + height: 100%; + columns: 2; + display: flex; + flex-direction: column; + row-gap: 10px; +} + +img.plot-preview { + max-height: 80%; + max-width: 100%; + position: relative; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.plot-save-dialog-action-bar { + display: flex; + position: absolute; + justify-content: space-between; + bottom: 0; + left: 0; + right: 0; + overflow: hidden; + height: 64px; + gap: 10px; + margin: 0 16px; +} + +div.plot-preview-image-wrapper { + height: 100%; + width: auto; } +.plot-save-dialog-action-bar .left, .plot-save-dialog-action-bar .right { display: flex; - justify-content: right; gap: 10px; margin-top: 15px; } diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx index 8bfb0fd375c..4dc92606cef 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx @@ -9,20 +9,71 @@ import { PositronModalDialog } from 'vs/base/browser/ui/positronModalDialog/posi import { PositronModalDialogReactRenderer } from 'vs/base/browser/ui/positronModalDialog/positronModalDialogReactRenderer'; import { localize } from 'vs/nls'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; +import { PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +interface SavePlotOptions { + width: number; + height: number; + path: string; +} + +interface SavePlotModalDialogProps { + layoutService: IWorkbenchLayoutService; + fileDialogService: IFileDialogService; + plotWidth: number; + plotHeight: number; + plotClient: PlotClientInstance; +} + +const SAVE_PLOT_MODAL_DIALOG_WIDTH = 600; +const SAVE_PLOT_MODAL_DIALOG_HEIGHT = 700; + +/** + * Show the save plot modal dialog for dynamic plots. + * @param props SavePlotModalDialogProps to set the size and the plot client + * @returns The requested size and path to save the plot. + */ export const showSavePlotModalDialog = async ( - layoutService: IWorkbenchLayoutService -): Promise => { + props: SavePlotModalDialogProps +): Promise => { - return new Promise((resolve) => { - const positronModalDialogReactRenderer = new PositronModalDialogReactRenderer(layoutService.mainContainer); + + return new Promise((resolve) => { + const positronModalDialogReactRenderer = new PositronModalDialogReactRenderer(props.layoutService.mainContainer); const ModalDialog = () => { + const showSaveDialog = () => { + props.fileDialogService.showSaveDialog({ + title: 'Save Plot', + filters: + [ + { + extensions: ['png'], + name: 'PNG', + }, + ], + }).then(result => { + if (result) { + setPath(result.fsPath); + } + }); + }; const [path, setPath] = React.useState(''); + const widthInput = React.useRef(null); + const heightInput = React.useRef(null); + const [uri, setUri] = React.useState(''); + + const browseHandler = async () => { + showSaveDialog(); + }; const acceptHandler = async () => { + const width = parseInt(widthInput.current!.value ?? '100'); + const height = parseInt(heightInput.current!.value ?? '100'); + positronModalDialogReactRenderer.destroy(); - resolve(undefined); + resolve({ width, height, path }); }; const cancelHandler = () => { @@ -30,69 +81,76 @@ export const showSavePlotModalDialog = async ( resolve(undefined); }; - const saveHandler = async () => { - const path = await showSaveFilePicker({ - types: [ - { - description: 'PNG', - accept: { - 'image/png': ['.png'] - } - }, - { - description: 'SVG', - accept: { - 'image/svg+xml': ['.svg'] - } - }, - { - description: 'JPEG', - accept: { - 'image/jpeg': ['.jpeg'] - } - } - ], - }); - path.getFile().then(file => { - setPath(file.name); - console.log(file.name); - }); + const updatePreview = () => { + if (!widthInput.current || !heightInput.current) { + return; + } + const width = parseInt(widthInput.current.value); + const height = parseInt(heightInput.current.value); + props.plotClient.preview(height, width, props.plotClient.lastRender?.pixel_ratio ?? 1) + .then((result) => { + setUri(result.uri); + }); }; + React.useEffect(() => { + setUri(props.plotClient.lastRender?.uri ?? ''); + if (!widthInput.current || !heightInput.current) { + return; + } + widthInput.current.focus(); + }, [props.plotClient.lastRender?.uri]); + return ( - - - - - - - - -
- - - - -
-
-
- - +
+
+
+ + +
+
+ + + {localize('positronPlotPixelsAbbrev', 'px')} +
+
+ + + {localize('positronPlotPixelsAbbrev', 'px')} +
+ { + uri && +
+ +
+ }
+
+
+ +
+
+ + +
+
+ ); }; diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts index 62ea448eded..a09013a7c02 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts @@ -26,6 +26,8 @@ import { IPositronIPyWidgetsService } from 'vs/workbench/services/positronIPyWid import { Schemas } from 'vs/base/common/network'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { decodeBase64 } from 'vs/base/common/buffer'; +import { showSavePlotModalDialog } from 'vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog'; +import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; /** The maximum number of recent executions to store. */ const MaxRecentExecutions = 10; @@ -99,7 +101,8 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe @IPositronNotebookOutputWebviewService private _notebookOutputWebviewService: IPositronNotebookOutputWebviewService, @IPositronIPyWidgetsService private _positronIPyWidgetsService: IPositronIPyWidgetsService, @IFileService private readonly _fileService: IFileService, - @IFileDialogService private readonly _fileDialogService: IFileDialogService) { + @IFileDialogService private readonly _fileDialogService: IFileDialogService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService) { super(); // Register for language runtime service startups @@ -669,55 +672,69 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe if (this._selectedPlotId) { const plot = this._plots.find(plot => plot.id === this._selectedPlotId); if (plot) { - // TODO: if it's a static plot, save the image to disk - // if it's a dynamic plot, present options dialog - this._fileDialogService.showSaveDialog({ - title: 'Save Plot', - filters: - [ - { - extensions: ['png'], - name: 'PNG', - }, - { - extensions: ['jpeg'], - name: 'JPEG', - } - ], - }).then(result => { - if (result) { - const htmlFileSystemProvider = this._fileService.getProvider(Schemas.file) as HTMLFileSystemProvider; - let uri = ''; - - if (plot instanceof StaticPlotClient) { - const staticPlot = plot as StaticPlotClient; - uri = staticPlot.uri; - } else if (plot instanceof PlotClientInstance) { + let uri = ''; + + if (plot instanceof StaticPlotClient) { + // if it's a static plot, save the image to disk + const staticPlot = plot as StaticPlotClient; + uri = staticPlot.uri; + this.showSavePlotDialog(uri); + } else if (plot instanceof PlotClientInstance) { + const savePlotModalDialogProps = { + layoutService: this._layoutService, + fileDialogService: this._fileDialogService, + plotWidth: plot.lastRender?.width ?? 100, + plotHeight: plot.lastRender?.height ?? 100, + plotClient: plot, + }; + // if it's a dynamic plot, present options dialog + showSavePlotModalDialog(savePlotModalDialogProps).then(result => { + if (result) { const plotClient = plot as PlotClientInstance; - uri = plotClient.lastRender?.uri || ''; - } else { - return; + // plotClient.preview(result.height, result.width, plot.lastRender?.pixel_ratio ?? 1).then(result => { + // this.showSavePlotDialog(result.uri); + // }); + plotClient.save(result.path, result.height, result.width); } + }); + } else { + // if it's a webview plot, do nothing + return; + } + } + } + } - const regex = /^data:.+\/(.+);base64,(.*)$/; - const matches = uri.match(regex); - - if (!matches || matches.length !== 3) { - return; - } + showSavePlotDialog(uri: string) { + const regex = /^data:.+\/(.+);base64,(.*)$/; + const matches = uri.match(regex); - const data = matches[2]; + if (!matches || matches.length !== 3) { + return; + } - htmlFileSystemProvider.writeFile(result, decodeBase64(data).buffer, { create: true, overwrite: true, unlock: true, atomic: false }) - .then(() => { - }); - } - }); + const data = matches[2]; + const extension = matches[1]; + + this._fileDialogService.showSaveDialog({ + title: 'Save Plot', + filters: + [ + { + extensions: [extension], + name: extension.toUpperCase(), + }, + ], + }).then(result => { + if (result) { + const htmlFileSystemProvider = this._fileService.getProvider(Schemas.file) as HTMLFileSystemProvider; + htmlFileSystemProvider.writeFile(result, decodeBase64(data).buffer, { create: true, overwrite: true, unlock: true, atomic: false }) + .then(() => { + }); } - } + }); } - /** * Generates a storage key for a plot. * diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts index 2179003a572..4a20100224b 100644 --- a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts +++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts @@ -299,13 +299,68 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien return deferred.promise; } + /** + * Requests that the plot be rendered at a specific size, but does not + * store the rendered plot to _lastRender. This is useful for previewing + * plots without updating the plot's state. + * + * @param height The plot height, in pixels + * @param width The plot width, in pixels + * @param pixel_ratio The device pixel ratio (e.g. 1 for standard displays, 2 for retina displays) + * @returns A promise that resolves when the render request is scheduled, or rejects with an error. + */ + public preview(height: number, width: number, pixel_ratio: number): Promise { + // Deal with whole pixels only + height = Math.floor(height); + width = Math.floor(width); + + // Create a new deferred promise to track the render request + const request: RenderRequest = { + height, + width, + pixel_ratio + }; + const deferred = new DeferredRender(request); + + // Check which render request is currently pending. If we are currently + // rendering, then it's the queued render request. Otherwise, it's the + // current render request. + const pending = this._state === PlotClientState.Rendering ? + this._queuedRender : this._currentRender; + + // If there is already a render request in flight, cancel it; this + // request supercedes it. + if (pending && !pending.isComplete) { + pending.cancel(); + } + + if (this._state === PlotClientState.Rendering) { + // We are currently rendering; don't start another render until we're done. + this._queuedRender = deferred; + } else { + // We are not currently rendering; start a new render. Render + // immediately if we have never rendered before; otherwise, throttle + // (debounce) the render. + this._currentRender = deferred; + this.scheduleRender(deferred, this._state === PlotClientState.Unrendered ? 0 : 500, true); + } + + return deferred.promise; + } + + public save(path: string, height: number, width: number): Promise { //, height: number, width: number, pixel_ratio: number, format: string): Promise { + // public save(path: string, height: number, width: number, pixel_ratio: number, format: string): Promise { + return this._comm.save(path, height, width); + // return this._comm.save(path, height, width, pixel_ratio, format); + } + /** * Schedules the render request to be performed after a short delay. * * @param request The render request to schedule * @param delay The delay, in milliseconds */ - private scheduleRender(request: DeferredRender, delay: number) { + private scheduleRender(request: DeferredRender, delay: number, preview = false) { // If there is a render throttle timer, clear it if (this._renderThrottleTimer) { @@ -316,7 +371,7 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien // throttle the request. this._stateEmitter.fire(PlotClientState.RenderPending); this._renderThrottleTimer = setTimeout(() => { - this.performDebouncedRender(request); + this.performDebouncedRender(request, preview); }, delay); } @@ -325,7 +380,7 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien * * @param request The render request to perform */ - private performDebouncedRender(request: DeferredRender) { + private performDebouncedRender(request: DeferredRender, preview = false) { this._stateEmitter.fire(PlotClientState.Rendering); // Record the time that the render started so we can estimate the render time @@ -346,13 +401,17 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien // The server returned a rendered plot image; save it and resolve the promise const uri = `data:${response.mime_type};base64,${response.data}`; - this._lastRender = { + const renderResult = { ...request.renderRequest, uri }; - request.complete(this._lastRender); + request.complete(renderResult); + + if (!preview) { + this._lastRender = renderResult; + } this._stateEmitter.fire(PlotClientState.Rendered); - this._completeRenderEmitter.fire(this._lastRender); + this._completeRenderEmitter.fire(renderResult); } // If there is a queued render request, promote it to the current diff --git a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts index e263c113b06..93f126e6652 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts @@ -58,6 +58,26 @@ export class PositronPlotComm extends PositronBaseComm { return super.performRpc('render', ['height', 'width', 'pixel_ratio'], [height, width, pixelRatio]); } + /** + * Save a plot + * + * Requests a plot to be saved to a file. + * + * @param path The path to save the plot to + * @param height The requested plot height, in pixels + * @param width The requested plot width, in pixels + * @param pixelRatio The pixel ratio of the display device + * @param format The format to save the plot in + * + * @returns The path to the saved plot + */ + // save(path: string, height: number, width: number, pixelRatio: number, format: string): Promise { + // return super.performRpc('save', ['path', 'height', 'width', 'pixel_ratio', 'format'], [path, height, width, pixelRatio, format]); + // } + save(path: string, height: number, width: number): Promise { + return super.performRpc('save', ['path', 'height', 'width'], [path, height, width]); + } + /** * Notification that a plot has been updated on the backend. From ae23f991baf4654b49aea553c280f224e11e90c7 Mon Sep 17 00:00:00 2001 From: Tim Mok Date: Mon, 18 Mar 2024 09:46:04 -0400 Subject: [PATCH 03/10] Add DPI to save dialog --- positron/comms/plot-backend-openrpc.json | 51 +++++++++++++++++++ .../modalDialogs/savePlotModalDialog.css | 4 +- .../modalDialogs/savePlotModalDialog.tsx | 29 ++++++++--- .../browser/positronPlotsService.ts | 2 +- .../common/languageRuntimePlotClient.ts | 8 ++- .../common/positronPlotComm.ts | 30 +++++++---- 6 files changed, 100 insertions(+), 24 deletions(-) diff --git a/positron/comms/plot-backend-openrpc.json b/positron/comms/plot-backend-openrpc.json index ae607cc8cc9..47e9e7a9a48 100644 --- a/positron/comms/plot-backend-openrpc.json +++ b/positron/comms/plot-backend-openrpc.json @@ -53,6 +53,57 @@ ] } } + }, + { + "name": "save", + "summary": "Save a plot", + "description": "Requests a plot to be saved to a file. The plot path is returned as a string.", + "params": [ + { + "name": "path", + "description": "The requested plot path", + "schema": { + "type": "string" + } + }, + { + "name": "height", + "description": "The requested plot height, in pixels", + "schema": { + "type": "integer" + } + }, + { + "name": "width", + "description": "The requested plot width, in pixels", + "schema": { + "type": "integer" + } + }, + { + "name": "dpi", + "description": "The pixel ratio of the display device", + "schema": { + "type": "number" + } + } + ], + "result": { + "schema": { + "name": "saved_plot", + "type": "object", + "description": "A saved plot's path", + "properties": { + "path": { + "description": "The plot path", + "type": "string" + } + }, + "required": [ + "path" + ] + } + } } ] } diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css index 5c9d0e9e73a..ce620888ce3 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.css @@ -26,8 +26,9 @@ row-gap: 10px; } +/* 99% to prevent the image from overflowing the container */ img.plot-preview { - max-height: 80%; + max-height: 99%; max-width: 100%; position: relative; top: 50%; @@ -51,6 +52,7 @@ img.plot-preview { div.plot-preview-image-wrapper { height: 100%; width: auto; + overflow: auto; } .plot-save-dialog-action-bar .left, diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx index 4dc92606cef..87549f23966 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx @@ -11,11 +11,14 @@ import { localize } from 'vs/nls'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { PlotClientInstance } from 'vs/workbench/services/languageRuntime/common/languageRuntimePlotClient'; import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { PositronButton } from 'vs/base/browser/ui/positronComponents/positronButton'; +import { confirmationModalDialog } from 'vs/workbench/browser/positronModalDialogs/confirmationModalDialog'; interface SavePlotOptions { width: number; height: number; path: string; + dpi: number; } interface SavePlotModalDialogProps { @@ -61,6 +64,7 @@ export const showSavePlotModalDialog = async ( const [path, setPath] = React.useState(''); const widthInput = React.useRef(null); const heightInput = React.useRef(null); + const dpiInput = React.useRef(null); const [uri, setUri] = React.useState(''); const browseHandler = async () => { @@ -70,10 +74,18 @@ export const showSavePlotModalDialog = async ( const acceptHandler = async () => { const width = parseInt(widthInput.current!.value ?? '100'); const height = parseInt(heightInput.current!.value ?? '100'); + const dpi = parseInt(dpiInput.current!.value ?? '100'); + + if (!path) { + confirmationModalDialog(props.layoutService, + localize('positronSavePlotModalDialogNoPathTitle', "No Path Specified"), + localize('positronSavePlotModalDialogNoPathMessage', "No path was specified.")); + return; + } positronModalDialogReactRenderer.destroy(); - resolve({ width, height, path }); + resolve({ width, height, path, dpi }); }; const cancelHandler = () => { @@ -87,7 +99,8 @@ export const showSavePlotModalDialog = async ( } const width = parseInt(widthInput.current.value); const height = parseInt(heightInput.current.value); - props.plotClient.preview(height, width, props.plotClient.lastRender?.pixel_ratio ?? 1) + const dpi = dpiInput.current ? parseInt(dpiInput.current?.value) : props.plotClient.lastRender?.pixel_ratio ?? 100; + props.plotClient.preview(height, width, dpi / 100) .then((result) => { setUri(result.uri); }); @@ -125,6 +138,10 @@ export const showSavePlotModalDialog = async ( {localize('positronPlotPixelsAbbrev', 'px')}
+
+ + +
{ uri && @@ -142,12 +159,12 @@ export const showSavePlotModalDialog = async (
- - +
diff --git a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts index a09013a7c02..9f49cc98b2e 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts +++ b/src/vs/workbench/contrib/positronPlots/browser/positronPlotsService.ts @@ -694,7 +694,7 @@ export class PositronPlotsService extends Disposable implements IPositronPlotsSe // plotClient.preview(result.height, result.width, plot.lastRender?.pixel_ratio ?? 1).then(result => { // this.showSavePlotDialog(result.uri); // }); - plotClient.save(result.path, result.height, result.width); + plotClient.save(result.path, result.height, result.width, result.dpi); } }); } else { diff --git a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts index 4a20100224b..bb7ab6cc6c4 100644 --- a/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts +++ b/src/vs/workbench/services/languageRuntime/common/languageRuntimePlotClient.ts @@ -7,7 +7,7 @@ import { IRuntimeClientInstance, RuntimeClientState } from 'vs/workbench/service import { Event, Emitter } from 'vs/base/common/event'; import { DeferredPromise } from 'vs/base/common/async'; import { IPositronPlotClient } from 'vs/workbench/services/positronPlots/common/positronPlots'; -import { PositronPlotComm } from 'vs/workbench/services/languageRuntime/common/positronPlotComm'; +import { PositronPlotComm, SavedPlot } from 'vs/workbench/services/languageRuntime/common/positronPlotComm'; /** * The possible states for the plot client instance @@ -348,10 +348,8 @@ export class PlotClientInstance extends Disposable implements IPositronPlotClien return deferred.promise; } - public save(path: string, height: number, width: number): Promise { //, height: number, width: number, pixel_ratio: number, format: string): Promise { - // public save(path: string, height: number, width: number, pixel_ratio: number, format: string): Promise { - return this._comm.save(path, height, width); - // return this._comm.save(path, height, width, pixel_ratio, format); + public save(path: string, height: number, width: number, pixelRatio: number): Promise { + return this._comm.save(path, height, width, pixelRatio); } /** diff --git a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts index 93f126e6652..6832733ce58 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronPlotComm.ts @@ -26,6 +26,17 @@ export interface PlotResult { } +/** + * A saved plot's path + */ +export interface SavedPlot { + /** + * The plot path + */ + path: string; + +} + /** * Event: Notification that a plot has been updated on the backend. */ @@ -61,21 +72,18 @@ export class PositronPlotComm extends PositronBaseComm { /** * Save a plot * - * Requests a plot to be saved to a file. + * Requests a plot to be saved to a file. The plot path is returned as a + * string. * - * @param path The path to save the plot to + * @param path The requested plot path * @param height The requested plot height, in pixels * @param width The requested plot width, in pixels - * @param pixelRatio The pixel ratio of the display device - * @param format The format to save the plot in + * @param dpi The pixel ratio of the display device * - * @returns The path to the saved plot - */ - // save(path: string, height: number, width: number, pixelRatio: number, format: string): Promise { - // return super.performRpc('save', ['path', 'height', 'width', 'pixel_ratio', 'format'], [path, height, width, pixelRatio, format]); - // } - save(path: string, height: number, width: number): Promise { - return super.performRpc('save', ['path', 'height', 'width'], [path, height, width]); + * @returns A saved plot's path + */ + save(path: string, height: number, width: number, dpi: number): Promise { + return super.performRpc('save', ['path', 'height', 'width', 'dpi'], [path, height, width, dpi]); } From bb4cca617daed8227a5a2dd12eb45bbcda3c5cf6 Mon Sep 17 00:00:00 2001 From: Tim Mok Date: Thu, 21 Mar 2024 11:14:55 -0400 Subject: [PATCH 04/10] save plot dialog layout update use a progress bar when rendering move to a grid layout to provide more space to the preview --- .../lib/stylelint/vscode-known-variables.json | 2 + positron/comms/plot-backend-openrpc.json | 51 ----- .../components/labeledFolderInput.css | 4 + .../components/labeledFolderInput.tsx | 3 +- .../components/labeledTextInput.css | 8 + .../components/labeledTextInput.tsx | 15 +- .../components/okCancelActionBar.tsx | 9 + src/vs/workbench/common/theme.ts | 16 ++ .../modalDialogs/savePlotModalDialog.css | 65 ++++-- .../modalDialogs/savePlotModalDialog.tsx | 215 +++++++++++------- .../browser/positronPlotsService.ts | 36 ++- 11 files changed, 267 insertions(+), 157 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 14df5ef5eed..b75ec63e93c 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -569,10 +569,12 @@ "--vscode-positronModalDialog-checkboxBackground", "--vscode-positronModalDialog-checkboxBorder", "--vscode-positronModalDialog-checkboxForeground", + "--vscode-positronModalDialog-contrastBackground", "--vscode-positronModalDialog-defaultButtonBackground", "--vscode-positronModalDialog-defaultButtonForeground", "--vscode-positronModalDialog-defaultButtonHoverBackground", "--vscode-positronModalDialog-foreground", + "--vscode-positronModalDialog-formBorder", "--vscode-positronModalDialog-separator", "--vscode-positronModalDialog-textInputBackground", "--vscode-positronModalDialog-textInputBorder", diff --git a/positron/comms/plot-backend-openrpc.json b/positron/comms/plot-backend-openrpc.json index 47e9e7a9a48..ae607cc8cc9 100644 --- a/positron/comms/plot-backend-openrpc.json +++ b/positron/comms/plot-backend-openrpc.json @@ -53,57 +53,6 @@ ] } } - }, - { - "name": "save", - "summary": "Save a plot", - "description": "Requests a plot to be saved to a file. The plot path is returned as a string.", - "params": [ - { - "name": "path", - "description": "The requested plot path", - "schema": { - "type": "string" - } - }, - { - "name": "height", - "description": "The requested plot height, in pixels", - "schema": { - "type": "integer" - } - }, - { - "name": "width", - "description": "The requested plot width, in pixels", - "schema": { - "type": "integer" - } - }, - { - "name": "dpi", - "description": "The pixel ratio of the display device", - "schema": { - "type": "number" - } - } - ], - "result": { - "schema": { - "name": "saved_plot", - "type": "object", - "description": "A saved plot's path", - "properties": { - "path": { - "description": "The plot path", - "type": "string" - } - }, - "required": [ - "path" - ] - } - } } ] } diff --git a/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.css b/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.css index d0d545ef643..8f03fecb0c3 100644 --- a/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.css +++ b/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.css @@ -42,3 +42,7 @@ outline-offset: 2px; outline: 1px solid var(--vscode-focusBorder) !important; } + +.positron-modal-dialog-box .labeled-folder-input div.error { + color: var(--vscode-errorForeground); +} diff --git a/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.tsx b/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.tsx index 16dc178a802..46d624116b5 100644 --- a/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.tsx +++ b/src/vs/workbench/browser/positronComponents/positronModalDialog/components/labeledFolderInput.tsx @@ -18,6 +18,7 @@ import { Button } from 'vs/base/browser/ui/positronComponents/button/button'; export interface LabeledFolderInputProps { label: string; value: string; + error?: string; onBrowse: VoidFunction; onChange: ChangeEventHandler; } @@ -31,7 +32,7 @@ export const LabeledFolderInput = (props: LabeledFolderInputProps) => { return (