feat: make layout presets adaptive to all windows

Major redesign of preset system from static templates to adaptive algorithms:

**Grid preset** (fully adaptive):
- Now arranges ALL windows in best-fit grid (2x2, 2x3, 3x3, etc.)
- Minimum: 2 windows (down from 4)
- Algorithm calculates optimal rows/cols for any N windows
- Examples:
  * 4 windows → 2×2 grid
  * 6 windows → 2×3 grid
  * 9 windows → 3×3 grid

**Side-by-side preset** (adaptive with limit):
- Arranges 2-4 windows in single horizontal row
- Maximum 4 windows (prevents unusably narrow splits)
- Equal splits: 50%, 33%, 25%
- Clear error message if >4 windows

**Main + Sidebar preset** (unchanged):
- Already worked correctly (intentionally hierarchical)
- One main window (70%) + sidebars stacked (30%)
- Adapts naturally to any N ≥ 2 windows

Architecture changes:
- Replace static MosaicNode templates with generate() functions
- Add minSlots/maxSlots instead of single slots field
- Implement buildHorizontalRow(), buildVerticalStack(), buildGridLayout()
- Remove fillLayoutTemplate() (no longer needed)
- Remove extra window stacking (all windows now equal)

Benefits:
- No arbitrary hierarchy (no "featured" vs "extra" windows)
- Matches tiling window manager patterns (Amethyst, yabai, etc.)
- More intuitive: "grid" grids ALL windows, not just first 4
- Better UX: Works with any number of windows in range

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2025-12-18 13:14:35 +01:00
parent b81ac599a2
commit dd40a5baca
2 changed files with 159 additions and 114 deletions

View File

