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

import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

async function importListHooks() {
const result = await build({
entryPoints: [path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')],
bundle: true,
write: false,
platform: 'node',
format: 'esm',
});
const code = result.outputFiles[0].text;
return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64'));
}

function makeEmojiItems(totalItems) {
return Array.from({ length: totalItems }, (_, index) => ({
id: `emoji-${index}`,
order: index,
sectionTitle: index < totalItems / 2 ? 'Smileys' : 'Symbols',
props: {
title: `Emoji ${index}`,
icon: '😀',
},
}));
}

function countEmojiCells(rows) {
return rows.reduce((count, row) => count + (row.type === 'emoji-row' ? row.items.length : 0), 0);
}

const hooks = await importListHooks();

test('Emoji grid virtualization', async (t) => {
const totalItems = 4096;
const items = makeEmojiItems(totalItems);
const groupedItems = hooks.groupListItems(items);
const gridRows = hooks.buildEmojiGridVirtualRows(groupedItems);
const rowMetrics = hooks.measureVirtualRows(gridRows);

await t.test('renders only the visible emoji cells plus overscan', () => {
assert.equal(hooks.shouldUseEmojiGrid(items, false, () => true), true, 'fixture should use the emoji grid path');
assert.equal(countEmojiCells(gridRows), totalItems, 'all items are represented by virtual rows');
assert.ok(gridRows.length < totalItems, 'grid rows compact many cells into fixed rows');

const range = hooks.getVisibleVirtualRange(gridRows, rowMetrics, 0, 600);
const visibleCells = countEmojiCells(gridRows.slice(range.visibleStart, range.visibleEnd));

assert.equal(visibleCells, 112);
assert.ok(visibleCells < totalItems * 0.05, 'initial window renders less than 5% of the list');
console.log(JSON.stringify({
mode: 'after-test',
totalItems,
virtualRows: gridRows.length,
visibleCells,
visibleStart: range.visibleStart,
visibleEnd: range.visibleEnd,
}));
});

await t.test('maps selected indices to their grouped grid rows', () => {
const itemToRow = hooks.buildItemToVirtualRowMap(gridRows, totalItems);

assert.equal(itemToRow[0], 1, 'first section starts after its header');
assert.equal(itemToRow[7], 1, 'first row contains eight cells');
assert.equal(itemToRow[8], 2, 'ninth cell starts the next grid row');
assert.equal(itemToRow[2047], 256, 'last item in the first section stays in its final row');
assert.equal(itemToRow[2048], 258, 'second section starts after the next header');

const selectedRow = itemToRow[3000];
const selectedTop = rowMetrics.offsets[selectedRow];
const selectedRange = hooks.getVisibleVirtualRange(gridRows, rowMetrics, selectedTop, 600);
const visibleCells = countEmojiCells(gridRows.slice(selectedRange.visibleStart, selectedRange.visibleEnd));

assert.ok(selectedRow >= selectedRange.visibleStart && selectedRow < selectedRange.visibleEnd, 'selected item row is visible');
assert.ok(visibleCells < 200, 'selection scroll window stays bounded');
});
});
135 changes: 133 additions & 2 deletions src/renderer/src/raycast-api/list-runtime-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types';

export const LIST_ROW_HEIGHT = 36;
export const LIST_HEADER_HEIGHT = 24;
export const LIST_OVERSCAN = 8;
export const EMOJI_GRID_COLUMNS = 8;
export const EMOJI_GRID_CELL_HEIGHT = 96;
export const EMOJI_GRID_ROW_GAP = 8;
export const EMOJI_GRID_ROW_HEIGHT = EMOJI_GRID_CELL_HEIGHT + EMOJI_GRID_ROW_GAP;
export const EMOJI_GRID_HEADER_HEIGHT = 28;

export type ListItemGroup = {
title?: string;
items: { item: ItemRegistration; globalIdx: number }[];
};

export type ListVirtualRow =
| { type: 'header'; title: string; key: string; height: number }
| { type: 'item'; item: ItemRegistration; globalIdx: number; key: string; height: number };

export type EmojiGridVirtualRow =
| { type: 'header'; title: string; count: number; key: string; height: number }
| { type: 'emoji-row'; items: ListItemGroup['items']; key: string; height: number };

export type VirtualRow = ListVirtualRow | EmojiGridVirtualRow;

export type VirtualRowMetrics = {
offsets: number[];
totalHeight: number;
};

function getReactTypeName(type: any): string {
return String(type?.displayName || type?.name || type || '');
}
Expand Down Expand Up @@ -155,8 +184,8 @@ export function shouldUseEmojiGrid(filteredItems: ItemRegistration[], isShowingD
return emojiIcons / Math.max(1, iconsWithValue) >= 0.95;
}

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

Expand All @@ -170,3 +199,105 @@ export function groupListItems(filteredItems: ItemRegistration[]) {

return groups;
}

export function buildListVirtualRows(groupedItems: ListItemGroup[]): ListVirtualRow[] {
const rows: ListVirtualRow[] = [];
for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) {
const group = groupedItems[groupIndex];
if (group.title) {
rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}`, height: LIST_HEADER_HEIGHT });
}
for (const entry of group.items) {
rows.push({
type: 'item',
item: entry.item,
globalIdx: entry.globalIdx,
key: entry.item.id,
height: LIST_ROW_HEIGHT,
});
}
}
return rows;
}

export function buildEmojiGridVirtualRows(groupedItems: ListItemGroup[], columns = EMOJI_GRID_COLUMNS): EmojiGridVirtualRow[] {
const rows: EmojiGridVirtualRow[] = [];
const safeColumns = Math.max(1, Math.floor(columns));

for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) {
const group = groupedItems[groupIndex];
if (group.title) {
rows.push({
type: 'header',
title: group.title,
count: group.items.length,
key: `__eg_h_${groupIndex}`,
height: EMOJI_GRID_HEADER_HEIGHT,
});
}

for (let start = 0; start < group.items.length; start += safeColumns) {
rows.push({
type: 'emoji-row',
items: group.items.slice(start, start + safeColumns),
key: `__eg_r_${groupIndex}_${start}`,
height: EMOJI_GRID_ROW_HEIGHT,
});
}
}

return rows;
}

export function measureVirtualRows(rows: Array<{ height: number }>): VirtualRowMetrics {
const offsets: number[] = new Array(rows.length);
let totalHeight = 0;
for (let index = 0; index < rows.length; index += 1) {
offsets[index] = totalHeight;
totalHeight += rows[index].height;
}
return { offsets, totalHeight };
}

export function getVisibleVirtualRange(
rows: Array<{ height: number }>,
rowMetrics: VirtualRowMetrics,
scrollTop: number,
containerHeight: number,
overscan = LIST_OVERSCAN,
) {
if (rows.length === 0) return { visibleStart: 0, visibleEnd: 0 };

const top = scrollTop;
const bottom = scrollTop + (containerHeight || 600);
let lo = 0;
let hi = rows.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (rowMetrics.offsets[mid] + rows[mid].height <= top) lo = mid + 1;
else hi = mid;
}

const visibleStart = Math.max(0, lo - overscan);
let visibleEnd = lo;
while (visibleEnd < rows.length && rowMetrics.offsets[visibleEnd] < bottom) visibleEnd += 1;
visibleEnd = Math.min(rows.length, visibleEnd + overscan);
return { visibleStart, visibleEnd };
}

export function buildItemToVirtualRowMap(rows: VirtualRow[], itemCount: number): number[] {
const map: number[] = new Array(itemCount);

for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex];
if (row.type === 'item') {
map[row.globalIdx] = rowIndex;
} else if (row.type === 'emoji-row') {
for (const entry of row.items) {
map[entry.globalIdx] = rowIndex;
}
}
}

return map;
}
Loading
Loading