diff --git a/package.json b/package.json index b42d457..521b2e3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,12 @@ "title": "Export FPP Diagram", "icon": "$(desktop-download)", "category": "FPP Diagram" + }, + { + "command": "fpp.diagram.toggle-unused-ports", + "title": "Show/Hide Unused Ports", + "icon": "$(activate-breakpoints)", + "category": "FPP Diagram" } ], "languages": [ @@ -131,6 +137,11 @@ "command": "fpp.diagram.export", "when": "fppDiagrams-focused == true", "group": "navigation" + }, + { + "command": "fpp.diagram.toggle-unused-ports", + "when": "fppDiagrams-focused == true", + "group": "navigation" } ], "explorer/context": [ diff --git a/src/diagram/generator.ts b/src/diagram/generator.ts index ccb0720..0d299ef 100644 --- a/src/diagram/generator.ts +++ b/src/diagram/generator.ts @@ -7,6 +7,7 @@ import type { ComponentSNode, PortSNode } from '../../common/models'; import { getInterfaceFullnameFromImport } from '../util'; import { FppAnnotator } from '../passes/annotator'; import { ExprTraverser } from '../evaluator'; +import { FppDiagramConfig } from './layout-config'; /** * The additional FPP information attached to ELK nodes @@ -20,7 +21,7 @@ interface FppData { componentKind?: string, // active, queued, passive // Port data - portKind?: string, // Tracking kind of GeneralInputPortInstance and GeneralInputPortInstance + portKind?: string, // Storing the `kind` field of GeneralInputPortInstance and GeneralInputPortInstance isOutput?: boolean, } @@ -47,15 +48,18 @@ export class GraphGenerator { return { // type: 'graph', id: 'root', - layoutOptions: { - 'elk.algorithm': 'layered', - }, children: [], edges: [], }; } - static async component(decl: DeclCollector, fullyQualifiedComponentName: string): Promise { + /** + * Generate an SGraph that renders a component definition (not a component instance). + * @param decl The DeclCollector with all info about the FPP files + * @param fullyQualifiedComponentName The name of the component definition to be rendered + * @returns An SGraph to be sent to webview + */ + static async component(decl: DeclCollector, diagramConfig: FppDiagramConfig, fullyQualifiedComponentName: string): Promise { const elkGraph: FppElkNode = this.initElkGraph(); const compDecl = decl.components.get(fullyQualifiedComponentName)!; if (!compDecl) { @@ -63,7 +67,7 @@ export class GraphGenerator { return; } - elkGraph.children?.push(this.createElkNodeComponent(decl, undefined, compDecl)); + elkGraph.children?.push(this.createElkNodeComponent(decl, diagramConfig, undefined, compDecl, undefined)); // Convert to SGraph const sGraph: SGraph = this.convertElkGraphToSGraph(elkGraph); @@ -76,7 +80,7 @@ export class GraphGenerator { * @param decl The DeclCollector with all info about the FPP files * @returns An SGraph to be sent to webview */ - static async topology(decl: DeclCollector, fullyQualifiedTopologyName: string, elkGraph: FppElkNode | undefined = undefined): Promise { + static async topology(decl: DeclCollector, diagramConfig: FppDiagramConfig, fullyQualifiedTopologyName: string, elkGraph: FppElkNode | undefined = undefined): Promise { if (!elkGraph) { elkGraph = this.initElkGraph(); } @@ -88,7 +92,7 @@ export class GraphGenerator { topologyDecl?.members.map(member => { if (member.type === 'TopologyImportStmt') { const subtopologyName = MemberTraverser.flat(member.symbol); - this.topology(decl, subtopologyName, elkGraph); + this.topology(decl, diagramConfig, subtopologyName, elkGraph); } }); @@ -104,14 +108,6 @@ export class GraphGenerator { } }); - // Create an ELK node for each component instance. - usedComponentInstances.forEach(e => { - const elkNode = this.createElkNodeFromComponentInstance(decl, e); - if (elkNode) { - elkGraph.children?.push(elkNode); - } - }); - // Iterate over all connection groups in the current topology and draw an ELK edge for each. const topologyConnGroups = topologyDecl?.members.filter(m => m.type === 'DirectGraphDecl') ?? []; topologyConnGroups.forEach(connGroup => { @@ -121,6 +117,14 @@ export class GraphGenerator { }); }); + // Create an ELK node for each component instance. + usedComponentInstances.forEach(instance => { + const elkNode = this.createElkNodeFromComponentInstance(decl, diagramConfig, instance, elkGraph.edges); + if (elkNode) { + elkGraph.children?.push(elkNode); + } + }); + // Convert to SGraph // console.log("ElkGraph constructed: ", elkGraph); const sGraph: SGraph = this.convertElkGraphToSGraph(elkGraph); @@ -134,7 +138,7 @@ export class GraphGenerator { * @param fullyQualifiedGraphGroupName Fully qualified name of the connection group to generate the graph for * @returns An SGraph to be sent to webview */ - static async connectionGroup(decl: DeclCollector, fullyQualifiedGraphGroupName: string): Promise { + static async connectionGroup(decl: DeclCollector, diagramConfig: FppDiagramConfig, fullyQualifiedGraphGroupName: string): Promise { const elkGraph: FppElkNode = this.initElkGraph(); const graphGroup = decl.graphGroups.get(fullyQualifiedGraphGroupName)!; if (!graphGroup) { @@ -169,20 +173,20 @@ export class GraphGenerator { } }); - // Generate a component ELK node for each component instance. - compInstances.forEach(e => { - const elkNode = this.createElkNodeFromComponentInstance(decl, e); - if (elkNode) { - elkGraph.children?.push(elkNode); - } - }); - // Draw all connections in this connection group. graphGroup.connections.forEach((conn, idx) => { const edge: FppElkEdge = this.createElkEdge(decl, graphGroup, conn, idx); elkGraph.edges?.push(edge); }); + // Generate a component ELK node for each component instance. + compInstances.forEach(instance => { + const elkNode = this.createElkNodeFromComponentInstance(decl, diagramConfig, instance, elkGraph.edges); + if (elkNode) { + elkGraph.children?.push(elkNode); + } + }); + // Convert to SGraph const sGraph: SGraph = this.convertElkGraphToSGraph(elkGraph); @@ -193,7 +197,14 @@ export class GraphGenerator { /* Helper functions for generating ELK / Sprotty components */ /************************************************************/ - static createElkNodeFromComponentInstance(decl: DeclCollector, componentInstanceDecl: ComponentInstanceDecl): FppElkNode | undefined { + /** + * A helper method for building an ELK model for an FPP component instance + * @param decl The decl collector + * @param diagramConfig Configurations for what to render and ELK layout options + * @param componentInstanceDecl A component instance to be rendered + * @param connections A list of connections in the diagram containing the component. This field is only used when hiding unused ports (i.e., diagramConfig.hideUnusedPorts === true). + */ + static createElkNodeFromComponentInstance(decl: DeclCollector, diagramConfig: FppDiagramConfig, componentInstanceDecl: ComponentInstanceDecl, connections: ElkExtendedEdge[] | undefined): FppElkNode | undefined { // For each instance, look up the ComponentDecl. const resolved = decl.resolve( componentInstanceDecl.fppType.type, @@ -208,30 +219,29 @@ export class GraphGenerator { const componentDecl = decl.get(componentName, SymbolType.component) as ComponentDecl; // Instantiate a component FppElkNode for the component type. - const node = this.createElkNodeComponent(decl, componentInstanceDecl, componentDecl); + const node = this.createElkNodeComponent(decl, diagramConfig, componentInstanceDecl, componentDecl, connections); return node; } /** - * A helper method for building an Sprotty model for an FPP component - * @param comp ComponentDecl from decl collector - * @param uid Component instance name, which is supposed to be unique. + * A helper method for building an ELK model for an FPP component + * @param decl The decl collector + * @param diagramConfig Configurations for what to render and ELK layout options + * @param instance A component instance to be rendered (by displaying the instance name). + * If this is undefined, only the component definition is rendered (without displaying instance names). + * @param comp A component definition to be rendered + * @param connections A list of connections in the diagram containing the component. + * If this component is inside a larger diagram, the `connections` field is used to compute + * a list of ports used by this component. If `diagramConfig.hideUnusedPorts` is true, + * unused ports are hidden to simplify the diagram. */ - static createElkNodeComponent(decl: DeclCollector, instance: ComponentInstanceDecl | undefined, comp: ComponentDecl): FppElkNode { + static createElkNodeComponent(decl: DeclCollector, diagramConfig: FppDiagramConfig, instance: ComponentInstanceDecl | undefined, comp: ComponentDecl, connections: ElkExtendedEdge[] | undefined): FppElkNode { // Instantiate an SNode for the component. const compId = instance ? `${instance.scope.map(e => e.value).join('.')}.${instance.name.value}` : `uninstantiatedComponent`; // DeploymentName.componentInstanceName const compClassName = comp.name.value; const compInstanceName = instance ? instance.name.value : ""; var node: FppElkNode = { id: compId, - layoutOptions: { - "elk.nodeLabels.placement": "INSIDE, H_CENTER, V_CENTER", - "elk.portLabels.placement": "NEXT_TO_PORT_OF_POSSIBLE", - "elk.portLabels.nextToPortIfPossible": 'true', - 'elk.portConstraints': 'FIXED_SIDE', // So that elk.port.side can take effect. - "elk.nodeSize.constraints": "PORTS, NODE_LABELS, MINIMUM_SIZE", - "elk.spacing.labelPortHorizontal": "5", - }, // IMPORTANT: x, y must be set, otherwise mysterious runtime errors could occur during ELK layout. // Set x, y both to 0, since layout will set them later. // But we do need to explicitly set them for the Sprotty front-end @@ -261,14 +271,14 @@ export class GraphGenerator { comp.members.forEach((m, i) => { if (GraphGenerator.isPortInstanceDecl(m)) { const portId = `${compId}.${m.name.value}`; - node.ports!.push(...GraphGenerator.createElkNodePort(m, portId, decl)); + node.ports!.push(...GraphGenerator.createElkNodePort(decl, m, portId)); } // Handle ports imported from include statememts. else if (GraphGenerator.isIncludeStmt(m)) { m.resolved?.members.forEach(member => { if (GraphGenerator.isPortInstanceDecl(member)) { const portId = `${compId}.${member.name.value}`; - node.ports!.push(...GraphGenerator.createElkNodePort(member, portId, decl)); + node.ports!.push(...GraphGenerator.createElkNodePort(decl, member, portId)); } }); } @@ -283,16 +293,28 @@ export class GraphGenerator { interfaceDecl?.members.forEach(member => { if (GraphGenerator.isPortInstanceDecl(member)) { const portId = `${compId}.${member.name.value}`; - node.ports!.push(...GraphGenerator.createElkNodePort(member, portId, decl)); + node.ports!.push(...GraphGenerator.createElkNodePort(decl, member, portId)); } }); } }); + // Check if hideUnusedPorts is true. If so, only keep the ports used by checking against outer connections. + if (diagramConfig.hideUnusedPorts) { + let portsUsed = connections?.flatMap(conn => conn.sources.concat(conn.targets)); + node.ports = node.ports!.filter(port => portsUsed?.includes(port.id)); + } + return node; } - static createElkNodePort(port: PortInstanceDecl, portIdPrefix: string, decl: DeclCollector): FppElkPort[] { + /** + * A helper method for building an ELK model for an FPP port + * @param decl The decl collector + * @param port A port instance to be rendered + * @param portIdPrefix A prefix string for making the port's ELK id unique + */ + static createElkNodePort(decl: DeclCollector, port: PortInstanceDecl, portIdPrefix: string): FppElkPort[] { // Extract the name and the kind of the port. // If portKind is empty, then special colors will not be applied. const portName = port.name.value; @@ -325,9 +347,6 @@ export class GraphGenerator { x: 0, y: 0, height: 10, width: 10, - layoutOptions: { - 'elk.port.side': port.kind.isOutput ? 'EAST' : 'WEST', - }, labels: [ // For now, each port label has fix width and height. // If size is not set, ELK sets it to 0, 0, not ideal. @@ -348,6 +367,13 @@ export class GraphGenerator { return portNodes; } + /** + * A helper method for building an ELK model for an FPP connection + * @param decl The decl collector + * @param directGraphDecl A connection group this connection belongs to + * @param conn The connection to be rendered + * @param idx The connection index for creating a unique ELK id + */ static createElkEdge(decl: DeclCollector, directGraphDecl: DirectGraphDecl, conn: Connection, idx: number): FppElkEdge { const annotator = new FppAnnotator(decl); const scope = directGraphDecl.scope; @@ -361,10 +387,10 @@ export class GraphGenerator { let sourceFullyQualifiedName = sourceResolve ? `${MemberTraverser.flat(scope)}.${MemberTraverser.flat(sourceId)}` : MemberTraverser.flat(sourceId); - // Resolve source index from expression. + // Resolve an integer source port index from expression. let sourceIndex = 0; if (conn.source.index) { - const sourceIndexExpr = annotator.exprTrav.traverse(conn.source.index, directGraphDecl.scope, ExprTraverser.intValidator); + const sourceIndexExpr = annotator.exprTrav.traverse(conn.source.index, scope, ExprTraverser.intValidator); sourceIndex = (sourceIndexExpr as IntExprValue).value; } @@ -374,10 +400,10 @@ export class GraphGenerator { let destFullyQualifiedName = destResolve ? `${MemberTraverser.flat(scope)}.${MemberTraverser.flat(destId)}` : MemberTraverser.flat(destId); - // Resolve dest index from expression. + // Resolve an integer destination port index from expression. let destIndex = 0; if (conn.destination.index) { - const destIndexExpr = annotator.exprTrav.traverse(conn.destination.index, directGraphDecl.scope, ExprTraverser.intValidator); + const destIndexExpr = annotator.exprTrav.traverse(conn.destination.index, scope, ExprTraverser.intValidator); destIndex = (destIndexExpr as IntExprValue).value; } diff --git a/src/diagram/layout-config.ts b/src/diagram/layout-config.ts index 7dc96a2..c912546 100644 --- a/src/diagram/layout-config.ts +++ b/src/diagram/layout-config.ts @@ -3,16 +3,23 @@ import { DefaultLayoutConfigurator } from "sprotty-elk"; import { SGraph, SEdge, SNode, SLabel } from 'sprotty-protocol'; import { SModelIndex } from "sprotty-protocol"; import { PortSNode } from "../../common/models"; +import { DiagramType } from "./manager"; -export class FppDiagramLayoutConfigurator extends DefaultLayoutConfigurator { - // options for the graph element +export class FppDiagramConfig extends DefaultLayoutConfigurator { + + // Stateful diagram options + public hideUnusedPorts = true; // By default, hide unused ports. + public currentDiagramType: DiagramType | undefined; // Current diagram type + public fullyQualifiedName: string = ""; // Fully qualified name of the element currently displayed + + // ELK Layout options for the graph element protected override graphOptions(sgraph: SGraph, index: SModelIndex): LayoutOptions | undefined { return { 'elk.algorithm': 'layered', }; } - // options for node elements + // ELK Layout options for node elements protected override nodeOptions(snode: SNode, index: SModelIndex): LayoutOptions | undefined { return { "elk.nodeLabels.placement": "INSIDE, H_CENTER, V_CENTER", @@ -20,22 +27,22 @@ export class FppDiagramLayoutConfigurator extends DefaultLayoutConfigurator { "elk.portLabels.nextToPortIfPossible": 'true', 'elk.portConstraints': 'FIXED_SIDE', // So that elk.port.side can take effect. "elk.nodeSize.constraints": "PORTS, NODE_LABELS, MINIMUM_SIZE", - "elk.spacing.labelPortHorizontal": "5", - // "elk.spacing.portPort": "15", // Does not seem to take effect. + "elk.spacing.labelPortHorizontal": "5", // Does not seem to take effect. + "elk.spacing.portPort": "15", // Does not seem to take effect. }; } - // options for edge elements + // ELK Layout options for edge elements protected override edgeOptions(sedge: SEdge, index: SModelIndex): LayoutOptions | undefined { return {}; } - // options for label elements + // ELK Layout options for label elements protected override labelOptions(slabel: SLabel, index: SModelIndex): LayoutOptions | undefined { return {}; } - // options for port elements + // ELK Layout options for port elements protected override portOptions(sport: PortSNode, index: SModelIndex): LayoutOptions | undefined { return { 'elk.port.side': sport.isOutput ? 'EAST' : 'WEST', diff --git a/src/diagram/manager.ts b/src/diagram/manager.ts index 912aee8..a29bf3d 100644 --- a/src/diagram/manager.ts +++ b/src/diagram/manager.ts @@ -9,7 +9,7 @@ * 2. the extension actively sending messages to the webview, upon user request * from the CodeLens buttons (buttons floating above definitions). */ -import { createFileUri, createWebviewPanel, SprottyDiagramIdentifier, WebviewEndpoint, WebviewPanelManager, WebviewPanelManagerOptions } from "sprotty-vscode"; +import { createWebviewPanel, SprottyDiagramIdentifier, WebviewEndpoint, WebviewPanelManager, WebviewPanelManagerOptions } from "sprotty-vscode"; import { RequestModelAction, ComputedBoundsAction, UpdateModelAction, FitToScreenAction, SGraph, RequestBoundsAction, applyBounds } from 'sprotty-protocol'; import * as vscode from "vscode"; import { FppProject } from "../project"; @@ -17,8 +17,10 @@ import { GraphGenerator } from "./generator"; import { ElkLayoutEngine } from "sprotty-elk/lib/elk-layout"; import ELK from 'elkjs/lib/elk-api.js'; import { FppLayoutEngine } from "./layout"; -import { FppDiagramLayoutConfigurator } from "./layout-config"; +import { FppDiagramConfig } from "./layout-config"; +// FIXME: Why can't this be moved into layout-config.ts? +// Codelens disappear after moving. export enum DiagramType { Component, ConnectionGroup, @@ -27,8 +29,9 @@ export enum DiagramType { export class FppWebviewPanelManager extends WebviewPanelManager { + public diagramConfig: FppDiagramConfig = new FppDiagramConfig(); + private sGraph: SGraph | undefined; - private diagramConfig: FppDiagramLayoutConfigurator = new FppDiagramLayoutConfigurator(); private elkEngine: ElkLayoutEngine = new FppLayoutEngine( () => new ELK({ workerFactory: function (url) { // the value of 'url' is irrelevant here @@ -39,10 +42,6 @@ export class FppWebviewPanelManager extends WebviewPanelManager { undefined, this.diagramConfig); - /* Private variables for remembering the current displayed diagram to handle diagram update on save */ - private currentDiagramType: DiagramType | undefined; - private fullyQualifiedName: string = ""; - constructor(readonly options: WebviewPanelManagerOptions, readonly fppProject: FppProject) { super(options); } @@ -86,18 +85,18 @@ export class FppWebviewPanelManager extends WebviewPanelManager { */ protected addRequestModelHandler(endpoint: WebviewEndpoint) { const handler = async (action: RequestModelAction) => { - switch (this.currentDiagramType) { + switch (this.diagramConfig.currentDiagramType) { case DiagramType.Component: this.sGraph = await GraphGenerator.component( - this.fppProject.decl, this.fullyQualifiedName); + this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; case DiagramType.ConnectionGroup: this.sGraph = await GraphGenerator.connectionGroup( - this.fppProject.decl, this.fullyQualifiedName); + this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; case DiagramType.Topology: this.sGraph = await GraphGenerator.topology( - this.fppProject.decl, this.fullyQualifiedName); + this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; } if (!this.sGraph) { @@ -153,8 +152,8 @@ export class FppWebviewPanelManager extends WebviewPanelManager { // Do not render if errors are detected in the editor. Diagrams can be incorrect when there are errors. if (await this.errorsDetectedInCurrentEditor()) return; // Store diagram type and fully qualified name for potential re-render on save. - this.currentDiagramType = diagramType; - this.fullyQualifiedName = fullyQualifiedName; + this.diagramConfig.currentDiagramType = diagramType; + this.diagramConfig.fullyQualifiedName = fullyQualifiedName; // Check if webview is active. let activeEndpoint = this.findOpenedWebview(); if (!activeEndpoint) { @@ -168,13 +167,13 @@ export class FppWebviewPanelManager extends WebviewPanelManager { // Generate a corresponding SGraph. switch (diagramType) { case DiagramType.Component: - this.sGraph = await GraphGenerator.component(this.fppProject.decl, fullyQualifiedName); + this.sGraph = await GraphGenerator.component(this.fppProject.decl, this.diagramConfig, fullyQualifiedName); break; case DiagramType.ConnectionGroup: - this.sGraph = await GraphGenerator.connectionGroup(this.fppProject.decl, fullyQualifiedName); + this.sGraph = await GraphGenerator.connectionGroup(this.fppProject.decl, this.diagramConfig, fullyQualifiedName); break; case DiagramType.Topology: - this.sGraph = await GraphGenerator.topology(this.fppProject.decl, fullyQualifiedName); + this.sGraph = await GraphGenerator.topology(this.fppProject.decl, this.diagramConfig, fullyQualifiedName); break; default: vscode.window.showErrorMessage('Unsupport diagram type: ', diagramType); @@ -198,18 +197,18 @@ export class FppWebviewPanelManager extends WebviewPanelManager { } // Do not render if errors are detected in the editor. Diagrams can be incorrect when there are errors. if (await this.errorsDetectedInCurrentEditor()) return; - switch (this.currentDiagramType) { + switch (this.diagramConfig.currentDiagramType) { case DiagramType.Component: - this.sGraph = await GraphGenerator.component(this.fppProject.decl, this.fullyQualifiedName); + this.sGraph = await GraphGenerator.component(this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; case DiagramType.ConnectionGroup: - this.sGraph = await GraphGenerator.connectionGroup(this.fppProject.decl, this.fullyQualifiedName); + this.sGraph = await GraphGenerator.connectionGroup(this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; case DiagramType.Topology: - this.sGraph = await GraphGenerator.topology(this.fppProject.decl, this.fullyQualifiedName); + this.sGraph = await GraphGenerator.topology(this.fppProject.decl, this.diagramConfig, this.diagramConfig.fullyQualifiedName); break; default: - console.error("Unsupported DiagramType: ", this.currentDiagramType); + console.error("Unsupported DiagramType: ", this.diagramConfig.currentDiagramType); return; } const msgRequestBounds = RequestBoundsAction.create(this.sGraph!); diff --git a/src/extension.ts b/src/extension.ts index 9bac72d..39b1c47 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1078,19 +1078,27 @@ export function activate(context: vscode.ExtensionContext) { registerDefaultCommands(webviewPanelManager, context, { extensionPrefix: 'fpp' }); console.log("Instantiated FPP webview panel manager."); - // Register command to update diagram on save. + // Register command to update diagram, so that we can call this command on save (registered elsewhere). context.subscriptions.push( vscode.commands.registerCommand('fpp.updateDiagram', () => { webviewPanelManager.updateDiagram(); }) ); - // Set up CodeLens provider to have neat buttons float above definitions. + // Set up CodeLens provider to have neat buttons floating above keywords. context.subscriptions.push( vscode.commands.registerCommand("fpp.displayDiagram", (diagramType: DiagramType, elemName: string) => webviewPanelManager.displayDiagram(diagramType, elemName) ), ); + + // Register command to toggle whether to hide unused ports. + context.subscriptions.push( + vscode.commands.registerCommand('fpp.diagram.toggle-unused-ports', () => { + webviewPanelManager.diagramConfig.hideUnusedPorts = !webviewPanelManager.diagramConfig.hideUnusedPorts; + webviewPanelManager.updateDiagram(); + }) + ); } export function deactivate() { }