fix: improved balanced window algo

This commit is contained in:
Alejandro Gómez
2025-12-18 15:45:34 +01:00
parent 3fba62b316
commit 5cc97484b5
8 changed files with 341 additions and 181 deletions

View File

@@ -9,6 +9,7 @@ import {
parseTagStructure,
getContentTypeDescription,
} from "@/lib/nostr-schema";
import { CenteredContent } from "./ui/CenteredContent";
// NIP-01 Kind ranges
const REPLACEABLE_START = 10000;
@@ -44,7 +45,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
}
return (
<div className="h-full w-full overflow-y-auto p-6 space-y-6">
<CenteredContent>
{/* Header */}
<div className="flex items-center gap-4">
{Icon && (
@@ -180,7 +181,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
</div>
</>
)}
</div>
</CenteredContent>
);
}

View File

@@ -1,6 +1,7 @@
import { getKindInfo } from "@/constants/kinds";
import { kindRenderers } from "./nostr/kinds";
import { NIPBadge } from "./NIPBadge";
import { CenteredContent } from "./ui/CenteredContent";
// Dynamically derive supported kinds from renderer registry
const SUPPORTED_KINDS = Object.keys(kindRenderers).map(Number);
@@ -14,10 +15,9 @@ export default function KindsViewer() {
const sortedKinds = [...SUPPORTED_KINDS].sort((a, b) => a - b);
return (
<div className="h-full w-full overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div>
<CenteredContent>
{/* Header */}
<div>
<h1 className="text-2xl font-bold mb-2">
Supported Event Kinds ({sortedKinds.length})
</h1>
@@ -70,7 +70,6 @@ export default function KindsViewer() {
);
})}
</div>
</div>
</div>
</CenteredContent>
);
}

View File

@@ -1,5 +1,6 @@
import { manPages } from "@/types/man";
import { useGrimoire } from "@/core/state";
import { CenteredContent } from "./ui/CenteredContent";
interface ManPageProps {
cmd: string;
@@ -48,17 +49,17 @@ export default function ManPage({ cmd }: ManPageProps) {
if (!page) {
return (
<div className="p-6 font-mono text-sm">
<CenteredContent maxWidth="4xl" spacing="4" className="font-mono text-sm">
<div className="text-destructive">No manual entry for {cmd}</div>
<div className="mt-4 text-muted-foreground">
Use 'help' to see available commands.
</div>
</div>
</CenteredContent>
);
}
return (
<div className="p-6 font-mono text-sm space-y-4 max-w-4xl">
<CenteredContent maxWidth="4xl" spacing="4" className="font-mono text-sm">
{/* Header */}
<div className="flex justify-between border-b border-border pb-2">
<span className="font-bold">{page.name.toUpperCase()}</span>
@@ -161,6 +162,6 @@ export default function ManPage({ cmd }: ManPageProps) {
<div className="border-t border-border pt-2 text-muted-foreground text-xs">
Grimoire 1.0.0 {new Date().getFullYear()}
</div>
</div>
</CenteredContent>
);
}

View File

@@ -2,6 +2,8 @@ import { useNip } from "@/hooks/useNip";
import { MarkdownContent } from "./nostr/MarkdownContent";
import { KindBadge } from "./KindBadge";
import { getKindsForNip } from "@/lib/nip-kinds";
import { CenteredContent } from "./ui/CenteredContent";
import { cn } from "@/lib/utils";
interface NipRendererProps {
nipId: string;
@@ -14,21 +16,17 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
if (loading) {
return (
<div className={`p-4 ${className}`}>
<div className="text-muted-foreground text-sm">
Loading NIP-{nipId}...
</div>
</div>
<CenteredContent className={cn("text-muted-foreground text-sm", className)}>
Loading NIP-{nipId}...
</CenteredContent>
);
}
if (error) {
return (
<div className={`p-4 ${className}`}>
<div className="text-destructive text-sm">
Error loading NIP-{nipId}: {error.message}
</div>
</div>
<CenteredContent className={cn("text-destructive text-sm", className)}>
Error loading NIP-{nipId}: {error.message}
</CenteredContent>
);
}
@@ -37,7 +35,7 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
}
return (
<div className={`p-4 overflow-x-hidden ${className}`}>
<CenteredContent className={cn("overflow-x-hidden", className)}>
<MarkdownContent content={content} />
{kinds.length > 0 && (
@@ -52,6 +50,6 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
</div>
</div>
)}
</div>
</CenteredContent>
);
}

View File

@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { NIPBadge } from "./NIPBadge";
import { useGrimoire } from "@/core/state";
import { CenteredContent } from "./ui/CenteredContent";
/**
* NipsViewer - Documentation introspection command
@@ -64,10 +65,9 @@ export default function NipsViewer() {
};
return (
<div className="h-full w-full overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div>
<CenteredContent>
{/* Header */}
<div>
<h1 className="text-2xl font-bold mb-2">
{search
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
@@ -122,7 +122,6 @@ export default function NipsViewer() {
<p className="text-sm">Try searching for a different term</p>
</div>
)}
</div>
</div>
</CenteredContent>
);
}

