mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
272 lines
8.0 KiB
TypeScript
272 lines
8.0 KiB
TypeScript
import type { MosaicNode } from "react-mosaic-component";
|
|
import type { LayoutConfig } from "@/types/app";
|
|
|
|
/**
|
|
* Statistics about the layout tree structure
|
|
*/
|
|
export interface LayoutStats {
|
|
/** Number of horizontal splits in the tree */
|
|
rowSplits: number;
|
|
/** Number of vertical splits in the tree */
|
|
columnSplits: number;
|
|
/** Maximum depth of the tree */
|
|
depth: number;
|
|
/** Total number of windows (leaf nodes) */
|
|
windowCount: number;
|
|
}
|
|
|
|
/**
|
|
* Information about a leaf node in the layout tree
|
|
*/
|
|
export interface LeafInfo {
|
|
/** The window ID (leaf node value) */
|
|
leafId: string;
|
|
/** Depth of this leaf in the tree (root = 0) */
|
|
depth: number;
|
|
/** Direction of the parent split (null if no parent) */
|
|
parentDirection: "row" | "column" | null;
|
|
}
|
|
|
|
/**
|
|
* Analyzes the layout tree and returns statistics
|
|
* Used by smart direction algorithm to balance splits
|
|
*/
|
|
export function analyzeLayoutStats(
|
|
node: MosaicNode<string> | null,
|
|
): LayoutStats {
|
|
if (node === null) {
|
|
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 0 };
|
|
}
|
|
|
|
if (typeof node === "string") {
|
|
// Leaf node (window ID)
|
|
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 1 };
|
|
}
|
|
|
|
// Branch node - recursively analyze children
|
|
const firstStats = analyzeLayoutStats(node.first);
|
|
const secondStats = analyzeLayoutStats(node.second);
|
|
|
|
return {
|
|
rowSplits:
|
|
firstStats.rowSplits +
|
|
secondStats.rowSplits +
|
|
(node.direction === "row" ? 1 : 0),
|
|
columnSplits:
|
|
firstStats.columnSplits +
|
|
secondStats.columnSplits +
|
|
(node.direction === "column" ? 1 : 0),
|
|
depth: Math.max(firstStats.depth, secondStats.depth) + 1,
|
|
windowCount: firstStats.windowCount + secondStats.windowCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finds all leaf nodes in the tree with their depth and parent direction
|
|
*/
|
|
export function findAllLeaves(
|
|
node: MosaicNode<string> | null,
|
|
depth: number = 0,
|
|
parentDirection: "row" | "column" | null = null,
|
|
): LeafInfo[] {
|
|
if (node === null) {
|
|
return [];
|
|
}
|
|
|
|
// Leaf node - return info
|
|
if (typeof node === "string") {
|
|
return [{ leafId: node, depth, parentDirection }];
|
|
}
|
|
|
|
// Branch node - recursively find leaves in children
|
|
const leftLeaves = findAllLeaves(node.first, depth + 1, node.direction);
|
|
const rightLeaves = findAllLeaves(node.second, depth + 1, node.direction);
|
|
|
|
return [...leftLeaves, ...rightLeaves];
|
|
}
|
|
|
|
/**
|
|
* Finds the shallowest leaf in the tree as a heuristic for largest screen space
|
|
* Accurate for balanced splits (default 50%), may be suboptimal for very unbalanced splits
|
|
* If multiple leaves at same depth, returns first one encountered (deterministic)
|
|
*/
|
|
export function findShallowstLeaf(
|
|
node: MosaicNode<string> | null,
|
|
): LeafInfo | null {
|
|
const leaves = findAllLeaves(node);
|
|
|
|
if (leaves.length === 0) return null;
|
|
|
|
// Find minimum depth
|
|
const minDepth = Math.min(...leaves.map((leaf) => leaf.depth));
|
|
|
|
// Return first leaf at minimum depth
|
|
return leaves.find((leaf) => leaf.depth === minDepth) || null;
|
|
}
|
|
|
|
/**
|
|
* Replaces a specific leaf node with a split containing the leaf + new window
|
|
*
|
|
* @param node - Current node in tree traversal
|
|
* @param targetLeafId - ID of leaf to replace
|
|
* @param newWindowId - ID of new window to insert
|
|
* @param direction - Direction of the new split
|
|
* @param splitPercentage - Split percentage for new split
|
|
* @param position - Where to place new window ('first' or 'second')
|
|
* @returns New tree with split at target leaf, or original tree if leaf not found
|
|
*/
|
|
export function replaceLeafWithSplit(
|
|
node: MosaicNode<string> | null,
|
|
targetLeafId: string,
|
|
newWindowId: string,
|
|
direction: "row" | "column",
|
|
splitPercentage: number,
|
|
position: "first" | "second" = "second",
|
|
): MosaicNode<string> | null {
|
|
if (node === null) return null;
|
|
|
|
// Leaf node - check if this is our target
|
|
if (typeof node === "string") {
|
|
if (node === targetLeafId) {
|
|
// Found target! Replace with split
|
|
const [first, second] =
|
|
position === "first"
|
|
? [newWindowId, targetLeafId] // New window first
|
|
: [targetLeafId, newWindowId]; // New window second (default)
|
|
|
|
return {
|
|
direction,
|
|
first,
|
|
second,
|
|
splitPercentage,
|
|
};
|
|
}
|
|
// Not our target, return unchanged
|
|
return node;
|
|
}
|
|
|
|
// Branch node - recursively check children
|
|
const newFirst = replaceLeafWithSplit(
|
|
node.first,
|
|
targetLeafId,
|
|
newWindowId,
|
|
direction,
|
|
splitPercentage,
|
|
position,
|
|
);
|
|
const newSecond = replaceLeafWithSplit(
|
|
node.second,
|
|
targetLeafId,
|
|
newWindowId,
|
|
direction,
|
|
splitPercentage,
|
|
position,
|
|
);
|
|
|
|
// Return new branch with potentially updated children
|
|
return {
|
|
direction: node.direction,
|
|
first: newFirst!,
|
|
second: newSecond!,
|
|
splitPercentage: node.splitPercentage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculates split direction for balanced insertion
|
|
* Rotates parent direction 90° (row→column, column→row)
|
|
* This creates a checkerboard pattern for more balanced layouts
|
|
*/
|
|
export function calculateBalancedDirection(
|
|
parentDirection: "row" | "column" | null,
|
|
): "row" | "column" {
|
|
if (parentDirection === null) {
|
|
return "row"; // Default to horizontal for first split
|
|
}
|
|
|
|
// Rotate 90 degrees
|
|
return parentDirection === "row" ? "column" : "row";
|
|
}
|
|
|
|
/**
|
|
* Inserts a new window into the layout tree according to the layout configuration
|
|
*
|
|
* Smart mode uses shallowest-leaf algorithm for balanced/dwindle-style layouts:
|
|
* - Finds the leaf node at minimum depth (heuristic for largest visual space)
|
|
* - Splits that leaf with direction rotated 90° from parent (row→column, column→row)
|
|
* - Creates checkerboard-like balanced layouts, progressively equalizing space
|
|
* - Works best with balanced split percentages (default 50%)
|
|
*
|
|
* This is NOT a spiral/fibonacci layout (which maintains a main window).
|
|
* It creates equal-ish space distribution similar to "dwindle" mode in other WMs.
|
|
*
|
|
* @param currentLayout - The current layout tree (null if no windows yet)
|
|
* @param newWindowId - The ID of the new window to insert
|
|
* @param config - Layout configuration specifying how to insert the window
|
|
* @returns The new layout tree with the window inserted
|
|
*/
|
|
export function insertWindow(
|
|
currentLayout: MosaicNode<string> | null,
|
|
newWindowId: string,
|
|
config: LayoutConfig,
|
|
): MosaicNode<string> {
|
|
// First window - just return the window ID as leaf node
|
|
if (currentLayout === null) {
|
|
return newWindowId;
|
|
}
|
|
|
|
// Smart mode: Use improved shallowest-leaf algorithm
|
|
if (config.insertionMode === "smart") {
|
|
// Find shallowest leaf to split (largest screen space)
|
|
const leafInfo = findShallowstLeaf(currentLayout);
|
|
|
|
if (!leafInfo) {
|
|
// Shouldn't happen, but fallback to simple root insertion
|
|
console.warn("[Layout] No leaf found, falling back to root insertion");
|
|
return {
|
|
direction: "row",
|
|
first: currentLayout,
|
|
second: newWindowId,
|
|
splitPercentage: config.splitPercentage,
|
|
};
|
|
}
|
|
|
|
// Determine split direction by rotating parent's direction
|
|
const direction = calculateBalancedDirection(leafInfo.parentDirection);
|
|
|
|
// Replace that leaf with a split
|
|
const newLayout = replaceLeafWithSplit(
|
|
currentLayout,
|
|
leafInfo.leafId,
|
|
newWindowId,
|
|
direction,
|
|
config.splitPercentage,
|
|
config.insertionPosition,
|
|
);
|
|
|
|
return newLayout || newWindowId; // Fallback if replacement failed
|
|
}
|
|
|
|
// Row/Column modes: Simple root-level wrapping (old behavior)
|
|
const direction =
|
|
config.insertionMode === "row"
|
|
? "row"
|
|
: config.insertionMode === "column"
|
|
? "column"
|
|
: "row";
|
|
|
|
// Determine which side gets the new window
|
|
const [firstNode, secondNode] =
|
|
config.insertionPosition === "first"
|
|
? [newWindowId, currentLayout] // New window on left/top
|
|
: [currentLayout, newWindowId]; // New window on right/bottom (default)
|
|
|
|
// Create split node with new window
|
|
return {
|
|
direction,
|
|
first: firstNode,
|
|
second: secondNode,
|
|
splitPercentage: config.splitPercentage,
|
|
};
|
|
}
|