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
160 changes: 160 additions & 0 deletions scripts/test-grid-virtualization.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#!/usr/bin/env node

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
import { importTs } from './lib/ts-import.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const helperPath = path.join(root, 'src/renderer/src/raycast-api/grid-runtime-virtualization.ts');

const ITEM_COUNT = 5000;
const VIEWPORT_HEIGHT = 640;
const CONTAINER_WIDTH = 800;
const DEFAULT_COLUMNS = 5;

function makeLargeGridGroups() {
const firstItems = [];
const secondItems = [];

for (let index = 0; index < ITEM_COUNT; index += 1) {
const item = {
item: {
id: `item-${index}`,
order: index,
props: {
id: `visible-${index}`,
title: `Item ${index}`,
subtitle: `Subtitle ${index}`,
content: index % 3 === 0
? { source: `asset-${index}.png`, tintColor: index % 2 === 0 ? 'blue' : undefined }
: { value: `Icon.Circle.${index}`, tooltip: `Icon ${index}` },
},
section: index < ITEM_COUNT / 2
? { id: 'section-a', title: 'Section A' }
: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' },
},
globalIdx: index,
};

if (index < ITEM_COUNT / 2) firstItems.push(item);
else secondItems.push(item);
}

return [
{ key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: firstItems },
{
key: 'section-b',
title: 'Section B',
section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' },
items: secondItems,
},
];
}

function countEagerRenderedCells(groups) {
return groups.reduce((total, group) => total + group.items.length, 0);
}

function simulateCellContentResolution(groups) {
let checksum = 0;
for (const group of groups) {
for (const { item } of group.items) {
const content = item.props.content;
if (typeof content?.source === 'string') checksum += content.source.length;
if (typeof content?.value === 'string') checksum += content.value.length;
if (typeof item.props.title === 'string') checksum += item.props.title.length;
}
}
return checksum;
}

function countItemsInRows(rows) {
return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0);
}

function rowContainsIndex(rows, index) {
return rows.some((row) => row.kind === 'items' && row.items.some((entry) => entry.globalIdx === index));
}

test('Grid virtualization keeps large grids to visible cells', async (t) => {
const groups = makeLargeGridGroups();
const eagerStart = performance.now();
const eagerCells = countEagerRenderedCells(groups);
const checksum = simulateCellContentResolution(groups);
const eagerDuration = performance.now() - eagerStart;

console.log(
`[grid-perf] baseline eager cells=${eagerCells} contentResolutions=${ITEM_COUNT} durationMs=${eagerDuration.toFixed(3)} checksum=${checksum}`,
);

assert.equal(eagerCells, ITEM_COUNT, 'legacy grid renders every cell in a large grid');

if (!fs.existsSync(helperPath)) {
console.log('[grid-perf] virtualization helper not present yet; baseline captured before renderer edits');
return;
}

const {
buildVirtualGridLayout,
countVirtualizedRowItems,
getScrollTopForItemIndex,
getVisibleVirtualRows,
} = await importTs(helperPath);

await t.test('renders only visible item cells plus overscan', () => {
const layout = buildVirtualGridLayout(groups, {
defaultColumns: DEFAULT_COLUMNS,
containerWidth: CONTAINER_WIDTH,
});
const visibleRows = getVisibleVirtualRows(layout.rows, {
scrollTop: 0,
viewportHeight: VIEWPORT_HEIGHT,
});
const visibleCells = countVirtualizedRowItems(visibleRows);

console.log(
`[grid-perf] virtualized visibleCells=${visibleCells} totalCells=${layout.itemCount} renderedRows=${visibleRows.length} totalRows=${layout.rows.length} totalHeight=${layout.totalHeight.toFixed(1)}`,
);

assert.equal(layout.itemCount, ITEM_COUNT);
assert.equal(visibleCells, countItemsInRows(visibleRows));
assert.ok(visibleCells > 0, 'initial viewport renders visible cells');
assert.ok(visibleCells < 80, `expected a small visible window, got ${visibleCells}`);
});

await t.test('scrolls selected items into the virtual window', () => {
const layout = buildVirtualGridLayout(groups, {
defaultColumns: DEFAULT_COLUMNS,
containerWidth: CONTAINER_WIDTH,
});
const targetIndex = 4242;
const scrollTop = getScrollTopForItemIndex(layout, targetIndex, {
currentScrollTop: 0,
viewportHeight: VIEWPORT_HEIGHT,
});
const selectedRows = getVisibleVirtualRows(layout.rows, {
scrollTop,
viewportHeight: VIEWPORT_HEIGHT,
});

assert.ok(rowContainsIndex(selectedRows, targetIndex), 'selected off-screen item is rendered after selection scroll');
});

await t.test('preserves section layout options', () => {
const layout = buildVirtualGridLayout(groups, {
defaultColumns: DEFAULT_COLUMNS,
containerWidth: CONTAINER_WIDTH,
});
const sectionRow = layout.rows.find((row) => row.kind === 'items' && row.sectionKey === 'section-b');

assert.ok(sectionRow, 'section row exists');
assert.equal(sectionRow.layout.columns, 4);
assert.equal(sectionRow.layout.fit, 'fill');
assert.equal(sectionRow.layout.inset, 'lg');
assert.notEqual(sectionRow.itemHeight, 160, 'aspect ratio creates a section-specific item row height');
});
});
31 changes: 24 additions & 7 deletions src/renderer/src/raycast-api/grid-runtime-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items';

