mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user