diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index ededfa6..7f9a2a0 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -64,9 +64,8 @@ function lines(diagram: string): string[] { const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); const calledMethod = (label: string) => `${label}`; const dimmedMethod = (label: string) => `${label}`; -// removes the hooks that only style the focused diagram -const withoutDimming = (diagram: string) => lines(diagram.replace(/(.*?)<\/span>/g, '$1')) - .filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed')); +const collapsedMethods = (labels: string[], count: string) => + `${count}${labels.map(label => `${label}`).join('')}`; describe('state error focus', () => { it('splits the possible states into valid and invalid ones', () => { @@ -93,6 +92,7 @@ describe('state error focus', () => { 'class closed ljStateInvalid', ])); const send = focusOnError ? dimmedMethod('send') : 'send'; + // a single dimmed method keeps its name for (const state of ['unbound', 'bound', 'connected']) { expect(diagram).toContain(`${state} --> ${state} : ${calledMethod('receive')}
${send}`); } @@ -103,16 +103,13 @@ describe('state error focus', () => { } }); - it('dims the states and transitions unrelated to the error when focused, without changing the labels', () => { + it('dims the states unrelated to the error and the transitions without the failed method when focused', () => { const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true); - const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); expect(lines(focused)).toContain('class broken ljStateDimmed'); - // both initial transitions, then bind, disconnect, close from unbound and connected, repair, and discard - expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge11 edge12']); - // focusing only adds styling hooks, so the layout stays the same - expect(withoutDimming(focused)).toEqual(lines(full)); - expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/); + // both constructors, then bind, connect, disconnect, every close, repair, and discard + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12']); + expect(focused).not.toContain('lj-fix'); }); it('draws entry transitions from the initial pseudo state, dimmed when focused', () => { @@ -126,35 +123,52 @@ describe('state error focus', () => { expect(dimmedTransitions(createMermaidDiagram(sm, 'TB', false, true))[0]).toMatch(/^%% lj-dimmed-transitions: edge0 edge1\b/); }); - it('only keeps the failed method and the ways out of the possible states bright', () => { - const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true)); + it('merges the entry transitions of each state into one collapsed transition when focused', () => { + const sm = { ...treeNode(), entryTransitions: [ + { to: 'maybeHasChildren', label: 'isLeaf', toCondition: '!return' }, + { to: 'maybeHasChildren', label: 'getChildCount', toCondition: 'return > 0' }, + ] }; - expect(diagram).toEqual(expect.arrayContaining([ - // leaving a possible state for another state + expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(`[*] --> maybeHasChildren : ${collapsedMethods([ + 'isLeaf
!return', + 'getChildCount
return > 0', + ], '2 methods')}`); + }); + + it('leaves the full diagram unchanged when showing all', () => { + const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); + + expect(full).not.toMatch(/ljStateDimmed|lj-dimmed|lj-fix|lj-collapsed/); + expect(lines(full)).toEqual(expect.arrayContaining([ + '[*] --> bound : new DatagramSocket()', + '[*] --> broken', 'bound --> connected : connect', - 'bound --> closed : close', - // self loops and transitions from other states - `bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}`, - `unbound --> bound : ${dimmedMethod('bind')}`, - `connected --> bound : ${dimmedMethod('disconnect')}`, - `broken --> closed : ${dimmedMethod('discard')}`, ])); }); - it('dims each method of a merged label, and the transition only when all of them are dimmed', () => { + it('collapses the dimmed methods of a label into a count, keeping them for the list on hover', () => { const sm = datagramSocket(); sm.transitions.push( - { from: 'broken', to: 'broken', label: 'receive' }, - { from: 'bound', to: 'connected', label: 'reconnect' }, + { from: 'bound', to: 'bound', label: 'getPort' }, + { from: 'bound', to: 'bound', label: 'getOption' }, + { from: 'unbound', to: 'bound', label: 'bindAll' }, + // an overload is counted once + { from: 'unbound', to: 'bound', label: 'bind' }, ); - const diagram = createMermaidDiagram(sm, 'TB', false, true); - expect(lines(diagram)).toEqual(expect.arrayContaining([ - `broken --> broken : ${dimmedMethod('repair')}
${calledMethod('receive')}`, - 'bound --> connected : connect
reconnect', + expect(lines(createMermaidDiagram(sm, 'TB', false, true))).toEqual(expect.arrayContaining([ + `bound --> bound : ${calledMethod('receive')}
${collapsedMethods(['send', 'getPort', 'getOption'], '+3 more')}`, + `unbound --> bound : ${collapsedMethods(['bind', 'bindAll'], '2 methods')}`, ])); - // the self loop of broken keeps its path for the failed method - expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge12']); + }); + + it('merges the constructors of each state into one collapsed transition when focused', () => { + const sm = treeNode(); + sm.initialTransitions.push({ to: 'childrenAllowed', constructorSignature: 'DefaultMutableTreeNode()' }); + + const diagram = lines(createMermaidDiagram(sm, 'TB', false, true)); + expect(diagram).toContain(`[*] --> childrenAllowed : ${collapsedMethods(['DefaultMutableTreeNode(Object)', 'DefaultMutableTreeNode()'], '2 constructors')}`); + expect(diagram.filter(line => line.startsWith('[*]'))).toHaveLength(1); }); it('dims the conditions of a dimmed method with it', () => { @@ -170,7 +184,7 @@ describe('state error focus', () => { sm.transitions.push({ from: 'bound', to: 'bound', label: 'receiveAll' }); expect(lines(createMermaidDiagram(sm, 'TB', false, true))) - .toContain(`bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}
${dimmedMethod('receiveAll')}`); + .toContain(`bound --> bound : ${calledMethod('receive')}
${collapsedMethods(['send', 'receiveAll'], '+2 more')}`); }); it('does not focus without accepted states', () => { @@ -194,8 +208,8 @@ describe('state error focus', () => { const diagram = createMermaidDiagram(sm, 'TB', false, true); expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted'); expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /); - // every state is possible, so only the self loops other than the failed method are dimmed - expect(lines(diagram)).toEqual(expect.arrayContaining(['unbound --> bound : bind', `broken --> broken : ${dimmedMethod('repair')}`])); + // only the failed method stays bright + expect(lines(diagram)).toEqual(expect.arrayContaining([`unbound --> bound : ${dimmedMethod('bind')}`, `broken --> broken : ${dimmedMethod('repair')}`])); }); it('marks the tree node example without an allowed state', () => { @@ -206,13 +220,12 @@ describe('state error focus', () => { 'class childrenAllowed ljStateInvalid', 'class maybeHasChildren ljStateAccepted', `maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`, - // add leaves childrenAllowed, removeAllChildren does not start from a possible state - 'childrenAllowed --> maybeHasChildren : add', + `childrenAllowed --> maybeHasChildren : ${dimmedMethod('add')}`, `maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`, ])); expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/); // the constructor is dimmed although it leads to a possible state - expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']); + expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2']); }); it('only highlights the possible states when the accepted states are unknown', () => { @@ -262,6 +275,108 @@ describe('rendered state error focus', () => { }); }); +describe('collapsed labels', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + async function render() { + const sm = datagramSocket(); + sm.transitions.push({ from: 'bound', to: 'bound', label: 'getPort' }); + const label = lines(createMermaidDiagram(sm, 'TB', false, true)).find(line => line.startsWith('bound --> bound'))!.split(' : ')[1]; + document.body.innerHTML = '
x
'; + const pre = document.querySelector('.mermaid')!; + const run = vi.fn(async () => { + pre.innerHTML = `
${label}
`; + }); + await renderMermaidDiagram(document, { mermaid: { run } }); + return document.querySelector('.lj-collapsed-methods')!; + } + const popover = () => document.querySelector('.lj-methods-popover'); + const items = () => Array.from(document.querySelectorAll('.lj-methods-popover .lj-collapsed-item'), node => node.textContent); + + it('lists the methods on hover without rendering the diagram again', async () => { + const label = await render(); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('mouseenter')); + expect(items()).toEqual(['send', 'getPort']); + label.dispatchEvent(new MouseEvent('mouseleave')); + expect(popover()).toBeNull(); + }); + + it('places the list beside the label, off the states', async () => { + const label = await render(); + const box = (left: number, top: number, width: number, height: number) => ({ left, top, right: left + width, bottom: top + height }) as DOMRect; + label.getBoundingClientRect = () => box(100, 100, 60, 20); + // a state right of the label + document.querySelector('svg')!.insertAdjacentHTML('beforeend', ''); + document.querySelector('g.node')!.getBoundingClientRect = () => box(170, 50, 200, 120); + const size = (name: 'offsetWidth' | 'offsetHeight', value: number) => vi.spyOn(HTMLElement.prototype, name, 'get').mockReturnValue(value); + const spies = [size('offsetWidth', 80), size('offsetHeight', 40)]; + // a 1000 x 800 page, where fixed elements land where they are put + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLDivElement) { + return box(parseFloat(this.style.left) || 0, parseFloat(this.style.top) || 0, 80, 40); + }); + + label.dispatchEvent(new MouseEvent('mouseenter')); + expect((popover() as HTMLElement).style.left).toBe(`${100 - 6 - 80}px`); + expect((popover() as HTMLElement).style.top).toBe('90px'); + spies.forEach(spy => spy.mockRestore()); + vi.restoreAllMocks(); + }); + + it('keeps the list after a click until the next click, a pan, or a zoom', async () => { + const label = await render(); + + label.dispatchEvent(new MouseEvent('mouseenter')); + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + label.dispatchEvent(new MouseEvent('mouseleave')); + expect(items()).toEqual(['send', 'getPort']); + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + document.querySelector('svg')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + document.querySelector('.diagram-container')!.dispatchEvent(new WheelEvent('wheel')); + expect(popover()).toBeNull(); + }); +}); + +describe('fitting a focused diagram', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + async function render(focused: boolean, height: number) { + document.body.innerHTML = `
x
`; + const pre = document.querySelector('.mermaid')!; + const run = vi.fn(async () => { + pre.innerHTML = ''; + Object.defineProperty(pre.querySelector('svg')!, 'clientHeight', { value: height }); + }); + document.querySelector('.diagram-viewport')!.getBoundingClientRect = () => ({ top: 200 }) as DOMRect; + await renderMermaidDiagram(document, { mermaid: { run }, innerHeight: 816 }); + return (document.querySelector('.mermaid svg') as SVGSVGElement).style.maxHeight; + } + + it('shrinks a tall diagram to the panel, but not below a readable scale', async () => { + // 816 - 200 - 16 = 600 pixels are left below the summary + expect(await render(true, 900)).toBe('600px'); + expect(await render(true, 2000)).toBe('1200px'); + expect(await render(true, 500)).toBe(''); + }); + + it('leaves the diagram as it is when showing all', async () => { + expect(await render(false, 900)).toBe(''); + }); +}); + describe('state errors on several state sets', () => { const tiling: LJStateMachine = { ...treeNode(null), className: 'ImageWriteParam', stateSet: 0 }; const compression: LJStateMachine = { ...treeNode(), className: 'ImageWriteParam', stateSet: 1 }; diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index d205ea1..16e5a51 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,6 +1,7 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; +import { placePopover, type Box, type Point } from "./popover-placement"; // constants const MIN_ZOOM = 0.2; @@ -8,8 +9,16 @@ const MAX_ZOOM = 5; const ZOOM_BUTTON_FACTOR = 1.5; const SCROLL_ZOOM_IN_FACTOR = 1.05; const SCROLL_ZOOM_OUT_FACTOR = 0.95; -// Mermaid comment listing the transitions to dim once rendered, which leaves the layout unchanged +// Mermaid comment listing the transitions to dim once rendered const DIMMED_TRANSITIONS_COMMENT = '%% lj-dimmed-transitions:'; +// a focused diagram is shrunk to fit the panel, but never below this scale so that its labels stay readable +const MIN_FOCUS_SCALE = 0.6; +const FOCUS_BOTTOM_MARGIN = 16; +// the list of a collapsed label's methods keeps this distance from the label and from the window's edges +const POPOVER_GAP = 6; +const POPOVER_MARGIN = 8; +// the step between the points of a transition line the list avoids +const LINE_SAMPLE_STEP = 8; // state variables let zoomLevel = 1; @@ -24,7 +33,7 @@ let startY = 0; * @param sm * @param orientation * @param showConditions - * @param focusOnError whether to dim the states and transitions unrelated to the selected state error + * @param focusOnError whether to dim the states unrelated to the selected state error and collapse the unrelated methods * @returns Mermaid diagram string */ export function createMermaidDiagram( @@ -39,6 +48,8 @@ export function createMermaidDiagram( const errorFocus = getStateErrorFocus(sm); const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : []; const isDimmed = (state: string) => relatedStates.length > 0 && !relatedStates.includes(state); + // focusing collapses the dimmed methods of each label, so the diagram is laid out again around the error + const collapse = relatedStates.length > 0; // Mermaid numbers the rendered transitions in the order they are declared const dimmedTransitions: string[] = []; let transitionCount = 0; @@ -52,25 +63,49 @@ export function createMermaidDiagram( lines.push(` direction ${orientation}`); // initial transitions, dimmed in focus mode since the receiver already exists at the call - sm.initialTransitions.forEach(transition => { - const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, relatedStates.length > 0); - }); + if (collapse) { + const targets = [...new Set(sm.initialTransitions.map(transition => transition.to))]; + targets.forEach(to => { + const signatures = sm.initialTransitions.filter(transition => transition.to === to) + .map(transition => transition.constructorSignature ? escapeMermaidLabel(transition.constructorSignature) : '') + .filter(Boolean); + const label = getCollapsedLabel([], signatures.map(signature => ({ name: signature, label: signature })), 'constructors'); + addTransition(` [*] --> ${to}${label ? ` : ${label}` : ''}`, true); + }); + } else { + sm.initialTransitions.forEach(transition => { + const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); + addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, false); + }); + } - // entry transitions of objects in an unknown state, also from the initial pseudo state and dimmed in focus mode - sm.entryTransitions?.forEach(transition => { - const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); - addTransition(` [*] --> ${transition.to} : ${label}`, relatedStates.length > 0); - }); - - // in focus mode, only the failed method and the ways out of the possible states stay bright - const isUsefulTransition = (from: string, to: string) => !!errorFocus?.possibleStates.includes(from) && from !== to; + // entry transitions of objects in an unknown state, also from the initial pseudo state, + // dimmed and collapsed in focus mode like the constructors + const entryTransitions = sm.entryTransitions ?? []; + if (collapse) { + [...new Set(entryTransitions.map(transition => transition.to))].forEach(to => { + const methods: CollapsibleLabel[] = []; + entryTransitions.filter(transition => transition.to === to).forEach(transition => { + const name = escapeMermaidLabel(transition.label); + const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); + if (!methods.some(method => method.name === name)) methods.push({ name, label }); + }); + addTransition(` [*] --> ${to} : ${getCollapsedLabel([], methods, 'methods')}`, true); + }); + } else { + entryTransitions.forEach(transition => { + const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); + addTransition(` [*] --> ${transition.to} : ${label}`, false); + }); + } // group transitions by from/to states and merge labels - const transitionMap = new Map(); + const transitionMap = new Map(); sm.transitions.forEach(transition => { + const key = `${transition.from}|${transition.to}`; const isCalledMethod = transition.label === sm.errorContext?.calledMethod; - const dimmed = relatedStates.length > 0 && !isCalledMethod && !isUsefulTransition(transition.from, transition.to); + // in focus mode, only the failed method stays bright + const dimmed = collapse && !isCalledMethod; const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -78,18 +113,18 @@ export function createMermaidDiagram( showConditions, isCalledMethod, ); - const key = `${transition.from}|${transition.to}`; - if (!transitionMap.has(key)) transitionMap.set(key, { labels: [], dimmed: true }); + if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] }); const group = transitionMap.get(key)!; - // wrapping the label keeps its text, so the layout stays the same - group.labels.push(dimmed ? `${label}` : label); - group.dimmed &&= dimmed; + const name = escapeMermaidLabel(transition.label); + // overloads share a name, so it is counted once + if (!dimmed) group.bright.push(label); + else if (!group.dimmed.some(method => method.name === name)) group.dimmed.push({ name, label }); }); // add transitions, dimming those whose methods are all dimmed - transitionMap.forEach(({ labels, dimmed }, key) => { + transitionMap.forEach(({ bright, dimmed }, key) => { const [from, to] = key.split('|'); - addTransition(` ${from} --> ${to} : ${labels.join('
')}`, dimmed); + addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0); }); if (!errorFocus) { @@ -118,6 +153,22 @@ export function createMermaidDiagram( return lines.join('\n'); } +// a method of a transition label that focus mode may collapse, by its name and its full label +type CollapsibleLabel = { name: string; label: string }; + +/** + * Gets the label of a transition whose dimmed methods are collapsed into a count + * A single dimmed method keeps its full label, since the count would be no shorter + * The full labels are kept hidden in the count, for the list shown on hover + */ +function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: 'methods' | 'constructors'): string { + if (dimmed.length === 0) return bright.join('
'); + if (dimmed.length === 1) return [...bright, `${dimmed[0].label}`].join('
'); + const count = bright.length > 0 ? `+${dimmed.length} more` : `${dimmed.length} ${noun}`; + const methods = `${dimmed.map(method => `${method.label}`).join('')}`; + return [...bright, `${count}${methods}`].join('
'); +} + function getTransitionLabel( label: string, fromCondition?: string | null, @@ -168,10 +219,14 @@ export async function renderMermaidDiagram(document: any, window: any) { if (mermaidElements.length === 0) return; try { - const dimmedTransitions = Array.from(mermaidElements, (element: any) => getDimmedTransitions(element.textContent)); + const dimmedTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, DIMMED_TRANSITIONS_COMMENT)); await mermaid.run({ nodes: mermaidElements }); highlightErrorTransitions(document); - mermaidElements.forEach((element: any, index: number) => dimTransitions(element, dimmedTransitions[index])); + mermaidElements.forEach((element: any, index: number) => { + markTransitions(element, dimmedTransitions[index], 'lj-dimmed'); + }); + fitFocusedDiagrams(document, window); + registerCollapsedLabels(document); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -195,15 +250,123 @@ function highlightErrorTransitions(document: any) { }); } -function getDimmedTransitions(diagram: string | null): string[] { - const comment = diagram?.split('\n').find(line => line.startsWith(DIMMED_TRANSITIONS_COMMENT)); - return comment?.slice(DIMMED_TRANSITIONS_COMMENT.length).trim().split(/\s+/) ?? []; +function getListedTransitions(diagram: string | null, prefix: string): string[] { + const comment = diagram?.split('\n').find(line => line.startsWith(prefix)); + return comment?.slice(prefix.length).trim().split(/\s+/) ?? []; } -function dimTransitions(element: any, edgeIds: string[]) { +function markTransitions(element: any, edgeIds: string[], className: string) { edgeIds.forEach(edgeId => { element.querySelectorAll(`path.transition[data-id="${edgeId}"], g.label[data-id="${edgeId}"]`) - .forEach((part: any) => part.classList.add('lj-dimmed')); + .forEach((part: any) => part.classList.add(className)); + }); +} + +// shrinks the focused diagrams with a state error so that they fit below the summary, keeping them readable +function fitFocusedDiagrams(document: any, window: any) { + const viewport = document.querySelector('.diagram-viewport.diagram-focused') as HTMLElement | null; + if (!viewport) return; + const available = window.innerHeight - viewport.getBoundingClientRect().top - FOCUS_BOTTOM_MARGIN; + viewport.querySelectorAll('.mermaid svg').forEach((svg: any) => { + if (!svg.querySelector('.ljStateInvalid, .ljStateValid, .ljStateAccepted')) return; + const height = svg.clientHeight; + if (!height || height <= available) return; + svg.style.maxHeight = `${Math.max(available, height * MIN_FOCUS_SCALE)}px`; + }); +} + +/** + * Shows the methods of a collapsed label in a list next to it, without laying out the diagram again + * Hovering shows the list, and a click pins it until the next click, a pan, or a zoom + */ +function registerCollapsedLabels(document: any) { + const container = document.querySelector('.diagram-container') as HTMLElement | null; + if (!container) return; + let popover: HTMLElement | null = null; + let pinned: Element | null = null; + + const hide = () => { + popover?.remove(); + popover = null; + pinned = null; + }; + const show = (label: Element) => { + popover?.remove(); + popover = document.createElement('div') as HTMLElement; + popover.className = 'lj-methods-popover'; + popover.innerHTML = label.querySelector('.lj-collapsed-list')?.innerHTML ?? ''; + container.appendChild(popover); + placeMethodsPopover(document, popover, label); + }; + + container.querySelectorAll('.lj-collapsed-methods').forEach(label => { + label.addEventListener('mouseenter', () => { + if (!pinned) show(label); + }); + label.addEventListener('mouseleave', () => { + if (!pinned) hide(); + }); + label.addEventListener('click', (e: Event) => { + e.stopPropagation(); + if (pinned === label) return hide(); + show(label); + pinned = label; + }); + }); + // the list would no longer be next to its label + container.addEventListener('mousedown', (e: Event) => { + if (!(e.target as Element).closest?.('.lj-collapsed-methods, .lj-methods-popover')) hide(); + }); + container.addEventListener('wheel', hide); +} + +// places the list of a collapsed label's methods where it covers the least of the label's diagram +function placeMethodsPopover(document: any, popover: HTMLElement, label: Element) { + // the visible area, without the scrollbars + const page = document.documentElement; + const bounds = { + left: POPOVER_MARGIN, + top: POPOVER_MARGIN, + right: page.clientWidth - POPOVER_MARGIN, + bottom: page.clientHeight - POPOVER_MARGIN, + }; + popover.style.maxHeight = `${Math.max(bounds.bottom - bounds.top, 0)}px`; + const svg = label.closest('svg'); + const anchor = toBox(label.getBoundingClientRect()); + const boxes = svg ? Array.from(svg.querySelectorAll('g.node, .edgeLabel'), (element: Element) => toBox(element.getBoundingClientRect())) : []; + const position = placePopover( + anchor, + { width: popover.offsetWidth, height: popover.offsetHeight }, + bounds, + svg ? toBox(svg.getBoundingClientRect()) : anchor, + boxes, + svg ? getLinePoints(svg) : [], + POPOVER_GAP, + ); + popover.style.left = `${position.x}px`; + popover.style.top = `${position.y}px`; + // the webview may offset fixed elements, so the popover is moved by what it missed + const placed = popover.getBoundingClientRect(); + popover.style.left = `${2 * position.x - placed.left}px`; + popover.style.top = `${2 * position.y - placed.top}px`; +} + +function toBox(rect: DOMRect): Box { + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; +} + +// points along the transition lines of a diagram, in window coordinates +function getLinePoints(svg: Element): Point[] { + return Array.from(svg.querySelectorAll('path.transition')).flatMap((path: any) => { + const matrix = path.getScreenCTM?.(); + if (!matrix || typeof path.getTotalLength !== 'function') return []; + const length = path.getTotalLength(); + const points: Point[] = []; + for (let at = 0; at <= length; at += LINE_SAMPLE_STEP) { + const { x, y } = path.getPointAtLength(at).matrixTransform(matrix); + points.push({ x, y }); + } + return points; }); } @@ -302,8 +465,8 @@ export function registerPanListeners(document: any) { const onMouseDown = (e: any) => { const target = e.target as any; if (target.tagName === 'A' || target.tagName === 'BUTTON') return; - // keep the error summary text selectable - if (target.closest?.('.diagram-error-summary')) return; + // keep the error summary text selectable, and the collapsed labels clickable + if (target.closest?.('.diagram-error-summary, .lj-collapsed-methods, .lj-methods-popover')) return; e.preventDefault(); isPanning = true; diff --git a/client/src/webview/popover-placement.spec.ts b/client/src/webview/popover-placement.spec.ts new file mode 100644 index 0000000..76de823 --- /dev/null +++ b/client/src/webview/popover-placement.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { placePopover, type Box } from './popover-placement'; + +const box = (left: number, top: number, width: number, height: number): Box => ({ left, top, right: left + width, bottom: top + height }); +const window = box(0, 0, 1000, 800); +const diagram = box(200, 100, 400, 500); +// a 40 x 20 label in the middle of the diagram +const anchor = box(380, 300, 40, 20); +const size = { width: 100, height: 60 }; +const place = (boxes: Box[], points: { x: number; y: number }[] = [], bounds = window) => + placePopover(anchor, size, bounds, diagram, boxes, points, 6); + +describe('popover placement', () => { + it('places the popover right of its label when nothing is there', () => { + expect(place([])).toEqual({ x: 426, y: 280 }); + }); + + it('moves left of the label when a state is right of it', () => { + expect(place([box(430, 270, 120, 80)])).toEqual({ x: 274, y: 280 }); + }); + + it('avoids the transition lines', () => { + // a vertical line right of the label, and one left of it + const line = (x: number) => Array.from({ length: 20 }, (_, i) => ({ x, y: 250 + i * 8 })); + expect(place([], [...line(470), ...line(330)])).toEqual({ x: 350, y: 234 }); + }); + + it('goes beside the diagram when the label is surrounded', () => { + const around = [box(426, 250, 120, 120), box(254, 250, 120, 120), box(300, 200, 200, 94), box(300, 326, 200, 100)]; + expect(place(around)).toEqual({ x: 606, y: 280 }); + }); + + it('stays inside its bounds, off its label', () => { + // no room right of the label + expect(place([], [], box(0, 0, 450, 800))).toEqual({ x: 274, y: 280 }); + // no room either side, so it is pushed against the edge + expect(place([], [], box(0, 0, 300, 800)).x).toBe(200); + }); +}); diff --git a/client/src/webview/popover-placement.ts b/client/src/webview/popover-placement.ts new file mode 100644 index 0000000..daed698 --- /dev/null +++ b/client/src/webview/popover-placement.ts @@ -0,0 +1,76 @@ +export type Box = { left: number; top: number; right: number; bottom: number }; +export type Point = { x: number; y: number }; + +// covering a point of a transition's line costs as much as covering this many square pixels of a state or label +const LINE_POINT_COST = 60; +// moving the popover one pixel further from its label costs as much as covering this many square pixels +const DISTANCE_COST = 2; + +/** + * Places a popover next to its anchor where it covers the least of the diagram + * It tries the right, left, top, and bottom of the anchor, then beside the diagram at the anchor's height, + * keeps each inside the window, and picks the one that covers the fewest states, labels, and transition lines, + * the closest one on a tie + * @param anchor the box of the label the popover lists the methods of + * @param size the size of the popover + * @param bounds the box the popover must stay in, usually the window less a margin + * @param diagram the box of the diagram, to place the popover beside it + * @param boxes the boxes of the states and labels the popover should not cover + * @param points points along the transition lines the popover should not cover + * @param gap the space between the popover and its anchor or the diagram + * @returns the top-left corner of the popover + */ +export function placePopover( + anchor: Box, + size: { width: number; height: number }, + bounds: Box, + diagram: Box, + boxes: Box[], + points: Point[], + gap: number, +): Point { + const { width, height } = size; + const middle = (anchor.top + anchor.bottom - height) / 2; + const center = (anchor.left + anchor.right - width) / 2; + const candidates: Point[] = [ + { x: anchor.right + gap, y: middle }, + { x: anchor.left - gap - width, y: middle }, + { x: center, y: anchor.top - gap - height }, + { x: center, y: anchor.bottom + gap }, + { x: diagram.right + gap, y: middle }, + { x: diagram.left - gap - width, y: middle }, + ].map(({ x, y }) => ({ + x: clamp(x, bounds.left, bounds.right - width), + y: clamp(y, bounds.top, bounds.bottom - height), + })); + + const cost = ({ x, y }: Point) => { + const box = { left: x, top: y, right: x + width, bottom: y + height }; + const covered = boxes.reduce((sum, other) => sum + overlap(box, other), 0) + + overlap(box, anchor) * 10 + + points.filter(point => contains(box, point)).length * LINE_POINT_COST; + return covered + distance(box, anchor) * DISTANCE_COST; + }; + return candidates.reduce((best, candidate) => cost(candidate) < cost(best) ? candidate : best); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +function overlap(a: Box, b: Box): number { + const width = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return width > 0 && height > 0 ? width * height : 0; +} + +function contains(box: Box, { x, y }: Point): boolean { + return x > box.left && x < box.right && y > box.top && y < box.bottom; +} + +// the gap between two boxes, 0 when they touch or overlap +function distance(a: Box, b: Box): number { + const dx = Math.max(b.left - a.right, a.left - b.right, 0); + const dy = Math.max(b.top - a.bottom, a.top - b.bottom, 0); + return Math.hypot(dx, dy); +} diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 2c99e1c..e682ed1 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1135,6 +1135,51 @@ export function getStyles(): string { stroke-width: 1px !important; stroke-dasharray: 4 3; } + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods { + font-style: italic; + } + /* hovering shows the collapsed methods, and a click keeps them shown */ + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods { + cursor: pointer; + } + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods:hover { + opacity: 1; + text-decoration: underline; + } + .lj-collapsed-list { + display: none; + } + .lj-methods-popover { + position: fixed; + z-index: 10; + overflow-y: auto; + padding: 0.35rem 0.6rem; + font-size: 0.8rem; + line-height: 1.4; + color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); + background: var(--vscode-editorHoverWidget-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36)); + cursor: default; + user-select: text; + } + .lj-methods-popover .lj-collapsed-item { + display: block; + } + .lj-methods-popover .lj-collapsed-item + .lj-collapsed-item { + margin-top: 0.2rem; + } + .lj-methods-popover .state-cond { + display: block; + font-size: 0.9em; + } + .lj-methods-popover .state-cond-pre { + color: var(--lj-state-cond-pre); + } + .lj-methods-popover .state-cond-post { + color: var(--lj-state-cond-post); + } .diagram-container .mermaid svg .ljStateDimmed, .diagram-container .mermaid svg .lj-dimmed, .diagram-container .mermaid .edgeLabel .lj-dimmed-method { diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 039f66d..24e186d 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -361,6 +361,16 @@ describe('state machine view', () => { .toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state']); }); + it('fits the diagram to the panel only when focusing on the error', () => { + const sm = { ...tiling, errorContext: { calledMethod: 'write', receiver: 'param', possibleStates: ['startTiling'], acceptedStates: ['tilingSet'], validStates: [], invalidStates: ['startTiling'] } }; + + renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, true); + expect(document.querySelector('.diagram-viewport.diagram-focused')).not.toBeNull(); + + renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, false); + expect(document.querySelector('.diagram-focused')).toBeNull(); + }); + it('names the call of the state set with the error when it cannot be marked', () => { const unmarked = { ...compressionError, acceptedStates: [], invalidStates: [] }; renderStateMachineView(document.body, [{ ...compression, errorContext: unmarked }, tiling], diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 8cd7ac2..bac2ae6 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -65,7 +65,7 @@ function renderStateMachineViewHtml(sms: LJStateMachine[], diagrams: string[], o Postcondition ` : ''} -
+
${sms.length === 1 ? `
${diagrams[0]}
` @@ -139,7 +139,7 @@ function renderStateErrorStepper({ index, count }: StateErrorNavigation): string `; } -// explains the state marks with the same outlines, fills, and icons as the diagram, next to the toggle that dims the rest +// explains the state marks with the same outlines, fills, and icons as the diagram, next to the toggle that collapses the rest function renderStateErrorLegend(focuses: StateErrorFocus[], focusOnError: boolean): string { const items = [ focuses.some(focus => focus.invalidStates.length > 0) ? renderLegendItem('invalid', `${INVALID_STATE_ICON} Actual state, not expected`) : '',