View File

@@ -0,0 +1,108 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface CenteredContentProps {
children: ReactNode;
/**
* Maximum width of the centered content
* @default '3xl' (48rem / 768px)
*/
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full";
/**
* Vertical spacing between child elements
* @default '6' (1.5rem)
*/
spacing?: "0" | "1" | "2" | "3" | "4" | "5" | "6" | "8" | "10" | "12";
/**
* Padding around the content
* @default '6' (1.5rem)
*/
padding?: "0" | "2" | "4" | "6" | "8";
/**
* Additional CSS classes for the inner content container
*/
className?: string;
}
const maxWidthClasses = {
sm: "max-w-sm", // 24rem / 384px
md: "max-w-md", // 28rem / 448px
lg: "max-w-lg", // 32rem / 512px
xl: "max-w-xl", // 36rem / 576px
"2xl": "max-w-2xl", // 42rem / 672px
"3xl": "max-w-3xl", // 48rem / 768px - DEFAULT
"4xl": "max-w-4xl", // 56rem / 896px
"5xl": "max-w-5xl", // 64rem / 1024px
"6xl": "max-w-6xl", // 72rem / 1152px
full: "max-w-full", // No limit
} as const;
const spacingClasses = {
"0": "space-y-0",
"1": "space-y-1",
"2": "space-y-2",
"3": "space-y-3",
"4": "space-y-4",
"5": "space-y-5",
"6": "space-y-6", // DEFAULT
"8": "space-y-8",
"10": "space-y-10",
"12": "space-y-12",
} as const;
const paddingClasses = {
"0": "p-0",
"2": "p-2",
"4": "p-4",
"6": "p-6", // DEFAULT
"8": "p-8",
} as const;
/**
* CenteredContent - Reusable container for centered, max-width content
*
* Provides consistent layout pattern for documentation-style pages:
* - Centered content with configurable max-width
* - Consistent padding and spacing
* - Works with WindowRenderer's scroll container
*
* @example
* // Default (3xl width, 6 spacing, 6 padding)
* <CenteredContent>
* {content}
* </CenteredContent>
*
* @example
* // Man pages (wider, tighter spacing)
* <CenteredContent maxWidth="4xl" spacing="4" className="font-mono text-sm">
* {content}
* </CenteredContent>
*
* @example
* // No padding (rare)
* <CenteredContent padding="0">
* {content}
* </CenteredContent>
*/
export function CenteredContent({
children,
maxWidth = "3xl",
spacing = "6",
padding = "6",
className,
}: CenteredContentProps) {
return (
<div className={paddingClasses[padding]}>
<div
className={cn(
"mx-auto",
maxWidthClasses[maxWidth],
spacingClasses[spacing],
className
)}
>
{children}
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import type { MosaicNode } from "react-mosaic-component";
import {
analyzeLayoutStats,
calculateSmartDirection,
insertWindow,
type LayoutStats,
} from "./layout-utils";
@@ -212,125 +211,6 @@ describe("analyzeLayoutStats", () => {
});
});
describe("calculateSmartDirection", () => {
describe("null and empty layouts", () => {
it("should default to row for null layout", () => {
const result = calculateSmartDirection(null);
expect(result).toBe("row");
});
it("should default to row for single window", () => {
const result = calculateSmartDirection("window-1");
expect(result).toBe("row");
});
});
describe("balanced layouts", () => {
it("should return row when splits are equal", () => {
// 1 row split, 1 column split - equal, default to row
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("row");
});
it("should return row when no splits exist yet", () => {
// Just two windows with one split
const layout: MosaicNode<string> = {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
// 1 row split, 0 column splits -> row > column, should favor column
expect(result).toBe("column");
});
});
describe("unbalanced layouts", () => {
it("should return column when more horizontal splits exist", () => {
// 2 row splits, 0 column splits
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("column");
});
it("should return row when more vertical splits exist", () => {
// 0 row splits, 2 column splits
const layout: MosaicNode<string> = {
direction: "column",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("row");
});
it("should favor column when significantly more horizontal splits", () => {
// 5 row splits, 1 column split
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
},
second: "w3",
splitPercentage: 50,
},
second: "w4",
splitPercentage: 50,
},
second: {
direction: "column",
first: "w5",
second: "w6",
splitPercentage: 50,
},
splitPercentage: 50,
},
second: "w7",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("column");
});
});
});
describe("insertWindow", () => {
describe("first window insertion", () => {
it("should return window ID for null layout", () => {
@@ -473,8 +353,16 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
expect(result).toHaveProperty("direction", "column");
expect(result).toHaveProperty("second", "window-4");
// NEW IMPROVED BEHAVIOR: Splits shallowest leaf (window-3 at depth 1)
// Parent direction is row, so rotates to column for the split
expect(result).toHaveProperty("direction", "row");
// window-3 should be replaced with [column: window-3 | window-4]
const secondNode = (result as any).second;
expect(secondNode).toHaveProperty("direction", "column");
expect(secondNode).toHaveProperty("first", "window-3");
expect(secondNode).toHaveProperty("second", "window-4");
});
it("should balance vertical splits by adding horizontal", () => {
@@ -496,8 +384,16 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
expect(result).toHaveProperty("direction", "row");
expect(result).toHaveProperty("second", "window-4");
// NEW IMPROVED BEHAVIOR: Splits shallowest leaf (window-3 at depth 1)
// Parent direction is column, so rotates to row for the split
expect(result).toHaveProperty("direction", "column");
// window-3 should be replaced with [row: window-3 | window-4]
const secondNode = (result as any).second;
expect(secondNode).toHaveProperty("direction", "row");
expect(secondNode).toHaveProperty("first", "window-3");
expect(secondNode).toHaveProperty("second", "window-4");
});
});
@@ -526,10 +422,20 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(quadLayout, "window-5", config);
// More column splits than row, should add row
// NEW IMPROVED BEHAVIOR: All 4 windows at depth 2 (equal depth)
// Algorithm picks first shallowest leaf (window-1)
// Parent is column, so rotates to row for the split
expect(result).toHaveProperty("direction", "row");
expect(result).toHaveProperty("first", quadLayout);
expect(result).toHaveProperty("second", "window-5");
const firstNode = (result as any).first;
expect(firstNode).toHaveProperty("direction", "column");
// window-1 should be replaced with [row: window-1 | window-5]
const window1Split = firstNode.first;
expect(window1Split).toHaveProperty("direction", "row");
expect(window1Split).toHaveProperty("first", "window-1");
expect(window1Split).toHaveProperty("second", "window-5");
});
});