@@ -33,9 +33,16 @@ export function LayoutControls() {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
if (windowCount < preset.slots) {
if (windowCount < preset.minSlots) {
toast.error(`Not enough windows`, {
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
description: `Preset "${preset.name}" requires at least ${preset.minSlots} windows, but only ${windowCount} available.`,
});
return;
}
if (preset.maxSlots && windowCount > preset.maxSlots) {
toast.error(`Too many windows`, {
description: `Preset "${preset.name}" supports maximum ${preset.maxSlots} windows, but ${windowCount} available.`,
});
return;
}
@@ -108,7 +115,21 @@ export function LayoutControls() {
Presets
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
const hasMin = windowCount >= preset.minSlots;
const hasMax = !preset.maxSlots || windowCount <= preset.maxSlots;
const canApply = hasMin && hasMax;
let statusText = "";
if (!hasMin) {
statusText = `Needs ${preset.minSlots}+ (have ${windowCount})`;
} else if (!hasMax) {
statusText = `Max ${preset.maxSlots} (have ${windowCount})`;
} else if (preset.maxSlots) {
statusText = `${preset.minSlots}-${preset.maxSlots} windows`;
} else {
statusText = `${preset.minSlots}+ windows`;
}
return (
<DropdownMenuItem
key={preset.id}
@@ -120,9 +141,7 @@ export function LayoutControls() {
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted-foreground truncate">
{canApply
? `${preset.slots}+ windows`
: `Needs ${preset.slots} (have ${windowCount})`}
{statusText}
</div>
</div>
</DropdownMenuItem>

View File

@@ -1,7 +1,7 @@
import type { MosaicNode } from "react-mosaic-component";
/**
* A layout preset template with null values that get filled with window IDs
* A layout preset that can be applied to arrange windows
*/
export interface LayoutPreset {
/** Unique identifier for the preset */
@@ -10,10 +10,101 @@ export interface LayoutPreset {
name: string;
/** Description of the layout arrangement */
description: string;
/** Template structure with null values to be replaced by window IDs */
template: MosaicNode<null>;
/** Number of windows required for this preset */
slots: number;
/** Minimum number of windows required */
minSlots: number;
/** Maximum number of windows (undefined = no limit) */
maxSlots?: number;
/** Function to generate layout for given window IDs */
generate: (windowIds: string[]) => MosaicNode<string>;
}
/**
* Builds a horizontal row of windows with equal splits
*/
function buildHorizontalRow(windowIds: string[]): MosaicNode<string> {
if (windowIds.length === 0) {
throw new Error("Cannot build row with zero windows");
}
if (windowIds.length === 1) {
return windowIds[0];
}
// Calculate percentage for first window to make equal splits
const splitPercent = (100 / windowIds.length);
return {
direction: "row",
first: windowIds[0],
second: buildHorizontalRow(windowIds.slice(1)),
splitPercentage: splitPercent,
};
}
/**
* Builds a vertical stack of windows with equal splits
*/
function buildVerticalStack(windowIds: string[]): MosaicNode<string> {
if (windowIds.length === 0) {
throw new Error("Cannot build stack with zero windows");
}
if (windowIds.length === 1) {
return windowIds[0];
}
// Calculate percentage for first window to make equal splits
const splitPercent = (100 / windowIds.length);
return {
direction: "column",
first: windowIds[0],
second: buildVerticalStack(windowIds.slice(1)),
splitPercentage: splitPercent,
};
}
/**
* Calculates best grid dimensions for N windows
* Prefers square-ish grids, slightly favoring more columns than rows
*/
function calculateGridDimensions(windowCount: number): { rows: number; cols: number } {
const sqrt = Math.sqrt(windowCount);
const rows = Math.floor(sqrt);
const cols = Math.ceil(windowCount / rows);
return { rows, cols };
}
/**
* Chunks an array into groups of specified size
*/
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Builds a grid layout from window IDs
*/
function buildGridLayout(windowIds: string[]): MosaicNode<string> {
if (windowIds.length === 0) {
throw new Error("Cannot build grid with zero windows");
}
if (windowIds.length === 1) {
return windowIds[0];
}
const { rows, cols } = calculateGridDimensions(windowIds.length);
// Split windows into rows
const rowChunks = chunkArray(windowIds, cols);
// Build each row as a horizontal split
const rowNodes = rowChunks.map(chunk => buildHorizontalRow(chunk));
// Stack rows vertically
return buildVerticalStack(rowNodes);
}
/**
@@ -23,92 +114,49 @@ export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
"side-by-side": {
id: "side-by-side",
name: "Side by Side",
description: "Two windows side-by-side (50/50 horizontal split)",
template: {
direction: "row",
first: null,
second: null,
splitPercentage: 50,
description: "All windows in a single row (max 4)",
minSlots: 2,
maxSlots: 4,
generate: (windowIds: string[]) => {
if (windowIds.length > 4) {
throw new Error("Side-by-side layout supports maximum 4 windows");
}
return buildHorizontalRow(windowIds);
},
slots: 2,
},
"main-sidebar": {
id: "main-sidebar",
name: "Main + Sidebar",
description: "Large main window with sidebar (70/30 horizontal split)",
template: {
direction: "row",
first: null,
second: null,
splitPercentage: 70,
description: "Large main window with sidebar windows stacked",
minSlots: 2,
generate: (windowIds: string[]) => {
const [main, ...sidebars] = windowIds;
if (sidebars.length === 0) {
return main;
}
return {
direction: "row",
first: main,
second: buildVerticalStack(sidebars),
splitPercentage: 70,
};
},
slots: 2,
},
grid: {
id: "grid",
name: "Grid",
description: "Four windows in 2×2 grid layout",
template: {
direction: "row",
first: {
direction: "column",
first: null,
second: null,
splitPercentage: 50,
},
second: {
direction: "column",
first: null,
second: null,
splitPercentage: 50,
},
splitPercentage: 50,
description: "All windows in an adaptive grid layout",
minSlots: 2,
generate: (windowIds: string[]) => {
return buildGridLayout(windowIds);
},
slots: 4,
},
};
/**
* Fills a layout template with actual window IDs
* Uses depth-first traversal to assign window IDs to null slots
*/
export function fillLayoutTemplate(
template: MosaicNode<null>,
windowIds: string[]
): MosaicNode<string> {
let windowIndex = 0;
const fill = (node: MosaicNode<null>): MosaicNode<string> => {
// Leaf node - replace null with next window ID
if (node === null) {
if (windowIndex >= windowIds.length) {
throw new Error("Not enough window IDs to fill template");
}
return windowIds[windowIndex++];
}
// Branch node - recursively fill children
return {
...node,
first: fill(node.first),
second: fill(node.second),
};
};
const result = fill(template);
// Verify all windows were used
if (windowIndex !== windowIds.length) {
throw new Error(
`Template requires ${windowIndex} windows but ${windowIds.length} were provided`
);
}
return result;
}
/**
* Collects window IDs from a layout tree in depth-first order
*/
@@ -128,8 +176,7 @@ export function collectWindowIds(
/**
* Applies a preset layout to existing windows
* Takes the first N windows from the current layout and arranges them according to the preset
* Preserves any remaining windows by adding them to the right side of the preset
* Uses ALL windows in the adaptive layout
*/
export function applyPresetToLayout(
currentLayout: MosaicNode<string> | null,
@@ -138,43 +185,22 @@ export function applyPresetToLayout(
// Collect all window IDs from current layout
const windowIds = collectWindowIds(currentLayout);
// Check if we have enough windows
if (windowIds.length < preset.slots) {
// Check minimum requirement
if (windowIds.length < preset.minSlots) {
throw new Error(
`Preset "${preset.name}" requires ${preset.slots} windows but only ${windowIds.length} available`
`Preset "${preset.name}" requires at least ${preset.minSlots} windows but only ${windowIds.length} available`
);
}
// Split windows: first N for preset, rest to preserve
const presetWindows = windowIds.slice(0, preset.slots);
const remainingWindows = windowIds.slice(preset.slots);
// Fill template with preset windows
let result = fillLayoutTemplate(preset.template, presetWindows);
// If there are remaining windows, add them to the right side
if (remainingWindows.length > 0) {
// Create a vertical stack for remaining windows
let remainingStack: MosaicNode<string> = remainingWindows[0];
for (let i = 1; i < remainingWindows.length; i++) {
remainingStack = {
direction: "column",
first: remainingStack,
second: remainingWindows[i],
splitPercentage: 50,
};
}
// Put preset on left, remaining on right (70/30 split)
result = {
direction: "row",
first: result,
second: remainingStack,
splitPercentage: 70, // Give more space to the preset layout
};
// Check maximum limit if defined
if (preset.maxSlots && windowIds.length > preset.maxSlots) {
throw new Error(
`Preset "${preset.name}" supports maximum ${preset.maxSlots} windows but ${windowIds.length} available`
);
}
return result;
// Generate layout using all windows
return preset.generate(windowIds);
}
/**