Skip to content

Commit f790710

Browse files
committed
Brief mode: Variable-width virtual-scroll math
- New `calculateVirtualWindowVariable` + `getScrollToPositionVariable` in `virtual-scroll.ts`, accepting a per-item-width prefix-sum array. Uniform versions remain for FullList. - Comprehensive unit tests covering edge boundaries, buffer math, and the off-by-buffer bug fixed during plan review. - Wired up in commit 3.
1 parent d84d5c2 commit f790710

2 files changed

Lines changed: 354 additions & 1 deletion

File tree

apps/desktop/src/lib/file-explorer/views/virtual-scroll.test.ts

Lines changed: 242 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,23 @@
22
* Tests for virtual-scroll.ts
33
*/
44
import { describe, it, expect } from 'vitest'
5-
import { calculateVirtualWindow, getScrollToPosition, type VirtualScrollConfig } from './virtual-scroll'
5+
import {
6+
calculateVirtualWindow,
7+
calculateVirtualWindowVariable,
8+
getScrollToPosition,
9+
getScrollToPositionVariable,
10+
type VirtualScrollConfig,
11+
} from './virtual-scroll'
12+
13+
/** Helper: build a prefix-sum array from per-item widths. */
14+
function prefixSumsFrom(widths: number[]): number[] {
15+
const sums = new Array<number>(widths.length + 1)
16+
sums[0] = 0
17+
for (let i = 0; i < widths.length; i++) {
18+
sums[i + 1] = sums[i] + widths[i]
19+
}
20+
return sums
21+
}
622