export interface GridItemGroup {
key: string;
title?: string;
subtitle?: string;
section?: GridItemRegistration['section'];
items: { item: GridItemRegistration; globalIdx: number }[];
}

export function useGridRegistry() {
const registryRef = useRef(new Map<string, GridItemRegistration>());
const [registryVersion, setRegistryVersion] = useState(0);
Expand All @@ -22,7 +30,8 @@ export function useGridRegistry() {
.map((entry) => {
const actionType = entry.props.actions?.type as any;
const actionName = actionType?.name || actionType?.displayName || typeof actionType || '';
return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`;
const section = entry.section;
return `${entry.id}:${entry.props.title || ''}:${section?.id || ''}:${section?.title || ''}:${section?.columns || ''}:${section?.aspectRatio || ''}:${section?.fit || ''}:${section?.inset || ''}:${actionName}`;
})
.join('|');
if (snapshot !== lastSnapshotRef.current) {
Expand All @@ -38,7 +47,7 @@ export function useGridRegistry() {
const existing = registryRef.current.get(id);
if (existing) {
existing.props = data.props;
existing.sectionTitle = data.sectionTitle;
existing.section = data.section;
existing.order = data.order;
} else {
registryRef.current.set(id, { id, ...data });
Expand All @@ -63,14 +72,22 @@ export function useGridRegistry() {
}

export function groupGridItems(filteredItems: GridItemRegistration[]) {
const groups: { title?: string; items: { item: GridItemRegistration; globalIdx: number }[] }[] = [];
let currentSection: string | undefined | null = null;
const groups: GridItemGroup[] = [];
let currentSectionKey: string | undefined | null = null;
let globalIndex = 0;

for (const item of filteredItems) {
if (item.sectionTitle !== currentSection || groups.length === 0) {
currentSection = item.sectionTitle;
groups.push({ title: item.sectionTitle, items: [] });
const section = item.section;
const sectionKey = section?.id || section?.title || '__default_grid_section';
if (sectionKey !== currentSectionKey || groups.length === 0) {
currentSectionKey = sectionKey;
groups.push({
key: sectionKey,
title: section?.title,
subtitle: section?.subtitle,
section,
items: [],
});
}
groups[groups.length - 1].items.push({ item, globalIdx: globalIndex++ });
}
Expand Down
74 changes: 60 additions & 14 deletions src/renderer/src/raycast-api/grid-runtime-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@
* Contains grid item registration contexts and row/cell renderers.
*/

import React, { createContext, useContext, useLayoutEffect, useRef } from 'react';
import React, { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'react';
import { resolveTintColor } from './icon-runtime-assets';
import { renderIcon } from './icon-runtime-render';

export interface GridSectionRegistration {
id: string;
title?: string;
subtitle?: string;
columns?: number;
aspectRatio?: string;
fit?: string;
inset?: string;
}

export interface GridItemRegistration {
id: string;
props: {
Expand All @@ -20,7 +30,7 @@ export interface GridItemRegistration {
accessory?: any;
quickLook?: { name?: string; path: string };
};
sectionTitle?: string;
section?: GridSectionRegistration;
order: number;
}

Expand All @@ -31,33 +41,53 @@ export interface GridRegistryAPI {

export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) {
let gridItemOrderCounter = 0;
let gridSectionOrderCounter = 0;

const GridRegistryContext = createContext<GridRegistryAPI>({
set: () => {},
delete: () => {},
});
const GridSectionTitleContext = createContext<string | undefined>(undefined);
const GridSectionContext = createContext<GridSectionRegistration | undefined>(undefined);

function GridItemComponent(props: any) {
const registry = useContext(GridRegistryContext);
const sectionTitle = useContext(GridSectionTitleContext);
const section = useContext(GridSectionContext);
const stableId = useRef(props.id || `__gi_${++gridItemOrderCounter}`).current;
const orderRef = useRef<number | null>(null);
if (orderRef.current === null) orderRef.current = ++gridItemOrderCounter;

useLayoutEffect(() => {
registry.set(stableId, { props, sectionTitle, order: orderRef.current! });
registry.set(stableId, { props, section, order: orderRef.current! });
return () => registry.delete(stableId);
}, [props, registry, sectionTitle, stableId]);
}, [props, registry, section, stableId]);

return null;
}

function GridSectionComponent({ children, title }: { children?: React.ReactNode; title?: string }) {
return <GridSectionTitleContext.Provider value={title}>{children}</GridSectionTitleContext.Provider>;
function GridSectionComponent({ children, title, subtitle, columns, aspectRatio, fit, inset }: any) {
const stableId = useRef(`__gs_${++gridSectionOrderCounter}`).current;
const section = useMemo(
() => ({ id: stableId, title, subtitle, columns, aspectRatio, fit, inset }),
[aspectRatio, columns, fit, inset, stableId, subtitle, title],
);

return <GridSectionContext.Provider value={section}>{children}</GridSectionContext.Provider>;
}

function GridItemRenderer({ title, subtitle, content, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) {
function GridItemRenderer({
title,
subtitle,
content,
accessory,
isSelected,
dataIdx,
itemHeight,
fit,
inset,
onSelect,
onActivate,
onContextAction,
}: any) {
const isImageLikeSourceString = (value: string): boolean => {
const source = String(value || '').trim();
if (!source) return false;
Expand Down Expand Up @@ -158,6 +188,17 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)

const swatchColor = getGridColor(content);
const renderableContent = swatchColor ? null : toRenderableContent(content);
const accessoryIcon = accessory?.icon ? toRenderableContent(accessory.icon) : null;
const accessoryTitle = typeof accessory?.tooltip === 'string' ? accessory.tooltip : undefined;
const contentFitClass = fit === 'fill' ? 'w-full h-full object-cover' : 'w-full h-full object-contain';
const insetClass =
inset === 'zero'
? 'p-0'
: inset === 'md'
? 'p-3'
: inset === 'lg'
? 'p-5'
: 'p-1.5';

return (
<div
Expand All @@ -168,7 +209,7 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
: 'border-[var(--launcher-card-border)] bg-[var(--launcher-card-bg)] hover:bg-[var(--launcher-card-hover-bg)]'
}`}
style={{
height: '160px',
height: `${itemHeight || 160}px`,
boxShadow: isSelected
? '0 0 0 2px rgba(var(--on-surface-rgb), 0.24), inset 0 0 0 1px rgba(var(--on-surface-rgb), 0.16)'
: undefined,
Expand All @@ -181,22 +222,27 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
onMouseMove={onSelect}
onContextMenu={onContextAction}
>
<div className="flex-1 flex items-center justify-center overflow-hidden p-1.5 min-h-0">
<div className={`flex-1 flex items-center justify-center overflow-hidden min-h-0 ${insetClass}`}>
{swatchColor ? (
<div className="w-full h-full rounded" style={{ backgroundColor: swatchColor }} />
) : renderableContent ? (
<div className="w-full h-full flex items-center justify-center">
{renderIcon(renderableContent, 'w-full h-full object-contain')}
{renderIcon(renderableContent, contentFitClass)}
</div>
) : (
<div className="w-full h-full bg-[var(--surface-tint-2)] rounded flex items-center justify-center text-[var(--text-subtle)] text-2xl">
{title ? title.charAt(0) : '?'}
</div>
)}
</div>
{title && (
{(title || subtitle || accessoryIcon) && (
<div className="px-2 pb-2 pt-1 flex-shrink-0">
<p className="truncate text-[11px] text-[var(--text-secondary)] text-center">{title}</p>
{accessoryIcon && (
<div className="mb-1 flex justify-center text-[var(--text-subtle)]" title={accessoryTitle}>
{renderIcon(accessoryIcon, 'w-3 h-3 object-contain')}
</div>
)}
{title && <p className="truncate text-[11px] text-[var(--text-secondary)] text-center">{title}</p>}
{subtitle && <p className="truncate text-[9px] text-[var(--text-subtle)] text-center">{subtitle}</p>}
</div>
)}
Expand Down
Loading
Loading