Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions log-viewer/src/features/timeline/__tests__/find-tooltip-hold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @jest-environment jsdom
*/

/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/

/**
* Stepping through find results centres the view on each match, which moves the frames under a
* still pointer. The chart asks what the pointer is over again, and the answer used to take the
* match's own panel away — so the panel appeared only when the pointer happened to rest outside
* the chart. It is held until the reader moves the pointer, and closing the find drops it.
*/

import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { ApexLogTimeline } from '../optimised/ApexLogTimeline.js';
import type { EventNode, HoverCause, TimelineMarker } from '../types/flamechart.types.js';

const MATCH: EventNode = {
id: '0-0-0',
timestamp: 1000,
duration: 500,
type: 'METHOD_ENTRY',
text: 'Match()',
original: { eventIndex: 7, isParent: true },
};

type Tooltip = { show: jest.Mock; showTruncation: jest.Mock; hide: jest.Mock };

function timeline(): {
navigateToMatch: () => void;
hover: (eventNode: EventNode | null, cause: HoverCause) => void;
closeFind: () => void;
deselect: () => void;
tooltip: Tooltip;
} {
const chart = new ApexLogTimeline();
const internals = chart as unknown as Record<string, unknown>;
const tooltip: Tooltip = { show: jest.fn(), showTruncation: jest.fn(), hide: jest.fn() };

internals['tooltipRenderer'] = tooltip;
internals['flamechart'] = {
containerYToDepth: () => 0,
getFrameRect: () => ({ x: 10, y: 20, width: 30, height: 15 }),
getChartTopY: () => 40,
clearSearch: jest.fn(),
locateByEventNodes: jest.fn(),
};
const container = document.createElement('div');
document.body.appendChild(container);
internals['container'] = container;

const navigate = internals['handleSearchNavigate'] as (
eventNode: EventNode,
screenX: number,
screenY: number,
depth: number,
) => void;
const mouseMove = internals['handleMouseMove'] as (
screenX: number,
screenY: number,
eventNode: EventNode | null,
marker: TimelineMarker | null,
cause: HoverCause,
) => void;
const findClose = internals['handleFindClose'] as () => void;
const select = internals['handleSelect'] as (eventNode: EventNode | null) => void;

return {
navigateToMatch: () => navigate.call(chart, MATCH, 200, 100, 0),
hover: (eventNode, cause) => mouseMove.call(chart, 50, 60, eventNode, null, cause),
closeFind: () => findClose.call(chart),
deselect: () => select.call(chart, null),
tooltip,
};
}