723
describe('calculateVirtualWindow', () => {
824
const baseConfig: VirtualScrollConfig = {
@@ -273,3 +289,228 @@ describe('getScrollToPosition', () => {
273289
})
274290
})
275291
})
292+
293+
describe('calculateVirtualWindowVariable', () => {
294+
describe('basic calculations', () => {
295+
it('returns all zeros for empty widths', () => {
296+
const result = calculateVirtualWindowVariable([0], 5, 600, 0, 0)
297+
expect(result.startIndex).toBe(0)
298+
expect(result.endIndex).toBe(0)
299+
expect(result.visibleCount).toBe(0)
300+
expect(result.totalSize).toBe(0)
301+
expect(result.offset).toBe(0)
302+
})
303+
304+
it('handles a single column wider than the container', () => {
305+
// One column 800px wide in a 600px viewport, scrolled to 0.
306+
const widths = [800]
307+
const prefixSums = prefixSumsFrom(widths) // [0, 800]
308+
const result = calculateVirtualWindowVariable(prefixSums, 0, 600, 0, widths.length)
309+
expect(result.startIndex).toBe(0)
310+
expect(result.endIndex).toBe(1)
311+
expect(result.visibleCount).toBe(1)
312+
expect(result.totalSize).toBe(800)
313+
expect(result.offset).toBe(0)
314+
})
315+
316+
it('handles a single column wider than the container, scrolled mid-column', () => {
317+
const widths = [800]
318+
const prefixSums = prefixSumsFrom(widths)
319+
// Scrolled to 200 — the (only) column still starts at 0 (off-left), so it's the first visible.
320+
const result = calculateVirtualWindowVariable(prefixSums, 0, 600, 200, widths.length)
321+
expect(result.startIndex).toBe(0)
322+
expect(result.endIndex).toBe(1)
323+
expect(result.offset).toBe(0)
324+
})
325+
326+
it('finds the correct range for many small columns scrolled to the middle', () => {
327+
// 20 columns of 100px each. Container 300px, scrolled to 1000 (column 10 starts at 1000).
328+
const widths = new Array<number>(20).fill(100)
329+
const prefixSums = prefixSumsFrom(widths)
330+
const result = calculateVirtualWindowVariable(prefixSums, 0, 300, 1000, widths.length)
331+
expect(result.startIndex).toBe(10)
332+
expect(result.endIndex).toBe(13) // 1000 + 300 = 1300; column 13 starts at 1300
333+
expect(result.totalSize).toBe(2000)
334+
expect(result.offset).toBe(prefixSums[result.startIndex])
335+
})
336+
337+
it('worked example from plan review round 3', () => {
338+
// prefixSums=[0,100,200,350,500,700], scrollLeft=150, containerWidth=300, buffer=0
339+
// → startIndex=1 (col 1 starts at 100), endIndex=4 (col 3 ends at 500, intersects 150..450).
340+
const prefixSums = [0, 100, 200, 350, 500, 700]
341+
const result = calculateVirtualWindowVariable(prefixSums, 0, 300, 150, 5)
342+
expect(result.startIndex).toBe(1)
343+
expect(result.endIndex).toBe(4)
344+
expect(result.visibleCount).toBe(3)
345+
expect(result.totalSize).toBe(700)
346+
expect(result.offset).toBe(100)
347+
})
348+
})
349+
350+
describe('boundaries', () => {
351+
it('treats an item whose right edge exactly equals viewport right as fully visible', () => {
352+
// 4 columns × 100px in a 200px viewport, scrolled to 100. prefixSums = [0,100,200,300,400].
353+
// viewportEnd = 300 = prefixSums[3]. The loop stops at j=3 because prefixSums[3] >= 300.
354+
// So columns 1 and 2 are visible (endIndex=3), not column 3.
355+
const widths = [100, 100, 100, 100]
356+
const prefixSums = prefixSumsFrom(widths)
357+
const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 100, widths.length)
358+
expect(result.startIndex).toBe(1)
359+
expect(result.endIndex).toBe(3)
360+
expect(result.offset).toBe(100)
361+
})
362+
363+
it('treats an item whose left edge exactly equals viewport left as the first visible', () => {
364+
// Scroll to 200, the boundary aligns with column 2's left edge. Column 2 is the first visible.
365+
const widths = [100, 100, 100, 100]
366+
const prefixSums = prefixSumsFrom(widths) // [0,100,200,300,400]
367+
const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 200, widths.length)
368+
expect(result.startIndex).toBe(2)
369+
expect(result.endIndex).toBe(4)
370+
expect(result.offset).toBe(200)
371+
})
372+
373+
it('handles totalSize correctly when scrolled to the far right', () => {
374+
const widths = [100, 100, 100, 100, 100]
375+
const prefixSums = prefixSumsFrom(widths) // total 500
376+
const result = calculateVirtualWindowVariable(prefixSums, 0, 200, 300, widths.length)
377+
// viewport 300..500 → first visible is column 3, walk to end of list
378+
expect(result.startIndex).toBe(3)
379+
expect(result.endIndex).toBe(5)
380+
expect(result.totalSize).toBe(500)
381+
})
382+
})
383+
384+
describe('buffer', () => {
385+
it('applies buffer symmetrically in the middle of the list', () => {
386+
// 20 columns × 100px, container 300px, scrolled to 1000 (column 10 start), buffer 2.
387+
// Without buffer: start=10, end=13. With buffer 2: start=8, end=15.
388+
const widths = new Array<number>(20).fill(100)
389+
const prefixSums = prefixSumsFrom(widths)
390+
const result = calculateVirtualWindowVariable(prefixSums, 2, 300, 1000, widths.length)
391+
expect(result.startIndex).toBe(8)
392+
expect(result.endIndex).toBe(15)
393+
expect(result.visibleCount).toBe(7)
394+
})
395+
396+
it("doesn't shrink the right buffer when the left buffer clamps at 0 (off-by-buffer guard)", () => {
397+
// 20 columns × 100px, container 300px, scrolled to 0, buffer 5.
398+
// Without the guard, a naive `endIndex = startIndex + visibleCount + 2 * bufferSize` style
399+
// would lose the 5 left-buffer slots and end at firstVisible + viewportColumns + 5 = 3 + 5 = 8.
400+
// With the correct math, startIndex = max(0, 0 - 5) = 0, but endIndex still gets the full
401+
// bufferSize=5 on the right: lastVisibleEnd=3 (300/100), endIndex = min(20, 3 + 5) = 8.
402+
// (Note: in this case the visible end happens to match — the bug only shows up when
403+
// the naive formula tries to "compensate" or when bufferSize is large enough that the
404+
// *right* edge gets clipped because the left clamp ate the buffer. See next test.)
405+
const widths = new Array<number>(20).fill(100)
406+
const prefixSums = prefixSumsFrom(widths)
407+
const result = calculateVirtualWindowVariable(prefixSums, 5, 300, 0, widths.length)
408+
expect(result.startIndex).toBe(0)
409+
expect(result.endIndex).toBe(8) // lastVisibleEnd=3, +5 buffer
410+
})
411+
412+
it("doesn't shrink the left buffer when the right buffer clamps at totalItems (off-by-buffer guard, mirrored)", () => {
413+
// 20 columns × 100px, container 300px, scrolled to 1700 (last 3 columns visible), buffer 5.
414+
// firstVisibleIndex = 17, lastVisibleEnd = 20 (clamped by totalItems).
415+
// startIndex = max(0, 17 - 5) = 12, endIndex = min(20, 20 + 5) = 20.
416+
// A naive formula tying end-buffer to start-buffer would shrink one when the other clamps.
417+
const widths = new Array<number>(20).fill(100)
418+
const prefixSums = prefixSumsFrom(widths)
419+
const result = calculateVirtualWindowVariable(prefixSums, 5, 300, 1700, widths.length)
420+
expect(result.startIndex).toBe(12)
421+
expect(result.endIndex).toBe(20)
422+
expect(result.visibleCount).toBe(8)
423+
})
424+
425+
it('buffer larger than available room clamps both ends independently', () => {
426+
// 5 columns × 100px, container 200px, scrolled to 0, buffer 100.
427+
// startIndex = max(0, 0 - 100) = 0
428+
// lastVisibleEnd = 2, endIndex = min(5, 2 + 100) = 5
429+
const widths = [100, 100, 100, 100, 100]
430+
const prefixSums = prefixSumsFrom(widths)
431+
const result = calculateVirtualWindowVariable(prefixSums, 100, 200, 0, widths.length)
432+
expect(result.startIndex).toBe(0)
433+
expect(result.endIndex).toBe(5)
434+
expect(result.visibleCount).toBe(5)
435+
})
436+
})
437+
438+
describe('invariants', () => {
439+
it('throws when prefixSums length does not match totalItems + 1', () => {
440+
expect(() => calculateVirtualWindowVariable([0, 100, 200], 0, 100, 0, 5)).toThrow(/prefixSums.length/)
441+
})
442+
443+
it('accepts the empty case (totalItems=0, prefixSums=[0])', () => {
444+
// Mirror of the empty test above, but explicit about the invariant boundary.
445+
expect(() => calculateVirtualWindowVariable([0], 0, 100, 0, 0)).not.toThrow()
446+
})
447+
})
448+
})
449+
450+
describe('getScrollToPositionVariable', () => {
451+
const widths = [100, 150, 200, 50, 100] // total 600
452+
const prefixSums = prefixSumsFrom(widths) // [0, 100, 250, 450, 500, 600]
453+
const containerSize = 300
454+
455+
describe('item is visible', () => {
456+
it('returns undefined when item is fully inside the viewport', () => {
457+
// Viewport 100..400. Item 1 spans 100..250 — fully visible.
458+
const result = getScrollToPositionVariable(prefixSums, 1, 100, containerSize)
459+
expect(result).toBeUndefined()
460+
})
461+
462+
it('returns undefined when item left edge exactly equals viewport left edge', () => {
463+
// Viewport 100..400. Item 1 starts at 100.
464+
const result = getScrollToPositionVariable(prefixSums, 1, 100, containerSize)
465+
expect(result).toBeUndefined()
466+
})
467+
468+
it('returns undefined when item right edge exactly equals viewport right edge', () => {
469+
// Item 2 ends at 450. Viewport ending at 450 = scrollOffset 150.
470+
const result = getScrollToPositionVariable(prefixSums, 2, 150, containerSize)
471+
expect(result).toBeUndefined()
472+
})
473+
})
474+
475+
describe('item is off-left', () => {
476+
it("returns the item's left edge X when off-left", () => {
477+
// Viewport 200..500. Item 0 spans 0..100 — off-left. Want to scroll to 0.
478+
const result = getScrollToPositionVariable(prefixSums, 0, 200, containerSize)
479+
expect(result).toBe(0)
480+
})
481+
482+
it('returns prefixSums[index] when item starts before scrollOffset', () => {
483+
// Viewport 300..600. Item 1 starts at 100, off-left. Scroll target = 100.
484+
const result = getScrollToPositionVariable(prefixSums, 1, 300, containerSize)
485+
expect(result).toBe(100)
486+
})
487+
})
488+
489+
describe('item is off-right', () => {
490+
it('returns right − containerSize when item is off-right', () => {
491+
// Viewport 0..300. Item 3 spans 450..500 — off-right. Scroll target = 500 - 300 = 200.
492+
const result = getScrollToPositionVariable(prefixSums, 3, 0, containerSize)
493+
expect(result).toBe(200)
494+
})
495+
496+
it('scrolls to fit the last item at the right edge', () => {
497+
// Viewport 0..300. Item 4 spans 500..600. Scroll = 600 - 300 = 300.
498+
const result = getScrollToPositionVariable(prefixSums, 4, 0, containerSize)
499+
expect(result).toBe(300)
500+
})
501+
})
502+
503+
describe('invariants', () => {
504+
it('throws on negative index', () => {
505+
expect(() => getScrollToPositionVariable(prefixSums, -1, 0, containerSize)).toThrow(/out of range/)
506+
})
507+
508+
it('throws when index equals totalItems', () => {
509+
expect(() => getScrollToPositionVariable(prefixSums, 5, 0, containerSize)).toThrow(/out of range/)
510+
})
511+
512+
it('throws when index exceeds totalItems', () => {
513+
expect(() => getScrollToPositionVariable(prefixSums, 100, 0, containerSize)).toThrow(/out of range/)
514+
})
515+
})
516+
})

