diff --git a/src/web/BUILD b/src/web/BUILD index cb9bbb24027..1bf1a8e5a8b 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -41,6 +41,9 @@ genrule( "src/tcl-completer.js", "src/hierarchy-browser.js", "src/menu-bar.js", + "src/context-menu.js", + "src/search-nav.js", + "src/capture.js", "src/title.js", "src/clock-tree-widget.js", "src/schematic-widget.js", @@ -67,6 +70,9 @@ genrule( " $(location src/tcl-completer.js)" + " $(location src/hierarchy-browser.js)" + " $(location src/menu-bar.js)" + + " $(location src/context-menu.js)" + + " $(location src/search-nav.js)" + + " $(location src/capture.js)" + " $(location src/title.js)" + " $(location src/clock-tree-widget.js)" + " $(location src/schematic-widget.js)" + @@ -94,6 +100,9 @@ _WEB_ASSET_FILES = [ "src/tcl-completer.js", "src/hierarchy-browser.js", "src/menu-bar.js", + "src/context-menu.js", + "src/search-nav.js", + "src/capture.js", "src/title.js", "src/clock-tree-widget.js", "src/schematic-widget.js", diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index 92d05bea299..6f2706f1938 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -28,6 +28,9 @@ set(WEB_ASSET_FILES src/tcl-completer.js src/hierarchy-browser.js src/menu-bar.js + src/context-menu.js + src/search-nav.js + src/capture.js src/title.js src/clock-tree-widget.js src/schematic-widget.js @@ -64,6 +67,9 @@ add_custom_command( ${CMAKE_CURRENT_SOURCE_DIR}/src/tcl-completer.js ${CMAKE_CURRENT_SOURCE_DIR}/src/hierarchy-browser.js ${CMAKE_CURRENT_SOURCE_DIR}/src/menu-bar.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/context-menu.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/search-nav.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/capture.js ${CMAKE_CURRENT_SOURCE_DIR}/src/title.js ${CMAKE_CURRENT_SOURCE_DIR}/src/clock-tree-widget.js ${CMAKE_CURRENT_SOURCE_DIR}/src/schematic-widget.js diff --git a/src/web/src/capture.js b/src/web/src/capture.js new file mode 100644 index 00000000000..13fb133aecc --- /dev/null +++ b/src/web/src/capture.js @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// WYSIWYG layout capture. Composites the currently rendered Leaflet layers +// (base tiles, the highlight/selection overlay, the active heatmap) plus the +// SVG overlays (rulers, selection outline, labels) into a PNG and downloads +// it — mirroring the Qt GUI's "Save image", which saves the displayed scene. +// +// All tiles are same-origin (blob: URLs), so the canvas is not tainted +// and toBlob() works. Tile positions are read from getBoundingClientRect so +// the composite is correct under fractional zoom / CSS transforms. +// +// entire=true first fits the whole design (no animation), waits for every +// layer's tiles to settle, then composites — capturing the full design exactly +// as the window shows it (layers + heatmap + highlights + rulers), and restores +// the previous view afterwards. + +function downloadBlob(blob, filename) { + if (!blob) { + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +// A layer's effective opacity (default 1 when unset). +function layerOpacity(layer) { + return (layer.options && layer.options.opacity != null) + ? layer.options.opacity : 1; +} + +// Draw one /element at its on-screen position relative to the map. Skips +// an that hasn't decoded yet, and swallows a not-yet-drawable element. +function drawElementAt(ctx, el, mapRect) { + if (!el) { + return; + } + if (el.tagName === 'IMG' && (!el.complete || !el.naturalWidth)) { + return; // still loading + } + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) { + return; + } + try { + ctx.drawImage(el, r.left - mapRect.left, r.top - mapRect.top, + r.width, r.height); + } catch (err) { + // Skip an element that can't be drawn yet. + } +} + +// Draw every loaded GridLayer tile onto ctx, in z-index order, at its +// on-screen position relative to the map container. +function drawGridLayers(app, ctx, mapRect) { + const layers = []; + app.map.eachLayer((layer) => { + if (layer instanceof L.GridLayer && layer._tiles) { + layers.push(layer); + } + }); + layers.sort((a, b) => + ((a.options && a.options.zIndex) || 0) + - ((b.options && b.options.zIndex) || 0)); + + for (const layer of layers) { + const opacity = layerOpacity(layer); + if (opacity <= 0) { + continue; + } + ctx.globalAlpha = opacity; + for (const key of Object.keys(layer._tiles)) { + const t = layer._tiles[key]; + if (!t || !t.current) { + continue; + } + drawElementAt(ctx, t.el, mapRect); + } + } + ctx.globalAlpha = 1; +} + +// Rasterize the SVG overlay panes (rulers, selection outline, labels) on top. +// Async: each SVG is drawn through an data URL. +function drawSvgOverlays(app, ctx, mapRect) { + const svgs = app.map.getContainer().querySelectorAll('.leaflet-pane svg'); + const jobs = []; + svgs.forEach((svg) => { + const r = svg.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) { + return; + } + const clone = svg.cloneNode(true); + clone.setAttribute('width', r.width); + clone.setAttribute('height', r.height); + const xml = new XMLSerializer().serializeToString(clone); + const url = 'data:image/svg+xml;charset=utf-8,' + + encodeURIComponent(xml); + jobs.push(new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + try { + ctx.drawImage(img, r.left - mapRect.left, + r.top - mapRect.top, r.width, r.height); + } catch (err) { /* ignore */ } + resolve(); + }; + img.onerror = () => resolve(); + img.src = url; + })); + }); + return Promise.all(jobs); +} + +// Count tiles that haven't finished loading across every GridLayer (base, +// overlay/highlights, heatmap, ...): a layer still marked _loading, or a +// current tile whose hasn't decoded yet. Zero => the scene is painted. +function tilesPending(app) { + let pending = 0; + app.map.eachLayer((layer) => { + if (!(layer instanceof L.GridLayer) || !layer._tiles) { + return; + } + if (layer._loading) { + pending++; + } + for (const key of Object.keys(layer._tiles)) { + const t = layer._tiles[key]; + const el = t && t.el; + if (t && t.current && el && el.tagName === 'IMG' + && (!el.complete || !el.naturalWidth)) { + pending++; + } + } + }); + return pending; +} + +// Resolve once all layers' tiles have settled (stable across a couple of polls) +// or a timeout elapses. Used after a fit so the capture waits for the newly +// requested tiles (base + overlay + heatmap) to arrive. +function whenTilesSettled(app, timeoutMs = 6000, intervalMs = 32) { + return new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + let stable = 0; + const check = () => { + if (tilesPending(app) === 0) { + if (++stable >= 2) { + resolve(); + return; + } + } else { + stable = 0; + } + if (Date.now() >= deadline) { + resolve(); + return; + } + setTimeout(check, intervalMs); + }; + setTimeout(check, intervalMs); + }); +} + +// Draw single-image overlays (e.g. the timing-path L.ImageOverlay) at their +// on-screen position, so they land in the capture like the tile layers. +function drawImageOverlays(app, ctx, mapRect) { + app.map.eachLayer((layer) => { + if (!(layer instanceof L.ImageOverlay)) { + return; + } + const opacity = layerOpacity(layer); + if (opacity <= 0) { + return; + } + ctx.globalAlpha = opacity; + drawElementAt(ctx, layer._image, mapRect); + ctx.globalAlpha = 1; + }); +} + +async function renderToBlob(app) { + const container = app.map.getContainer(); + const mapRect = container.getBoundingClientRect(); + const w = Math.max(1, Math.round(mapRect.width)); + const h = Math.max(1, Math.round(mapRect.height)); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + // Background matches the viewer (--bg-map); fall back to #111 when the + // computed color is empty or fully transparent (both would export a + // transparent PNG, hiding light elements on white viewers). + const bg = getComputedStyle(container).backgroundColor; + ctx.fillStyle = (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') + ? bg : '#111'; + ctx.fillRect(0, 0, w, h); + drawGridLayers(app, ctx, mapRect); + drawImageOverlays(app, ctx, mapRect); + await drawSvgOverlays(app, ctx, mapRect); + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); +} + +// Public entry point. entire=false composites the current viewport; +// entire=true fits the whole design first, waits for the newly requested tiles +// to settle, captures, then restores the previous view. Both are WYSIWYG: +// base layers + heatmap + highlights/selection + rulers, exactly as shown. +export async function captureLayout(app, { entire = false } = {}) { + if (!app.map) { + return; + } + // Entire needs whole-design bounds; without them the capture would just be + // the current viewport, so save it (honestly) as the visible layout. + if (!entire || !app.fitBounds) { + downloadBlob(await renderToBlob(app), 'layout_visible.png'); + return; + } + + const prevCenter = app.map.getCenter(); + const prevZoom = app.map.getZoom(); + try { + app.map.fitBounds(app.fitBounds, { animate: false }); + await whenTilesSettled(app); + downloadBlob(await renderToBlob(app), 'layout_entire.png'); + } finally { + app.map.setView(prevCenter, prevZoom, { animate: false }); + } +} diff --git a/src/web/src/context-menu.js b/src/web/src/context-menu.js new file mode 100644 index 00000000000..8817a0bf388 --- /dev/null +++ b/src/web/src/context-menu.js @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Canvas right-click context menu. Mirrors the Qt GUI LayoutViewer +// Select / Highlight / View / Save / Clear submenus; the backend replicates +// MainWindow::selectHighlightConnected{Insts,Nets,BufferTrees} and the Save / +// Clear actions. +// +// Highlight offers all 16 groups (colors), inline, on every item — a superset +// of the Qt right-click menu, which only exposes colors under "All buffer +// trees" and wires just 4 of them. + +import { applySelectionFlags } from './ui-utils.js'; + +// Mirrors gui::Painter::kHighlightColors in the backend (index == group). +const HIGHLIGHT_COLORS = [ + { name: 'green', hex: '#00ff00' }, + { name: 'yellow', hex: '#ffff00' }, + { name: 'cyan', hex: '#00ffff' }, + { name: 'magenta', hex: '#ff00ff' }, + { name: 'red', hex: '#ff0000' }, + { name: 'dark_green', hex: '#008000' }, + { name: 'dark_magenta', hex: '#800080' }, + { name: 'blue', hex: '#0000ff' }, + { name: 'orange', hex: '#ffa500' }, + { name: 'purple', hex: '#800080' }, + { name: 'lime', hex: '#bfff00' }, + { name: 'teal', hex: '#008080' }, + { name: 'pink', hex: '#ffc0cb' }, + { name: 'brown', hex: '#8b4513' }, + { name: 'indigo', hex: '#4b0082' }, + { name: 'turquoise', hex: '#40e0d0' }, +]; + +// Shared targets for the "Select →" and "Highlight →" submenus. `suffix` is +// appended to `select_` / `highlight_`; `needsNet` items require a selected +// net, the rest require a selected instance. +const CONNECTED_ITEMS = [ + { label: 'Connected Insts', suffix: 'connected_insts', needsNet: true }, + { label: 'Output Nets', suffix: 'connected_nets', args: { output: true, input: false } }, + { label: 'Input Nets', suffix: 'connected_nets', args: { output: false, input: true } }, + { label: 'All Nets', suffix: 'connected_nets', args: { output: true, input: true } }, + { label: 'All buffer trees', suffix: 'connected_buffer_trees' }, + // Web-only superset of the Qt behavior: also walk inverter repeater chains. + { label: 'All buffer trees (incl. inverters)', suffix: 'connected_buffer_trees', + args: { include_inverters: true } }, +]; + +export class ContextMenu { + constructor(app) { + this._app = app; + this._el = document.createElement('div'); + this._el.className = 'context-menu'; + this._el.style.display = 'none'; + this._el.style.zIndex = '10000'; + document.body.appendChild(this._el); + + this._bindEvents(); + } + + _bindEvents() { + // Hide the menu when clicking (left or right) outside of it. + document.addEventListener('mousedown', (e) => { + if (!this._el.contains(e.target)) { + this.hide(); + } + }); + + // Hide the menu when the map moves. + if (this._app.map) { + this._app.map.on('movestart', () => this.hide()); + } + } + + show(e) { + this._el.innerHTML = ''; + + // Enable items by selection type, mirroring the Qt GUI's + // updateContextMenuItems(): "Connected Insts" needs a selected net; + // the net/buffer-tree items need a selected instance. + const hasInst = !!this._app.selHasInst; + const hasNet = !!this._app.selHasNet; + const noSel = !(hasInst || hasNet); + // Select and Highlight share the same targets and enable rules; only + // the leaf differs (action vs. color submenu). Build both from one + // descriptor so they can't drift. + const connectedSubmenu = (leaf) => CONNECTED_ITEMS.map((it) => ({ + label: it.label, + disabled: it.needsNet ? !hasNet : !hasInst, + ...leaf(it.suffix, it.args || {}), + })); + const menuItems = [ + { label: 'Select', disabled: noSel, + subMenu: connectedSubmenu((suffix, args) => ({ + action: () => this._sendContextAction('select_' + suffix, args), + })) }, + { label: 'Highlight', disabled: noSel, + subMenu: connectedSubmenu((suffix, args) => ({ + subMenu: this._buildColorSubmenu('highlight_' + suffix, args), + })) }, + { label: 'Save', subMenu: [ + { label: 'Visible layout', + action: () => this._app.captureLayout?.({ entire: false }) }, + { label: 'Entire layout', + action: () => this._app.captureLayout?.({ entire: true }) }, + ]}, + { label: 'Clear', subMenu: [ + { label: 'Selections', action: () => this._sendContextAction('clear_selections') }, + { label: 'Highlights', action: () => this._sendContextAction('clear_highlights') }, + { label: 'Rulers', action: () => { this._app.rulerManager?.clearAllRulers(); } }, + { label: 'Focus nets', action: () => this._sendContextAction('clear_focus_nets') }, + { label: 'Route Guides', action: () => this._sendContextAction('clear_route_guides') }, + { label: 'All', action: () => { + this._sendContextAction('clear_all'); + this._app.rulerManager?.clearAllRulers(); + }}, + ]}, + ]; + + this._buildMenu(menuItems, this._el); + + // Show first so we can measure, then clamp into the viewport so the + // menu (and its submenus) never spills off the right/bottom edge. + this._el.style.display = 'block'; + const margin = 4; + const rect = this._el.getBoundingClientRect(); + const maxLeft = window.innerWidth - rect.width - margin; + const maxTop = window.innerHeight - rect.height - margin; + const left = Math.max(margin, + Math.min(e.originalEvent.clientX, maxLeft)); + const top = Math.max(margin, + Math.min(e.originalEvent.clientY, maxTop)); + this._el.style.left = left + 'px'; + this._el.style.top = top + 'px'; + } + + hide() { + this._el.style.display = 'none'; + } + + // Build a 16-color submenu for a highlight action; each entry sends the + // group index (0-15) matching gui::Painter::kHighlightColors. + _buildColorSubmenu(action, baseArgs = {}) { + return HIGHLIGHT_COLORS.map(({ name, hex }, idx) => ({ + label: name, + swatch: hex, + action: () => this._sendContextAction(action, { ...baseArgs, group: idx }), + })); + } + + _buildMenu(items, container) { + items.forEach(item => { + const row = document.createElement('div'); + row.className = 'cm-item'; + if (item.swatch) { + const sw = document.createElement('span'); + sw.className = 'cm-swatch'; + sw.style.background = item.swatch; + row.appendChild(sw); + } + row.appendChild(document.createTextNode(item.label)); + + if (item.disabled) { + // Greyed-out entry: no action, no submenu expansion. + row.classList.add('disabled'); + if (item.subMenu) { + row.classList.add('has-submenu'); + } + container.appendChild(row); + return; + } + + if (item.subMenu) { + row.classList.add('has-submenu'); + const subContainer = document.createElement('div'); + subContainer.className = 'cm-submenu'; + this._buildMenu(item.subMenu, subContainer); + row.appendChild(subContainer); + // Flip the submenu to the left when it would overflow the + // viewport on the right. + row.addEventListener('mouseenter', () => { + subContainer.classList.remove('flip-left'); + const sub = subContainer.getBoundingClientRect(); + if (sub.right > window.innerWidth) { + subContainer.classList.add('flip-left'); + } + }); + } else if (item.action) { + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + this.hide(); + try { + item.action(); + } catch (err) { + console.error('Context menu item action threw:', + item.label, err); + } + }); + } + + container.appendChild(row); + }); + } + + _sendContextAction(action, args = {}) { + if (!this._app.websocketManager) { + return; + } + this._app.websocketManager + .request({ type: 'context_action', action, ...args }) + .then((resp) => { + // Keep the client-side selection flags in sync so the menu can + // enable/disable items by type on the next open. + applySelectionFlags(this._app, resp); + // Improvement over Qt (which is silent): tell the user when the + // action found nothing, usually because the wrong object type + // was selected (Connected Insts wants a net; the others want an + // instance). + if (resp && resp.connected_count === 0) { + this._toast('Nothing connected — select an ' + + 'instance (or a net for "Connected Insts") first.'); + } + if (typeof this._app.redrawAllLayers === 'function') { + this._app.redrawAllLayers(); + } + }) + .catch((err) => { + console.error('Context action failed:', action, err); + }); + } + + // Minimal transient message shown near the top of the map container. + _toast(msg) { + const host = (this._app.map && this._app.map.getContainer) + ? this._app.map.getContainer() + : document.body; + const el = document.createElement('div'); + el.className = 'cm-toast'; + el.textContent = msg; + host.appendChild(el); + setTimeout(() => { el.classList.add('cm-toast-hide'); }, 1800); + setTimeout(() => { el.remove(); }, 2200); + } +} diff --git a/src/web/src/inspector.js b/src/web/src/inspector.js index 228324fbbf8..2d8b6f294ad 100644 --- a/src/web/src/inspector.js +++ b/src/web/src/inspector.js @@ -265,9 +265,9 @@ export function createInspectorPanel(app, redrawAllLayers, refreshOverlay) { if (data.bbox && app.map && app.designScale) { const [x1, y1, x2, y2] = data.bbox; if (data.type !== 'Inst') { - highlightBBox(x1, y1, x2, y2); + highlightBBox(x1, y1, x2, y2, data.type); } - pulseHighlight(data.bbox); + pulseHighlight(data.bbox, data.type); } // Redraw tiles to restore selection-set highlights. redrawAllLayers(); @@ -313,9 +313,9 @@ export function createInspectorPanel(app, redrawAllLayers, refreshOverlay) { // For non-instance objects, show dashed bbox outline // (instances get the yellow tile-based highlight instead) if (data.type !== 'Inst') { - highlightBBox(x1, y1, x2, y2); + highlightBBox(x1, y1, x2, y2, data.type); } - pulseHighlight(data.bbox); + pulseHighlight(data.bbox, data.type); } // Refresh overlay to update instance highlight refreshOverlay(); @@ -355,9 +355,9 @@ export function createInspectorPanel(app, redrawAllLayers, refreshOverlay) { if (data.bbox && app.map && app.designScale) { if (data.type !== 'Inst') { const [x1, y1, x2, y2] = data.bbox; - highlightBBox(x1, y1, x2, y2); + highlightBBox(x1, y1, x2, y2, data.type); } - pulseHighlight(data.bbox); + pulseHighlight(data.bbox, data.type); } refreshOverlay(); }) @@ -367,9 +367,15 @@ export function createInspectorPanel(app, redrawAllLayers, refreshOverlay) { }); } - function highlightBBox(x1, y1, x2, y2) { + function highlightBBox(x1, y1, x2, y2, type) { if (app.highlightRect) { app.map.removeLayer(app.highlightRect); + app.highlightRect = null; + } + // A net's bbox spans its whole extent, which is distracting; the net's + // trace is already highlighted via the overlay, so skip the rectangle. + if (type === 'Net') { + return; } const bounds = dbuRectToBounds(x1, y1, x2, y2, app.designScale, app.designMaxDXDY, app.designOriginX, app.designOriginY); app.highlightRect = L.rectangle(bounds, { @@ -382,8 +388,10 @@ export function createInspectorPanel(app, redrawAllLayers, refreshOverlay) { // animation. The pulse is a filled rectangle that fades in and out // several times, then removes itself. let pulseLayer = null; - function pulseHighlight(bbox) { + function pulseHighlight(bbox, type) { if (!bbox || !app.map || !app.designScale) return; + // A net's bbox spans the whole net; don't pulse a giant rectangle. + if (type === 'Net') return; if (pulseLayer) { app.map.removeLayer(pulseLayer); pulseLayer = null; diff --git a/src/web/src/main.js b/src/web/src/main.js index 8c05041f01b..c093c9fde52 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -2,7 +2,7 @@ // Copyright (c) 2026, The OpenROAD Authors import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0'; -import { latLngToDbu } from './coordinates.js'; +import { latLngToDbu, dbuRectToBounds } from './coordinates.js'; import { WebSocketManager } from './websocket-manager.js'; import { createWebSocketTileLayer, createOverlayTileLayer } from './websocket-tile-layer.js'; import { TimingWidget } from './timing-widget.js'; @@ -10,7 +10,7 @@ import { ClockTreeWidget } from './clock-tree-widget.js'; import { ChartsWidget } from './charts-widget.js'; import { HierarchyBrowser } from './hierarchy-browser.js'; import { createInspectorPanel } from './inspector.js'; -import { isStaticMode } from './ui-utils.js'; +import { isStaticMode, buildVisibilityFlags, applySelectionFlags } from './ui-utils.js'; import { populateDisplayControls } from './display-controls.js'; import { createMenuBar } from './menu-bar.js'; import { RulerManager } from './ruler.js'; @@ -20,6 +20,9 @@ import { TclCompleter } from './tcl-completer.js'; import { getCookie, setCookie, applyGLTheme } from './theme.js'; import { updateDocumentTitle } from './title.js'; import { ThreeDViewerWidget } from './3d-viewer-widget.js'; +import { ContextMenu } from './context-menu.js'; +import { showFindDialog, showGotoDialog } from './search-nav.js'; +import { captureLayout } from './capture.js'; // ─── Status Indicator ─────────────────────────────────────────────────────── @@ -72,6 +75,9 @@ function updateStatus() { const app = { map: null, fitBounds: null, + lastSelectionBounds: null, // Leaflet bounds of the last selected object + selHasInst: false, // selection contains any instance + selHasNet: false, // selection contains any net displayControlsEl: null, allLayers: [], designScale: null, // pixels-per-DBU for coordinate conversion @@ -261,6 +267,11 @@ const selectability = { routing_obstructions: true, }; +// Expose the live visibility/selectability so the context menu "Save" can +// serialize the same payload the tile requests use (visibility-aware export). +app.visibility = visibility; +app.selectability = selectability; + try { const saved = getCookie('or_selectability'); if (saved) { @@ -451,6 +462,13 @@ function scheduleRedrawAllLayers() { }); } +// Expose so the context menu can refresh base + overlay tiles after a +// server-side context_action (e.g. Select → Connected). +app.redrawAllLayers = redrawAllLayers; + +// Expose the WYSIWYG capture so the context menu "Save" can grab the scene. +app.captureLayout = (opts = {}) => captureLayout(app, opts); + function createLayoutViewer(container) { const mapDiv = document.createElement('div'); mapDiv.className = 'layout-viewer'; @@ -475,6 +493,26 @@ function createLayoutViewer(container) { hoverPane.style.zIndex = '650'; hoverPane.style.pointerEvents = 'none'; + // "Fit" button below the default zoom (+/-) control, top-left. + const FitControl = L.Control.extend({ + onAdd: function() { + const c = L.DomUtil.create( + 'div', 'leaflet-bar leaflet-control leaflet-control-fit'); + const a = L.DomUtil.create('a', '', c); + a.href = '#'; + a.title = 'Fit'; + a.setAttribute('role', 'button'); + a.setAttribute('aria-label', 'Fit'); + a.textContent = '⤢'; + L.DomEvent.on(a, 'click', L.DomEvent.stop) + .on(a, 'click', () => { + if (app.fitBounds) app.map.fitBounds(app.fitBounds); + }); + return c; + }, + }); + new FitControl({ position: 'topleft' }).addTo(app.map); + new ResizeObserver(() => { app.map.invalidateSize({ animate: false }); }).observe(mapDiv); @@ -967,6 +1005,9 @@ app.toggleShowDbu = function() { createMenuBar(app); +// Canvas right-click context menu ("Select →" connected objects). +app.contextMenu = new ContextMenu(app); + // Debug-graphics pause affordance: appended lazily when the first // debug_paused push arrives. Clicking "Continue" tells the server to // release the placer thread. @@ -1115,22 +1156,20 @@ app.websocketManager.readyPromise.then(async () => { // Hide loading overlay — shapes are always ready in static mode. document.getElementById('loading-overlay').style.display = 'none'; } - if (!staticCache) app.map.on('click', (e) => { - if (!app.designScale) return; - if (app.rulerManager && app.rulerManager.isActive()) return; + // Shared select-at-point logic used by left-click and right-click. + // Returns the server response (or null). `focusInspector` switches the + // panel to the Inspector (left-click); right-click keeps the current + // panel and only updates the selection so the context menu can reflect + // the object under the cursor. + function selectAtLatLng(latlng, opts = {}) { + const { addToSelection = false, focusInspector = true, + context = false } = opts; + if (!app.designScale) return Promise.resolve(null); const { dbuX: dbu_x, dbuY: dbu_y } = latLngToDbu( - e.latlng.lat, e.latlng.lng, app.designScale, app.designMaxDXDY, + latlng.lat, latlng.lng, app.designScale, app.designMaxDXDY, app.designOriginX, app.designOriginY); - const vf = {}; - for (const [k, v] of Object.entries(visibility)) { - vf[k] = !!v; - } - // Selectability is sent with `s_` prefix to mirror the flat - // visibility key scheme; the server parses both columns. - for (const [k, v] of Object.entries(selectability)) { - vf['s_' + k] = !!v; - } + const vf = buildVisibilityFlags(visibility, selectability); const selectRequest = { type: 'select', dbu_x, @@ -1141,16 +1180,21 @@ app.websocketManager.readyPromise.then(async () => { use_dbu: app.showDbu, ...vf, }; - if (e.originalEvent && e.originalEvent.shiftKey) { + if (addToSelection) { selectRequest.add_to_selection = true; } + if (context) { + selectRequest.context = true; + } if (app.visibleChiplets instanceof Set) { selectRequest.visible_chiplets = [...app.visibleChiplets]; } - app.websocketManager.request(selectRequest) + return app.websocketManager.request(selectRequest) .then(data => { console.log('Select response:', data, 'at dbu', dbu_x, dbu_y); app.map.closePopup(); + // Type flags so the context menu can enable items by type. + applySelectionFlags(app, data); if (data.selected && data.selected.length > 0) { const inst = data.selected[0]; if (inst.type === 'Inst') { @@ -1160,28 +1204,45 @@ app.websocketManager.readyPromise.then(async () => { } } updateInspector(data); - focusComponent('Inspector'); - // Highlight selected instance bbox + if (focusInspector) focusComponent('Inspector'); + // Highlight selected object's bbox (skipped for nets, + // whose trace is already shown by the overlay). if (inst.bbox) { highlightBBox(inst.bbox[0], inst.bbox[1], - inst.bbox[2], inst.bbox[3]); - pulseHighlight(inst.bbox); + inst.bbox[2], inst.bbox[3], inst.type); + pulseHighlight(inst.bbox, inst.type); + // Remember bounds for "Zoom to Selection". + app.lastSelectionBounds = dbuRectToBounds( + inst.bbox[0], inst.bbox[1], + inst.bbox[2], inst.bbox[3], + app.designScale, app.designMaxDXDY, + app.designOriginX, app.designOriginY); } - } else if (!selectRequest.add_to_selection) { - // Shift+click on empty space preserves the existing - // multi-selection on the server, so leave the - // inspector/navigation controls and highlight intact. + } else if (!addToSelection && !context) { + // Empty left-click: clear inspector/highlight. An empty + // right-click (context) keeps the current selection. updateInspector(null); if (app.highlightRect) { app.map.removeLayer(app.highlightRect); app.highlightRect = null; } + app.lastSelectionBounds = null; } refreshOverlay(); + return data; }) .catch(err => { console.error('Select failed:', err); + return null; }); + } + app.selectAtLatLng = selectAtLatLng; + + if (!staticCache) app.map.on('click', (e) => { + if (app.rulerManager && app.rulerManager.isActive()) return; + selectAtLatLng(e.latlng, { + addToSelection: !!(e.originalEvent && e.originalEvent.shiftKey), + }); }); // ─── Right-click rubber-band zoom ────────────────────────────── @@ -1230,18 +1291,33 @@ app.websocketManager.readyPromise.then(async () => { rbStart = null; app.map.dragging.enable(); - if (!wasShowing) return; - - // Convert the two screen corners to lat/lng and zoom const rect = container.getBoundingClientRect(); - const p1 = app.map.containerPointToLatLng([ - start.x - rect.left, start.y - rect.top]); - const p2 = app.map.containerPointToLatLng([ + + if (wasShowing) { + // Drag — rubber-band zoom. + const p1 = app.map.containerPointToLatLng([ + start.x - rect.left, start.y - rect.top]); + const p2 = app.map.containerPointToLatLng([ + e.clientX - rect.left, e.clientY - rect.top]); + app.map.fitBounds([ + [Math.min(p1.lat, p2.lat), Math.min(p1.lng, p2.lng)], + [Math.max(p1.lat, p2.lat), Math.max(p1.lng, p2.lng)], + ]); + return; + } + + // Pure click — select the object under the cursor, then show + // the context menu contextualized on it (Qt-like). Only over + // the map and not while a ruler is being placed. + const overMap = e.clientX >= rect.left && e.clientX <= rect.right + && e.clientY >= rect.top && e.clientY <= rect.bottom; + if (!overMap) return; + if (app.rulerManager && app.rulerManager.isActive()) return; + + const latlng = app.map.containerPointToLatLng([ e.clientX - rect.left, e.clientY - rect.top]); - app.map.fitBounds([ - [Math.min(p1.lat, p2.lat), Math.min(p1.lng, p2.lng)], - [Math.max(p1.lat, p2.lat), Math.max(p1.lng, p2.lng)], - ]); + app.selectAtLatLng(latlng, { context: true, focusInspector: false }) + .then(() => app.contextMenu.show({ originalEvent: e })); }); } @@ -1305,6 +1381,12 @@ document.addEventListener('keydown', (e) => { } else { app.map.zoomOut(); } + } else if (key === 'f' && (e.ctrlKey || e.metaKey)) { + // Ctrl/Cmd+F: Find (prevent the browser's own find bar). + e.preventDefault(); + if (app.designScale) showFindDialog(app); + } else if (key === 'g' && e.shiftKey && !e.ctrlKey && !e.metaKey) { + if (app.designScale) showGotoDialog(app); } else if (key === 't' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { app.toggleTheme(); } diff --git a/src/web/src/menu-bar.js b/src/web/src/menu-bar.js index 4c9a80260e3..8678f874ecc 100644 --- a/src/web/src/menu-bar.js +++ b/src/web/src/menu-bar.js @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026, The OpenROAD Authors +import { showFindDialog, showGotoDialog } from './search-nav.js'; + // Creates a menu bar in #menu-bar and returns keyboard shortcut bindings. export function createMenuBar(app) { const menus = [ @@ -19,8 +21,12 @@ export function createMenuBar(app) { { label: 'Show DBU', action: () => app.toggleShowDbu(), checked: () => app.showDbu }, { type: 'separator' }, - { label: 'Find...', shortcut: 'Ctrl+F', disabled: true }, - { label: 'Go to Position...', shortcut: 'Shift+G', disabled: true }, + { label: 'Find...', shortcut: 'Ctrl+F', + action: () => showFindDialog(app), + enabledWhen: () => !!app.designScale }, + { label: 'Go to Position...', shortcut: 'Shift+G', + action: () => showGotoDialog(app), + enabledWhen: () => !!app.designScale }, ]}, { label: 'Tools', items: [ { label: 'Ruler', shortcut: 'K', diff --git a/src/web/src/request_handler.cpp b/src/web/src/request_handler.cpp index 0f95e00a074..62e9ab569fc 100644 --- a/src/web/src/request_handler.cpp +++ b/src/web/src/request_handler.cpp @@ -3,6 +3,8 @@ #include "request_handler.h" +#include + #include #include #include @@ -65,6 +67,7 @@ class ShapeCollector : public gui::Painter std::vector rects; std::vector polys; + std::vector> lines; void drawRect(const odb::Rect& rect, int, int) override { @@ -76,6 +79,14 @@ class ShapeCollector : public gui::Painter } void drawOctagon(const odb::Oct& oct) override { polys.emplace_back(oct); } + // Capture line segments (net flywires, route paths, guide lines) so net + // highlights are visible in the overlay. All Painter::drawLine overloads + // funnel through this virtual. + void drawLine(const odb::Point& p1, const odb::Point& p2) override + { + lines.emplace_back(p1, p2); + } + // No-ops Color getPenColor() override { return {}; } void setPen(odb::dbTechLayer*, bool) override {} @@ -86,7 +97,6 @@ class ShapeCollector : public gui::Painter void setFont(const Font&) override {} void saveState() override {} void restoreState() override {} - void drawLine(const odb::Point&, const odb::Point&) override {} void drawCircle(int, int, int) override {} void drawX(int, int, int) override {} void drawPolygon(const std::vector&) override {} @@ -236,12 +246,15 @@ static boost::json::object serializeProperty( return o; } -static void collectHighlightShapes(const gui::Selected& sel, - std::vector& rects, - std::vector& polys) +static void collectHighlightShapes( + const gui::Selected& sel, + std::vector& rects, + std::vector& polys, + std::vector>& lines) { rects.clear(); polys.clear(); + lines.clear(); if (!sel) { return; } @@ -249,6 +262,7 @@ static void collectHighlightShapes(const gui::Selected& sel, sel.highlight(collector); rects = std::move(collector.rects); polys = std::move(collector.polys); + lines = std::move(collector.lines); } // Return the 0-based position of the iterator within the selection set, @@ -264,12 +278,15 @@ static int selectionIteratorPosition(const gui::SelectionSet& set, } // Accumulate highlight shapes from all items in a selection set. -static void collectMultiHighlightShapes(const gui::SelectionSet& selections, - std::vector& rects, - std::vector& polys) +static void collectMultiHighlightShapes( + const gui::SelectionSet& selections, + std::vector& rects, + std::vector& polys, + std::vector>& lines) { rects.clear(); polys.clear(); + lines.clear(); for (const auto& sel : selections) { if (!sel) { continue; @@ -278,7 +295,33 @@ static void collectMultiHighlightShapes(const gui::SelectionSet& selections, sel.highlight(collector); rects.insert(rects.end(), collector.rects.begin(), collector.rects.end()); polys.insert(polys.end(), collector.polys.begin(), collector.polys.end()); + lines.insert(lines.end(), collector.lines.begin(), collector.lines.end()); + } +} + +// Report whether the selection set contains any instance / any net, so the +// frontend context menu can enable/disable items by type (mirrors the Qt +// LayoutViewer::updateContextMenuItems + Gui::anyObjectInSet). +static void addSelectionTypeFlags(boost::json::object& root, + const gui::SelectionSet& selection) +{ + bool has_inst = false; + bool has_net = false; + for (const auto& s : selection) { + if (!s) { + continue; + } + if (s.isInst()) { + has_inst = true; + } else if (s.isNet()) { + has_net = true; + } + if (has_inst && has_net) { + break; + } } + root["sel_has_inst"] = has_inst; + root["sel_has_net"] = has_net; } static void writeInspectPayload(boost::json::object& o, @@ -546,6 +589,11 @@ void SelectHandler::registerRequests(RequestDispatcher& d) [this](const WebSocketRequest& req, SessionState& state) { return handleSelectFanoutBin(req, state); }); + d.add("find", + WebSocketRequest::kFind, + [this](const WebSocketRequest& req, SessionState& state) { + return handleFind(req, state); + }); d.add("set_route_guides", WebSocketRequest::kSetRouteGuides, [this](const WebSocketRequest& req, SessionState& state) { @@ -556,6 +604,11 @@ void SelectHandler::registerRequests(RequestDispatcher& d) [this](const WebSocketRequest& req, SessionState&) { return handleGet3DData(req); }); + d.add("context_action", + WebSocketRequest::kContextAction, + [this](const WebSocketRequest& req, SessionState& state) { + return handleContextAction(req, state); + }); } WebSocketResponse SelectHandler::handleSelect(const WebSocketRequest& req, @@ -579,6 +632,9 @@ WebSocketResponse SelectHandler::handleSelect(const WebSocketRequest& req, arrayAsStringSet(req.json.at("visible_layers"))); const bool add_to_selection = jsonOr(req.json, "add_to_selection", false); + // Right-click (context menu) select: only replace the selection when the + // cursor is over a NOT-already-selected object; otherwise keep it. + const bool context = jsonOr(req.json, "context", false); // STA's highlight() and getProperties() are not thread-safe; // serialize with other STA callers (timing, clock tree, tcl eval). @@ -645,8 +701,17 @@ WebSocketResponse SelectHandler::handleSelect(const WebSocketRequest& req, if (inspected_sel) { state.selection_itr = state.selection_set.insert(inspected_sel).first; } + } else if (context + && (!inspected_sel + || state.selection_set.contains(inspected_sel))) { + // Right-click on empty space or on an already-selected object: keep the + // current selection so the context menu operates on it. + if (inspected_sel) { + state.selection_itr = state.selection_set.find(inspected_sel); + } } else { - // Normal click: replace selection set. + // Normal click, or a context click on a new (unselected) object: + // replace the selection set. state.selection_set.clear(); if (inspected_sel) { state.selection_set.insert(inspected_sel); @@ -655,14 +720,20 @@ WebSocketResponse SelectHandler::handleSelect(const WebSocketRequest& req, } // Highlight all items in the selection set. - collectMultiHighlightShapes( - state.selection_set, state.highlight_rects, state.highlight_polys); - state.current_inspected = inspected_sel; + collectMultiHighlightShapes(state.selection_set, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); + // Keep the current inspected object on an empty context click. + if (inspected_sel || !context) { + state.current_inspected = inspected_sel; + } root["selection_count"] = static_cast(state.selection_set.size()); root["selection_index"] = static_cast( selectionIteratorPosition(state.selection_set, state.selection_itr)); + addSelectionTypeFlags(root, state.selection_set); } writePayload(resp, root); @@ -674,6 +745,299 @@ WebSocketResponse SelectHandler::handleSelect(const WebSocketRequest& req, return resp; } +// ── Context-menu "Select →" actions ─────────────────────────────────────── +// +// Mirror the Qt GUI's LayoutViewer "Select →" context menu, operating on the +// session's current selection set. Connected-object traversal replicates +// MainWindow::selectHighlightConnected{Insts,Nets,BufferTrees} +// (mainWindow.cpp). + +namespace { + +// True if `inst` is a buffer cell (per liberty). When `include_inverters` is +// set, single-input inverters also count, so buffer-tree traversal follows +// inverter-based repeater chains (a superset of the Qt behavior, opt-in). +bool isRepeaterInst(odb::dbInst* inst, sta::dbSta* sta, bool include_inverters) +{ + if (inst == nullptr || sta == nullptr) { + return false; + } + sta::dbNetwork* network = sta->getDbNetwork(); + sta::Cell* cell = network->dbToSta(inst->getMaster()); + if (cell == nullptr) { + return false; + } + sta::LibertyCell* lib_cell = network->libertyCell(cell); + if (lib_cell == nullptr) { + return false; + } + return lib_cell->isBuffer() || (include_inverters && lib_cell->isInverter()); +} + +// Walk the buffer tree rooted at `net`, collecting every net and buffer inst +// reachable by passing through buffer (optionally inverter) cells. Mirrors +// gui::BufferTree::populate without depending on the gui module's private +// BufferTree type. +void collectBufferTree(odb::dbNet* net, + sta::dbSta* sta, + bool include_inverters, + odb::PtrSet& nets, + odb::PtrSet& insts) +{ + if (net == nullptr || nets.contains(net)) { + return; + } + nets.insert(net); + for (auto* iterm : net->getITerms()) { + odb::dbInst* inst = iterm->getInst(); + if (!isRepeaterInst(inst, sta, include_inverters)) { + continue; + } + insts.insert(inst); + for (auto* next : inst->getITerms()) { + if (!next->getSigType().isSupply()) { + collectBufferTree(next->getNet(), sta, include_inverters, nets, insts); + } + } + } +} + +// Collect the objects connected to the current selection, per `action`. +gui::SelectionSet computeConnectedSet(const std::string& action, + const gui::SelectionSet& selection, + const bool output, + const bool input, + const bool include_inverters, + sta::dbSta* sta) +{ + auto* registry = gui::DescriptorRegistry::instance(); + gui::SelectionSet result; + + const bool want_insts = action.find("insts") != std::string::npos; + const bool want_buffer_trees + = action.find("buffer_trees") != std::string::npos; + const bool want_nets + = !want_buffer_trees && action.find("nets") != std::string::npos; + + for (const auto& sel : selection) { + if (!sel) { + continue; + } + if (want_insts && sel.isNet()) { + // "Connected Insts": from selected net(s), select the owning instances + // of the net's terminals (deduped by the SelectionSet) so they show as + // bboxes, matching the label. + auto* net = std::any_cast(sel.getObject()); + if (net == nullptr) { + continue; + } + for (auto* iterm : net->getITerms()) { + odb::dbInst* inst = iterm->getInst(); + if (inst != nullptr) { + result.insert(registry->makeSelected(inst)); + } + } + } else if (want_nets && sel.isInst()) { + // "Output/Input/All Nets": from selected inst(s), select signal nets by + // pin direction (mirrors MainWindow::selectHighlightConnectedNets). + auto* inst = std::any_cast(sel.getObject()); + if (inst == nullptr) { + continue; + } + for (auto* iterm : inst->getITerms()) { + odb::dbNet* net = iterm->getNet(); + if (net == nullptr || net->getSigType() != odb::dbSigType::SIGNAL) { + continue; + } + const auto dir = iterm->getIoType(); + if (output + && (dir == odb::dbIoType::OUTPUT || dir == odb::dbIoType::INOUT)) { + result.insert(registry->makeSelected(net)); + } + if (input + && (dir == odb::dbIoType::INPUT || dir == odb::dbIoType::INOUT)) { + // TODO: the Qt GUI selects DbNetDescriptor::NetWithSink{net, iterm} + // so the input flightline points at this specific sink pin. That + // descriptor lives in a private gui header; until it is exposed the + // web selects the plain net (all sinks highlighted). + result.insert(registry->makeSelected(net)); + } + } + } else if (want_buffer_trees && sel.isInst()) { + // "All buffer trees": from selected inst(s), expand the buffer tree(s) + // reachable through its signal pins. + auto* inst = std::any_cast(sel.getObject()); + if (inst == nullptr) { + continue; + } + odb::PtrSet tree_nets; + odb::PtrSet tree_insts; + for (auto* iterm : inst->getITerms()) { + const auto dir = iterm->getIoType(); + if (iterm->getSigType().isSupply() + || (dir != odb::dbIoType::INPUT && dir != odb::dbIoType::OUTPUT + && dir != odb::dbIoType::INOUT)) { + continue; + } + odb::dbNet* net = iterm->getNet(); + if (net == nullptr || net->getSigType() != odb::dbSigType::SIGNAL) { + continue; + } + collectBufferTree(net, sta, include_inverters, tree_nets, tree_insts); + } + // Select the tree's nets and buffer instances via their already + // registered dbNet/dbInst descriptors (no gui::BufferTree dependency). + for (auto* tree_net : tree_nets) { + result.insert(registry->makeSelected(tree_net)); + } + for (auto* tree_inst : tree_insts) { + result.insert(registry->makeSelected(tree_inst)); + } + } + } + return result; +} + +// Map a highlight group index to its web RGBA color. Mirrors the Qt GUI's +// gui::Painter::kHighlightColors (16 entries, fill alpha ~100). +Color toWebHighlightColor(const int group) +{ + const auto& colors = gui::Painter::kHighlightColors; + const int num_colors = static_cast(colors.size()); + if (num_colors == 0) { + return Color{.r = 0, .g = 0, .b = 0, .a = 255}; + } + // Signed modulo so a negative group still maps into [0, num_colors). + const auto& c = colors[((group % num_colors) + num_colors) % num_colors]; + return Color{.r = static_cast(c.r), + .g = static_cast(c.g), + .b = static_cast(c.b), + .a = static_cast(c.a)}; +} + +} // namespace + +WebSocketResponse SelectHandler::handleContextAction( + const WebSocketRequest& req, + SessionState& state) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = WebSocketResponse::kJson; + try { + const std::string action(req.json.at("action").as_string()); + const bool output = jsonOr(req.json, "output", false); + const bool input = jsonOr(req.json, "input", false); + const bool include_inverters = jsonOr(req.json, "include_inverters", false); + const int group = static_cast(jsonOr(req.json, "group", int64_t{0})); + + // STA traversal and makeSelected() are not thread-safe; serialize with + // other STA callers (select, timing, clock tree, tcl eval). + std::lock_guard sta_lock(tcl_eval_->mutex); + std::lock_guard lock(state.selection_mutex); + + // Reset helpers, composed by clear_all. + auto clearSelection = [&]() { + state.selection_set.clear(); + state.selection_itr = state.selection_set.end(); + state.current_inspected = gui::Selected(); + state.navigation_history.clear(); + state.highlight_rects.clear(); + state.highlight_polys.clear(); + state.highlight_lines.clear(); + }; + auto clearHighlightGroups = [&]() { + state.highlight_group_rects.clear(); + state.highlight_group_polys.clear(); + state.highlight_group_lines.clear(); + }; + auto clearFocusNets = [&]() { + std::lock_guard flock(state.focus_nets_mutex); + state.focus_net_ids.clear(); + }; + auto clearRouteGuides = [&]() { + std::lock_guard rlock(state.route_guides_mutex); + state.route_guide_net_ids.clear(); + }; + + int connected_count = 0; + if (action.starts_with("select_") || action.starts_with("highlight_")) { + // Both branches resolve the same connected set (computeConnectedSet keys + // off the "insts"/"nets"/"buffer_trees" substring, not the prefix). + const gui::SelectionSet connected + = computeConnectedSet(action, + state.selection_set, + output, + input, + include_inverters, + gen_->getSta()); + if (action.starts_with("select_")) { + for (const auto& s : connected) { + if (state.selection_set.insert(s).second) { + ++connected_count; + } + } + state.selection_itr = state.selection_set.begin(); + collectMultiHighlightShapes(state.selection_set, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); + } else { + const Color color = toWebHighlightColor(group); + for (const auto& s : connected) { + if (!s) { + continue; + } + ++connected_count; + ShapeCollector collector; + s.highlight(collector); + for (const auto& r : collector.rects) { + state.highlight_group_rects.push_back(ColoredRect{ + .rect = r, .color = color, .layer = "", .filled = true}); + } + for (const auto& p : collector.polys) { + state.highlight_group_polys.push_back( + ColoredPolygon{.poly = p, .color = color}); + } + for (const auto& [p1, p2] : collector.lines) { + state.highlight_group_lines.push_back( + FlightLine{.p1 = p1, .p2 = p2, .color = color}); + } + } + } + } else if (action == "clear_highlights") { + clearHighlightGroups(); + } else if (action == "clear_selections") { + clearSelection(); + } else if (action == "clear_focus_nets") { + clearFocusNets(); + } else if (action == "clear_route_guides") { + clearRouteGuides(); + } else if (action == "clear_all") { + clearSelection(); + clearHighlightGroups(); + clearFocusNets(); + clearRouteGuides(); + } else { + throw std::runtime_error("unknown context action: " + action); + } + + boost::json::object root; + root["ok"] = true; + // connected_count == 0 lets the frontend surface "nothing connected — + // select an instance/net first" (the Qt GUI is silent in this case). + root["connected_count"] = static_cast(connected_count); + root["selection_count"] = static_cast(state.selection_set.size()); + addSelectionTypeFlags(root, state.selection_set); + writePayload(resp, root); + } catch (const std::exception& e) { + resp.type = WebSocketResponse::kError; + const std::string err = std::string("context_action error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + WebSocketResponse SelectHandler::handleInspect(const WebSocketRequest& req, SessionState& state) { @@ -711,7 +1075,10 @@ WebSocketResponse SelectHandler::handleInspect(const WebSocketRequest& req, state.hover_rects.clear(); state.timing_rects.clear(); state.timing_lines.clear(); - collectHighlightShapes(sel, state.highlight_rects, state.highlight_polys); + collectHighlightShapes(sel, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); if (sel) { if (state.current_inspected && state.current_inspected != sel) { state.navigation_history.push_back(state.current_inspected); @@ -777,7 +1144,10 @@ WebSocketResponse SelectHandler::handleInspectBack(const WebSocketRequest& req, sel = state.current_inspected; } - collectHighlightShapes(sel, state.highlight_rects, state.highlight_polys); + collectHighlightShapes(sel, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); can_navigate_back = !state.navigation_history.empty(); sel_count = static_cast(state.selection_set.size()); sel_index @@ -846,8 +1216,10 @@ static WebSocketResponse handleSelectionCycle( state.navigation_history.clear(); // Restore selection-set highlights (handleInspect may have // replaced them with a single linked object's shapes). - collectMultiHighlightShapes( - state.selection_set, state.highlight_rects, state.highlight_polys); + collectMultiHighlightShapes(state.selection_set, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); } sel_index = selectionIteratorPosition(state.selection_set, state.selection_itr); @@ -1061,8 +1433,10 @@ WebSocketResponse SelectHandler::handleSelectFanoutBin( state.selection_itr = state.selection_set.begin(); } // Highlight every selected net in the layout. - collectMultiHighlightShapes( - state.selection_set, state.highlight_rects, state.highlight_polys); + collectMultiHighlightShapes(state.selection_set, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); state.current_inspected = first; root["selection_count"] @@ -1082,6 +1456,129 @@ WebSocketResponse SelectHandler::handleSelectFanoutBin( return resp; } +// Find objects by name/glob (mirrors the Qt FindObjectDialog + Gui::select): +// obj_type in {inst, net, port}; pattern is a Unix glob (*, ?, []) or an exact +// name; selects all matches and returns their union bbox for auto-zoom. +WebSocketResponse SelectHandler::handleFind(const WebSocketRequest& req, + SessionState& state) +{ + WebSocketResponse resp; + resp.id = req.id; + resp.type = WebSocketResponse::kJson; + try { + const std::string obj_type(req.json.at("obj_type").as_string()); + const std::string pattern(req.json.at("pattern").as_string()); + const bool match_case = jsonOr(req.json, "match_case", false); + const bool use_dbu = jsonOr(req.json, "use_dbu", false); + + odb::dbBlock* block = gen_->getBlock(); + if (block == nullptr) { + throw std::runtime_error("no design loaded"); + } + + // STA highlight()/getProperties() and makeSelected() are not thread-safe. + std::lock_guard sta_lock(tcl_eval_->mutex); + ScopedDbuFormat dbu_fmt(block, use_dbu); + + const int fn_flags = match_case ? 0 : FNM_CASEFOLD; + auto matches = [&](const char* name) { + return name != nullptr && fnmatch(pattern.c_str(), name, fn_flags) == 0; + }; + + auto* registry = gui::DescriptorRegistry::instance(); + gui::SelectionSet found; + constexpr size_t kMaxFindSelection = 1000; + int total = 0; + auto collect = [&](auto&& range) { + for (auto* obj : range) { + if (matches(obj->getConstName())) { + ++total; + if (found.size() < kMaxFindSelection) { + found.insert(registry->makeSelected(obj)); + } + } + } + }; + if (obj_type == "inst") { + collect(block->getInsts()); + } else if (obj_type == "net") { + collect(block->getNets()); + } else if (obj_type == "port") { + collect(block->getBTerms()); + } else { + throw std::runtime_error("unknown obj_type: " + obj_type); + } + + boost::json::object root; + root["count"] = static_cast(total); + root["truncated"] = total > static_cast(kMaxFindSelection); + + if (!found.empty()) { + // Union bbox of all matched objects (for the frontend auto-zoom). + odb::Rect uni; + uni.mergeInit(); + for (const auto& sel : found) { + odb::Rect b; + if (sel && sel.getBBox(b)) { + uni.merge(b); + } + } + if (!uni.isInverted()) { + boost::json::array bbox; + bbox.emplace_back(uni.xMin()); + bbox.emplace_back(uni.yMin()); + bbox.emplace_back(uni.xMax()); + bbox.emplace_back(uni.yMax()); + root["bbox"] = std::move(bbox); + } + + const gui::Selected first = *found.begin(); + std::vector new_selectables; + writeInspectPayload(root, + first, + new_selectables, + /*can_navigate_back=*/false); + { + std::lock_guard lock(state.selectables_mutex); + state.selectables = std::move(new_selectables); + } + { + std::lock_guard lock(state.selection_mutex); + state.hover_rects.clear(); + state.timing_rects.clear(); + state.timing_lines.clear(); + state.navigation_history.clear(); + state.selection_set = std::move(found); + state.selection_itr = state.selection_set.begin(); + collectMultiHighlightShapes(state.selection_set, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); + state.current_inspected = first; + root["selection_count"] + = static_cast(state.selection_set.size()); + root["selection_index"] + = static_cast(selectionIteratorPosition( + state.selection_set, state.selection_itr)); + } + } else { + root["selection_count"] = static_cast(0); + } + + { + std::lock_guard lock(state.selection_mutex); + addSelectionTypeFlags(root, state.selection_set); + } + + writePayload(resp, root); + } catch (const std::exception& e) { + resp.type = WebSocketResponse::kError; + const std::string err = std::string("find error: ") + e.what(); + resp.payload.assign(err.begin(), err.end()); + } + return resp; +} + WebSocketResponse SelectHandler::handleSetRouteGuides( const WebSocketRequest& req, SessionState& state) @@ -1784,7 +2281,10 @@ WebSocketResponse SelectHandler::handleSchematicInspect( state.hover_rects.clear(); state.timing_rects.clear(); state.timing_lines.clear(); - collectHighlightShapes(sel, state.highlight_rects, state.highlight_polys); + collectHighlightShapes(sel, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); state.current_inspected = sel; state.navigation_history.clear(); } @@ -2021,6 +2521,7 @@ WebSocketResponse TimingHandler::handleTimingHighlight( state.timing_lines = std::move(new_lines); state.highlight_rects.clear(); state.highlight_polys.clear(); + state.highlight_lines.clear(); } const std::string json = "{\"ok\": true}"; @@ -2178,6 +2679,7 @@ WebSocketResponse ClockTreeHandler::handleClockTreeHighlight( std::lock_guard lock(state.selection_mutex); state.highlight_rects.clear(); state.highlight_polys.clear(); + state.highlight_lines.clear(); state.timing_rects.clear(); state.timing_lines.clear(); @@ -2352,14 +2854,19 @@ WebSocketResponse TileHandler::handleOverlayTile(const WebSocketRequest& req, if (state.current_inspected) { collectHighlightShapes(state.current_inspected, state.highlight_rects, - state.highlight_polys); + state.highlight_polys, + state.highlight_lines); } } + // Selection highlight color (yellow), matching drawHighlight's border. + constexpr Color kSelectionLine{.r = 255, .g = 255, .b = 0, .a = 255}; + // Snapshot current highlight state std::vector rects; std::vector polys; std::vector colored; + std::vector colored_polys; std::vector lines; { std::lock_guard lock(state.selection_mutex); @@ -2369,6 +2876,18 @@ WebSocketResponse TileHandler::handleOverlayTile(const WebSocketRequest& req, polys = state.highlight_polys; colored = state.timing_rects; lines = state.timing_lines; + // Net flywires / route paths from the selection highlight, drawn yellow. + for (const auto& [p1, p2] : state.highlight_lines) { + lines.push_back(FlightLine{.p1 = p1, .p2 = p2, .color = kSelectionLine}); + } + // Context-menu "Highlight →" group shapes carry their own group color. + colored.insert(colored.end(), + state.highlight_group_rects.begin(), + state.highlight_group_rects.end()); + colored_polys = state.highlight_group_polys; + lines.insert(lines.end(), + state.highlight_group_lines.begin(), + state.highlight_group_lines.end()); } // Merge DRC overlay shapes @@ -2414,7 +2933,8 @@ WebSocketResponse TileHandler::handleOverlayTile(const WebSocketRequest& req, lines, route_guide_ptr, has_vis_layers, - vis_layers); + vis_layers, + colored_polys); return resp; } @@ -3267,12 +3787,16 @@ WebSocketResponse DRCHandler::handleDRCHighlight(const WebSocketRequest& req, state.hover_rects.clear(); state.timing_rects.clear(); state.timing_lines.clear(); - collectHighlightShapes( - sel, state.highlight_rects, state.highlight_polys); + collectHighlightShapes(sel, + state.highlight_rects, + state.highlight_polys, + state.highlight_lines); state.current_inspected = sel; state.navigation_history.clear(); } else { state.highlight_rects.push_back(bbox); + state.highlight_polys.clear(); + state.highlight_lines.clear(); } } @@ -3297,6 +3821,7 @@ WebSocketResponse DRCHandler::handleDRCHighlight(const WebSocketRequest& req, std::lock_guard lock(state.selection_mutex); state.highlight_rects.clear(); state.highlight_polys.clear(); + state.highlight_lines.clear(); } root["ok"] = 0; } diff --git a/src/web/src/request_handler.h b/src/web/src/request_handler.h index c36b4102e92..b1f4668bd2c 100644 --- a/src/web/src/request_handler.h +++ b/src/web/src/request_handler.h @@ -97,6 +97,7 @@ struct WebSocketRequest kSlackHistogram, kFanoutHistogram, kSelectFanoutBin, + kFind, kChartFilters, kModuleHierarchy, kSetModuleColors, @@ -123,6 +124,7 @@ struct WebSocketRequest kDebugCharts, kGet3DData, kOverlayTile, + kContextAction, kUnknown }; @@ -164,6 +166,17 @@ struct SessionState std::mutex selection_mutex; std::vector highlight_rects; std::vector highlight_polys; + // Selection highlight line segments (net flywires / route paths) — rendered + // in the selection color (yellow) via the overlay flight-line channel. + // Guarded by selection_mutex. + std::vector> highlight_lines; + // Context-menu "Highlight →" groups: colored shapes carrying the per-group + // color (gui::Painter::kHighlightColors[group]). Rendered via the colored + // overlay path, independent of the single-style selection highlight above. + // Guarded by selection_mutex. + std::vector highlight_group_rects; + std::vector highlight_group_polys; + std::vector highlight_group_lines; std::vector hover_rects; std::vector timing_rects; std::vector timing_lines; @@ -235,6 +248,8 @@ class SelectHandler SessionState& state); WebSocketResponse handleSelectFanoutBin(const WebSocketRequest& req, SessionState& state); + WebSocketResponse handleFind(const WebSocketRequest& req, + SessionState& state); WebSocketResponse handleSetRouteGuides(const WebSocketRequest& req, SessionState& state); WebSocketResponse handleSelectNext(const WebSocketRequest& req, @@ -247,6 +262,8 @@ class SelectHandler WebSocketResponse handleSchematicInspect(const WebSocketRequest& req, SessionState& state); WebSocketResponse handleGet3DData(const WebSocketRequest& req); + WebSocketResponse handleContextAction(const WebSocketRequest& req, + SessionState& state); private: std::shared_ptr gen_; diff --git a/src/web/src/search-nav.js b/src/web/src/search-nav.js new file mode 100644 index 00000000000..6e60614e8f3 --- /dev/null +++ b/src/web/src/search-nav.js @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +// Search & navigation dialogs (issue #10619 table 2.8): +// - Find: name/glob search by object type (mirrors Qt FindObjectDialog). +// - Go to position: jump the view to x,y in microns (mirrors Qt GotoDialog). +// Both reuse the .modal-overlay/.modal-dialog pattern from menu-bar.js. + +import { dbuToLatLng, dbuRectToBounds, latLngToDbu } from './coordinates.js'; +import { applySelectionFlags } from './ui-utils.js'; + +// Build a modal shell and return handles. Closes on backdrop click / Escape. +function buildModal(title, bodyHtml, okLabel) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + + const close = () => overlay.remove(); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); }); + overlay.querySelector('.cancel').addEventListener('click', close); + overlay.querySelector('.modal-close').addEventListener('click', close); + + const errorDiv = overlay.querySelector('.modal-error'); + const showError = (msg, isInfo) => { + errorDiv.textContent = msg; + errorDiv.style.display = msg ? 'block' : 'none'; + errorDiv.classList.toggle('info', !!isInfo); + }; + return { overlay, close, okBtn: overlay.querySelector('.ok'), showError }; +} + +// ── Find ────────────────────────────────────────────────────────────────── + +const FIND_TYPES = [ + { label: 'Instance', value: 'inst' }, + { label: 'Net', value: 'net' }, + { label: 'Port', value: 'port' }, +]; + +export function showFindDialog(app) { + if (!app.designScale) { + return; + } + const typeOptions = FIND_TYPES + .map((t) => ``) + .join(''); + const { overlay, close, okBtn, showError } = buildModal('Find Object', ` +
+ + +
+
+ + +
+ `, 'Find'); + + const patternInput = overlay.querySelector('.sn-pattern'); + const typeSel = overlay.querySelector('.sn-type'); + const caseChk = overlay.querySelector('.sn-case'); + patternInput.focus(); + + function doFind() { + const pattern = patternInput.value.trim(); + if (!pattern) { + showError('Enter a name or glob pattern.'); + return; + } + if (!app.websocketManager) { + return; + } + showError('Searching…', true); + app.websocketManager + .request({ + type: 'find', + obj_type: typeSel.value, + pattern, + match_case: caseChk.checked, + }) + .then((resp) => { + applySelectionFlags(app, resp); + const count = resp && resp.count ? resp.count : 0; + if (!count) { + showError('No objects found.'); + return; + } + // Auto-zoom to the matched objects — an improvement over the Qt + // Find dialog, which only selects without centering. + if (Array.isArray(resp.bbox)) { + app.lastSelectionBounds = dbuRectToBounds( + resp.bbox[0], resp.bbox[1], resp.bbox[2], resp.bbox[3], + app.designScale, app.designMaxDXDY, + app.designOriginX, app.designOriginY); + app.map.fitBounds(app.lastSelectionBounds); + } + if (typeof app.updateInspector === 'function') { + app.updateInspector(resp); + } + if (typeof app.redrawAllLayers === 'function') { + app.redrawAllLayers(); + } + const shown = resp.selection_count || count; + showError(resp.truncated + ? `${count} found (showing first ${shown}).` + : `${count} found.`, true); + }) + .catch((err) => showError('Find failed: ' + err)); + } + + okBtn.addEventListener('click', doFind); + patternInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); doFind(); } + }); + return { close }; +} + +// ── Go to position ────────────────────────────────────────────────────────── + +export function showGotoDialog(app) { + if (!app.designScale || !app.map) { + return; + } + const dbuPerUm = app.getDbuPerMicron ? app.getDbuPerMicron() : 1000; + + // Prefill X/Y with the current view center (in microns). + const c = app.map.getCenter(); + const centerDbu = latLngToDbu(c.lat, c.lng, app.designScale, + app.designMaxDXDY, app.designOriginX, app.designOriginY); + const fmt = (dbu) => (dbu / dbuPerUm).toFixed(3); + + const { overlay, close, okBtn, showError } = buildModal('Go to Position', ` +
+
+
+
+
+
`, + 'Go to'); + + const xIn = overlay.querySelector('.sn-x'); + const yIn = overlay.querySelector('.sn-y'); + const sizeIn = overlay.querySelector('.sn-size'); + xIn.focus(); + xIn.select(); + + function doGoto() { + const x = parseFloat(xIn.value); + const y = parseFloat(yIn.value); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + showError('X and Y must be numbers (microns).'); + return; + } + const dbuX = Math.round(x * dbuPerUm); + const dbuY = Math.round(y * dbuPerUm); + const latlng = dbuToLatLng(dbuX, dbuY, app.designScale, + app.designMaxDXDY, app.designOriginX, app.designOriginY); + + const sizeVal = sizeIn.value.trim(); + const size = sizeVal ? parseFloat(sizeVal) : NaN; + if (Number.isFinite(size) && size > 0) { + const half = (size * dbuPerUm) / 2; + app.map.fitBounds(dbuRectToBounds( + dbuX - half, dbuY - half, dbuX + half, dbuY + half, + app.designScale, app.designMaxDXDY, + app.designOriginX, app.designOriginY)); + } else { + app.map.setView(latlng, app.map.getZoom()); + } + close(); + } + + okBtn.addEventListener('click', doGoto); + for (const el of [xIn, yIn, sizeIn]) { + el.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); doGoto(); } + }); + } + return { close }; +} diff --git a/src/web/src/style.css b/src/web/src/style.css index a0a49f05d1e..a3079febe83 100644 --- a/src/web/src/style.css +++ b/src/web/src/style.css @@ -240,6 +240,7 @@ html, body { justify-content: center; } .modal-dialog { + position: relative; background: var(--bg-header); border: 1px solid var(--border-strong); border-radius: 6px; @@ -248,6 +249,24 @@ html, body { color: var(--fg-primary); font: 13px monospace; } +.modal-dialog .modal-close { + position: absolute; + top: 8px; + right: 10px; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent; + color: var(--fg-primary); + font-size: 18px; + line-height: 20px; + cursor: pointer; + opacity: 0.7; +} +.modal-dialog .modal-close:hover { + opacity: 1; +} .modal-dialog h3 { margin: 0 0 12px 0; font-size: 14px; @@ -303,6 +322,29 @@ html, body { cursor: default; } +/* Search & navigation dialogs (Find / Go to) */ +.search-nav-dialog .sn-row { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0; +} +.search-nav-dialog .sn-row label { + width: 64px; + flex-shrink: 0; +} +.search-nav-dialog .sn-row .fb-path-input, +.search-nav-dialog .sn-type { + flex: 1; +} +.search-nav-dialog .sn-check { + display: block; + margin: 8px 0 2px; +} +.modal-dialog .modal-error.info { + color: var(--fg-primary); +} + /* File browser dialog */ .file-browser-dialog { min-width: 520px; @@ -446,6 +488,13 @@ html, body { .leaflet-container { cursor: default !important; } +/* "Fit" control button — inherits .leaflet-bar sizing from Leaflet's CSS. */ +.leaflet-control-fit a { + font-size: 16px; + font-weight: bold; + line-height: 26px; + text-align: center; +} .leaflet-dragging .leaflet-container { cursor: grabbing !important; } @@ -727,6 +776,80 @@ html, body { background: var(--bg-hover); color: var(--fg-bright); } +.cm-item { + position: relative; + padding: 5px 12px; + padding-right: 24px; + cursor: pointer; + white-space: nowrap; +} +.cm-item:hover { + background: var(--bg-hover); + color: var(--fg-bright); +} +.cm-item.disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} +.cm-item.has-submenu::after { + content: '▶'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + opacity: 0.7; +} +.cm-submenu { + position: absolute; + left: 100%; + top: -5px; + display: none; + background: var(--bg-context); + border: 1px solid var(--border-strong); + border-radius: 4px; + min-width: 160px; + padding: 4px 0; + box-shadow: 0 2px 8px var(--shadow); +} +.cm-item.has-submenu:hover > .cm-submenu { + display: block; +} +.cm-submenu.flip-left { + left: auto; + right: 100%; +} +.cm-swatch { + display: inline-block; + width: 11px; + height: 11px; + margin-right: 8px; + border: 1px solid var(--border-strong); + vertical-align: middle; + flex-shrink: 0; +} +.cm-toast { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 10001; + max-width: 80%; + padding: 6px 12px; + background: var(--bg-context); + border: 1px solid var(--border-strong); + border-radius: 4px; + box-shadow: 0 2px 8px var(--shadow); + color: var(--fg-bright); + font-size: 12px; + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease; +} +.cm-toast-hide { + opacity: 0; +} /* Tcl Console */ .tcl-console { diff --git a/src/web/src/tile_generator.cpp b/src/web/src/tile_generator.cpp index 93a5aed565f..389f1010759 100644 --- a/src/web/src/tile_generator.cpp +++ b/src/web/src/tile_generator.cpp @@ -1394,7 +1394,8 @@ std::vector TileGenerator::generateOverlayTile( const std::vector& flight_lines, const std::set* route_guide_net_ids, const bool has_visible_layers, - const std::set& visible_layers) const + const std::set& visible_layers, + const std::vector& colored_polys) const { constexpr int kBufferSize = kTileSizeInPixel * kTileSizeInPixel * 4; std::vector image(kBufferSize, 0); // fully transparent @@ -1408,7 +1409,7 @@ std::vector TileGenerator::generateOverlayTile( // Short-circuit: if there's nothing to draw, return a blank tile. if (highlight_rects.empty() && highlight_polys.empty() - && colored_rects.empty() && flight_lines.empty() + && colored_rects.empty() && colored_polys.empty() && flight_lines.empty() && (!route_guide_net_ids || route_guide_net_ids->empty())) { std::vector png; lodepng::encode(png, image, kTileSizeInPixel, kTileSizeInPixel); @@ -1441,6 +1442,9 @@ std::vector TileGenerator::generateOverlayTile( // their per-layer tag (the overlay sits above all base layers). drawColoredHighlight(image, colored_rects, "", dbu_tile, scale); } + if (!colored_polys.empty()) { + drawColoredPolygons(image, colored_polys, dbu_tile, scale); + } if (!flight_lines.empty()) { drawFlightLines(image, flight_lines, dbu_tile, scale); } @@ -2823,16 +2827,17 @@ static void compositePixel(unsigned char* dst, const unsigned char* src) dst[3] = out_a; } -void TileGenerator::saveImage(const std::string& filename, - const odb::Rect& region, - const int width_px, - const double dbu_per_pixel, - const TileVisibility& vis) const +std::vector TileGenerator::renderImagePng( + const odb::Rect& region, + const int width_px, + const double dbu_per_pixel, + const TileVisibility& vis, + const Color& bg) const { odb::dbBlock* block = getBlock(); if (!block) { logger_->error(utl::WEB, 20, "No design loaded."); - return; + return {}; } // Determine rendering region (DBU). @@ -2864,7 +2869,7 @@ void TileGenerator::saveImage(const std::string& filename, if (img_w <= 0 || img_h <= 0) { logger_->error(utl::WEB, 21, "Invalid image dimensions."); - return; + return {}; } // Cap image size at 16k x 16k to prevent excessive memory usage. @@ -2968,32 +2973,52 @@ void TileGenerator::saveImage(const std::string& filename, = tile_span_h - crop_y_bottom - static_cast(area.dy() * tile_scale); // Resample to exact requested dimensions (nearest-neighbor from tile_scale - // to target scale). - std::vector final_buf(4UL * final_w * final_h, 0); + // to target scale). Start from the requested background color and composite + // the (possibly semi-transparent) tiles on top, so the saved image matches + // the viewer's background instead of coming out transparent. + std::vector final_buf(4UL * final_w * final_h); for (int fy = 0; fy < final_h; ++fy) { for (int fx = 0; fx < final_w; ++fx) { + const int dst_idx = (fy * final_w + fx) * 4; + // Start each pixel at the background, then composite the tile on top. + final_buf[dst_idx + 0] = bg.r; + final_buf[dst_idx + 1] = bg.g; + final_buf[dst_idx + 2] = bg.b; + final_buf[dst_idx + 3] = bg.a; // Map final pixel to tile-span pixel. const int sx = crop_x + static_cast(fx * tile_scale / scale); const int sy = crop_y + static_cast(fy * tile_scale / scale); if (sx >= 0 && sx < tile_span_w && sy >= 0 && sy < tile_span_h) { const int src_idx = (sy * tile_span_w + sx) * 4; - const int dst_idx = (fy * final_w + fx) * 4; - std::memcpy(&final_buf[dst_idx], &output[src_idx], 4); + compositePixel(&final_buf[dst_idx], &output[src_idx]); } } } - // Encode to PNG and save. + // Encode to PNG. std::vector png_data; const unsigned error = lodepng::encode(png_data, final_buf, final_w, final_h); if (error) { logger_->error( utl::WEB, 23, "PNG encode error: {}", lodepng_error_text(error)); + return {}; + } + return png_data; +} + +void TileGenerator::saveImage(const std::string& filename, + const odb::Rect& region, + const int width_px, + const double dbu_per_pixel, + const TileVisibility& vis) const +{ + const std::vector png_data + = renderImagePng(region, width_px, dbu_per_pixel, vis); + if (png_data.empty()) { return; } lodepng::save_file(png_data, filename); - logger_->info( - utl::WEB, 24, "Saved {}x{} image to {}", final_w, final_h, filename); + logger_->info(utl::WEB, 24, "Saved image to {}", filename); } std::vector TileGenerator::renderOverlayPng( @@ -3605,6 +3630,42 @@ void TileGenerator::drawHighlight(std::vector& image, } } +void TileGenerator::drawColoredPolygons( + std::vector& image, + const std::vector& polys, + const odb::Rect& dbu_tile, + const double scale) const +{ + for (const ColoredPolygon& cp : polys) { + const odb::Polygon& poly = cp.poly; + if (!dbu_tile.overlaps(poly.getEnclosingRect())) { + continue; + } + Color border = cp.color; + border.a = 255; + + // Semi-transparent fill in the group's color. + fillPolygon(image, poly, dbu_tile, scale, cp.color, /*blend=*/true); + + // Solid outline in the group's color — draw each edge, wrapping the last + // point back to the first so the outline is closed even if the point list + // is open. + const auto& points = poly.getPoints(); + const int n = static_cast(points.size()); + for (int i = 0; i < n; ++i) { + const odb::Point& p0 = points[i]; + const odb::Point& p1 = points[(i + 1) % n]; + const int px0 = static_cast((p0.x() - dbu_tile.xMin()) * scale); + const int py0 + = 255 - static_cast((p0.y() - dbu_tile.yMin()) * scale); + const int px1 = static_cast((p1.x() - dbu_tile.xMin()) * scale); + const int py1 + = 255 - static_cast((p1.y() - dbu_tile.yMin()) * scale); + drawLine(image, px0, py0, px1, py1, border); + } + } +} + void TileGenerator::drawColoredHighlight(std::vector& image, const std::vector& rects, const std::string& current_layer, diff --git a/src/web/src/tile_generator.h b/src/web/src/tile_generator.h index f82869e95b4..1d9da606980 100644 --- a/src/web/src/tile_generator.h +++ b/src/web/src/tile_generator.h @@ -58,6 +58,12 @@ struct FlightLine Color color; }; +struct ColoredPolygon +{ + odb::Polygon poly; + Color color; +}; + struct SelectionResult { std::any object; // dbInst*, dbNet*, etc. @@ -388,12 +394,22 @@ class TileGenerator const std::vector& flight_lines = {}, const std::set* route_guide_net_ids = nullptr, bool has_visible_layers = false, - const std::set& visible_layers = {}) const; + const std::set& visible_layers = {}, + const std::vector& colored_polys = {}) const; std::vector generateHeatMapTile(gui::HeatMapDataSource& source, int z, int x, int y) const; + // Render full design (or region) to PNG bytes. Works without a running + // web server. region in DBU; if zero-area, defaults to die + 5% margin. + // Returns an empty vector on error (no design / invalid dimensions). + std::vector renderImagePng(const odb::Rect& region, + int width_px, + double dbu_per_pixel, + const TileVisibility& vis, + const Color& bg = {}) const; + // Render full design (or region) to a PNG file. Works without a running // web server. region in DBU; if zero-area, defaults to die + 5% margin. void saveImage(const std::string& filename, @@ -499,6 +515,11 @@ class TileGenerator const odb::Rect& dbu_tile, double scale) const; + void drawColoredPolygons(std::vector& image, + const std::vector& polys, + const odb::Rect& dbu_tile, + double scale) const; + // Private counterpart of setDebugOverlayCallback: invokes the // installed callback (if any) for this tile. See the public API // above for rationale. diff --git a/src/web/src/ui-utils.js b/src/web/src/ui-utils.js index 970bdde81e6..7978878aabb 100644 --- a/src/web/src/ui-utils.js +++ b/src/web/src/ui-utils.js @@ -9,6 +9,31 @@ export function isStaticMode(app) { return !!app?.websocketManager?.isStaticMode; } +// Serialize the layer/selectability visibility flags the way the server +// parses them: each visibility key as a boolean, each selectability key with +// an `s_` prefix. Callers add request-specific fields (visible_layers, +// selectable_layers, visible_chiplets) on top. Shared by the tile requests, +// click-select, and the Save export so the columns can't drift. +export function buildVisibilityFlags(visibility, selectability) { + const vf = {}; + for (const [k, v] of Object.entries(visibility || {})) { + vf[k] = !!v; + } + for (const [k, v] of Object.entries(selectability || {})) { + vf['s_' + k] = !!v; + } + return vf; +} + +// Sync the client-side selection type flags from a server response so the +// context menu can enable/disable items by selection type. +export function applySelectionFlags(app, resp) { + if (resp && typeof resp.sel_has_inst === 'boolean') { + app.selHasInst = resp.sel_has_inst; + app.selHasNet = !!resp.sel_has_net; + } +} + // Make table column headers resizable by dragging. // widths is an optional array of CSS width strings (e.g. saved from a // previous render); when given, it is applied directly instead of diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp index 9e64a7e2a4f..b0e1a49e7bf 100644 --- a/src/web/src/web.cpp +++ b/src/web/src/web.cpp @@ -5,6 +5,8 @@ #include +#include +#include #include #include #include @@ -19,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +34,7 @@ #include "boost/beast/websocket.hpp" #include "boost/json/array.hpp" #include "boost/json/object.hpp" +#include "boost/json/parse.hpp" #include "boost/json/serialize.hpp" #include "boost/json/value.hpp" #include "clock_tree_report.h" @@ -80,11 +84,200 @@ static std::vector serialize_response( } //------------------------------------------------------------------------------ -// HTTP request handler (serves embedded static assets) +// HTTP request handler (serves embedded static assets + image downloads) //------------------------------------------------------------------------------ +namespace { + +// Sanitize a client-supplied download filename before it goes into the +// Content-Disposition header: drop directory components, keep only +// [A-Za-z0-9._-] (replacing the rest with '_'), cap the length, and fall back +// to a default when empty. Prevents header injection via quotes/CRLF. +std::string sanitizeFilename(std::string name) +{ + const auto slash = name.find_last_of("/\\"); + if (slash != std::string::npos) { + name.erase(0, slash + 1); + } + constexpr std::size_t kMaxLen = 128; + if (name.size() > kMaxLen) { + name.resize(kMaxLen); + } + for (char& c : name) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '.' || c == '_' + || c == '-'; + if (!ok) { + c = '_'; + } + } + if (name.empty()) { + name = "layout.png"; + } + return name; +} + +// Parse an integer that must span the whole string_view (no trailing garbage), +// exception-free. Returns false on any malformed/partial input. +template +bool parseIntExact(std::string_view s, T& out, int base = 10) +{ + const char* const first = s.data(); + const char* const last = first + s.size(); + const auto res = std::from_chars(first, last, out, base); + return res.ec == std::errc{} && res.ptr == last; +} + +// Percent-decode a URL query value (e.g. the JSON `vis` payload). +std::string urlDecode(std::string_view s) +{ + std::string out; + out.reserve(s.size()); + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '%' && i + 2 < s.size()) { + int value = 0; + if (parseIntExact(s.substr(i + 1, 2), value, 16)) { + out.push_back(static_cast(value)); + i += 2; + continue; + } + // Not valid hex: keep the literal '%'. + } + out.push_back(s[i] == '+' ? ' ' : s[i]); + } + return out; +} + +// Parse the "k=v&k2=v2" query of a request target into a map (values decoded). +std::map parseQuery(std::string_view target) +{ + std::map params; + const auto qpos = target.find('?'); + if (qpos == std::string_view::npos) { + return params; + } + const std::string_view qs = target.substr(qpos + 1); + std::size_t start = 0; + while (start < qs.size()) { + std::size_t amp = qs.find('&', start); + if (amp == std::string_view::npos) { + amp = qs.size(); + } + const std::string_view kv = qs.substr(start, amp - start); + const std::size_t eq = kv.find('='); + if (eq != std::string_view::npos) { + params.emplace(std::string(kv.substr(0, eq)), + urlDecode(kv.substr(eq + 1))); + } + start = amp + 1; + } + return params; +} + +// Parse "x0,y0,x1,y1" (DBU) into a Rect. Returns false on malformed input. +bool parseBbox(const std::string& s, odb::Rect& out) +{ + const std::string_view sv(s); + int v[4]; + int idx = 0; + std::size_t start = 0; + while (idx < 4) { + const std::size_t comma = sv.find(',', start); + const std::string_view tok + = sv.substr(start, + comma == std::string_view::npos ? std::string_view::npos + : comma - start); + if (!parseIntExact(tok, v[idx++])) { + return false; + } + if (comma == std::string_view::npos) { + break; + } + start = comma + 1; + } + if (idx != 4) { + return false; + } + out = odb::Rect(std::min(v[0], v[2]), + std::min(v[1], v[3]), + std::max(v[0], v[2]), + std::max(v[1], v[3])); + return true; +} + +// Render the layout image requested by /download/image into `res`. +void handleImageDownload(const std::shared_ptr& generator, + std::string_view target, + http::response& res) +{ + if (!generator) { + res.result(http::status::not_found); + res.body() = "No design loaded."; + return; + } + const auto params = parseQuery(target); + const auto type_it = params.find("type"); + const std::string type = type_it == params.end() ? "entire" : type_it->second; + + odb::Rect region; // zero-area => entire die (renderImagePng default) + if (type == "visible") { + const auto bbox_it = params.find("bbox"); + if (bbox_it == params.end() || !parseBbox(bbox_it->second, region)) { + res.result(http::status::bad_request); + res.body() = "Missing or malformed bbox."; + return; + } + } + + // Respect the session's layer visibility: the frontend sends the same + // visibility payload it uses for tiles as a JSON `vis` query param. + TileVisibility vis; + const auto vis_it = params.find("vis"); + if (vis_it != params.end()) { + // Non-throwing parse: a malformed vis falls back to defaults (all visible). + std::error_code ec; + const boost::json::value parsed = boost::json::parse(vis_it->second, ec); + if (!ec && parsed.is_object()) { + vis.parseFromJson(parsed.as_object()); + } + } + + // Background color (RRGGBB hex) so the saved image matches the viewer's + // background; absent => transparent. + Color bg{}; // {0,0,0,0} + const auto bg_it = params.find("bg"); + unsigned rgb = 0; + if (bg_it != params.end() && bg_it->second.size() == 6 + && parseIntExact(bg_it->second, rgb, 16)) { + bg = Color{.r = static_cast((rgb >> 16) & 0xFF), + .g = static_cast((rgb >> 8) & 0xFF), + .b = static_cast(rgb & 0xFF), + .a = 255}; + } + // Malformed/absent bg: keep transparent. + + const std::vector png = generator->renderImagePng( + region, /*width_px=*/0, /*dbu_per_pixel=*/0, vis, bg); + if (png.empty()) { + res.result(http::status::internal_server_error); + res.body() = "Image render failed."; + return; + } + + const auto fname_it = params.find("filename"); + const std::string filename = sanitizeFilename( + fname_it == params.end() ? "layout.png" : fname_it->second); + res.set(http::field::content_type, "image/png"); + res.set(http::field::content_disposition, + "attachment; filename=\"" + filename + "\""); + res.body().assign(reinterpret_cast(png.data()), png.size()); +} + +} // namespace + static http::response handle_request( - http::request&& req) + http::request&& req, + const std::shared_ptr& generator) { http::response res{http::status::ok, req.version()}; res.set(http::field::server, "Boost.Beast Server (C++17)"); @@ -93,17 +286,27 @@ static http::response handle_request( res.set(http::field::access_control_allow_origin, "*"); if (req.method() == http::verb::get) { - std::string file_path(req.target()); - if (file_path == "/") { - file_path = "/index.html"; + const std::string target(req.target()); + // Strip any query string for route matching / asset lookup. + std::string path = target; + if (const auto qpos = path.find('?'); qpos != std::string::npos) { + path.resize(qpos); } - const auto* asset = findEmbeddedAsset(file_path); - if (asset) { - res.set(http::field::content_type, asset->content_type); - res.body() = std::string(asset->content()); + + if (path == "/download/image") { + handleImageDownload(generator, target, res); } else { - res.result(http::status::not_found); - res.body() = "Resource not found."; + if (path == "/") { + path = "/index.html"; + } + const auto* asset = findEmbeddedAsset(path); + if (asset) { + res.set(http::field::content_type, asset->content_type); + res.body() = std::string(asset->content()); + } else { + res.result(http::status::not_found); + res.body() = "Resource not found."; + } } } else { res.result(http::status::not_found); @@ -636,10 +839,13 @@ class HttpSession : public std::enable_shared_from_this beast::flat_buffer buffer_; std::shared_ptr> res_; http::request req_; + std::shared_ptr generator_; utl::Logger* logger_; public: - HttpSession(Tcp::socket&& socket, utl::Logger* logger); + HttpSession(Tcp::socket&& socket, + std::shared_ptr generator, + utl::Logger* logger); void run() { do_read(); } @@ -654,8 +860,12 @@ class HttpSession : public std::enable_shared_from_this void do_close(); }; -HttpSession::HttpSession(Tcp::socket&& socket, utl::Logger* logger) - : stream_(std::move(socket)), logger_(logger) +HttpSession::HttpSession(Tcp::socket&& socket, + std::shared_ptr generator, + utl::Logger* logger) + : stream_(std::move(socket)), + generator_(std::move(generator)), + logger_(logger) { } @@ -692,7 +902,7 @@ void HttpSession::on_read(beast::error_code ec) } res_ = std::make_shared>( - handle_request(std::move(req_))); + handle_request(std::move(req_), generator_)); do_write(); } @@ -815,7 +1025,8 @@ void DetectSession::on_read(beast::error_code ec) websocket_session->run(std::move(req_)); } else { // Regular HTTP - hand off to session with already-read request - auto s = std::make_shared(stream_.release_socket(), logger_); + auto s = std::make_shared( + stream_.release_socket(), generator_, logger_); s->run_with_request(std::move(req_), std::move(buffer_)); } } diff --git a/src/web/src/websocket-tile-layer.js b/src/web/src/websocket-tile-layer.js index a37f7878c9d..da3f4cfadb3 100644 --- a/src/web/src/websocket-tile-layer.js +++ b/src/web/src/websocket-tile-layer.js @@ -3,6 +3,8 @@ // Leaflet tile layer that fetches tiles via WebSocket. +import { buildVisibilityFlags } from './ui-utils.js'; + // `app` (last arg) is read lazily on every request so that // app.visibleChiplets, populated by display-controls.js after the tech // metadata arrives, is reflected in tile requests without rebuilding @@ -21,15 +23,7 @@ export function createWebSocketTileLayer(visibility, visibleLayers, // send it on every request so the wire schema stays uniform with // selectAt requests. function buildTileRequest(coords, layerName) { - const vf = {}; - for (const [k, v] of Object.entries(visibility)) { - vf[k] = !!v; - } - if (selectability) { - for (const [k, v] of Object.entries(selectability)) { - vf['s_' + k] = !!v; - } - } + const vf = buildVisibilityFlags(visibility, selectability); const req = { type: 'tile', layer: layerName, diff --git a/src/web/test/BUILD b/src/web/test/BUILD index baa738973b9..7404276c2f4 100644 --- a/src/web/test/BUILD +++ b/src/web/test/BUILD @@ -72,6 +72,27 @@ js_test( no_copy_to_bin = JS_FILES, ) +js_test( + name = "context_menu_test", + data = DOM_TEST_DATA, + entry_point = "js/test-context-menu.js", + no_copy_to_bin = JS_FILES, +) + +js_test( + name = "search_nav_test", + data = DOM_TEST_DATA, + entry_point = "js/test-search-nav.js", + no_copy_to_bin = JS_FILES, +) + +js_test( + name = "capture_test", + data = DOM_TEST_DATA, + entry_point = "js/test-capture.js", + no_copy_to_bin = JS_FILES, +) + js_test( name = "websocket_manager_test", data = JS_FILES, diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp index 8002c6df874..068f1b4ab44 100644 --- a/src/web/test/cpp/TestRequestHandler.cpp +++ b/src/web/test/cpp/TestRequestHandler.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "boost/json/object.hpp" @@ -1188,6 +1189,100 @@ TEST_F(SelectHandlerTest, SelectNextRestoresSelectionSetHighlights) EXPECT_TRUE(found_previous); } +//------------------------------------------------------------------------------ +// Find (name/glob search) tests. The gui descriptors are not registered in +// this unit context, so makeSelected() yields empty Selecteds and inserting +// more than one into the SelectionSet would dereference a null descriptor in +// the comparator. We therefore assert the match `count` for patterns matching +// at most one object — enough to exercise the glob (*, ?), exact-match and +// case-folding logic plus the error path. Multi-match selection and the +// sel_has_inst/sel_has_net flags are covered by the WebSocket end-to-end tests. +//------------------------------------------------------------------------------ + +class FindHandlerTest : public tst::Nangate45Fixture +{ + protected: + void SetUp() override + { + block_->setDieArea(odb::Rect(0, 0, 100000, 100000)); + gen_ = std::make_shared( + getDb(), /*sta=*/nullptr, getLogger()); + tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger()); + handler_ = std::make_unique(gen_, tcl_eval_); + } + + void placeInst(const char* master_name, const char* inst_name, int x, int y) + { + odb::dbMaster* master = lib_->findMaster(master_name); + ASSERT_NE(master, nullptr) << master_name; + odb::dbInst* inst = odb::dbInst::create(block_, master, inst_name); + inst->setLocation(x, y); + inst->setPlacementStatus(odb::dbPlacementStatus::PLACED); + } + + void runFind(const std::string& obj_type, + const std::string& pattern, + bool match_case = false) + { + WebSocketRequest req; + req.id = 1; + req.type = WebSocketRequest::kFind; + boost::json::object json; + json["obj_type"] = obj_type; + json["pattern"] = pattern; + json["match_case"] = match_case; + req.json = std::move(json); + last_resp_ = handler_->handleFind(req, state_); + } + + int64_t findCount(const std::string& obj_type, + const std::string& pattern, + bool match_case = false) + { + runFind(obj_type, pattern, match_case); + return parseObj(payloadStr(last_resp_)).at("count").as_int64(); + } + + std::shared_ptr gen_; + std::shared_ptr tcl_eval_; + std::unique_ptr handler_; + SessionState state_; + WebSocketResponse last_resp_; +}; + +TEST_F(FindHandlerTest, InstGlobExactAndNoMatch) +{ + placeInst("BUF_X16", "u_buf0", 0, 0); + placeInst("BUF_X16", "reg_a", 1000, 0); + + EXPECT_EQ(findCount("inst", "u_*"), 1); // glob '*' + EXPECT_EQ(findCount("inst", "u_bu?0"), 1); // glob '?' + EXPECT_EQ(findCount("inst", "u_buf0"), 1); // exact + EXPECT_EQ(findCount("inst", "zzz*"), 0); // no match +} + +TEST_F(FindHandlerTest, RespectsMatchCase) +{ + placeInst("BUF_X16", "MixedCase", 0, 0); + + EXPECT_EQ(findCount("inst", "mixedcase", /*match_case=*/false), 1); + EXPECT_EQ(findCount("inst", "mixedcase", /*match_case=*/true), 0); +} + +TEST_F(FindHandlerTest, NetByName) +{ + odb::dbNet::create(block_, "clk"); + + EXPECT_EQ(findCount("net", "cl*"), 1); + EXPECT_EQ(findCount("net", "nope"), 0); +} + +TEST_F(FindHandlerTest, UnknownTypeReturnsError) +{ + runFind("bogus", "*"); + EXPECT_EQ(last_resp_.type, WebSocketResponse::kError); +} + //------------------------------------------------------------------------------ // DRCHandler tests //------------------------------------------------------------------------------ diff --git a/src/web/test/js/test-capture.js b/src/web/test/js/test-capture.js new file mode 100644 index 00000000000..3bf421f800e --- /dev/null +++ b/src/web/test/js/test-capture.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +import './setup-dom.js'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { captureLayout } from '../../src/capture.js'; + +// capture.js reads `layer instanceof L.GridLayer` / `L.ImageOverlay`; provide a +// stub L whose classes nothing matches (the mock map has no real layers). +globalThis.L = { GridLayer: class {}, ImageOverlay: class {} }; + +// Stub so renderToBlob runs headless (jsdom has no 2d context). +function withCanvasStub(fn) { + const orig = document.createElement.bind(document); + document.createElement = (tag) => { + if (tag === 'canvas') { + return { + width: 0, + height: 0, + getContext: () => ({ + fillRect() {}, drawImage() {}, + set fillStyle(_) {}, set globalAlpha(_) {}, + }), + toBlob: (cb) => cb(null), + }; + } + return orig(tag); + }; + return Promise.resolve(fn()).finally(() => { document.createElement = orig; }); +} + +function makeApp() { + const container = document.createElement('div'); + document.body.appendChild(container); + const calls = []; + return { + calls, + map: { + getCenter: () => ({ lat: 1, lng: 2 }), + getZoom: () => 5, + getContainer: () => container, + eachLayer() {}, // no layers → tilesPending stays 0 + fitBounds(b, opts) { calls.push(['fitBounds', b, opts]); }, + setView(c, z, opts) { calls.push(['setView', c, z, opts]); }, + }, + fitBounds: [[0, 0], [10, 10]], + }; +} + +describe('captureLayout entire → fit whole design then restore', () => { + beforeEach(() => { document.body.innerHTML = ''; }); + afterEach(() => { document.body.innerHTML = ''; }); + + it('fits the design without animation and restores the prior view', async () => { + const app = makeApp(); + await withCanvasStub(() => captureLayout(app, { entire: true })); + + const fit = app.calls.find((c) => c[0] === 'fitBounds'); + assert.ok(fit, 'fitBounds was called'); + assert.equal(fit[1], app.fitBounds, 'fits the whole-design bounds'); + assert.equal(fit[2].animate, false, 'no animation (avoids mid-fit capture)'); + + const restore = app.calls.find((c) => c[0] === 'setView'); + assert.ok(restore, 'view is restored after capture'); + assert.deepEqual(restore[1], { lat: 1, lng: 2 }); + assert.equal(restore[2], 5); + }); + + it('visible scope neither fits nor moves the view', async () => { + const app = makeApp(); + await withCanvasStub(() => captureLayout(app, { entire: false })); + assert.equal(app.calls.length, 0, 'no fitBounds/setView for the visible capture'); + }); +}); diff --git a/src/web/test/js/test-context-menu.js b/src/web/test/js/test-context-menu.js new file mode 100644 index 00000000000..e25d269a96f --- /dev/null +++ b/src/web/test/js/test-context-menu.js @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +import './setup-dom.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ContextMenu } from '../../src/context-menu.js'; + +// Build a mock `app` whose map records setZoomAround/fitBounds calls. +function makeApp(overrides = {}) { + const calls = []; + const app = { + map: { + _zoom: 5, + options: { zoomDelta: 1 }, + on() {}, + getZoom() { return this._zoom; }, + setZoomAround(latlng, z) { calls.push(['setZoomAround', latlng, z]); }, + fitBounds(b) { calls.push(['fitBounds', b]); }, + getContainer() { return document.body; }, + }, + fitBounds: [[0, 0], [10, 10]], + lastSelectionBounds: [[1, 1], [2, 2]], + hasSelection: true, + selHasInst: true, + selHasNet: true, + ...overrides, + }; + return { app, calls }; +} + +function disabledLabels() { + return [...document.querySelectorAll('.cm-item.disabled')] + .map((el) => el.textContent); +} + +// Click the leaf menu item whose text is exactly `label`. +function clickItem(label) { + const items = [...document.querySelectorAll('.cm-item')]; + const el = items.find(i => i.textContent === label); + assert.ok(el, `menu item "${label}" not found`); + el.click(); +} + +function openMenu(app) { + document.body.innerHTML = ''; + const cm = new ContextMenu(app); + cm.show({ originalEvent: { clientX: 5, clientY: 5 } }); + return cm; +} + +describe('ContextMenu selection-dependent items (type-aware)', () => { + it('nothing selected → Select and Highlight parents disabled', () => { + const { app } = makeApp({ selHasInst: false, selHasNet: false }); + openMenu(app); + const d = disabledLabels(); + assert.ok(d.includes('Select'), 'Select should be disabled'); + assert.ok(d.includes('Highlight'), 'Highlight should be disabled'); + // A disabled parent must not build its submenu. + const select = [...document.querySelectorAll('.cm-item')] + .find((el) => el.textContent === 'Select'); + assert.equal(select.querySelector('.cm-submenu'), null); + }); + + it('instance selected → net/buffer items enabled, Connected Insts disabled', () => { + const { app } = makeApp({ selHasInst: true, selHasNet: false }); + openMenu(app); + const d = disabledLabels(); + assert.ok(!d.includes('Select'), 'Select parent enabled'); + assert.ok(d.includes('Connected Insts'), 'Connected Insts needs a net'); + assert.ok(!d.includes('Output Nets'), 'net items enabled for an instance'); + }); + + it('net selected → only Connected Insts enabled', () => { + const { app } = makeApp({ selHasInst: false, selHasNet: true }); + openMenu(app); + const d = disabledLabels(); + assert.ok(!d.includes('Select'), 'Select parent enabled'); + assert.ok(!d.includes('Connected Insts'), 'Connected Insts enabled for a net'); + assert.ok(d.includes('Output Nets'), 'net items need an instance'); + }); +}); + +describe('ContextMenu has no View submenu', () => { + it('does not expose Zoom/View items (moved to the on-canvas Fit control)', () => { + const { app } = makeApp(); + openMenu(app); + const labels = [...document.querySelectorAll('.cm-item')] + .map((el) => el.textContent); + assert.ok(!labels.includes('Zoom In')); + assert.ok(!labels.includes('Zoom to Selection')); + assert.ok(!labels.some((l) => l === 'View')); + }); +}); + +describe('ContextMenu Save submenu', () => { + it('Save items call the WYSIWYG capture with the right scope', () => { + const calls = []; + const { app } = makeApp({ captureLayout: (opts) => calls.push(opts) }); + openMenu(app); + clickItem('Entire layout'); + clickItem('Visible layout'); + assert.deepEqual(calls, [{ entire: true }, { entire: false }]); + }); +}); + +describe('ContextMenu Clear submenu', () => { + function appWithWs() { + const sent = []; + const cleared = []; + const { app } = makeApp({ + websocketManager: { request(m) { sent.push(m); return Promise.resolve({}); } }, + rulerManager: { clearAllRulers() { cleared.push('rulers'); } }, + }); + return { app, sent, cleared }; + } + + it('Selections / Focus nets / Route Guides send the right action', () => { + for (const [label, action] of [ + ['Selections', 'clear_selections'], + ['Focus nets', 'clear_focus_nets'], + ['Route Guides', 'clear_route_guides'], + ]) { + const { app, sent } = appWithWs(); + openMenu(app); + clickItem(label); + assert.equal(sent[0].type, 'context_action'); + assert.equal(sent[0].action, action); + } + }); + + it('Rulers clears client-side rulers without a server call', () => { + const { app, sent, cleared } = appWithWs(); + openMenu(app); + clickItem('Rulers'); + assert.deepEqual(cleared, ['rulers']); + assert.equal(sent.length, 0); + }); + + it('All sends clear_all and clears rulers', () => { + const { app, sent, cleared } = appWithWs(); + openMenu(app); + clickItem('All'); + assert.equal(sent[0].action, 'clear_all'); + assert.deepEqual(cleared, ['rulers']); + }); +}); diff --git a/src/web/test/js/test-search-nav.js b/src/web/test/js/test-search-nav.js new file mode 100644 index 00000000000..28fba9ed620 --- /dev/null +++ b/src/web/test/js/test-search-nav.js @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +import './setup-dom.js'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { showFindDialog, showGotoDialog } from '../../src/search-nav.js'; +import { dbuToLatLng, dbuRectToBounds } from '../../src/coordinates.js'; + +const wait = () => new Promise((r) => setTimeout(r, 0)); + +// scale=1, maxDXDY=0, origins=0, 1000 dbu/µm keeps the math easy to assert. +function makeApp(overrides = {}) { + const calls = []; + const app = { + designScale: 1, designMaxDXDY: 0, designOriginX: 0, designOriginY: 0, + getDbuPerMicron: () => 1000, + map: { + _zoom: 3, + getZoom() { return this._zoom; }, + getCenter() { return { lat: 0, lng: 0 }; }, + setView(latlng, z) { calls.push(['setView', latlng, z]); }, + fitBounds(b) { calls.push(['fitBounds', b]); }, + }, + ...overrides, + }; + return { app, calls }; +} + +function clickOk() { document.querySelector('.modal-dialog .ok').click(); } +function setInput(sel, val) { + const el = document.querySelector(sel); + el.value = val; + return el; +} +function errorText() { + const e = document.querySelector('.modal-error'); + return e && e.style.display !== 'none' ? e.textContent : ''; +} + +describe('Go to Position dialog', () => { + it('centers the map on the x,y converted from microns', () => { + document.body.innerHTML = ''; + const { app, calls } = makeApp(); + showGotoDialog(app); + setInput('.sn-x', '5'); // 5 µm -> 5000 dbu + setInput('.sn-y', '7'); // 7 µm -> 7000 dbu + clickOk(); + const expected = dbuToLatLng(5000, 7000, 1, 0, 0, 0); + assert.deepEqual(calls, [['setView', expected, 3]]); + }); + + it('fits a Size window when Size is provided', () => { + document.body.innerHTML = ''; + const { app, calls } = makeApp(); + showGotoDialog(app); + setInput('.sn-x', '10'); + setInput('.sn-y', '10'); + setInput('.sn-size', '4'); // 4 µm window => half = 2000 dbu + clickOk(); + const expected = dbuRectToBounds(10000 - 2000, 10000 - 2000, + 10000 + 2000, 10000 + 2000, 1, 0, 0, 0); + assert.deepEqual(calls, [['fitBounds', expected]]); + }); + + it('rejects non-numeric coordinates', () => { + document.body.innerHTML = ''; + const { app, calls } = makeApp(); + showGotoDialog(app); + setInput('.sn-x', 'abc'); + clickOk(); + assert.equal(calls.length, 0); + assert.match(errorText(), /must be numbers/i); + }); +}); + +describe('Find dialog', () => { + function findApp(response) { + const sent = []; + const { app, calls } = makeApp({ + websocketManager: { request(m) { sent.push(m); return Promise.resolve(response); } }, + updateInspector() {}, + redrawAllLayers() {}, + }); + return { app, calls, sent }; + } + + it('sends a find request and auto-zooms to the result bbox', async () => { + document.body.innerHTML = ''; + const { app, calls, sent } = findApp( + { count: 2, selection_count: 2, bbox: [0, 0, 3000, 4000] }); + showFindDialog(app); + setInput('.sn-pattern', '_4*'); + document.querySelector('.sn-type').value = 'inst'; + clickOk(); + await wait(); + assert.deepEqual(sent, [{ type: 'find', obj_type: 'inst', + pattern: '_4*', match_case: false }]); + const expected = dbuRectToBounds(0, 0, 3000, 4000, 1, 0, 0, 0); + assert.deepEqual(calls, [['fitBounds', expected]]); + assert.match(errorText(), /2 found/); + }); + + it('reports when nothing is found and does not zoom', async () => { + document.body.innerHTML = ''; + const { app, calls } = findApp({ count: 0, selection_count: 0 }); + showFindDialog(app); + setInput('.sn-pattern', 'nope*'); + clickOk(); + await wait(); + assert.equal(calls.length, 0); + assert.match(errorText(), /No objects found/i); + }); + + it('requires a pattern', () => { + document.body.innerHTML = ''; + const { app, sent } = findApp({ count: 0 }); + showFindDialog(app); + clickOk(); + assert.equal(sent.length, 0); + assert.match(errorText(), /pattern/i); + }); + + it('closes via the X button', () => { + document.body.innerHTML = ''; + const { app } = findApp({ count: 0 }); + showFindDialog(app); + assert.ok(document.querySelector('.modal-overlay'), 'dialog open'); + document.querySelector('.modal-close').click(); + assert.equal(document.querySelector('.modal-overlay'), null, 'dialog closed'); + }); +});