diff --git a/extensions/positron-python/python_files/posit/positron/plot_comm.py b/extensions/positron-python/python_files/posit/positron/plot_comm.py index 95941b6076e..87dc6cad5c5 100644 --- a/extensions/positron-python/python_files/posit/positron/plot_comm.py +++ b/extensions/positron-python/python_files/posit/positron/plot_comm.py @@ -19,9 +19,20 @@ @enum.unique -class RenderFormat(str, enum.Enum): +class PlotUnit(str, enum.Enum): + """ + Possible values for PlotUnit + """ + + Pixels = "pixels" + + Inches = "inches" + + +@enum.unique +class PlotRenderFormat(str, enum.Enum): """ - Possible values for RenderFormat + Possible values for PlotRenderFormat """ Png = "png" @@ -35,17 +46,6 @@ class RenderFormat(str, enum.Enum): Tiff = "tiff" -@enum.unique -class PlotUnit(str, enum.Enum): - """ - Possible values for PlotUnit - """ - - Pixels = "pixels" - - Inches = "inches" - - class IntrinsicSize(BaseModel): """ The intrinsic size of a plot, if known @@ -81,9 +81,9 @@ class PlotResult(BaseModel): description="The MIME type of the plot data", ) - policy: Optional[RenderPolicy] = Field( + settings: Optional[PlotRenderSettings] = Field( default=None, - description="The policy used to render the plot", + description="The settings used to render the plot", ) @@ -101,21 +101,21 @@ class PlotSize(BaseModel): ) -class RenderPolicy(BaseModel): +class PlotRenderSettings(BaseModel): """ - The policy used to render the plot + The settings used to render the plot """ size: PlotSize = Field( - description="Plot size of the render policy", + description="Plot size to render the plot to", ) pixel_ratio: Union[StrictInt, StrictFloat] = Field( description="The pixel ratio of the display device", ) - format: RenderFormat = Field( - description="Format of the render policy", + format: PlotRenderFormat = Field( + description="Format in which to render the plot", ) @@ -163,7 +163,7 @@ class RenderParams(BaseModel): description="The pixel ratio of the display device", ) - format: RenderFormat = Field( + format: PlotRenderFormat = Field( description="The requested plot format", ) @@ -215,7 +215,7 @@ class PlotFrontendEvent(str, enum.Enum): PlotSize.update_forward_refs() -RenderPolicy.update_forward_refs() +PlotRenderSettings.update_forward_refs() GetIntrinsicSizeRequest.update_forward_refs() diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py b/extensions/positron-python/python_files/posit/positron/tests/test_plots.py index 50d3c82f9c8..b6837e691c5 100644 --- a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py +++ b/extensions/positron-python/python_files/posit/positron/tests/test_plots.py @@ -182,7 +182,7 @@ def verify_response(response, filename: str, expected_size: Tuple[float, float], assert percent_difference(image.size[1], expected_size[1] * pixel_ratio) <= threshold # Check the rest of the response. - assert response == json_rpc_response({"mime_type": f"image/{format_}", "policy": None}) + assert response == json_rpc_response({"mime_type": f"image/{format_}", "settings": None}) verify_response(response, "test-mpl-render-0-explicit-size", (size.width, size.height)) diff --git a/extensions/positron-python/python_files/posit/positron/ui_comm.py b/extensions/positron-python/python_files/posit/positron/ui_comm.py index cf3a89e6019..07190fc32a1 100644 --- a/extensions/positron-python/python_files/posit/positron/ui_comm.py +++ b/extensions/positron-python/python_files/posit/positron/ui_comm.py @@ -17,6 +17,8 @@ from ._vendor.pydantic import BaseModel, Field, StrictBool, StrictFloat, StrictInt, StrictStr +from .plot_comm import PlotRenderSettings + Param = Any CallMethodResult = Any @@ -137,10 +139,47 @@ class UiBackendRequest(str, enum.Enum): An enumeration of all the possible requests that can be sent to the backend ui comm. """ + # Notification that the settings to render a plot (i.e. the plot size) + # have changed. + DidChangePlotsRenderSettings = "did_change_plots_render_settings" + # Run a method in the interpreter and return the result to the frontend CallMethod = "call_method" +class DidChangePlotsRenderSettingsParams(BaseModel): + """ + Typically fired when the plot component has been resized by the user. + This notification is useful to produce accurate pre-renderings of + plots. + """ + + settings: PlotRenderSettings = Field( + description="Plot rendering settings.", + ) + + +class DidChangePlotsRenderSettingsRequest(BaseModel): + """ + Typically fired when the plot component has been resized by the user. + This notification is useful to produce accurate pre-renderings of + plots. + """ + + params: DidChangePlotsRenderSettingsParams = Field( + description="Parameters to the DidChangePlotsRenderSettings method", + ) + + method: Literal[UiBackendRequest.DidChangePlotsRenderSettings] = Field( + description="The JSON-RPC method name (did_change_plots_render_settings)", + ) + + jsonrpc: str = Field( + default="2.0", + description="The JSON-RPC version specifier", + ) + + class CallMethodParams(BaseModel): """ Unlike other RPC methods, `call_method` calls into methods implemented @@ -180,7 +219,10 @@ class CallMethodRequest(BaseModel): class UiBackendMessageContent(BaseModel): comm_id: str - data: CallMethodRequest + data: Union[ + DidChangePlotsRenderSettingsRequest, + CallMethodRequest, + ] = Field(..., discriminator="method") @enum.unique @@ -477,6 +519,10 @@ class ShowHtmlFileParams(BaseModel): Range.update_forward_refs() +DidChangePlotsRenderSettingsParams.update_forward_refs() + +DidChangePlotsRenderSettingsRequest.update_forward_refs() + CallMethodParams.update_forward_refs() CallMethodRequest.update_forward_refs() diff --git a/extensions/positron-r/src/provider.ts b/extensions/positron-r/src/provider.ts index f980911d94d..b6c06f1fcf9 100644 --- a/extensions/positron-r/src/provider.ts +++ b/extensions/positron-r/src/provider.ts @@ -279,6 +279,9 @@ export async function makeMetadata( config.get('shutdownTimeout', 'immediately') !== 'immediately' ? positron.LanguageRuntimeSessionLocation.Machine : positron.LanguageRuntimeSessionLocation.Workspace; + // Subscribe to UI notifications of interest + const uiSubscriptions = [positron.UiRuntimeNotifications.DidChangePlotsRenderSettings]; + const metadata: positron.LanguageRuntimeMetadata = { runtimeId, runtimeName, @@ -295,6 +298,7 @@ export async function makeMetadata( ).toString('base64'), sessionLocation, startupBehavior, + uiSubscriptions, extraRuntimeData }; diff --git a/positron/comms/README.md b/positron/comms/README.md index 9cd7b96f9b8..18487a0e750 100644 --- a/positron/comms/README.md +++ b/positron/comms/README.md @@ -68,10 +68,16 @@ npm install If you've already installed dependencies, just run the code generator. This will (re-)generate code for all comms. -``` +```sh npx ts-node generate-comms.ts ``` +If you have `just` installed, then just run `just`: + +```sh +just +``` + For each contract, this writes Rust, Python, and Typescript modules using the current contract. It also prints each affected filepath. @@ -82,6 +88,12 @@ This will (re-)generate code only for the ui and variables comms. npx ts-node generate-comms.ts ui variables ``` +You can pass arguments via `just` as well but you'll need to explicitly name the `gen` recipe instead of relying on it being the default: + +``` +just gen ui variables +``` + Above we've targetted two comms by name, "ui" and "variables". It's also acceptable to specify a comm by mentioning any member of its trio of files, i.e. "ui.json", "ui-frontend-openrpc.json", and "ui-backend-openrpc.json" are the same as specifying "ui". diff --git a/positron/comms/generate-comms.ts b/positron/comms/generate-comms.ts index 88171db72f5..900b142f44c 100644 --- a/positron/comms/generate-comms.ts +++ b/positron/comms/generate-comms.ts @@ -16,6 +16,7 @@ import { execSync } from 'child_process'; import path from 'path'; const commsDir = `${__dirname}`; + let comms = [...new Set(readdirSync(commsDir) .filter(file => file.endsWith('.json')) .map(file => resolveComm(file)))]; @@ -153,10 +154,22 @@ function snakeCaseToSentenceCase(name: string) { * string, or undefined if the ref could not be parsed or found. */ function parseRefFromContract(ref: string, contract: any): string | undefined { + // The default target is the contract for internal references + let target: any = contract; + + // Check for external references (e.g. "schemas.json#/components/schemas/plot_size") + const extPointer = externalRefComponents(ref)?.jsonPointer; + if (extPointer) { + // Here we just return the reference without checking for existence like we + // do below. This check is performed at a later step when we generate a + // contract for external references. + const parts = ref.split('/'); + return snakeCaseToSentenceCase(parts[parts.length - 1]); + } + // Split the ref into parts, and then walk the contract to find the // referenced object const parts = ref.split('/'); - let target = contract; for (let i = 0; i < parts.length; i++) { if (parts[i] === '#') { continue; @@ -167,9 +180,32 @@ function parseRefFromContract(ref: string, contract: any): string | undefined { return undefined; } } + return snakeCaseToSentenceCase(parts[parts.length - 1]); } +/** + * Extracts the components of an external reference. + * + * This function parses a `$ref` string to determine if it points to an external + * file and extracts the file path and JSON pointer if applicable. + * + * @param ref The `$ref` string to parse. + * @returns An object containing the `filePath` and `jsonPointer` if the reference + * is external, or `undefined` if it is not. + */ +function externalRefComponents(ref: string): { filePath: string; jsonPointer: string } | undefined { + const match = ref.match(/^([^#]+)#(.+)$/); + if (!match) { + return undefined; + } + + return { + filePath: match[1], + jsonPointer: match[2], + }; +} + /** * Parse a ref tag to get the name of the object referred to by the ref. * Searches all the given contracts for the ref; throws if the ref cannot be @@ -420,16 +456,54 @@ function* oneOfVisitor( } } +/** + * Collect external references from contracts and returns them as arrays. + * + * @param contracts The OpenRPC contracts to process + * @returns An array of imported symbols grouped by external files. Each element + * represents one external file for which we detected external references. + * `fileName` is the bare name without `-backend/fronted-openrpc.json` suffix. + * `refs` is an array of imported type names. + */ +function collectExternalReferences(contracts: any[]): Array<{fileName: string; refs: Array}> { + const externalRefs = new Map>(); + + for (const contract of contracts) { + for (const ref of refVisitor(contract)) { + const externalRef = externalRefComponents(ref); + if (externalRef) { + const filePath = externalRef.filePath; + const refName = filePath.replace(/\.json$/, '').replace(/-(back|front)end-openrpc$/, ''); + if (!externalRefs.has(refName)) { + externalRefs.set(refName, new Set()); + } + + const parts = externalRef.jsonPointer.split('/'); + const schemaName = parts[parts.length - 1]; + externalRefs.get(refName)!.add(snakeCaseToSentenceCase(schemaName)); + } + } + } + + return Array.from(externalRefs.entries()).map(([fileName, refsSet]) => ({ + fileName, + refs: Array.from(refsSet) + })); +} + /** * Create a Rust comm for a given OpenRPC contract. * * @param name The name of the comm * @param frontend The OpenRPC contract for the frontend * @param backend The OpenRPC contract for the backend + * @param external The external schema contract (if any) * * @returns A generator that yields the Rust code for the comm */ function* createRustComm(name: string, frontend: any, backend: any): Generator { + const contracts = [backend, frontend].filter(element => element !== undefined); + yield `// @generated /*--------------------------------------------------------------------------------------------- @@ -442,149 +516,34 @@ function* createRustComm(name: string, frontend: any, backend: any): Generator, o: Record) { - if (o.description) { - yield formatComment('/// ', o.description); - } else { - yield formatComment('/// ', - snakeCaseToSentenceCase(context[0]) + ' in ' + - snakeCaseToSentenceCase(context[1])); - } - const name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; - const props = Object.keys(o.properties); - - // Map "any" type to `Value` - if (props.length === 0 && o.additionalProperties === true) { - return yield `pub type ${snakeCaseToSentenceCase(name)} = serde_json::Value;\n\n`; - } - - if (o.rust?.copy === true) { - yield '#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; - } else { - yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; - } - yield `pub struct ${snakeCaseToSentenceCase(name)} {\n`; - - for (let i = 0; i < props.length; i++) { - const key = props[i]; - const prop = o.properties[key]; - if (prop.description) { - yield formatComment('\t/// ', prop.description); - } - if (key === 'type') { - yield '\t#[serde(rename = "type")]\n'; - yield `\tpub ${name}_type: `; - } else { - yield `\tpub ${key}: `; - } - if (!o.required || !o.required.includes(key)) { - yield 'Option<'; - yield deriveType(contracts, RustTypeMap, [key, ...context], prop); - yield '>'; - - } else { - yield deriveType(contracts, RustTypeMap, [key, ...context], prop); - } - if (i < props.length - 1) { - yield ',\n'; + // Add imports for external references + const externalReferences = collectExternalReferences(contracts); + if (externalReferences.length) { + for (const { fileName, refs } of externalReferences) { + if (refs.length) { + for (const ref of refs) { + yield `use super::${fileName}_comm::${ref};`; } yield '\n'; } - yield '}\n\n'; - }); + } + } - // Create enums for all enum types - yield* enumVisitor([], source, function* (context: Array, values: Array) { - if (context.length === 1) { - // Shared enum at the components.schemas level - yield formatComment(`/// `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0])); - yield '#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display)]\n'; - yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; - } else { - // Enum field within another interface - yield formatComment(`/// `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0]) + ` in ` + - snakeCaseToSentenceCase(context[1])); - yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display)]\n'; - yield `pub enum ${snakeCaseToSentenceCase(context[1])}${snakeCaseToSentenceCase(context[0])} {\n`; - } - for (let i = 0; i < values.length; i++) { - const value = values[i]; - yield `\t#[serde(rename = "${value}")]\n`; - yield `\t#[strum(to_string = "${value}")]\n`; - yield `\t${snakeCaseToSentenceCase(value)}`; - if (i < values.length - 1) { - yield ',\n\n'; - } else { - yield '\n'; - } - } - yield '}\n\n'; - }); + yield `\n`; - // Create enums for all oneOf types for unions - yield* oneOfVisitor([], source, function* (context: Array, o: Record) { - if (context.length === 1) { - // Shared oneOf at the components.schemas level - yield formatComment(`/// `, - `Union type ` + - snakeCaseToSentenceCase(context[0])); - if (o.description) { - yield formatComment(`/// `, o.description); - } - yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; - yield '#[serde(untagged)]\n'; - yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; - } else { - // Enum field within another interface - yield formatComment(`/// `, - `Union type ` + - snakeCaseToSentenceCase(context[0]) + ` in ` + - snakeCaseToSentenceCase(context[1])); - yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; - yield '#[serde(untagged)]\n'; - yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; - } - for (let i = 0; i < o.oneOf.length; i++) { - const option = o.oneOf[i]; - if (option.name === undefined) { - throw new Error(`No name in option: ${JSON.stringify(option)}`); - } - const derivedType = deriveType(contracts, RustTypeMap, [option.name, ...context], - option); - yield `\t${snakeCaseToSentenceCase(option.name)}(${derivedType})`; - if (i < o.oneOf.length - 1) { - yield ',\n\n'; - } else { - yield '\n'; - } - } - yield '}\n\n'; - }); + const namedContracts = [ + { name: 'Backend', source: backend }, + { name: 'Frontend', source: frontend } + ]; + + for (const contract of contracts) { + yield* createRustValueTypes(contract, contracts); } // Create parameter objects for each method for (const source of contracts) { - if (!source) { - continue; - } for (const method of source.methods) { if (method.params.length > 0) { yield formatComment(`/// `, @@ -777,6 +736,259 @@ pub fn ${name}_frontend_reply_from_value( } } +/** + * Process schema fields and create types for objects, enums, and one-ofs. + */ +function* createRustValueTypes(source: any, contracts: any[]): Generator { + // Create structs for all object types + yield* objectVisitor([], source, function* (context: Array, o: Record) { + if (o.description) { + yield formatComment('/// ', o.description); + } else { + yield formatComment('/// ', + snakeCaseToSentenceCase(context[0]) + ' in ' + + snakeCaseToSentenceCase(context[1])); + } + const name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; + const props = Object.keys(o.properties); + + // Map "any" type to `Value` + if (props.length === 0 && o.additionalProperties === true) { + return yield `pub type ${snakeCaseToSentenceCase(name)} = serde_json::Value;\n\n`; + } + + if (o.rust?.copy === true) { + yield '#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; + } else { + yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; + } + yield `pub struct ${snakeCaseToSentenceCase(name)} {\n`; + + for (let i = 0; i < props.length; i++) { + const key = props[i]; + const prop = o.properties[key]; + if (prop.description) { + yield formatComment('\t/// ', prop.description); + } + if (key === 'type') { + yield '\t#[serde(rename = "type")]\n'; + yield `\tpub ${name}_type: `; + } else { + yield `\tpub ${key}: `; + } + if (!o.required || !o.required.includes(key)) { + yield 'Option<'; + yield deriveType(contracts, RustTypeMap, [key, ...context], prop); + yield '>'; + + } else { + yield deriveType(contracts, RustTypeMap, [key, ...context], prop); + } + if (i < props.length - 1) { + yield ',\n'; + } + yield '\n'; + } + yield '}\n\n'; + }); + + // Create enums for all enum types + yield* enumVisitor([], source, function* (context: Array, values: Array) { + if (context.length === 1) { + // Shared enum at the components.schemas level + yield formatComment(`/// `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0])); + yield '#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display)]\n'; + yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; + } else { + // Enum field within another interface + yield formatComment(`/// `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0]) + ` in ` + + snakeCaseToSentenceCase(context[1])); + yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display)]\n'; + yield `pub enum ${snakeCaseToSentenceCase(context[1])}${snakeCaseToSentenceCase(context[0])} {\n`; + } + for (let i = 0; i < values.length; i++) { + const value = values[i]; + yield `\t#[serde(rename = "${value}")]\n`; + yield `\t#[strum(to_string = "${value}")]\n`; + yield `\t${snakeCaseToSentenceCase(value)}`; + if (i < values.length - 1) { + yield ',\n\n'; + } else { + yield '\n'; + } + } + yield '}\n\n'; + }); + + // Create enums for all oneOf types for unions + yield* oneOfVisitor([], source, function* (context: Array, o: Record) { + if (context.length === 1) { + // Shared oneOf at the components.schemas level + yield formatComment(`/// `, + `Union type ` + + snakeCaseToSentenceCase(context[0])); + if (o.description) { + yield formatComment(`/// `, o.description); + } + yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; + yield '#[serde(untagged)]\n'; + yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; + } else { + // Enum field within another interface + yield formatComment(`/// `, + `Union type ` + + snakeCaseToSentenceCase(context[0]) + ` in ` + + snakeCaseToSentenceCase(context[1])); + yield '#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\n'; + yield '#[serde(untagged)]\n'; + yield `pub enum ${snakeCaseToSentenceCase(context[0])} {\n`; + } + for (let i = 0; i < o.oneOf.length; i++) { + const option = o.oneOf[i]; + if (option.name === undefined) { + throw new Error(`No name in option: ${JSON.stringify(option)}`); + } + const derivedType = deriveType(contracts, RustTypeMap, [option.name, ...context], + option); + yield `\t${snakeCaseToSentenceCase(option.name)}(${derivedType})`; + if (i < o.oneOf.length - 1) { + yield ',\n\n'; + } else { + yield '\n'; + } + } + yield '}\n\n'; + }); +} + +/** + * Process schema fields and create types for objects, enums, and one-ofs in Python. + */ +function* createPythonValueTypes(source: any, contracts: any[], models: string[]): Generator { + // Create enums for all enum types + yield* enumVisitor([], source, function* (context: Array, values: Array) { + if (context.length === 1) { + // Shared enum at the components.schemas level + yield '@enum.unique\n'; + yield `class ${snakeCaseToSentenceCase(context[0])}(str, enum.Enum):\n`; + yield ' """\n'; + yield formatComment(` `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0])); + } else { + // Enum field within another interface + yield '@enum.unique\n'; + yield `class ${snakeCaseToSentenceCase(context[1])}`; + yield `${snakeCaseToSentenceCase(context[0])}(str, enum.Enum):\n`; + yield ' """\n'; + yield formatComment(` `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0]) + + ` in ` + + snakeCaseToSentenceCase(context[1])); + } + yield ' """\n'; + yield '\n'; + for (let i = 0; i < values.length; i++) { + const value = values[i]; + yield ` ${snakeCaseToSentenceCase(value)} = "${value}"`; + if (i < values.length - 1) { + yield '\n\n'; + } else { + yield '\n'; + } + } + yield '\n\n'; + }); + + // Create pydantic models for all object types + yield* objectVisitor([], source, function* ( + context: Array, + o: Record) { + + let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; + name = snakeCaseToSentenceCase(name); + + // Empty object specs map to `Any` + const props = Object.keys(o.properties); + if ((!props || !props.length) && o.additionalProperties === true) { + return yield `${name} = Any\n`; + } + + // Preamble + models.push(name); + yield `class ${name}(BaseModel):\n`; + + // Docstring + if (o.description) { + yield ' """\n'; + yield formatComment(' ', o.description); + yield ' """\n'; + yield '\n'; + } else { + yield ' """\n'; + yield formatComment(' ', snakeCaseToSentenceCase(context[0]) + ' in ' + + snakeCaseToSentenceCase(context[1])); + yield ' """\n'; + yield '\n'; + } + + // Fields + for (const prop of Object.keys(o.properties)) { + const schema = o.properties[prop]; + yield ` ${prop}: `; + if (!o.required || !o.required.includes(prop)) { + yield 'Optional['; + yield deriveType(contracts, PythonTypeMap, [prop, ...context], schema); + yield ']'; + } else { + yield deriveType(contracts, PythonTypeMap, [prop, ...context], schema); + } + yield ' = Field(\n'; + if (!o.required || !o.required.includes(prop)) { + yield ` default=None,\n`; + } + yield ` description="${schema.description}",\n`; + yield ` )\n\n`; + } + yield '\n\n'; + }); + + // Create declare out-of-line union types + yield* oneOfVisitor([], source, function* ( + context: Array, + o: Record) { + + let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; + name = snakeCaseToSentenceCase(name); + + // Document origin of union + if (o.description) { + yield formatComment('# ', o.description); + } else if (context.length === 1) { + yield formatComment('# ', snakeCaseToSentenceCase(context[0])); + } else { + yield formatComment('# ', snakeCaseToSentenceCase(context[0]) + ' in ' + + snakeCaseToSentenceCase(context[1])); + } + + yield `${name} = Union[`; + // Options + for (const option of o.oneOf) { + if (option.name === undefined) { + throw new Error(`No name in option: ${JSON.stringify(option)}`); + } + yield deriveType(contracts, PythonTypeMap, [option.name, ...context], option); + yield ', '; + } + yield ']\n'; + }); +} + /** * Create a Python comm for a given OpenRPC contract. * @@ -786,9 +998,7 @@ pub fn ${name}_frontend_reply_from_value( * * @returns A generator that yields the Python code for the comm */ -function* createPythonComm(name: string, - frontend: any, - backend: any): Generator { +function* createPythonComm(name: string, frontend: any, backend: any): Generator { yield `# # Copyright (C) 2024-${year} Posit Software, PBC. All rights reserved. # Licensed under the Elastic License 2.0. See LICENSE.txt for license information. @@ -810,133 +1020,23 @@ from ._vendor.pydantic import BaseModel, Field, StrictBool, StrictFloat, StrictI `; - const models = Array(); - - const contracts = [backend, frontend]; - for (const source of contracts) { - if (!source) { - continue; - } - - // Create enums for all enum types - yield* enumVisitor([], source, function* (context: Array, values: Array) { - if (context.length === 1) { - // Shared enum at the components.schemas level - yield '@enum.unique\n'; - yield `class ${snakeCaseToSentenceCase(context[0])}(str, enum.Enum):\n`; - yield ' """\n'; - yield formatComment(` `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0])); - } else { - // Enum field within another interface - yield '@enum.unique\n'; - yield `class ${snakeCaseToSentenceCase(context[1])}`; - yield `${snakeCaseToSentenceCase(context[0])}(str, enum.Enum):\n`; - yield ' """\n'; - yield formatComment(` `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0]) + - ` in ` + - snakeCaseToSentenceCase(context[1])); - } - yield ' """\n'; - yield '\n'; - for (let i = 0; i < values.length; i++) { - const value = values[i]; - yield ` ${snakeCaseToSentenceCase(value)} = "${value}"`; - if (i < values.length - 1) { - yield '\n\n'; - } else { - yield '\n'; - } - } - yield '\n\n'; - }); - - // Create pydantic models for all object types - yield* objectVisitor([], source, function* ( - context: Array, - o: Record) { - - let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; - name = snakeCaseToSentenceCase(name); - - // Empty object specs map to `Any` - const props = Object.keys(o.properties); - if ((!props || !props.length) && o.additionalProperties === true) { - return yield `${name} = Any\n`; - } - - // Preamble - models.push(name); - yield `class ${name}(BaseModel):\n`; + const contracts = [backend, frontend].filter(element => element !== undefined); - // Docstring - if (o.description) { - yield ' """\n'; - yield formatComment(' ', o.description); - yield ' """\n'; - yield '\n'; - } else { - yield ' """\n'; - yield formatComment(' ', snakeCaseToSentenceCase(context[0]) + ' in ' + - snakeCaseToSentenceCase(context[1])); - yield ' """\n'; - yield '\n'; + // Add imports for external references + const externalReferences = collectExternalReferences(contracts); + if (externalReferences.length) { + for (const { fileName, refs } of externalReferences) { + if (refs.length) { + yield `from .${fileName}_comm import ${refs.join(', ')}\n`; } + } + yield `\n`; + } - // Fields - for (const prop of Object.keys(o.properties)) { - const schema = o.properties[prop]; - yield ` ${prop}: `; - if (!o.required || !o.required.includes(prop)) { - yield 'Optional['; - yield deriveType(contracts, PythonTypeMap, [prop, ...context], schema); - yield ']'; - } else { - yield deriveType(contracts, PythonTypeMap, [prop, ...context], schema); - } - yield ' = Field(\n'; - if (!o.required || !o.required.includes(prop)) { - yield ` default=None,\n`; - } - yield ` description="${schema.description}",\n`; - yield ` )\n\n`; - } - yield '\n\n'; - }); - - // Create declare out-of-line union types - yield* oneOfVisitor([], source, function* ( - context: Array, - o: Record) { - - let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; - name = snakeCaseToSentenceCase(name); - - // Document origin of union - - if (o.description) { - yield formatComment('# ', o.description); - } else if (context.length === 1) { - yield formatComment('# ', snakeCaseToSentenceCase(context[0])); - } else { - yield formatComment('# ', snakeCaseToSentenceCase(context[0]) + ' in ' + - snakeCaseToSentenceCase(context[1])); - } + const models = Array(); - yield `${name} = Union[`; - // Options - for (const option of o.oneOf) { - if (option.name === undefined) { - throw new Error(`No name in option: ${JSON.stringify(option)}`); - } - yield deriveType(contracts, PythonTypeMap, [option.name, ...context], option); - yield ', '; - } - yield ']\n'; - }); + for (const source of contracts) { + yield* createPythonValueTypes(source, contracts, models); } if (backend) { @@ -1020,7 +1120,7 @@ from ._vendor.pydantic import BaseModel, Field, StrictBool, StrictFloat, StrictI } // Create the backend message content class - if (backend) { + if (backend && backend.methods) { yield `class ${snakeCaseToSentenceCase(name)}BackendMessageContent(BaseModel):\n`; yield ` comm_id: str\n`; if (backend.methods.length === 1) { @@ -1180,128 +1280,22 @@ function* createTypescriptComm(name: string, frontend: any, backend: any): Gener import { IRuntimeClientInstance } from './languageRuntimeClientInstance.js'; `; - const contracts = [backend, frontend]; - const namedContracts = [{ name: 'Backend', source: backend }, - { name: 'Frontend', source: frontend }]; - for (const source of contracts) { - if (!source) { - continue; - } - yield* objectVisitor([], source, - function* (context: Array, o: Record): Generator { - const name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; - const description = o.description ? o.description : - snakeCaseToSentenceCase(context[0]) + ' in ' + - snakeCaseToSentenceCase(context[1]); - const additionalProperties = o.additionalProperties ? o.additionalProperties : false; - yield* createTypescriptInterface(contracts, context, name, description, o.properties, - o.required ? o.required : [], additionalProperties); - }); - - // Create declare out-of-line union types - yield* oneOfVisitor([], source, function* ( - context: Array, - o: Record) { - - let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; - name = snakeCaseToSentenceCase(name); - - // Document origin of union - if (o.description) { - yield formatComment('/// ', o.description); - } else if (context.length === 1) { - yield formatComment('/// ', snakeCaseToSentenceCase(context[0])); - } else { - yield formatComment('/// ', snakeCaseToSentenceCase(context[0]) + ' in ' + - snakeCaseToSentenceCase(context[1])); - } - yield `export type ${name} = `; - // Options - for (let i = 0; i < o.oneOf.length; i++) { - const option = o.oneOf[i]; - if (option.name === undefined) { - throw new Error(`No name in option: ${JSON.stringify(option)}`); - } - yield deriveType(contracts, TypescriptTypeMap, [option.name, ...context], option); - if (i < o.oneOf.length - 1) { - yield ' | '; - } - } - yield ';\n\n'; - }); + const contracts = [backend, frontend].filter(element => element !== undefined); - // Create enums for all enum types - yield* enumVisitor([], source, function* (context: Array, values: Array) { - yield '/**\n'; - if (context.length === 1) { - // Shared enum at the components.schemas level - yield formatComment(` * `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0])); - yield ' */\n'; - yield `export enum ${snakeCaseToSentenceCase(context[0])} {\n`; - } else { - // Enum field within another interface - yield formatComment(` * `, - `Possible values for ` + - snakeCaseToSentenceCase(context[0]) + ` in ` + - snakeCaseToSentenceCase(context[1])); - yield ' */\n'; - yield `export enum ${snakeCaseToSentenceCase(context[1])}${snakeCaseToSentenceCase(context[0])} {\n`; - } - for (let i = 0; i < values.length; i++) { - const value = values[i]; - yield `\t${snakeCaseToSentenceCase(value)} = '${value}'`; - if (i < values.length - 1) { - yield ',\n'; - } else { - yield '\n'; - } - } - yield '}\n\n'; - }); - - // Create parameter objects for each method - for (const method of source.methods) { - if (method.params.length > 0) { - yield '/**\n'; - yield formatComment(` * `, - `Parameters for the ` + - snakeCaseToSentenceCase(method.name) + ` ` + - `method.`); - yield ' */\n'; - yield `export interface ${snakeCaseToSentenceCase(method.name)}Params {\n`; - for (let i = 0; i < method.params.length; i++) { - const param = method.params[i]; - if (param.description) { - yield '\t/**\n'; - yield formatComment('\t * ', param.description); - yield '\t */\n'; - } - if (param.schema.enum) { - // Use an enum type if the schema has an enum - yield `\t${param.name}: ${snakeCaseToSentenceCase(method.name)}${snakeCaseToSentenceCase(param.name)};\n`; - } else if (param.schema.type === 'object' && Object.keys(param.schema.properties).length === 0) { - // Handle the "any" type - yield `\t${param.name}: any;\n`; - } else { - // Otherwise use the type directly - yield `\t${param.name}`; - if (isOptional(param)) { - yield `?`; - } - yield `: `; - yield deriveType(contracts, TypescriptTypeMap, [param.name], param.schema); - yield `;\n`; - } - if (i < method.params.length - 1) { - yield '\n'; - } - } - yield `}\n\n`; + // Add imports for external references + const externalReferences = collectExternalReferences(contracts); + if (externalReferences.length) { + for (const { fileName, refs } of externalReferences) { + if (refs.length) { + yield `import { ${refs.join(', ')} } from './positron${snakeCaseToSentenceCase(fileName)}Comm.js';\n`; } } + yield `\n`; + } + + for (const source of contracts) { + yield* createTypescriptValueTypes(source, contracts); } if (frontend) { @@ -1518,6 +1512,158 @@ import { IRuntimeClientInstance } from './languageRuntimeClientInstance.js'; yield `}\n\n`; } +/** + * Process schema fields and create types for objects, enums, and one-ofs in TypeScript. + */ +function* createTypescriptValueTypes(source: any, contracts: any[]): Generator { + // Create interfaces for all object types + yield* objectVisitor([], source, + function* (context: Array, o: Record): Generator { + const name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; + const description = o.description ? o.description : + snakeCaseToSentenceCase(context[0]) + ' in ' + + snakeCaseToSentenceCase(context[1]); + const additionalProperties = o.additionalProperties ? o.additionalProperties : false; + yield* createTypescriptInterface(contracts, context, name, description, o.properties, + o.required ? o.required : [], additionalProperties); + }); + + // Create declare out-of-line union types + yield* oneOfVisitor([], source, function* ( + context: Array, + o: Record) { + + let name = o.name ? o.name : context[0] === 'items' ? context[1] : context[0]; + name = snakeCaseToSentenceCase(name); + + // Document origin of union + if (o.description) { + yield formatComment('/// ', o.description); + } else if (context.length === 1) { + yield formatComment('/// ', snakeCaseToSentenceCase(context[0])); + } else { + yield formatComment('/// ', snakeCaseToSentenceCase(context[0]) + ' in ' + + snakeCaseToSentenceCase(context[1])); + } + yield `export type ${name} = `; + // Options + for (let i = 0; i < o.oneOf.length; i++) { + const option = o.oneOf[i]; + if (option.name === undefined) { + throw new Error(`No name in option: ${JSON.stringify(option)}`); + } + yield deriveType(contracts, TypescriptTypeMap, [option.name, ...context], option); + if (i < o.oneOf.length - 1) { + yield ' | '; + } + } + yield ';\n\n'; + }); + + // Create enums for all enum types + yield* enumVisitor([], source, function* (context: Array, values: Array) { + yield '/**\n'; + if (context.length === 1) { + // Shared enum at the components.schemas level + yield formatComment(` * `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0])); + yield ' */\n'; + yield `export enum ${snakeCaseToSentenceCase(context[0])} {\n`; + } else { + // Enum field within another interface + yield formatComment(` * `, + `Possible values for ` + + snakeCaseToSentenceCase(context[0]) + ` in ` + + snakeCaseToSentenceCase(context[1])); + yield ' */\n'; + yield `export enum ${snakeCaseToSentenceCase(context[1])}${snakeCaseToSentenceCase(context[0])} {\n`; + } + for (let i = 0; i < values.length; i++) { + const value = values[i]; + yield `\t${snakeCaseToSentenceCase(value)} = '${value}'`; + if (i < values.length - 1) { + yield ',\n'; + } else { + yield '\n'; + } + } + yield '}\n\n'; + }); + + // Create parameter objects for each method + if (source.methods) { + for (const method of source.methods) { + if (method.params.length > 0) { + yield '/**\n'; + yield formatComment(` * `, + `Parameters for the ` + + snakeCaseToSentenceCase(method.name) + ` ` + + `method.`); + yield ' */\n'; + yield `export interface ${snakeCaseToSentenceCase(method.name)}Params {\n`; + for (let i = 0; i < method.params.length; i++) { + const param = method.params[i]; + if (param.description) { + yield '\t/**\n'; + yield formatComment('\t * ', param.description); + yield '\t */\n'; + } + if (param.schema.enum) { + // Use an enum type if the schema has an enum + yield `\t${param.name}: ${snakeCaseToSentenceCase(method.name)}${snakeCaseToSentenceCase(param.name)};\n`; + } else if (param.schema.type === 'object' && Object.keys(param.schema.properties).length === 0) { + // Handle the "any" type + yield `\t${param.name}: any;\n`; + } else { + // Otherwise use the type directly + yield `\t${param.name}`; + if (isOptional(param)) { + yield `?`; + } + yield `: `; + yield deriveType(contracts, TypescriptTypeMap, [param.name], param.schema); + yield `;\n`; + } + if (i < method.params.length - 1) { + yield '\n'; + } + } + yield `}\n\n`; + } + } + } +} + +/** + * Visitor function for collecting `$ref` references in an OpenRPC contract. + * Recursively discovers and yields all `$ref` values. + * + * @param contract The OpenRPC contract to visit. + * @returns A generator that yields each `$ref` reference. + */ +function* refVisitor( + contract: any, +): Generator { + if (Array.isArray(contract)) { + for (const item of contract) { + yield* refVisitor(item); + } + return; + } + + if (contract && typeof contract === 'object') { + // Found one + if (contract.$ref) { + yield contract.$ref; + } + + for (const key of Object.keys(contract)) { + yield* refVisitor(contract[key]); + } + } +} + async function createCommInterface() { for (const file of commsFiles) { // Ignore non-JSON files diff --git a/positron/comms/justfile b/positron/comms/justfile new file mode 100644 index 00000000000..aa5e76f69d3 --- /dev/null +++ b/positron/comms/justfile @@ -0,0 +1,2 @@ +gen +args="": + npx ts-node generate-comms.ts {{args}} diff --git a/positron/comms/plot-backend-openrpc.json b/positron/comms/plot-backend-openrpc.json index f2f5f7c0c00..c4dc011b38f 100644 --- a/positron/comms/plot-backend-openrpc.json +++ b/positron/comms/plot-backend-openrpc.json @@ -67,7 +67,7 @@ "name": "format", "description": "The requested plot format", "schema": { - "$ref": "#/components/schemas/render_format" + "$ref": "#/components/schemas/plot_render_format" } } ], @@ -85,13 +85,12 @@ "description": "The MIME type of the plot data", "type": "string" }, - "policy": { - "description": "The policy used to render the plot", - "$ref": "#/components/schemas/render_policy" + "settings": { + "description": "The settings used to render the plot", + "$ref": "#/components/schemas/plot_render_settings" } }, "required": [ - "size", "data", "mime_type" ] @@ -101,6 +100,11 @@ ], "components": { "schemas": { + "plot_unit": { + "type": "string", + "description": "The unit of measurement of a plot's dimensions", + "enum": ["pixels", "inches"] + }, "plot_size": { "type": "object", "description": "The size of a plot", @@ -114,30 +118,22 @@ "type": "integer" } }, - "required": [ - "height", - "width" - ], + "required": ["height", "width"], "rust": { "copy": true } }, - "render_format":{ + "plot_render_format": { "description": "The requested plot format", "type": "string", "enum": ["png", "jpeg", "svg", "pdf", "tiff"] }, - "plot_unit": { - "type": "string", - "description": "The unit of measurement of a plot's dimensions", - "enum": ["pixels", "inches"] - }, - "render_policy": { + "plot_render_settings": { "type": "object", - "description": "The policy used to render the plot", + "description": "The settings used to render the plot", "properties": { "size": { - "description": "Plot size of the render policy", + "description": "Plot size to render the plot to", "$ref": "#/components/schemas/plot_size" }, "pixel_ratio": { @@ -145,15 +141,11 @@ "type": "number" }, "format": { - "description": "Format of the render policy", - "$ref": "#/components/schemas/render_format" + "description": "Format in which to render the plot", + "$ref": "#/components/schemas/plot_render_format" } }, - "required": [ - "size", - "pixel_ratio", - "format" - ], + "required": ["size", "pixel_ratio", "format"], "rust": { "copy": true } diff --git a/positron/comms/ui-backend-openrpc.json b/positron/comms/ui-backend-openrpc.json index 89defefbcac..0c4fa05c27f 100644 --- a/positron/comms/ui-backend-openrpc.json +++ b/positron/comms/ui-backend-openrpc.json @@ -5,6 +5,27 @@ "version": "1.0.0" }, "methods": [ + { + "name": "did_change_plots_render_settings", + "summary": "Notification that the settings to render a plot (i.e. the plot size) have changed.", + "description": "Typically fired when the plot component has been resized by the user. This notification is useful to produce accurate pre-renderings of plots.", + "params": [ + { + "name": "settings", + "description": "Plot rendering settings.", + "schema": { + "$ref": "plot-backend-openrpc.json#/components/schemas/plot_render_settings" + } + } + ], + "result": { + "schema": { + "name": "did_change_plots_render_settings_result", + "description": "Unused response to notification", + "type": "null" + } + } + }, { "name": "call_method", "summary": "Run a method in the interpreter and return the result to the frontend", diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index c0f25138a98..dab248cc2b6 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -465,6 +465,20 @@ declare module 'positron' { * when creating a new session from the metadata. */ extraRuntimeData: any; + + /** + * Subscriptions to notifications from the UI. When subscribed, the frontend sends + * notifications to the backend via the UI client. + */ + uiSubscriptions?: UiRuntimeNotifications[]; + } + + /** + * UI notifications from frontend to backends. + */ + export enum UiRuntimeNotifications { + /** Notification that the settings for rendering a plot have changed, typically because the plot area did */ + DidChangePlotsRenderSettings = 'did_change_plots_render_settings', } export interface RuntimeSessionMetadata { @@ -1330,6 +1344,29 @@ declare module 'positron' { installDependencies?: () => Promise; } + /** + * Settings necessary to render a plot in the format expected by the plot widget. + */ + export interface PlotRenderSettings { + size: { + width: number; + height: number; + }; + pixel_ratio: number; + format: PlotRenderFormat; + } + + /** + * Possible formats for rendering a plot. + */ + export enum PlotRenderFormat { + Png = 'png', + Jpeg = 'jpeg', + Svg = 'svg', + Pdf = 'pdf', + Tiff = 'tiff' + } + namespace languages { /** * Register a statement range provider. @@ -1452,6 +1489,18 @@ declare module 'positron' { * Returns the current width of the console input, in characters. */ export function getConsoleWidth(): Thenable; + + /** + * Fires when the settings necessary to render a plot in the format expected by + * plot widget have changed. + */ + export const onDidChangePlotsRenderSettings: vscode.Event; + + /** + * Returns the settings necessary to render a plot in the format expected by + * plot widget. + */ + export function getPlotsRenderSettings(): Thenable; } namespace runtime { diff --git a/src/vs/workbench/api/browser/positron/mainThreadPlotsService.ts b/src/vs/workbench/api/browser/positron/mainThreadPlotsService.ts new file mode 100644 index 00000000000..246c82028d5 --- /dev/null +++ b/src/vs/workbench/api/browser/positron/mainThreadPlotsService.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ExtHostPlotsServiceShape, ExtHostPositronContext, MainPositronContext, MainThreadPlotsServiceShape } from '../../common/positron/extHost.positron.protocol.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; +import { IPositronPlotsService, PlotRenderSettings } from '../../../services/positronPlots/common/positronPlots.js'; + +@extHostNamedCustomer(MainPositronContext.MainThreadPlotsService) +export class MainThreadPlotsService implements MainThreadPlotsServiceShape { + + private readonly _disposables = new DisposableStore(); + private readonly _proxy: ExtHostPlotsServiceShape; + + constructor( + extHostContext: IExtHostContext, + @IPositronPlotsService private readonly _positronPlotsService: IPositronPlotsService + ) { + // Create the proxy for the extension host. + this._proxy = extHostContext.getProxy(ExtHostPositronContext.ExtHostPlotsService); + + // Forward changes to the plot rendering settings to the extension host. + this._disposables.add( + this._positronPlotsService.onDidChangePlotsRenderSettings((settings) => { + this._proxy.$onDidChangePlotsRenderSettings(settings); + })); + } + + dispose(): void { + this._disposables.dispose(); + } + + async $getPlotsRenderSettings(): Promise { + return this._positronPlotsService.getPlotsRenderSettings(); + } +} diff --git a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts index b3ec2ecb6ed..b3d1b16d41a 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts @@ -35,6 +35,7 @@ import { ExtHostAiFeatures } from './extHostAiFeatures.js'; import { IToolInvocationContext } from '../../../contrib/chat/common/languageModelToolsService.js'; import { IPositronLanguageModelSource } from '../../../contrib/positronAssistant/common/interfaces/positronAssistantService.js'; import { ExtHostEnvironment } from './extHostEnvironment.js'; +import { ExtHostPlotsService } from './extHostPlotsService.js'; /** * Factory interface for creating an instance of the Positron API. @@ -76,6 +77,7 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce const extHostModalDialogs = rpcProtocol.set(ExtHostPositronContext.ExtHostModalDialogs, new ExtHostModalDialogs(rpcProtocol)); const extHostContextKeyService = rpcProtocol.set(ExtHostPositronContext.ExtHostContextKeyService, new ExtHostContextKeyService(rpcProtocol)); const extHostConsoleService = rpcProtocol.set(ExtHostPositronContext.ExtHostConsoleService, new ExtHostConsoleService(rpcProtocol, extHostLogService)); + const extHostPlotsService = rpcProtocol.set(ExtHostPositronContext.ExtHostPlotsService, new ExtHostPlotsService(rpcProtocol)); const extHostMethods = rpcProtocol.set(ExtHostPositronContext.ExtHostMethods, new ExtHostMethods(rpcProtocol, extHostEditors, extHostDocuments, extHostModalDialogs, extHostLanguageRuntime, extHostWorkspace, extHostQuickOpen, extHostCommands, extHostContextKeyService)); @@ -184,6 +186,12 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce }, getConsoleWidth(): Thenable { return extHostConsoleService.getConsoleWidth(); + }, + get onDidChangePlotsRenderSettings() { + return extHostPlotsService.onDidChangePlotsRenderSettings; + }, + getPlotsRenderSettings(): Thenable { + return extHostPlotsService.getPlotsRenderSettings(); } }; @@ -298,6 +306,8 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce RuntimeOnlineState: extHostTypes.RuntimeOnlineState, RuntimeState: extHostTypes.RuntimeState, RuntimeCodeFragmentStatus: extHostTypes.RuntimeCodeFragmentStatus, + PlotRenderFormat: extHostTypes.PlotRenderFormat, + UiRuntimeNotifications: extHostTypes.UiRuntimeNotifications, }; }; } diff --git a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts index 00cc4e6df34..5f4d49979ab 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts @@ -16,6 +16,7 @@ import { IAvailableDriverMethods } from '../../browser/positron/mainThreadConnec import { IChatRequestData, IPositronChatContext, IPositronLanguageModelConfig, IPositronLanguageModelSource } from '../../../contrib/positronAssistant/common/interfaces/positronAssistantService.js'; import { IChatAgentData } from '../../../contrib/chat/common/chatAgents.js'; import { ILanguageRuntimeCodeExecutedEvent } from '../../../services/positronConsole/browser/interfaces/positronConsoleService.js'; +import { PlotRenderSettings } from '../../../services/positronPlots/common/positronPlots.js'; // NOTE: This check is really to ensure that extHost.protocol is included by the TypeScript compiler // as a dependency of this module, and therefore that it's initialized first. This is to avoid a @@ -157,6 +158,14 @@ export interface ExtHostAiFeaturesShape { $onCompleteLanguageModelConfig(id: string): void; } +export interface MainThreadPlotsServiceShape { + $getPlotsRenderSettings(): Promise; +} + +export interface ExtHostPlotsServiceShape { + $onDidChangePlotsRenderSettings(settings: PlotRenderSettings): void; +} + /** * The view state of a preview in the Preview panel. Only one preview can be * active at a time (the one currently loaded into the panel); the active @@ -233,6 +242,7 @@ export const ExtHostPositronContext = { ExtHostConnections: createProxyIdentifier('ExtHostConnections'), ExtHostAiFeatures: createProxyIdentifier('ExtHostAiFeatures'), ExtHostQuickOpen: createProxyIdentifier('ExtHostQuickOpen'), + ExtHostPlotsService: createProxyIdentifier('ExtHostPlotsService'), }; export const MainPositronContext = { @@ -245,4 +255,5 @@ export const MainPositronContext = { MainThreadMethods: createProxyIdentifier('MainThreadMethods'), MainThreadConnections: createProxyIdentifier('MainThreadConnections'), MainThreadAiFeatures: createProxyIdentifier('MainThreadAiFeatures'), + MainThreadPlotsService: createProxyIdentifier('MainThreadPlotsService'), }; diff --git a/src/vs/workbench/api/common/positron/extHostPlotsService.ts b/src/vs/workbench/api/common/positron/extHostPlotsService.ts new file mode 100644 index 00000000000..210282e21ea --- /dev/null +++ b/src/vs/workbench/api/common/positron/extHostPlotsService.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as extHostProtocol from './extHost.positron.protocol.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { PlotRenderSettings } from '../../../services/positronPlots/common/positronPlots.js'; + +export class ExtHostPlotsService implements extHostProtocol.ExtHostPlotsServiceShape { + private readonly _proxy: extHostProtocol.MainThreadPlotsServiceShape; + private readonly _onDidChangePlotsRenderSettings = new Emitter(); + + constructor( + mainContext: extHostProtocol.IMainPositronContext, + ) { + this._proxy = mainContext.getProxy(extHostProtocol.MainPositronContext.MainThreadPlotsService); + } + + onDidChangePlotsRenderSettings = this._onDidChangePlotsRenderSettings.event; + + /** + * Queries the main thread for the current plot render settings. + */ + getPlotsRenderSettings(): Promise { + return this._proxy.$getPlotsRenderSettings(); + } + + // --- from main thread + + $onDidChangePlotsRenderSettings(settings: PlotRenderSettings): void { + this._onDidChangePlotsRenderSettings.fire(settings); + } +} diff --git a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts index aa402305b22..0201b107c85 100644 --- a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts +++ b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts @@ -371,3 +371,6 @@ export enum CodeAttributionSource { Paste = 'paste', Script = 'script', } + +export { UiRuntimeNotifications } from '../../../services/languageRuntime/common/languageRuntimeService.js' +export { PlotRenderSettings, PlotRenderFormat } from '../../../services/positronPlots/common/positronPlots.js'; diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx index 53c3efe4944..6f2b04f98b4 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/dynamicPlotInstance.tsx @@ -51,6 +51,12 @@ export const DynamicPlotInstance = (props: DynamicPlotInstanceProps) => { const ratio = DOM.getActiveWindow().devicePixelRatio; const disposables = new DisposableStore(); + // The frontend shoudn't send invalid sizes so be defensive. Sometimes the + // plot container is in a strange state when it's hidden. + if (props.height <= 0 || props.width <= 0) { + return; + } + // If the plot is already rendered, use the old image until the new one is ready. if (props.plotClient.lastRender) { setUri(props.plotClient.lastRender.uri); diff --git a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx index decb3126e1e..28c553ea205 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/components/plotsContainer.tsx @@ -10,6 +10,7 @@ import './plotsContainer.css'; import React, { useEffect } from 'react'; // Other dependencies. +import * as DOM from '../../../../../base/browser/dom.js'; import { DynamicPlotInstance } from './dynamicPlotInstance.js'; import { DynamicPlotThumbnail } from './dynamicPlotThumbnail.js'; import { PlotGalleryThumbnail } from './plotGalleryThumbnail.js'; @@ -20,13 +21,17 @@ import { WebviewPlotThumbnail } from './webviewPlotThumbnail.js'; import { usePositronPlotsContext } from '../positronPlotsContext.js'; import { WebviewPlotClient } from '../webviewPlotClient.js'; import { PlotClientInstance } from '../../../../services/languageRuntime/common/languageRuntimePlotClient.js'; -import { DarkFilter, IPositronPlotClient } from '../../../../services/positronPlots/common/positronPlots.js'; +import { DarkFilter, IPositronPlotClient, IPositronPlotsService, PlotRenderFormat } from '../../../../services/positronPlots/common/positronPlots.js'; import { StaticPlotClient } from '../../../../services/positronPlots/common/staticPlotClient.js'; +import { PlotSizingPolicyIntrinsic } from '../../../../services/positronPlots/common/sizingPolicyIntrinsic.js'; +import { PlotSizingPolicyAuto } from '../../../../services/positronPlots/common/sizingPolicyAuto.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; /** * PlotContainerProps interface. */ interface PlotContainerProps { + positronPlotsService: IPositronPlotsService, width: number; height: number; x: number; @@ -61,8 +66,8 @@ export const PlotsContainer = (props: PlotContainerProps) => { const historyPx = props.showHistory ? HistoryPx : 0; const historyEdge = historyBottom ? 'history-bottom' : 'history-right'; - const plotHeight = historyBottom ? props.height - historyPx : props.height; - const plotWidth = historyBottom ? props.width : props.width - historyPx; + const plotHeight = historyBottom && props.height > 0 ? props.height - historyPx : props.height; + const plotWidth = historyBottom || props.width <= 0 ? props.width : props.width - historyPx; useEffect(() => { // Ensure the selected plot is visible. We do this so that the history @@ -82,7 +87,49 @@ export const PlotsContainer = (props: PlotContainerProps) => { plotHistory.scrollTop = plotHistory.scrollHeight; } } - }); + }, [plotHistoryRef]); + + useEffect(() => { + // Be defensive against null sizes when pane is invisible + if (plotWidth <= 0 || plotHeight <= 0) { + return; + } + + const notify = () => { + let policy = props.positronPlotsService.selectedSizingPolicy; + + if (policy instanceof PlotSizingPolicyIntrinsic) { + policy = new PlotSizingPolicyAuto; + } + + const viewPortSize = { + height: plotHeight, + width: plotWidth, + } + let size = policy.getPlotSize(viewPortSize); + size = size ? size : viewPortSize; + + props.positronPlotsService.setPlotsRenderSettings({ + size, + pixel_ratio: DOM.getActiveWindow().devicePixelRatio, + format: PlotRenderFormat.Png, // Currently hard-coded + }); + }; + + // Renotify if the sizing policy changes + const disposables = new DisposableStore(); + disposables.add(props.positronPlotsService.onDidChangeSizingPolicy((_policy) => { + notify(); + })); + + // Propagate current render settings. Use a debouncer to avoid excessive + // messaging to language kernels. + const debounceTimer = setTimeout(() => { + notify() + }, 500); + + return () => clearTimeout(debounceTimer); + }, [plotWidth, plotHeight, props.positronPlotsService]); /** * Renders either a DynamicPlotInstance (resizable plot), a @@ -113,6 +160,7 @@ export const PlotsContainer = (props: PlotContainerProps) => { visible={props.visible} width={plotWidth} />; } + return null; }; diff --git a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx index 105e8feea7e..c02e23c6c08 100644 --- a/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx +++ b/src/vs/workbench/contrib/positronPlots/browser/modalDialogs/savePlotModalDialog.tsx @@ -28,7 +28,7 @@ import { FileFilter } from 'electron'; import { DropDownListBox } from '../../../../browser/positronComponents/dropDownListBox/dropDownListBox.js'; import { DropDownListBoxItem } from '../../../../browser/positronComponents/dropDownListBox/dropDownListBoxItem.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IntrinsicSize, RenderFormat } from '../../../../services/languageRuntime/common/positronPlotComm.js'; +import { IntrinsicSize } from '../../../../services/languageRuntime/common/positronPlotComm.js'; import { Checkbox } from '../../../../browser/positronComponents/positronModalDialog/components/checkbox.js'; import { IPlotSize, IPositronPlotSizingPolicy } from '../../../../services/positronPlots/common/sizingPolicy.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -40,6 +40,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { combineLabelWithPathUri, pathUriToLabel } from '../../../../browser/utils/path.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { Button } from '../../../../../base/browser/ui/positronComponents/button/button.js'; +import { PlotRenderFormat } from '../../../../services/positronPlots/common/positronPlots.js'; export interface SavePlotOptions { uri: string; @@ -138,7 +139,7 @@ interface DirectoryState { const SavePlotModalDialog = (props: SavePlotModalDialogProps) => { const [directory, setDirectory] = React.useState({ value: props.suggestedPath ?? URI.file(''), valid: true }); const [name, setName] = React.useState({ value: 'plot', valid: true }); - const [format, setFormat] = React.useState(RenderFormat.Png); + const [format, setFormat] = React.useState(PlotRenderFormat.Png); const [enableIntrinsicSize, setEnableIntrinsicSize] = React.useState(props.enableIntrinsicSize); const [width, setWidth] = React.useState({ value: props.plotSize?.width ?? 100, valid: true }); const [height, setHeight] = React.useState({ value: props.plotSize?.height ?? 100, valid: true }); @@ -148,7 +149,7 @@ const SavePlotModalDialog = (props: SavePlotModalDialogProps) => { const inputRef = React.useRef(null!); const filterEntries: FileFilter[] = []; - for (const filter in RenderFormat) { + for (const filter in PlotRenderFormat) { filterEntries.push({ extensions: [filter.toLowerCase()], name: filter.toUpperCase() }); } @@ -257,7 +258,7 @@ const SavePlotModalDialog = (props: SavePlotModalDialogProps) => { } setRendering(true); try { - const plotResult = await generatePreview(RenderFormat.Png); + const plotResult = await generatePreview(PlotRenderFormat.Png); setUri(plotResult.uri); } catch (error) { props.logService.error('Error rendering plot:', error); @@ -266,7 +267,7 @@ const SavePlotModalDialog = (props: SavePlotModalDialogProps) => { } }; - const generatePreview = async (format: RenderFormat): Promise => { + const generatePreview = async (format: PlotRenderFormat): Promise => { let size: IPlotSize | undefined; if (!enableIntrinsicSize) { if (!width.value || !height.value) { @@ -348,11 +349,11 @@ const SavePlotModalDialog = (props: SavePlotModalDialogProps) => {