View File

@@ -15,6 +15,18 @@ export interface LayoutStats {
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
@@ -50,31 +62,139 @@ export function analyzeLayoutStats(
}
/**
* Calculates the optimal split direction to balance the layout tree
* Returns 'column' if there are more horizontal splits (to balance)
* Returns 'row' if there are more vertical splits or equal (default to horizontal)
* Finds all leaf nodes in the tree with their depth and parent direction
*/
export function calculateSmartDirection(
layout: MosaicNode<string> | null
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 (closest to root = largest screen space)
* If multiple leaves at same depth, returns first one encountered
*/
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 (layout === null) {
if (parentDirection === null) {
return "row"; // Default to horizontal for first split
}
const stats = analyzeLayoutStats(layout);
// If more horizontal splits, add vertical to balance
if (stats.rowSplits > stats.columnSplits) {
return "column";
}
// Otherwise, default to horizontal (including when equal)
return "row";
// 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 trees:
* - Finds the leaf node at minimum depth (approximates largest visual space)
* - Splits that leaf with direction rotated from parent (row→column, column→row)
* - Creates more balanced layouts than root-level wrapping
*
* @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
@@ -90,18 +210,46 @@ export function insertWindow(
return newWindowId;
}
// Determine split direction based on insertion mode
let direction: "row" | "column";
// Smart mode: Use improved shallowest-leaf algorithm
if (config.insertionMode === "smart") {
// Find shallowest leaf to split (largest screen space)
const leafInfo = findShallowstLeaf(currentLayout);
if (config.insertionMode === "row") {
direction = "row";
} else if (config.insertionMode === "column") {
direction = "column";
} else {
// smart mode - calculate balanced direction
direction = calculateSmartDirection(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"