From af8b3a426950c7eb70be805cc4b01996af360c22 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:25:25 +0200 Subject: [PATCH] perf(grid): virtualize grid rendering --- scripts/test-grid-virtualization.mjs | 160 ++++++++++ .../src/raycast-api/grid-runtime-hooks.ts | 31 +- .../src/raycast-api/grid-runtime-items.tsx | 74 ++++- .../grid-runtime-virtualization.ts | 276 ++++++++++++++++++ src/renderer/src/raycast-api/grid-runtime.tsx | 186 +++++++++--- 5 files changed, 667 insertions(+), 60 deletions(-) create mode 100644 scripts/test-grid-virtualization.mjs create mode 100644 src/renderer/src/raycast-api/grid-runtime-virtualization.ts diff --git a/scripts/test-grid-virtualization.mjs b/scripts/test-grid-virtualization.mjs new file mode 100644 index 00000000..90b746db --- /dev/null +++ b/scripts/test-grid-virtualization.mjs @@ -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'); + }); +}); diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..b402c061 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -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()); const [registryVersion, setRegistryVersion] = useState(0); @@ -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) { @@ -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 }); @@ -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++ }); } diff --git a/src/renderer/src/raycast-api/grid-runtime-items.tsx b/src/renderer/src/raycast-api/grid-runtime-items.tsx index d06d0269..8f36d6e2 100644 --- a/src/renderer/src/raycast-api/grid-runtime-items.tsx +++ b/src/renderer/src/raycast-api/grid-runtime-items.tsx @@ -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: { @@ -20,7 +30,7 @@ export interface GridItemRegistration { accessory?: any; quickLook?: { name?: string; path: string }; }; - sectionTitle?: string; + section?: GridSectionRegistration; order: number; } @@ -31,33 +41,53 @@ export interface GridRegistryAPI { export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) { let gridItemOrderCounter = 0; + let gridSectionOrderCounter = 0; const GridRegistryContext = createContext({ set: () => {}, delete: () => {}, }); - const GridSectionTitleContext = createContext(undefined); + const GridSectionContext = createContext(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(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 {children}; + 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 {children}; } - 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; @@ -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 (
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, @@ -181,12 +222,12 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) onMouseMove={onSelect} onContextMenu={onContextAction} > -
+
{swatchColor ? (
) : renderableContent ? (
- {renderIcon(renderableContent, 'w-full h-full object-contain')} + {renderIcon(renderableContent, contentFitClass)}
) : (
@@ -194,9 +235,14 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
)}
- {title && ( + {(title || subtitle || accessoryIcon) && (
-

{title}

+ {accessoryIcon && ( +
+ {renderIcon(accessoryIcon, 'w-3 h-3 object-contain')} +
+ )} + {title &&

{title}

} {subtitle &&

{subtitle}

}
)} diff --git a/src/renderer/src/raycast-api/grid-runtime-virtualization.ts b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts new file mode 100644 index 00000000..5ed85fe5 --- /dev/null +++ b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts @@ -0,0 +1,276 @@ +/** + * Pure layout helpers for virtualized Grid rendering. + */ + +export const GRID_DEFAULT_COLUMNS = 5; +export const GRID_MIN_COLUMNS = 1; +export const GRID_MAX_COLUMNS = 8; +export const GRID_DEFAULT_ITEM_HEIGHT = 160; +export const GRID_ITEM_LABEL_HEIGHT = 34; +export const GRID_SECTION_HEADER_HEIGHT = 30; +export const GRID_ROW_GAP = 8; +export const GRID_GROUP_GAP = 8; +export const GRID_DEFAULT_OVERSCAN = GRID_DEFAULT_ITEM_HEIGHT * 2; + +export type GridFitValue = 'contain' | 'fill'; +export type GridInsetValue = 'zero' | 'sm' | 'md' | 'lg'; + +export interface GridSectionLayoutOptions { + id?: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + +export interface VirtualGridItemEntry { + item: { + id: string; + props: any; + section?: GridSectionLayoutOptions; + }; + globalIdx: number; +} + +export interface VirtualGridGroup { + key?: string; + title?: string; + subtitle?: string; + section?: GridSectionLayoutOptions; + items: VirtualGridItemEntry[]; +} + +export interface ResolvedGridSectionLayout { + columns: number; + aspectRatio?: string; + fit: GridFitValue; + inset: GridInsetValue; + itemHeight: number; +} + +export type VirtualGridRow = + | { + kind: 'section'; + key: string; + sectionKey: string; + title?: string; + subtitle?: string; + top: number; + height: number; + } + | { + kind: 'items'; + key: string; + sectionKey: string; + top: number; + height: number; + itemHeight: number; + items: VirtualGridItemEntry[]; + layout: ResolvedGridSectionLayout; + }; + +export interface VirtualGridLayout { + rows: VirtualGridRow[]; + totalHeight: number; + itemCount: number; + itemPositions: Array<{ top: number; height: number } | undefined>; + itemColumns: Array; +} + +export interface BuildVirtualGridLayoutOptions { + defaultColumns?: number; + itemSize?: string; + defaultAspectRatio?: string; + defaultFit?: string; + defaultInset?: string; + containerWidth?: number; +} + +export interface VisibleRowsOptions { + scrollTop: number; + viewportHeight: number; + overscan?: number; +} + +export interface ItemScrollOptions { + currentScrollTop: number; + viewportHeight: number; + scrollPadding?: number; +} + +export function normalizeGridColumns(columns?: number, fallback = GRID_DEFAULT_COLUMNS, itemSize?: string): number { + const sizeColumns = itemSize === 'small' ? 8 : itemSize === 'large' ? 3 : undefined; + const explicitColumns = typeof columns === 'number' && Number.isFinite(columns) ? columns : undefined; + const candidate = explicitColumns ?? sizeColumns ?? fallback; + const normalized = Number.isFinite(candidate) ? Math.floor(candidate) : GRID_DEFAULT_COLUMNS; + return Math.max(GRID_MIN_COLUMNS, Math.min(GRID_MAX_COLUMNS, normalized)); +} + +export function normalizeGridFit(value?: string): GridFitValue { + return value === 'fill' ? 'fill' : 'contain'; +} + +export function normalizeGridInset(value?: string): GridInsetValue { + if (value === 'zero' || value === 'none') return 'zero'; + if (value === 'sm' || value === 'small') return 'sm'; + if (value === 'md' || value === 'medium') return 'md'; + if (value === 'lg' || value === 'large') return 'lg'; + return 'sm'; +} + +export function normalizeGridAspectRatio(value?: string): string | undefined { + if (!value) return undefined; + const ratio = parseGridAspectRatio(value); + return ratio > 0 ? value : undefined; +} + +export function parseGridAspectRatio(value?: string): number { + if (!value) return 0; + if (value.includes('/')) { + const [width, height] = value.split('/').map((part) => Number(part)); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width / height; + } + return 0; + } + + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +export function getGridItemHeight(columns: number, containerWidth?: number, aspectRatio?: string): number { + const ratio = parseGridAspectRatio(aspectRatio); + if (!ratio || !containerWidth || containerWidth <= 0) return GRID_DEFAULT_ITEM_HEIGHT; + + const safeColumns = normalizeGridColumns(columns); + const totalGap = GRID_ROW_GAP * Math.max(0, safeColumns - 1); + const itemWidth = Math.max(1, (containerWidth - totalGap) / safeColumns); + return Math.max(96, Math.ceil(itemWidth / ratio + GRID_ITEM_LABEL_HEIGHT)); +} + +export function resolveGridSectionLayout( + section: GridSectionLayoutOptions | undefined, + options: BuildVirtualGridLayoutOptions, +): ResolvedGridSectionLayout { + const columns = normalizeGridColumns(section?.columns, normalizeGridColumns(options.defaultColumns, GRID_DEFAULT_COLUMNS, options.itemSize)); + const aspectRatio = normalizeGridAspectRatio(section?.aspectRatio ?? options.defaultAspectRatio); + const fit = normalizeGridFit(section?.fit ?? options.defaultFit); + const inset = normalizeGridInset(section?.inset ?? options.defaultInset); + const itemHeight = getGridItemHeight(columns, options.containerWidth, aspectRatio); + + return { columns, aspectRatio, fit, inset, itemHeight }; +} + +export function buildVirtualGridLayout( + groups: VirtualGridGroup[], + options: BuildVirtualGridLayoutOptions = {}, +): VirtualGridLayout { + const rows: VirtualGridRow[] = []; + const itemPositions: Array<{ top: number; height: number } | undefined> = []; + const itemColumns: Array = []; + let top = 0; + let itemCount = 0; + + groups.forEach((group, groupIndex) => { + const section = group.section; + const sectionKey = group.key || section?.id || group.title || `group-${groupIndex}`; + const title = group.title ?? section?.title; + const subtitle = group.subtitle ?? section?.subtitle; + + if (title) { + rows.push({ + kind: 'section', + key: `${sectionKey}:header`, + sectionKey, + title, + subtitle, + top, + height: GRID_SECTION_HEADER_HEIGHT, + }); + top += GRID_SECTION_HEADER_HEIGHT; + } + + const layout = resolveGridSectionLayout(section, options); + for (let start = 0; start < group.items.length; start += layout.columns) { + const rowItems = group.items.slice(start, start + layout.columns); + rows.push({ + kind: 'items', + key: `${sectionKey}:items:${start}`, + sectionKey, + top, + height: layout.itemHeight, + itemHeight: layout.itemHeight, + items: rowItems, + layout, + }); + + for (const entry of rowItems) { + itemPositions[entry.globalIdx] = { top, height: layout.itemHeight }; + itemColumns[entry.globalIdx] = layout.columns; + } + + itemCount += rowItems.length; + top += layout.itemHeight; + if (start + layout.columns < group.items.length) top += GRID_ROW_GAP; + } + + if (title || group.items.length > 0) top += GRID_GROUP_GAP; + }); + + return { + rows, + totalHeight: top, + itemCount, + itemPositions, + itemColumns, + }; +} + +export function getVisibleVirtualRows(rows: VirtualGridRow[], options: VisibleRowsOptions): VirtualGridRow[] { + const overscan = options.overscan ?? GRID_DEFAULT_OVERSCAN; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const start = Math.max(0, options.scrollTop - overscan); + const end = options.scrollTop + viewportHeight + overscan; + + return rows.filter((row) => row.top + row.height >= start && row.top <= end); +} + +export function getScrollTopForItemIndex( + layout: Pick, + itemIndex: number, + options: ItemScrollOptions, +): number { + const position = layout.itemPositions[itemIndex]; + if (!position) return options.currentScrollTop; + + const scrollPadding = options.scrollPadding ?? GRID_ROW_GAP; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const currentTop = Math.max(0, options.currentScrollTop); + const currentBottom = currentTop + viewportHeight; + const itemTop = position.top; + const itemBottom = position.top + position.height; + + if (itemTop < currentTop + scrollPadding) { + return Math.max(0, itemTop - scrollPadding); + } + + if (itemBottom > currentBottom - scrollPadding) { + return Math.max(0, itemBottom - viewportHeight + scrollPadding); + } + + return currentTop; +} + +export function getColumnsForItemIndex( + layout: Pick, + itemIndex: number, + fallbackColumns = GRID_DEFAULT_COLUMNS, +): number { + return normalizeGridColumns(layout.itemColumns[itemIndex], fallbackColumns); +} + +export function countVirtualizedRowItems(rows: VirtualGridRow[]): number { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} diff --git a/src/renderer/src/raycast-api/grid-runtime.tsx b/src/renderer/src/raycast-api/grid-runtime.tsx index 0b30a849..58a4d733 100644 --- a/src/renderer/src/raycast-api/grid-runtime.tsx +++ b/src/renderer/src/raycast-api/grid-runtime.tsx @@ -10,6 +10,14 @@ import type { ExtractedAction } from './action-runtime'; import { transliterateForSearch } from '../utils/transliterate'; import { createGridItemsRuntime } from './grid-runtime-items'; import { groupGridItems, useGridRegistry } from './grid-runtime-hooks'; +import { + GRID_DEFAULT_COLUMNS, + buildVirtualGridLayout, + getColumnsForItemIndex, + getScrollTopForItemIndex, + getVisibleVirtualRows, + normalizeGridColumns, +} from './grid-runtime-virtualization'; import { useI18n } from '../i18n'; interface GridRuntimeDeps { @@ -53,6 +61,10 @@ export function createGridRuntime(deps: GridRuntimeDeps) { function GridComponent({ children, columns, + itemSize, + aspectRatio, + fit, + inset, isLoading, searchBarPlaceholder, onSearchTextChange, @@ -73,9 +85,37 @@ export function createGridRuntime(deps: GridRuntimeDeps) { const gridRef = useRef(null); const { pop } = useNavigation(); - const cols = columns || 5; + const rootColumns = useMemo(() => normalizeGridColumns(columns, GRID_DEFAULT_COLUMNS, itemSize), [columns, itemSize]); + const [gridViewport, setGridViewport] = useState({ scrollTop: 0, viewportHeight: 0, containerWidth: 0 }); const { registryAPI, allItems } = useGridRegistry(); + const measureGridViewport = useCallback(() => { + const node = gridRef.current; + if (!node) return; + + const nextViewport = { + scrollTop: node.scrollTop, + viewportHeight: node.clientHeight, + containerWidth: Math.max(0, node.clientWidth - 16), + }; + + setGridViewport((current) => ( + Math.abs(current.scrollTop - nextViewport.scrollTop) < 1 + && current.viewportHeight === nextViewport.viewportHeight + && current.containerWidth === nextViewport.containerWidth + ? current + : nextViewport + )); + }, []); + + const handleGridScroll = useCallback((event: React.UIEvent) => { + const node = event.currentTarget; + setGridViewport((current) => { + if (Math.abs(current.scrollTop - node.scrollTop) < 1) return current; + return { ...current, scrollTop: node.scrollTop }; + }); + }, []); + useEffect(() => { if (controlledSearch === undefined) return; setInternalSearch(controlledSearch); @@ -109,6 +149,26 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }); }, [allItems, filtering, internalSearch, onSearchTextChange]); + const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); + const virtualLayout = useMemo( + () => buildVirtualGridLayout(groupedItems, { + defaultColumns: rootColumns, + itemSize, + defaultAspectRatio: aspectRatio, + defaultFit: fit, + defaultInset: inset, + containerWidth: gridViewport.containerWidth, + }), + [aspectRatio, fit, gridViewport.containerWidth, groupedItems, inset, itemSize, rootColumns], + ); + const visibleRows = useMemo( + () => getVisibleVirtualRows(virtualLayout.rows, { + scrollTop: gridViewport.scrollTop, + viewportHeight: gridViewport.viewportHeight, + }), + [gridViewport.scrollTop, gridViewport.viewportHeight, virtualLayout.rows], + ); + const debounceRef = useRef | null>(null); const handleSearchChange = useCallback( (value: string) => { @@ -171,14 +231,16 @@ export function createGridRuntime(deps: GridRuntimeDeps) { if (event.key === 'ArrowRight') setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft') setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + cols, filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - cols, 0)); - else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); + else if (event.key === 'ArrowDown') { + setSelectedIdx((value) => Math.min(value + getColumnsForItemIndex(virtualLayout, value, rootColumns), filteredItems.length - 1)); + } else if (event.key === 'ArrowUp') { + setSelectedIdx((value) => Math.max(value - getColumnsForItemIndex(virtualLayout, value, rootColumns), 0)); + } else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; event.preventDefault(); }, - [cols, filteredItems.length, isMetaK, matchesShortcut, primaryAction, selectedActions, showActions], + [filteredItems.length, isMetaK, matchesShortcut, primaryAction, rootColumns, selectedActions, showActions, virtualLayout], ); useEffect(() => { @@ -192,21 +254,43 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }, [filteredItems.length, selectedIdx]); useEffect(() => { - gridRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }, [selectedIdx]); + const node = gridRef.current; + if (!node || filteredItems.length === 0) return; + + const nextScrollTop = getScrollTopForItemIndex(virtualLayout, selectedIdx, { + currentScrollTop: node.scrollTop, + viewportHeight: node.clientHeight || gridViewport.viewportHeight, + }); + if (Math.abs(nextScrollTop - node.scrollTop) < 1) return; + + node.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + requestAnimationFrame(measureGridViewport); + }, [filteredItems.length, gridViewport.viewportHeight, measureGridViewport, selectedIdx, virtualLayout]); useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { + measureGridViewport(); + const node = gridRef.current; + if (!node) return; + + const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measureGridViewport) : null; + resizeObserver?.observe(node); + window.addEventListener('resize', measureGridViewport); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', measureGridViewport); + }; + }, [measureGridViewport]); + useEffect(() => { if (onSelectionChange && filteredItems[selectedIdx]) { onSelectionChange(filteredItems[selectedIdx]?.props?.id || null); } }, [filteredItems, onSelectionChange, selectedIdx]); - const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); - return (
@@ -229,40 +313,64 @@ export function createGridRuntime(deps: GridRuntimeDeps) { {searchBarAccessory &&
{searchBarAccessory}
}
-
+
{isLoading && filteredItems.length === 0 ? (

{t('common.loading')}

) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}
} -
- {group.items.map(({ item, globalIdx }) => ( - setSelectedIdx(globalIdx)} - onActivate={() => { - setSelectedIdx(globalIdx); - inputRef.current?.focus(); - }} - onContextAction={(event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setSelectedIdx(globalIdx); - setShowActions(true); - }} - /> - ))} -
-
- )) +
+ {visibleRows.map((row) => ( + row.kind === 'section' ? ( +
+ {row.title} + {row.subtitle && ( + {row.subtitle} + )} +
+ ) : ( +
+ {row.items.map(({ item, globalIdx }) => ( + setSelectedIdx(globalIdx)} + onActivate={() => { + setSelectedIdx(globalIdx); + inputRef.current?.focus(); + }} + onContextAction={(event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedIdx(globalIdx); + setShowActions(true); + }} + /> + ))} +
+ ) + ))} +
)}
@@ -300,7 +408,7 @@ export function createGridRuntime(deps: GridRuntimeDeps) { ); } - const GridInset = { Small: 'small', Medium: 'medium', Large: 'large' } as const; + const GridInset = { Zero: 'zero', Small: 'sm', Medium: 'md', Large: 'lg' } as const; const GridItemSize = { Small: 'small', Medium: 'medium', Large: 'large' } as const; const GridFit = { Contain: 'contain', Fill: 'fill' } as const;