mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf: cache same-axis pane subtree weights during equalization (#19500)
* perf: cache same-axis pane subtree weights during equalization * test(pane-manager): prove cached equalize weights match the uncached walk --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { equalizePaneSplitSizes, findPaneChildren } from './pane-tree-equalization'
|
||||
|
||||
type Direction = 'vertical' | 'horizontal'
|
||||
|
||||
/** The pre-cache weight walk, as the differential oracle. */
|
||||
function referenceWeight(el: HTMLElement, direction: Direction): number {
|
||||
if (!el.classList.contains('pane-split')) {
|
||||
return 1
|
||||
}
|
||||
const own = el.classList.contains('is-horizontal') ? 'horizontal' : 'vertical'
|
||||
if (own !== direction) {
|
||||
return 1
|
||||
}
|
||||
return Math.max(
|
||||
1,
|
||||
findPaneChildren(el).reduce((sum, child) => sum + referenceWeight(child, direction), 0)
|
||||
)
|
||||
}
|
||||
|
||||
/** The pre-cache sweep, writing into a parallel attribute so both can be compared. */
|
||||
function referenceEqualize(el: HTMLElement): void {
|
||||
if (!el.classList.contains('pane-split')) {
|
||||
return
|
||||
}
|
||||
const direction: Direction = el.classList.contains('is-horizontal') ? 'horizontal' : 'vertical'
|
||||
const children = findPaneChildren(el)
|
||||
if (children.length >= 2) {
|
||||
for (const child of children) {
|
||||
child.dataset.expectedFlex = `${referenceWeight(child, direction)} 1 0%`
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
referenceEqualize(child)
|
||||
}
|
||||
}
|
||||
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
function makePane(): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'pane'
|
||||
return element
|
||||
}
|
||||
|
||||
function makeDivider(): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'pane-divider'
|
||||
return element
|
||||
}
|
||||
|
||||
/** Random split tree with mixed axes, 2-4 children per split, and dividers interleaved. */
|
||||
function makeTree(random: () => number, depth: number): HTMLElement {
|
||||
if (depth <= 0 || random() < 0.35) {
|
||||
return makePane()
|
||||
}
|
||||
const split = document.createElement('div')
|
||||
split.className = random() < 0.5 ? 'pane-split is-horizontal' : 'pane-split'
|
||||
const childCount = 2 + Math.floor(random() * 3)
|
||||
for (let i = 0; i < childCount; i += 1) {
|
||||
if (i > 0) {
|
||||
split.append(makeDivider())
|
||||
}
|
||||
split.append(makeTree(random, depth - 1))
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
function allElements(root: HTMLElement): HTMLElement[] {
|
||||
return [root, ...Array.from(root.querySelectorAll<HTMLElement>('.pane, .pane-split'))]
|
||||
}
|
||||
|
||||
it('produces the same flex on every element as the uncached weight walk', () => {
|
||||
let splitCases = 0
|
||||
for (let seed = 1; seed <= 400; seed += 1) {
|
||||
const random = makeRandom(seed)
|
||||
const root = makeTree(random, 5)
|
||||
referenceEqualize(root)
|
||||
equalizePaneSplitSizes(root)
|
||||
for (const element of allElements(root)) {
|
||||
expect(element.style.flex || undefined, `seed ${seed}`).toEqual(element.dataset.expectedFlex)
|
||||
if (element.dataset.expectedFlex) {
|
||||
splitCases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(splitCases).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
it('keeps every split weight equal to the integer sum of its same-axis children', () => {
|
||||
for (let seed = 1; seed <= 200; seed += 1) {
|
||||
const root = makeTree(makeRandom(seed), 5)
|
||||
equalizePaneSplitSizes(root)
|
||||
for (const split of allElements(root).filter((el) => el.classList.contains('pane-split'))) {
|
||||
const children = findPaneChildren(split)
|
||||
if (children.length < 2) {
|
||||
continue
|
||||
}
|
||||
const direction: Direction = split.classList.contains('is-horizontal')
|
||||
? 'horizontal'
|
||||
: 'vertical'
|
||||
const total = children.reduce((sum, child) => {
|
||||
const grow = Number(child.style.flex.split(' ')[0])
|
||||
// Integer weights only: no float drift can make the row fail to fill.
|
||||
expect(Number.isInteger(grow)).toBe(true)
|
||||
expect(grow).toBeGreaterThanOrEqual(1)
|
||||
return sum + grow
|
||||
}, 0)
|
||||
expect(total, `seed ${seed}`).toBe(referenceWeight(split, direction))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('recomputes weights after a split is added and after one is closed', () => {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'pane-split'
|
||||
const left = makePane()
|
||||
const right = makePane()
|
||||
root.append(left, makeDivider(), right)
|
||||
equalizePaneSplitSizes(root)
|
||||
expect(left.style.flex).toBe('1 1 0%')
|
||||
|
||||
// Split the right pane on the same axis: the parent must re-weight it to 2.
|
||||
const nested = document.createElement('div')
|
||||
nested.className = 'pane-split'
|
||||
nested.append(makePane(), makeDivider(), makePane())
|
||||
right.replaceWith(nested)
|
||||
expect(equalizePaneSplitSizes(root)).toBe(true)
|
||||
expect(nested.style.flex).toBe('2 1 0%')
|
||||
expect(left.style.flex).toBe('1 1 0%')
|
||||
|
||||
// Close one of the nested panes: the weight must drop back to 1.
|
||||
nested.lastElementChild!.remove()
|
||||
expect(equalizePaneSplitSizes(root)).toBe(true)
|
||||
expect(nested.style.flex).toBe('1 1 0%')
|
||||
|
||||
// A resize sweep with no structural change writes nothing.
|
||||
expect(equalizePaneSplitSizes(root)).toBe(false)
|
||||
})
|
||||
|
||||
it('weights a cross-axis nested split as one unit', () => {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'pane-split'
|
||||
const left = makePane()
|
||||
const crossAxis = document.createElement('div')
|
||||
crossAxis.className = 'pane-split is-horizontal'
|
||||
crossAxis.append(makePane(), makeDivider(), makePane(), makeDivider(), makePane())
|
||||
root.append(left, makeDivider(), crossAxis)
|
||||
equalizePaneSplitSizes(root)
|
||||
expect(crossAxis.style.flex).toBe('1 1 0%')
|
||||
expect(left.style.flex).toBe('1 1 0%')
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { equalizePaneSplitSizes } from './pane-tree-equalization'
|
||||
|
||||
it('computes nested same-axis pane weights once per split', () => {
|
||||
const pane = () => {
|
||||
const element = document.createElement('div')
|
||||
element.className = 'pane'
|
||||
return element
|
||||
}
|
||||
let root = pane()
|
||||
let reads = 0
|
||||
const children = Object.getOwnPropertyDescriptor(Element.prototype, 'children')!.get!
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const split = document.createElement('div')
|
||||
split.className = 'pane-split'
|
||||
split.append(pane(), root)
|
||||
Object.defineProperty(split, 'children', {
|
||||
get() {
|
||||
reads += 1
|
||||
return children.call(this)
|
||||
}
|
||||
})
|
||||
root = split
|
||||
}
|
||||
expect(equalizePaneSplitSizes(root)).toBe(true)
|
||||
expect(reads).toBeLessThan(500)
|
||||
expect((root.lastElementChild as HTMLElement).style.flex).toBe('200 1 0%')
|
||||
expect(equalizePaneSplitSizes(root)).toBe(false)
|
||||
})
|
||||
@@ -11,16 +11,29 @@ function getSplitDirection(split: HTMLElement): 'vertical' | 'horizontal' {
|
||||
return split.classList.contains('is-horizontal') ? 'horizontal' : 'vertical'
|
||||
}
|
||||
|
||||
function getEqualizeWeight(el: HTMLElement, direction: 'vertical' | 'horizontal'): number {
|
||||
function getEqualizeWeight(
|
||||
el: HTMLElement,
|
||||
direction: 'vertical' | 'horizontal',
|
||||
weights: Map<HTMLElement, number>
|
||||
): number {
|
||||
if (!el.classList.contains('pane-split') || getSplitDirection(el) !== direction) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Keyed by element alone: `direction` is always the parent split's axis, which the
|
||||
// tree position fixes, so one element is never asked for two different weights.
|
||||
// The map lives for one sweep, so a split/close/resize can never read a stale weight.
|
||||
const cached = weights.get(el)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const children = findPaneChildren(el)
|
||||
return Math.max(
|
||||
const weight = Math.max(
|
||||
1,
|
||||
children.reduce((sum, child) => sum + getEqualizeWeight(child, direction), 0)
|
||||
children.reduce((sum, child) => sum + getEqualizeWeight(child, direction, weights), 0)
|
||||
)
|
||||
weights.set(el, weight)
|
||||
return weight
|
||||
}
|
||||
|
||||
export function equalizePaneSplitSizes(root: HTMLElement | null): boolean {
|
||||
@@ -28,6 +41,7 @@ export function equalizePaneSplitSizes(root: HTMLElement | null): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const weights = new Map<HTMLElement, number>()
|
||||
let changed = false
|
||||
const visit = (el: HTMLElement): void => {
|
||||
if (!el.classList.contains('pane-split')) {
|
||||
@@ -40,7 +54,7 @@ export function equalizePaneSplitSizes(root: HTMLElement | null): boolean {
|
||||
for (const child of children) {
|
||||
// Why: same-axis nested splits need pane-count weighting so three
|
||||
// side-by-side panes become thirds, not 50/25/25.
|
||||
const weight = getEqualizeWeight(child, direction)
|
||||
const weight = getEqualizeWeight(child, direction, weights)
|
||||
const nextFlex = `${weight} 1 0%`
|
||||
if (child.style.flex !== nextFlex) {
|
||||
child.style.flex = nextFlex
|
||||
|
||||
Reference in New Issue
Block a user