describe('the panel on a find match', () => {
let chart: ReturnType<typeof timeline>;

beforeEach(() => {
chart = timeline();
chart.navigateToMatch();
expect(chart.tooltip.show).toHaveBeenCalledTimes(1);
});

it('survives the re-hit the centring causes under a still pointer', () => {
chart.hover(null, 'frames');

expect(chart.tooltip.hide).not.toHaveBeenCalled();
});

it('is not replaced by whatever the frames slid under the pointer', () => {
chart.hover({ ...MATCH, id: 'other', original: { eventIndex: 9 } }, 'frames');

expect(chart.tooltip.show).toHaveBeenCalledTimes(1);
});

it('is handed back on the next real pointer move', () => {
chart.hover(null, 'pointer');

expect(chart.tooltip.hide).toHaveBeenCalledTimes(1);
});

it('goes when the find closes', () => {
chart.closeFind();

expect(chart.tooltip.hide).toHaveBeenCalledTimes(1);
});

// Escape closes the panel without moving the pointer. Nothing is held after that, so the
// next zoom or pan shows the frame the pointer ended up over.
it('holds nothing once something else closed it', () => {
chart.deselect();
chart.tooltip.show.mockClear();

chart.hover({ ...MATCH, id: 'other', original: { eventIndex: 9 } }, 'frames');

expect(chart.tooltip.show).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ describe('SearchHighlightRenderer', () => {
expect(mockGraphics.rect).toHaveBeenCalled();
expect(mockGraphics.fill).toHaveBeenCalled();

// Should have called rect twice (once for fill, once for stroke)
expect(mockGraphics.rect).toHaveBeenCalledTimes(2);
// Three rects: the wash, the halo outside the border, and the border
expect(mockGraphics.rect).toHaveBeenCalledTimes(3);
});

it('should render for small rectangles', () => {
Expand Down
39 changes: 26 additions & 13 deletions log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
BUCKET_CONSTANTS,
type EditorColors,
type EventNode,
type HoverCause,
type ModifierKeys,
type TimelineMarker,
type TimelineOptions,
Expand Down Expand Up @@ -91,6 +92,8 @@ export class ApexLogTimeline {
private echoGuard = new SelectionEchoGuard();
/** Frame last reported to the inspector as under the pointer. */
private locatedEventIndex: number | null = null;
/** The panel belongs to a frame find or the keyboard moved to, not to the pointer. */
private navigationTooltip = false;
/** The frames kept in colour while the rest of the chart is dimmed. */
private emphasis = new InspectorEmphasis();

Expand Down Expand Up @@ -156,8 +159,8 @@ export class ApexLogTimeline {
markers,
{ ...options, enableSearch: true }, // Enable search via options
{
onMouseMove: (screenX, screenY, event, marker) => {
this.handleMouseMove(screenX, screenY, event, marker);
onMouseMove: (screenX, screenY, event, marker, cause) => {
this.handleMouseMove(screenX, screenY, event, marker, cause);
},
onClick: (screenX, screenY, event, marker, modifiers) => {
this.handleClick(screenX, screenY, event, marker, modifiers);
Expand Down Expand Up @@ -507,6 +510,7 @@ export class ApexLogTimeline {
screenY: number,
eventNode: EventNode | null,
marker: TimelineMarker | null,
cause: HoverCause,
): void {
if (!this.tooltipRenderer) {
return;
Expand All @@ -519,6 +523,13 @@ export class ApexLogTimeline {

this.reportLocatedFrame(eventNode);

// The frames moved under a still pointer: the wash and the located row follow them, the
// panel stays with the frame it was opened for. A real pointer move hands it back.
if (cause === 'frames' && this.navigationTooltip) {
return;
}
this.navigationTooltip = false;

// Priority: Events take precedence over truncation markers
if (eventNode) {
// Extract LogEvent from EventNode.original for tooltip display
Expand Down Expand Up @@ -649,6 +660,7 @@ export class ApexLogTimeline {

if (!eventNode) {
// Selection cleared - hide tooltip
this.navigationTooltip = false;
if (this.tooltipRenderer) {
this.tooltipRenderer.hide();
}
Expand Down Expand Up @@ -695,6 +707,7 @@ export class ApexLogTimeline {

if (!marker) {
// Marker selection cleared - hide tooltip
this.navigationTooltip = false;
if (this.tooltipRenderer) {
this.tooltipRenderer.hide();
}
Expand Down Expand Up @@ -723,6 +736,7 @@ export class ApexLogTimeline {
const eventWithOriginal = event as EventNode & { original?: LogEvent };
const logEvent = eventWithOriginal.original;
if (logEvent) {
this.navigationTooltip = true;
this.tooltipRenderer.show(logEvent, this.buildAnchor(event, screenX, screenY));
}
}
Expand All @@ -735,6 +749,7 @@ export class ApexLogTimeline {
if (!this.tooltipRenderer) {
return;
}
this.navigationTooltip = true;
this.tooltipRenderer.showTruncation(marker, this.buildAnchor(marker, screenX, screenY));
}

Expand Down Expand Up @@ -896,6 +911,7 @@ export class ApexLogTimeline {
this.selectedMarkerForContextMenu = null;

// Hide tooltip since we're not over a frame or marker
this.navigationTooltip = false;
if (this.tooltipRenderer) {
this.tooltipRenderer.hideImmediate();
}
Expand Down Expand Up @@ -1136,6 +1152,12 @@ export class ApexLogTimeline {
// Clear search cursor reference
this.searchCursor = null;

// The panel belongs to the match, so it closes with the find.
if (this.navigationTooltip) {
this.navigationTooltip = false;
this.tooltipRenderer?.hide();
}

// Clear search state (FlameChart handles render)
this.flamechart.clearSearch();

Expand All @@ -1144,24 +1166,15 @@ export class ApexLogTimeline {

/**
* Handle search navigation callback from FlameChart.
* Shows tooltip for the current search match.
* Shows tooltip for the current search match, as keyboard navigation does for a frame.
*/
private handleSearchNavigate(
eventNode: EventNode,
screenX: number,
screenY: number,
_depth: number,
): void {
if (!this.tooltipRenderer) {
return;
}
// EventNode may have original LogEvent stored from tree conversion
const eventWithOriginal = eventNode as EventNode & { original?: LogEvent };
const logEvent = eventWithOriginal.original;

if (logEvent) {
this.tooltipRenderer.show(logEvent, this.buildAnchor(eventNode, screenX, screenY));
}
this.handleFrameNavigate(eventNode, screenX, screenY);
}

/**
Expand Down
19 changes: 14 additions & 5 deletions log-viewer/src/features/timeline/optimised/FlameChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
EditorColors,
EventNode,
HeatStripTimeSeries,
HoverCause,
ModifierKeys,
TimelineMarker,
TimelineOptions,
Expand Down Expand Up @@ -85,11 +86,13 @@ const SIZE_WAIT_FRAMES = 60;
const PAN_ANIMATION_MS = 300;

export interface FlameChartCallbacks {
/** Called with what the pointer is over, and with what changed that. */
onMouseMove?: (
screenX: number,
screenY: number,
eventNode: EventNode | null,
marker: TimelineMarker | null,
cause: HoverCause,
) => void;
onClick?: (
screenX: number,
Expand Down Expand Up @@ -1217,7 +1220,7 @@ export class FlameChart<E extends EventNode = EventNode> {
this.requestHoverRender();
// Notify callback that mouse left (clears tooltip)
if (this.callbacks.onMouseMove) {
this.callbacks.onMouseMove(0, 0, null, null);
this.callbacks.onMouseMove(0, 0, null, null, 'pointer');
}
},
onDragStart: () => {
Expand Down Expand Up @@ -1718,7 +1721,7 @@ export class FlameChart<E extends EventNode = EventNode> {
);
}

private handleMouseMove(screenX: number, screenY: number): void {
private handleMouseMove(screenX: number, screenY: number, cause: HoverCause = 'pointer'): void {
if (!this.viewport || !this.index || !this.hitDetector) {
return;
}
Expand Down Expand Up @@ -1751,7 +1754,13 @@ export class FlameChart<E extends EventNode = EventNode> {
// Notify callback with container-relative coordinates
// (screenY is canvas-relative, add minimap offset for container-relative positioning)
if (this.callbacks.onMouseMove) {
this.callbacks.onMouseMove(screenX, screenY + this.mainTimelineYOffset, eventNode, marker);
this.callbacks.onMouseMove(
screenX,
screenY + this.mainTimelineYOffset,
eventNode,
marker,
cause,
);
}
}

Expand Down Expand Up @@ -2389,12 +2398,12 @@ export class FlameChart<E extends EventNode = EventNode> {
// stays marked stale, and the first render after the drag washes what it settled on.
if (this.hoverTracker.setHovered(null)) {
dirty.overlays = true;
this.callbacks.onMouseMove?.(0, 0, null, null);
this.callbacks.onMouseMove?.(0, 0, null, null, 'pointer');
}
} else {
const stale = this.hoverTracker.takeStaleHit();
if (stale) {
this.handleMouseMove(stale.x, stale.y);
this.handleMouseMove(stale.x, stale.y, 'frames');
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @jest-environment jsdom
*/

/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/

/**
* The source colour is the host theme's, and several themes give it a grey close to the frames
* around a highlight. The halo is what keeps the highlight readable there: it takes the tone the
* source colour does not, so one of the two always reads against what is under them.
*/

import { describe, expect, it } from '@jest/globals';
import { Graphics } from 'pixi.js';
import { TIMELINE_CONSTANTS, type ViewportState } from '../../types/flamechart.types.js';
import {
createHighlightColors,
MIN_HIGHLIGHT_WIDTH,
renderHighlight,
} from '../rendering/HighlightRenderer.js';

const viewport: ViewportState = {
zoom: 1,
offsetX: 0,
offsetY: 0,
displayWidth: 800,
displayHeight: 300,
} as ViewportState;

/** The colours the graphics was told to stroke with, in order: the halo, then the border. */
function strokeColors(graphics: Graphics): number[] {
return graphics.context.instructions
.filter((instruction) => instruction.action === 'stroke')
.map((instruction) => (instruction.data as { style: { color: number } }).style.color);
}

describe('the halo around a highlight', () => {
// #515c6a and #a8ac94: the greys Dark+ and Light+ give `editor.findMatchBackground`.
it('goes light behind a dark source colour', () => {
const graphics = new Graphics();
renderHighlight(graphics, 0, 100, 0, viewport, createHighlightColors(0x515c6a));

expect(strokeColors(graphics)).toEqual([0xffffff, 0x515c6a]);
});

it('goes dark behind a light source colour', () => {
const graphics = new Graphics();
renderHighlight(graphics, 0, 100, 0, viewport, createHighlightColors(0xa8ac94));

expect(strokeColors(graphics)).toEqual([0x000000, 0xa8ac94]);
});

it('borders a frame too thin to border at the width the wash widened it to', () => {
const graphics = new Graphics();
renderHighlight(graphics, 500, 0.5, 0, viewport, createHighlightColors(0xea5c00));

// Was a wash alone: a hairline the reader could step to and not find.
expect(strokeColors(graphics)).toHaveLength(2);
const bounds = graphics.context.bounds;
expect(bounds.maxX - bounds.minX).toBeGreaterThanOrEqual(MIN_HIGHLIGHT_WIDTH);
expect(bounds.maxY - bounds.minY).toBeGreaterThanOrEqual(TIMELINE_CONSTANTS.EVENT_HEIGHT);
});
});
Loading
Loading