apps/desktop/src/lib/file-explorer/views/virtual-scroll.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,115 @@ export function getScrollToPosition(
9393
// Item is already visible
9494
return undefined
9595
}
96+
97+
/**
98+
* Variable-size variant of `calculateVirtualWindow`.
99+
*
100+
* Where the uniform version derives positions from `index * itemSize`, this
101+
* variant takes a `prefixSums` array of length `totalItems + 1` where
102+
* `prefixSums[i]` is the cumulative size of items `[0..i)`. That makes
103+
* `prefixSums[0] = 0` and `prefixSums[totalItems]` the total content size.
104+
*
105+
* Used by BriefList's shrink-wrapped columns where each column has its own
106+
* measured width. FullList still uses the uniform `calculateVirtualWindow`.
107+
*/
108+
export function calculateVirtualWindowVariable(
109+
prefixSums: number[],
110+
bufferSize: number,
111+
containerSize: number,
112+
scrollOffset: number,
113+
totalItems: number,
114+
): VirtualWindow {
115+
if (prefixSums.length !== totalItems + 1) {
116+
throw new Error(
117+
`calculateVirtualWindowVariable: prefixSums.length (${prefixSums.length}) must equal totalItems + 1 (${totalItems + 1})`,
118+
)
119+
}
120+
121+
if (totalItems === 0) {
122+
return {
123+
startIndex: 0,
124+
endIndex: 0,
125+
visibleCount: 0,
126+
totalSize: 0,
127+
offset: 0,
128+
}
129+
}
130+
131+
const totalSize = prefixSums[totalItems]
132+
133+
// Binary search for the largest i in [0, totalItems) such that prefixSums[i] <= scrollOffset.
134+
// That's the first column whose left edge is at or before the viewport's left edge — i.e. the
135+
// first visible column (it may extend right into the viewport even if it starts before it).
136+
let lo = 0
137+
let hi = totalItems
138+
while (lo < hi) {
139+
const mid = (lo + hi + 1) >>> 1
140+
if (prefixSums[mid] <= scrollOffset) {
141+
lo = mid
142+
} else {
143+
hi = mid - 1
144+
}
145+
}
146+
const firstVisibleIndex = lo
147+
148+
// Walk forward from firstVisibleIndex to find the smallest j >= firstVisibleIndex such that
149+
// prefixSums[j] >= scrollOffset + containerSize. That's the exclusive end of the visible range.
150+
// For typical layouts (a few visible columns), linear walk is faster than binary search.
151+
const viewportEnd = scrollOffset + containerSize
152+
let lastVisibleEnd = firstVisibleIndex
153+
while (lastVisibleEnd < totalItems && prefixSums[lastVisibleEnd] < viewportEnd) {
154+
lastVisibleEnd++
155+
}
156+
157+
// Apply buffer on both sides — clamp independently so the right-edge buffer expansion is not
158+
// limited by the (possibly clamped) left-edge buffer. This is the off-by-buffer bug guard:
159+
// computing end as `startIndex + visibleCount + 2 * bufferSize` would underestimate the right
160+
// edge whenever `firstVisibleIndex - bufferSize` clamps to 0, because the "lost" left buffer
161+
// would shrink the right buffer too.
162+
const startIndex = Math.max(0, firstVisibleIndex - bufferSize)
163+
const endIndex = Math.min(totalItems, lastVisibleEnd + bufferSize)
164+
165+
return {
166+
startIndex,
167+
endIndex,
168+
visibleCount: endIndex - startIndex,
169+
totalSize,
170+
offset: prefixSums[startIndex],
171+
}
172+
}
173+
174+
/**
175+
* Variable-size variant of `getScrollToPosition`.
176+
*
177+
* Returns the scroll offset that brings item `index` into view, or `undefined` if it's already
178+
* fully visible. Uses `prefixSums[index]` for the left edge and `prefixSums[index + 1]` for the
179+
* right edge.
180+
*/
181+
export function getScrollToPositionVariable(
182+
prefixSums: number[],
183+
index: number,
184+
scrollOffset: number,
185+
containerSize: number,
186+
): number | undefined {
187+
if (index < 0 || index >= prefixSums.length - 1) {
188+
throw new Error(`getScrollToPositionVariable: index ${index} out of range [0, ${prefixSums.length - 1})`)
189+
}
190+
191+
const itemLeft = prefixSums[index]
192+
const itemRight = prefixSums[index + 1]
193+
const viewportRight = scrollOffset + containerSize
194+
195+
if (itemLeft < scrollOffset) {
196+
// Item is off-left - scroll so its left edge aligns with the viewport's left edge.
197+
return itemLeft
198+
}
199+
200+
if (itemRight > viewportRight) {
201+
// Item is off-right - scroll so its right edge aligns with the viewport's right edge.
202+
return itemRight - containerSize
203+
}
204+
205+
// Item is fully visible.
206+
return undefined
207+
}

0 commit comments

Comments
 (0)