diff --git a/src/models.ts b/src/models.ts index 79a5db8..151c3d4 100644 --- a/src/models.ts +++ b/src/models.ts @@ -22,12 +22,10 @@ export interface SplitPaneGutterOptions { color?: RGBA | string; } -export interface GutterOptions extends Pick { +export interface GutterOptions + extends Pick, SplitPaneGutterOptions { direction: SplitDirection; onGrab: (event: MouseEvent) => void; - visible?: boolean; - glyphs?: GutterGlyphs; - color?: RGBA | string; } export interface SplitPaneOptions extends BoxOptions { diff --git a/src/pane-dragdrop.ts b/src/pane-dragdrop.ts index ac742de..90e6ab7 100644 --- a/src/pane-dragdrop.ts +++ b/src/pane-dragdrop.ts @@ -7,11 +7,13 @@ import { } from "@opentui/core"; import type { PaneDragDrop, PaneDragDropOptions } from "./models.js"; import { + containsPoint, findPaneTarget, type PaneTarget, paneTargetAtPoint, reorderPane, swapPanes, + isVisibleInTree, } from "./pane-target.js"; // Reorder panes by dragging within a split or across splits to swap places. @@ -53,9 +55,6 @@ export function createPaneDragDrop( return index; }; - const containsPoint = (pane: BoxRenderable, x: number, y: number): boolean => - x >= pane.x && x < pane.x + pane.width && y >= pane.y && y < pane.y + pane.height; - const onDown = (event: MouseEvent): void => { active = null; if (event.button !== MouseButton.LEFT || !modifierHeld(event)) return; @@ -67,6 +66,7 @@ export function createPaneDragDrop( if (!active) return; const target = active; active = null; + if (!isVisibleInTree(target.pane) || target.pane.parent !== target.split) return; const drop = paneTargetAtPoint(root, event.x, event.y, isPane); if (drop) { @@ -79,17 +79,24 @@ export function createPaneDragDrop( } }; - root.onMouse = (event) => { - if (event.type === "down") onDown(event); - if (event.type === "up" || event.type === "drag-end") onDrop(event); + let disposed = false; + const processMouseEvent = root.processMouseEvent; + const listener = function (this: Renderable, event: MouseEvent): void { + if (!disposed) { + if (event.type === "down") onDown(event); + if (event.type === "up" || event.type === "drag-end") onDrop(event); + } + processMouseEvent.call(this, event); }; + root.processMouseEvent = listener; return { get dragging() { return active !== null; }, dispose: () => { - root.onMouse = undefined; + disposed = true; + if (root.processMouseEvent === listener) root.processMouseEvent = processMouseEvent; active = null; }, }; diff --git a/src/pane-navigator.ts b/src/pane-navigator.ts index d1d51e8..dbe82eb 100644 --- a/src/pane-navigator.ts +++ b/src/pane-navigator.ts @@ -6,6 +6,7 @@ import { type Renderable, } from "@opentui/core"; import type { PaneNavigator, PaneNavigatorOptions } from "./models.js"; +import { isVisible, isVisibleInTree } from "./pane-target.js"; function defaultKeymap(event: KeyEvent): "next" | "previous" | undefined { if (event.name !== "tab" || event.ctrl || event.meta || event.option) return; @@ -17,9 +18,10 @@ function collectPanes( isPane: (renderable: Renderable) => boolean, ): Renderable[] { const panes: Renderable[] = []; + if (!isVisibleInTree(root)) return panes; const visit = (node: BaseRenderable): void => { for (const child of node.getChildren()) { - if (isRenderable(child) && isPane(child)) panes.push(child); + if (isVisible(child) && isRenderable(child) && isPane(child)) panes.push(child); visit(child); } }; diff --git a/src/pane-reorder.ts b/src/pane-reorder.ts index e82852e..37bd6c3 100644 --- a/src/pane-reorder.ts +++ b/src/pane-reorder.ts @@ -1,5 +1,4 @@ -import type { CliRenderer, KeyEvent } from "@opentui/core"; -import type { BoxRenderable } from "@opentui/core"; +import type { BoxRenderable, CliRenderer, KeyEvent } from "@opentui/core"; import type { PaneMoveDirection, PaneReorder, diff --git a/src/pane-sizing.ts b/src/pane-sizing.ts new file mode 100644 index 0000000..f83d064 --- /dev/null +++ b/src/pane-sizing.ts @@ -0,0 +1,21 @@ +export function scaleSizes(sizes: number[], total: number): number[] { + if (sizes.length === 0) return []; + const sum = sizes.reduce((value, size) => value + size, 0); + const exact = sizes.map((size) => (sum > 0 ? (size * total) / sum : total / sizes.length)); + const scaled = exact.map(Math.floor); + const remainder = total - scaled.reduce((value, size) => value + size, 0); + const order = exact + .map((size, index) => ({ index, fraction: size - scaled[index]! })) + .sort((first, second) => second.fraction - first.fraction); + for (let index = 0; index < remainder; index++) scaled[order[index]!.index]!++; + return scaled; +} + +export function fitSizes(sizes: number[], total: number): number[] { + if (sizes.length === 0) return []; + const fitted = sizes.map(Math.round); + const fixedTotal = fitted.slice(0, -1).reduce((value, size) => value + size, 0); + if (fixedTotal > total) return scaleSizes(fitted, total); + fitted[fitted.length - 1] = total - fixedTotal; + return fitted; +} diff --git a/src/pane-target.ts b/src/pane-target.ts index e5d1cec..eac5aec 100644 --- a/src/pane-target.ts +++ b/src/pane-target.ts @@ -1,4 +1,4 @@ -import { BoxRenderable, isRenderable, type Renderable } from "@opentui/core"; +import { type BaseRenderable, BoxRenderable, isRenderable, type Renderable } from "@opentui/core"; import type { PaneMoveDirection, PanePredicate, @@ -12,11 +12,24 @@ export interface PaneTarget { pane: BoxRenderable; } +export function isVisible(node: BaseRenderable): boolean { + return node.visible && !(isRenderable(node) && node.isDestroyed); +} + +export function isVisibleInTree(node: BaseRenderable | null): boolean { + if (!node) return false; + for (let current: BaseRenderable | null = node; current; current = current.parent) { + if (!isVisible(current)) return false; + } + return true; +} + export function findPaneTarget( start: Renderable | null, root: Renderable, isPane: PanePredicate, ): PaneTarget | null { + if (!isVisibleInTree(start)) return null; let target: PaneTarget | null = null; for (let node = start; node; node = node.parent) { @@ -47,9 +60,12 @@ export function isLeafPane(pane: BoxRenderable): boolean { export function collectLeafPanes(root: Renderable, isPane: PanePredicate): BoxRenderable[] { const panes: BoxRenderable[] = []; + if (!isVisibleInTree(root)) return panes; const visit = (node: Renderable): void => { + if (!isVisible(node)) return; if (node instanceof SplitPaneRenderable) { for (const pane of node.paneList) { + if (!isVisible(pane)) continue; if (isLeafPane(pane)) { if (isPane(pane)) panes.push(pane); } else { @@ -64,7 +80,7 @@ export function collectLeafPanes(root: Renderable, isPane: PanePredicate): BoxRe return panes; } -function containsPoint(pane: BoxRenderable, x: number, y: number): boolean { +export function containsPoint(pane: BoxRenderable, x: number, y: number): boolean { return x >= pane.x && x < pane.x + pane.width && y >= pane.y && y < pane.y + pane.height; } @@ -74,9 +90,11 @@ export function paneTargetAtPoint( y: number, isPane: PanePredicate, ): PaneTarget | null { + if (!isVisibleInTree(root)) return null; const visitChildren = (node: Renderable): PaneTarget | null => { for (const child of node.getChildren()) { - if (!(child instanceof BoxRenderable) || !containsPoint(child, x, y)) continue; + if (!(child instanceof BoxRenderable) || !isVisible(child) || !containsPoint(child, x, y)) + continue; const target = visit(child); if (target) return target; } @@ -85,7 +103,7 @@ export function paneTargetAtPoint( const visit = (node: Renderable): PaneTarget | null => { if (node instanceof SplitPaneRenderable) { for (const pane of node.paneList) { - if (!containsPoint(pane, x, y)) continue; + if (!isVisible(pane) || !containsPoint(pane, x, y)) continue; if (pane instanceof SplitPaneRenderable) return visit(pane); return ( visitChildren(pane) ?? (isPane(pane) && isLeafPane(pane) ? { split: node, pane } : null) diff --git a/src/renderables/gutter.ts b/src/renderables/gutter.ts index e9baffb..b29415d 100644 --- a/src/renderables/gutter.ts +++ b/src/renderables/gutter.ts @@ -13,9 +13,9 @@ import type { GutterOptions, SplitDirection } from "../models.js"; export class GutterRenderable extends BoxRenderable { private grabbed = false; private hovered = false; - private readonly direction: SplitDirection; - private readonly glyph: string; - private readonly color: RGBA; + private direction: SplitDirection; + private glyph: string; + private color: RGBA; private gutterVisible: boolean; constructor(ctx: RenderContext, options: GutterOptions) { @@ -59,16 +59,27 @@ export class GutterRenderable extends BoxRenderable { }; } - get showHairline(): boolean { + get gutterVisibility(): boolean { return this.gutterVisible; } - set showHairline(value: boolean) { + set gutterVisibility(value: boolean) { if (value === this.gutterVisible) return; this.gutterVisible = value; this.requestRender(); } + updateAppearance( + options: Pick, + ): void { + const color = options.color === undefined ? DefaultGutterColor : parseColor(options.color); + this.direction = options.direction; + this.glyph = options.glyphs?.[options.direction] ?? DefaultGutterGlyphs[options.direction]; + this.color = color; + this.gutterVisible = options.visible ?? true; + this.requestRender(); + } + protected renderSelf(buffer: OptimizedBuffer): void { if (!this.gutterVisible) return; diff --git a/src/renderables/split-pane.ts b/src/renderables/split-pane.ts index 76eec7c..b0fc6b8 100644 --- a/src/renderables/split-pane.ts +++ b/src/renderables/split-pane.ts @@ -2,10 +2,13 @@ import { type BaseRenderable, BoxRenderable, type BoxOptions, + isRenderable, type MouseEvent, type RenderContext, + Yoga, } from "@opentui/core"; import { DefaultMinSize, DefaultPaneSize } from "../constants.js"; +import { fitSizes, scaleSizes } from "../pane-sizing.js"; import type { GutterOptions, SplitDirection, @@ -51,21 +54,6 @@ function resolveSize( return requested ?? current ?? pending ?? fallback; } -function scaleSizes(sizes: number[], total: number): number[] { - const previousTotal = sizes.reduce((sum, size) => sum + size, 0); - if (sizes.length === 0 || previousTotal <= 0) return sizes.map(() => 0); - - const exact = sizes.map((size) => (size * total) / previousTotal); - const scaled = exact.map(Math.floor); - const remainder = total - scaled.reduce((sum, size) => sum + size, 0); - const order = exact - .map((size, index) => ({ index, fraction: size - scaled[index]! })) - .sort((a, b) => b.fraction - a.fraction); - - for (let index = 0; index < remainder; index++) scaled[order[index]!.index]!++; - return scaled; -} - function containsNode(parent: BaseRenderable, child: BaseRenderable): boolean { for (let node = child.parent; node; node = node.parent) { if (node === parent) return true; @@ -97,7 +85,8 @@ export class SplitPaneRenderable extends BoxRenderable { private dragLeftBasis = 0; private dragRightBasis = 0; private isDestroying = false; - private lastLayoutSize: number; + private lastAvailable: number | undefined; + private allocationBasis: number[] = []; constructor(ctx: RenderContext, options: SplitPaneOptions) { const { @@ -131,7 +120,6 @@ export class SplitPaneRenderable extends BoxRenderable { this.userMouseUp = onMouseUp; this.userMouseDragEnd = onMouseDragEnd; this.setupDragHandling(); - this.lastLayoutSize = this.isHorizontal ? this.width : this.height; } get direction(): SplitDirection { @@ -142,7 +130,6 @@ export class SplitPaneRenderable extends BoxRenderable { const direction = validateDirection(value); if (direction === this._direction) return; this._direction = direction; - this.lastLayoutSize = this.isHorizontal ? this.width : this.height; this.flexDirection = direction === "horizontal" ? "row" : "column"; this.rebuildChildren(); } @@ -157,6 +144,7 @@ export class SplitPaneRenderable extends BoxRenderable { this.sizesState = this.panes.map( (_, index) => sizes[index] ?? this.sizesState[index] ?? DefaultPaneSize, ); + this.allocationBasis = [...this.sizesState]; this.applySizing(); this.requestRender(); } @@ -181,7 +169,8 @@ export class SplitPaneRenderable extends BoxRenderable { const size = validateGutterSize(value); if (size === this._gutterSize) return; this._gutterSize = size; - this.rebuildChildren(); + for (const gutter of this.gutters) this.writeExtent(gutter, size); + this.requestRender(); } set gutterOptions(options: SplitPaneGutterOptions | null | undefined) { @@ -192,7 +181,7 @@ export class SplitPaneRenderable extends BoxRenderable { this.gutterVisible = visible; this.gutterColor = color; this.gutterGlyphs = glyphs; - this.rebuildChildren(); + for (const gutter of this.gutters) gutter.updateAppearance(this.gutterAppearance()); } private hasGutterOptions( @@ -236,10 +225,6 @@ export class SplitPaneRenderable extends BoxRenderable { return this.isHorizontal ? event.x : event.y; } - private readExtent(pane: BoxRenderable): number { - return this.isHorizontal ? pane.width : pane.height; - } - private writeExtent(pane: BoxRenderable, size: number): void { if (this.isHorizontal) { pane.width = size; @@ -248,9 +233,24 @@ export class SplitPaneRenderable extends BoxRenderable { } } - protected onResize(width: number, height: number): void { - super.onResize(width, height); - this.handleLayoutResize(width, height); + override updateFromLayout(): void { + super.updateFromLayout(); + this.handleLayoutResize(); + } + + protected override _hasVisibleChildFilter(): boolean { + return this.sizesState.includes(0); + } + + protected override _getVisibleChildren(): number[] { + return this.getChildren() + .filter((child) => { + const paneIndex = this.panes.indexOf(child as BoxRenderable); + if (paneIndex >= 0 && this.sizesState[paneIndex] === 0) return false; + const layout = child.getLayoutNode().getComputedLayout(); + return layout.width > 0 && layout.height > 0; + }) + .map((child) => child.num); } protected createGutter(options: GutterOptions): GutterRenderable { @@ -304,10 +304,13 @@ export class SplitPaneRenderable extends BoxRenderable { private paneSize(index: number): number { const pane = this.panes[index]; - return pane ? this.readExtent(pane) : 0; + if (!pane) return 0; + const layout = pane.getLayoutNode().getComputedLayout(); + return this.isHorizontal ? layout.width : layout.height; } private applySizing(): void { + this.lastAvailable = undefined; const lastIndex = this.panes.length - 1; this.panes.forEach((pane, index) => { @@ -333,47 +336,60 @@ export class SplitPaneRenderable extends BoxRenderable { this.writeExtent(pane, this.sizesState[index] ?? 0); } - // Minimums constrain dragging, but the split may shrink below their sum. - private handleLayoutResize(width: number, height: number): void { - const next = this.isHorizontal ? width : height; - const prev = this.lastLayoutSize; - this.lastLayoutSize = next; - if (prev <= 0) return; - if (next === prev) return; + private availableSize(): number { + const node = this.getLayoutNode(); + const layout = node.getComputedLayout(); + const edges = this.isHorizontal + ? [Yoga.Edge.Left, Yoga.Edge.Right] + : [Yoga.Edge.Top, Yoga.Edge.Bottom]; + const insets = edges.reduce( + (total, edge) => total + node.getComputedPadding(edge) + node.getComputedBorder(edge), + 0, + ); + const content = Math.max( + 0, + Math.round((this.isHorizontal ? layout.width : layout.height) - insets), + ); + const gutterSize = + this.gutters.length > 0 + ? Math.min(this._gutterSize, Math.floor(content / this.gutters.length)) + : 0; + for (const gutter of this.gutters) this.writeExtent(gutter, gutterSize); + return Math.max(0, content - this.gutters.length * gutterSize); + } + + private fitInitialSizes(available: number): void { + if (available > 0) { + this.sizesState = fitSizes(this.sizesState, available); + this.allocationBasis = [...this.sizesState]; + } else { + this.sizesState = this.sizesState.map(() => 0); + } + } + private handleLayoutResize(): void { const lastIndex = this.panes.length - 1; if (lastIndex < 0) return; - - const gutterSpace = this.gutters.length * this._gutterSize; - const previousAvailable = Math.max(0, prev - gutterSpace); - const nextAvailable = Math.max(0, next - gutterSpace); - const previousSizes = this.sizesBeforeResize(previousAvailable, lastIndex); - const previousTotal = previousSizes.reduce((total, size) => total + size, 0); - if (previousTotal <= 0) return; - - this.sizesState = scaleSizes(previousSizes, nextAvailable); - + const next = this.availableSize(); + const previous = this.lastAvailable; + if (next === previous) return; + this.lastAvailable = next; + const before = this.sizesState; + if (previous === undefined) { + this.fitInitialSizes(next); + } else { + this.sizesState = scaleSizes(this.allocationBasis, next); + } for (let index = 0; index < lastIndex; index++) this.applyBasis(index); - this.resetActiveDragBasis(); - - if (lastIndex > 0) { + if (before.some((size, index) => size !== this.sizesState[index])) { queueMicrotask(() => { if (!this.isDestroyed) this.requestRender(); }); - this.resizeCallback?.([...this.sizesState]); + if (previous !== undefined) this.resizeCallback?.([...this.sizesState]); } } - private sizesBeforeResize(available: number, lastIndex: number): number[] { - const fixedTotal = this.panes - .slice(0, lastIndex) - .reduce((total, _, index) => total + this.paneSize(index), 0); - return this.panes.map((_, index) => - index === lastIndex ? Math.max(0, available - fixedTotal) : this.paneSize(index), - ); - } - private resetActiveDragBasis(): void { if (this.activeGutter < 0) return; this.dragLeftBasis = this.sizesState[this.activeGutter] ?? 0; @@ -398,6 +414,7 @@ export class SplitPaneRenderable extends BoxRenderable { this.sizesState[left] = newLeft; this.applyBasis(left); this.sizesState[right] = total - newLeft; + this.allocationBasis = [...this.sizesState]; if (right !== lastIndex) { this.applyBasis(right); } @@ -407,6 +424,7 @@ export class SplitPaneRenderable extends BoxRenderable { override add(obj: unknown, index?: number): number { if (this.isAuxiliaryChild(obj)) { + if (this.isDestroyed || (isRenderable(obj) && obj.isDestroyed)) return -1; this.auxiliaryChildren.set(obj, this.paneIndexAt(index)); return super.add(obj, index); } @@ -417,6 +435,7 @@ export class SplitPaneRenderable extends BoxRenderable { override insertBefore(obj: unknown, anchor?: unknown): number { if (this.isAuxiliaryChild(obj)) { + if (this.isDestroyed || (isRenderable(obj) && obj.isDestroyed)) return -1; const anchorIndex = this.getChildren().findIndex((child) => child === anchor); this.auxiliaryChildren.set(obj, this.paneIndexAt(anchorIndex < 0 ? undefined : anchorIndex)); return super.insertBefore(obj, anchor); @@ -449,18 +468,9 @@ export class SplitPaneRenderable extends BoxRenderable { throw new Error("SplitPaneRenderable: Cannot remove a generated gutter directly"); } - const paneIndex = this.panes.indexOf(child as BoxRenderable); - if (paneIndex < 0) { - super.remove(child); - return; - } - - this.panes.splice(paneIndex, 1); - this.sizesState.splice(paneIndex, 1); - this.minSizesState.splice(paneIndex, 1); - this.shiftAuxiliaryChildren(paneIndex, -1); + const detached = this.detachPane(child as BoxRenderable); super.remove(child); - this.rebuildChildren(); + if (detached) this.rebuildChildren(); } addPane(pane: BoxRenderable, size?: number, minSize?: number): void { @@ -485,9 +495,11 @@ export class SplitPaneRenderable extends BoxRenderable { if (flexibleSize > 0) this.sizesState[last] = flexibleSize; const [movedPane] = this.panes.splice(from, 1); const [movedSize] = this.sizesState.splice(from, 1); + const [movedBasis] = this.allocationBasis.splice(from, 1); const [movedMin] = this.minSizesState.splice(from, 1); this.panes.splice(to, 0, movedPane!); this.sizesState.splice(to, 0, movedSize!); + this.allocationBasis.splice(to, 0, movedBasis ?? movedSize!); this.minSizesState.splice(to, 0, movedMin!); this.arrangeChildren(); this.applySizing(); @@ -532,7 +544,16 @@ export class SplitPaneRenderable extends BoxRenderable { if (pane.parent === this) super.remove(pane); } + private canInsertPane(pane: BoxRenderable): boolean { + if (this.isDestroyed || pane.isDestroyed) return false; + if (pane === this || containsNode(pane, this)) { + throw new Error("SplitPaneRenderable: Cannot insert a pane into itself or its descendants"); + } + return true; + } + private insertPane(pane: BoxRenderable, index: number, size?: number, minSize?: number): number { + if (!this.canInsertPane(pane)) return -1; const requestedSize = validateOptionalSize("size", size); const requestedMin = validateOptionalSize("minSize", minSize); const current = this.detachPane(pane); @@ -547,6 +568,7 @@ export class SplitPaneRenderable extends BoxRenderable { 0, resolveSize(requestedSize, current?.size, this.pendingSizes[index], DefaultPaneSize), ); + this.allocationBasis.splice(index, 0, current?.basis ?? this.sizesState[index]!); this.minSizesState.splice( index, 0, @@ -557,17 +579,21 @@ export class SplitPaneRenderable extends BoxRenderable { return this.getChildren().indexOf(pane); } - private detachPane( - pane: BoxRenderable, - ): { index: number; size: number | undefined; minSize: number | undefined } | null { + private detachPane(pane: BoxRenderable): { + index: number; + size: number | undefined; + basis: number | undefined; + minSize: number | undefined; + } | null { const index = this.panes.indexOf(pane); if (index < 0) return null; const [size] = this.sizesState.splice(index, 1); + const [basis] = this.allocationBasis.splice(index, 1); const [minSize] = this.minSizesState.splice(index, 1); this.panes.splice(index, 1); this.shiftAuxiliaryChildren(index, -1); - return { index, size, minSize }; + return { index, size, basis, minSize }; } private shiftAuxiliaryChildren(paneIndex: number, delta: -1 | 1): void { @@ -608,33 +634,53 @@ export class SplitPaneRenderable extends BoxRenderable { this.gutters[this.activeGutter]?.release(); this.activeGutter = -1; - for (const gutter of this.gutters) { + while (this.gutters.length > Math.max(0, this.panes.length - 1)) { + const gutter = this.gutters.pop()!; if (gutter.parent === this) super.remove(gutter); gutter.destroy(); } - this.gutters = []; for (let gutterIndex = 0; gutterIndex < this.panes.length - 1; gutterIndex++) { - const gutter = this.createGutter({ - id: `${this.id}-gutter-${gutterIndex}`, - direction: this._direction, - width: this.isHorizontal ? this._gutterSize : "auto", - height: this.isHorizontal ? "auto" : this._gutterSize, - onGrab: (event) => this.grabGutter(gutterIndex, event), - visible: this.gutterVisible, - ...(this.gutterGlyphs !== undefined ? { glyphs: this.gutterGlyphs } : {}), - ...(this.gutterColor !== undefined ? { color: this.gutterColor } : {}), - }); - this.gutters.push(gutter); - super.add(gutter); + const gutter = this.reconcileGutter(gutterIndex); + if (gutter.parent !== this) super.add(gutter); } this.arrangeChildren(); this.applySizing(); } + private reconcileGutter(index: number): GutterRenderable { + const width = this.isHorizontal ? this._gutterSize : "auto"; + const height = this.isHorizontal ? "auto" : this._gutterSize; + const existing = this.gutters[index]; + if (existing) { + existing.width = width; + existing.height = height; + existing.updateAppearance(this.gutterAppearance()); + return existing; + } + const gutter = this.createGutter({ + id: `${this.id}-gutter-${index}`, + width, + height, + onGrab: (event) => this.grabGutter(index, event), + ...this.gutterAppearance(), + }); + this.gutters.push(gutter); + return gutter; + } + + private gutterAppearance(): Pick { + return { + direction: this._direction, + visible: this.gutterVisible, + ...(this.gutterGlyphs !== undefined ? { glyphs: this.gutterGlyphs } : {}), + ...(this.gutterColor !== undefined ? { color: this.gutterColor } : {}), + }; + } + setGutterVisible(visible: boolean): void { this.gutterVisible = visible; - for (const gutter of this.gutters) gutter.showHairline = visible; + for (const gutter of this.gutters) gutter.gutterVisibility = visible; } override destroyRecursively(): void { diff --git a/tests/pane-movement.test.ts b/tests/pane-movement.test.ts index a7abc31..433539a 100644 --- a/tests/pane-movement.test.ts +++ b/tests/pane-movement.test.ts @@ -69,7 +69,11 @@ describe("pane movement", () => { panes[0]!.add(new TextRenderable(setup.renderer, { content: "widget" })); const moves: Array<[number, number]> = []; let downs = 0; + let genericDowns = 0; setup.renderer.root.onMouseDown = () => downs++; + setup.renderer.root.onMouse = (event) => { + if (event.type === "down") genericDowns++; + }; const controller = createPaneMovement(setup.renderer, { onReorder: (_split, from, to) => moves.push([from, to]), }); @@ -86,12 +90,16 @@ describe("pane movement", () => { ); await drag(); await drag(CTRL); + setup.renderer.root.onMouse = (event) => { + if (event.type === "down") genericDowns += 10; + }; controller.dispose(); await drag(CTRL); expect(ids(split)).toEqual(["b", "c", "a"]); expect(moves).toEqual([[0, 2]]); expect(downs).toBe(3); + expect(genericDowns).toBe(12); }); test("drag inserts beside the target and cancels outside the split", async () => { @@ -232,4 +240,16 @@ describe("pane movement", () => { expect(setup.renderer.currentFocusedRenderable).toBe(panes.a); expect(swaps).toEqual([["a", "c"]]); }); + + test("movement excludes hidden ancestors and roots", async () => { + const { right, panes } = await buildNested(); + const controller = createPaneMovement(setup.renderer, { keymap: false }); + right.visible = false; + panes.a.focus(); + expect(controller.move("right")).toBe(false); + panes.c.focus(); + expect(controller.move("down")).toBe(false); + expect(ids(right)).toEqual(["c", "d"]); + controller.dispose(); + }); }); diff --git a/tests/pane-navigator.test.ts b/tests/pane-navigator.test.ts index 5d4046d..b27aaf5 100644 --- a/tests/pane-navigator.test.ts +++ b/tests/pane-navigator.test.ts @@ -83,4 +83,22 @@ describe("createPaneNavigator", () => { setup.mockInput.pressKey("l", { ctrl: true }); expect(navigator.current).toBe(editor); }); + + test("excludes hidden panes, ancestors, and roots", () => { + const visible = addPane("visible"); + const hidden = addPane("hidden"); + const child = new BoxRenderable(setup.renderer, { id: "child", focusable: true }); + + hidden.add(child); + hidden.visible = false; + + const navigator = createPaneNavigator(setup.renderer, { keymap: false }); + expect(navigator.panes).toEqual([visible]); + + const scoped = createPaneNavigator(setup.renderer, { root: hidden, keymap: false }); + expect(scoped.panes).toEqual([]); + + navigator.focusNext(); + expect(navigator.current).toBe(visible); + }); }); diff --git a/tests/react-children.test.tsx b/tests/react-children.test.tsx index 4ff53f5..cce11af 100644 --- a/tests/react-children.test.tsx +++ b/tests/react-children.test.tsx @@ -60,7 +60,7 @@ test("React applies split options and updates them declaratively", async () => { [first, gutter, second] = split.getChildren(); expect([first!.height, gutter!.height, second!.height]).toEqual([15, 2, 43]); expect([first!.width, second!.width]).toEqual([60, 60]); - expect((gutter as GutterRenderable).showHairline).toBe(false); + expect((gutter as GutterRenderable).gutterVisibility).toBe(false); split.height = 100; setup!.renderer.start(); diff --git a/tests/solid-children.solid.tsx b/tests/solid-children.solid.tsx index c83b96b..8380309 100644 --- a/tests/solid-children.solid.tsx +++ b/tests/solid-children.solid.tsx @@ -60,7 +60,7 @@ test("Solid applies split options and updates children declaratively", async () expect( updated .filter((child) => child instanceof GutterRenderable) - .every((gutter) => !gutter.showHairline), + .every((gutter) => !gutter.gutterVisibility), ).toBe(true); setup.renderer.destroy(); }); diff --git a/tests/split-pane.test.ts b/tests/split-pane.test.ts index 171739c..efcb543 100644 --- a/tests/split-pane.test.ts +++ b/tests/split-pane.test.ts @@ -222,7 +222,7 @@ describe("SplitPaneRenderable", () => { "second", ]); const gutters = split.getChildren().filter((child) => child instanceof GutterRenderable); - expect(gutters.every((gutter) => gutter.showHairline)).toBe(true); + expect(gutters.every((gutter) => gutter.gutterVisibility)).toBe(true); split.remove(middle); await setup.renderOnce(); @@ -253,6 +253,58 @@ describe("SplitPaneRenderable", () => { text.destroy(); }); + test("fits sizes with insets and restores collapsed proportions", async () => { + const render = () => setup.renderOnce().then(() => setup.renderOnce()); + for (const options of [{}, { border: true }, { paddingLeft: 2, paddingRight: 2 }]) { + const split = new SplitPaneRenderable(setup.renderer, { + id: "bounded", + width: 50, + height: 10, + ...options, + }); + split.addPane(new BoxRenderable(setup.renderer, { id: "first" }), 20); + split.addPane(new BoxRenderable(setup.renderer, { id: "second" }), 29); + setup.renderer.root.add(split); + await setup.renderOnce(); + const original = split.sizes; + for (const width of [100, 1, 0, 50]) { + split.width = width; + await render(); + expect(split.sizes).toEqual( + split.paneList.map((pane) => pane.getLayoutNode().getComputedLayout().width), + ); + } + expect(split.sizes).toEqual(original); + split.destroyRecursively(); + } + const small = new SplitPaneRenderable(setup.renderer, { id: "small", width: 10, height: 10 }); + small.addPane(new BoxRenderable(setup.renderer, { id: "small-first" })); + small.addPane(new BoxRenderable(setup.renderer, { id: "small-second" })); + setup.renderer.root.add(small); + await render(); + expect(childrenFit(small)).toBe(true); + small.width = 0; + await setup.renderOnce(); + small.addPane(new BoxRenderable(setup.renderer, { id: "small-third" }), 10); + await setup.renderOnce(); + small.width = 12; + await render(); + expect(small.sizes).toEqual([3, 2, 5]); + }); + + test("rejects destroyed and cyclic children without changing pane ownership", () => { + const split = new SplitPaneRenderable(setup.renderer, { id: "split" }); + const dead = new BoxRenderable(setup.renderer, { id: "dead" }); + dead.destroy(); + + expect(split.add(dead)).toBe(-1); + expect(() => split.add(split)).toThrow("Cannot insert a pane"); + expect(split.paneList).toEqual([]); + expect(split.getChildren()).toEqual([]); + + split.destroyRecursively(); + }); + test("rejects invalid pane and gutter sizes", () => { expect( () => @@ -286,7 +338,7 @@ describe("SplitPaneRenderable", () => { await setup.renderOnce(); const gutter = split.getChildren().find((child) => child instanceof GutterRenderable); - expect((gutter as GutterRenderable).showHairline).toBe(false); + expect((gutter as GutterRenderable).gutterVisibility).toBe(false); // Dragging the (invisible) gutter still conserves size. await setup.mockMouse.drag(40, 5, 50, 5, MouseButtons.LEFT); @@ -347,13 +399,13 @@ describe("SplitPaneRenderable", () => { .getChildren() .filter((child) => child instanceof GutterRenderable) as GutterRenderable[]; expect(gutters).toHaveLength(2); - expect(gutters.every((gutter) => !gutter.showHairline)).toBe(true); + expect(gutters.every((gutter) => !gutter.gutterVisibility)).toBe(true); split.setGutterVisible(true); - expect(gutters.every((gutter) => gutter.showHairline)).toBe(true); + expect(gutters.every((gutter) => gutter.gutterVisibility)).toBe(true); }); - test("rebuilds gutters when gutterSize changes", async () => { + test("reuses gutters when gutter size or appearance changes", async () => { const split = new SplitPaneRenderable(setup.renderer, { id: "split", width: 60, @@ -364,11 +416,15 @@ describe("SplitPaneRenderable", () => { setup.renderer.root.add(split); await setup.renderOnce(); + const original = split.getChildren()[1]; split.gutterSize = 3; + split.gutterOptions = { color: "red", visible: false }; await setup.renderOnce(); const gutter = split.getChildren()[1]; expect(gutter).toBeInstanceOf(GutterRenderable); + expect(gutter).toBe(original); expect(gutter!.width).toBe(3); + expect((gutter as GutterRenderable).gutterVisibility).toBe(false); }); });