feat: add layout presets system with /layout command

Phase 3 implementation:
- Created layout-presets.ts with 3 built-in presets (side-by-side, main-sidebar, grid)
- Implemented fillLayoutTemplate() for recursive template filling with window IDs
- Added collectWindowIds() for depth-first traversal of layout trees
- Created applyPresetToLayout() to reorganize existing windows

- Created layout-parser.ts for /layout command argument parsing
- Added layout command to man.ts with documentation and examples

- Built LayoutViewer component with:
  * Visual preset gallery with diagrams
  * Window count validation
  * Apply preset functionality
  * Error handling for insufficient windows
  * Command-line preset specification support

- Wired LayoutViewer into WindowRenderer with lazy loading
- Added "layout" to AppId type definition
- Exposed applyPresetLayout in useGrimoire hook

Presets allow users to quickly reorganize multiple windows into
common layouts: 50/50 splits, 70/30 main+sidebar, or 2×2 grids.

Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alejandro Gómez
2025-12-18 12:13:28 +01:00
parent a78b5226ce
commit 52f39a8073
8 changed files with 501 additions and 1 deletions

View File

@@ -0,0 +1,216 @@
import { useState } from "react";
import { useGrimoire } from "@/core/state";
import { getAllPresets } from "@/lib/layout-presets";
import type { LayoutPreset } from "@/lib/layout-presets";
import { Button } from "./ui/button";
import { Grid2X2, Columns2, Split, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
interface LayoutViewerProps {
presetId?: string;
error?: string;
}
/**
* LAYOUT viewer - displays available layout presets and allows applying them
*/
export function LayoutViewer({ presetId, error }: LayoutViewerProps) {
const { state, applyPresetLayout } = useGrimoire();
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
const windowCount = activeWorkspace.windowIds.length;
const presets = getAllPresets();
const [applying, setApplying] = useState<string | null>(null);
// If a preset was specified via command line, show error or success
const specifiedPreset = presetId
? presets.find((p) => p.id === presetId)
: null;
const handleApplyPreset = async (preset: LayoutPreset) => {
if (windowCount < preset.slots) {
toast.error(`Not enough windows`, {
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
});
return;
}
setApplying(preset.id);
try {
applyPresetLayout(preset);
toast.success(`Layout applied`, {
description: `Applied "${preset.name}" preset to workspace ${activeWorkspace.number}`,
});
} catch (error) {
toast.error(`Failed to apply layout`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
} finally {
setApplying(null);
}
};
const getPresetIcon = (presetId: string) => {
switch (presetId) {
case "side-by-side":
return <Columns2 className="h-8 w-8" />;
case "main-sidebar":
return <Split className="h-8 w-8" />;
case "grid":
return <Grid2X2 className="h-8 w-8" />;
default:
return <Grid2X2 className="h-8 w-8" />;
}
};
const getPresetDiagram = (preset: LayoutPreset) => {
// Visual representation of the layout
switch (preset.id) {
case "side-by-side":
return (
<div className="flex gap-2 h-16">
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
case "main-sidebar":
return (
<div className="flex gap-2 h-16">
<div className="flex-[7] border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="flex-[3] border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
case "grid":
return (
<div className="grid grid-cols-2 grid-rows-2 gap-2 h-16">
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
</div>
);
default:
return null;
}
};
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
<div className="flex-1 overflow-y-auto p-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2">Layout Presets</h1>
<p className="text-muted-foreground text-sm">
Apply preset layouts to reorganize windows in workspace{" "}
{activeWorkspace.number}
</p>
<div className="mt-2 text-sm">
<span className="text-muted-foreground">Current windows: </span>
<span className="font-semibold">{windowCount}</span>
</div>
</div>
{/* Command-line specified preset with error */}
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/50 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold text-destructive mb-1">
Command Error
</div>
<div className="text-sm text-muted-foreground">{error}</div>
</div>
</div>
)}
{/* Command-line specified preset (valid) */}
{specifiedPreset && !error && (
<div className="mb-6 p-4 rounded-lg bg-accent/10 border border-accent/50 flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-accent-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-semibold mb-1">
Preset: {specifiedPreset.name}
</div>
<div className="text-sm text-muted-foreground mb-3">
{specifiedPreset.description}
</div>
{windowCount < specifiedPreset.slots ? (
<div className="text-sm text-destructive">
Not enough windows (requires {specifiedPreset.slots}, have{" "}
{windowCount})
</div>
) : (
<Button
onClick={() => handleApplyPreset(specifiedPreset)}
size="sm"
disabled={applying === specifiedPreset.id}
>
{applying === specifiedPreset.id
? "Applying..."
: "Apply Preset"}
</Button>
)}
</div>
</div>
)}
{/* Preset Gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
const isApplying = applying === preset.id;
return (
<div
key={preset.id}
className={`p-4 rounded-lg border transition-colors ${
canApply
? "border-border hover:border-accent/50 hover:bg-accent/5"
: "border-muted-foreground/20 opacity-60"
}`}
>
{/* Icon and Title */}
<div className="flex items-center gap-3 mb-3">
<div className="text-muted-foreground">
{getPresetIcon(preset.id)}
</div>
<div>
<div className="font-semibold">{preset.name}</div>
<div className="text-xs text-muted-foreground">
{preset.slots} windows
</div>
</div>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mb-3">
{preset.description}
</p>
{/* Visual Diagram */}
<div className="mb-3">{getPresetDiagram(preset)}</div>
{/* Apply Button */}
{canApply ? (
<Button
onClick={() => handleApplyPreset(preset)}
className="w-full"
variant="outline"
size="sm"
disabled={isApplying}
>
{isApplying ? "Applying..." : "Apply"}
</Button>
) : (
<div className="text-xs text-muted-foreground text-center py-2">
Requires {preset.slots} windows (have {windowCount})
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
);
const ConnViewer = lazy(() => import("./ConnViewer"));
const LayoutViewer = lazy(() =>
import("./LayoutViewer").then((m) => ({ default: m.LayoutViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -162,6 +165,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "conn":
content = <ConnViewer />;
break;
case "layout":
content = (
<LayoutViewer
presetId={window.props.presetId}
error={window.props.error}
/>
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
/**
* Finds the lowest available workspace number.
@@ -373,3 +374,35 @@ export const updateWorkspaceLayoutConfig = (
},
};
};
/**
* Applies a preset layout to the active workspace.
* Reorganizes existing windows according to the preset template.
*/
export const applyPresetLayout = (
state: GrimoireState,
preset: LayoutPreset,
): GrimoireState => {
const activeId = state.activeWorkspaceId;
const ws = state.workspaces[activeId];
try {
// Apply preset to current layout
const newLayout = applyPresetToLayout(ws.layout, preset);
return {
...state,
workspaces: {
...state.workspaces,
[activeId]: {
...ws,
layout: newLayout,
},
},
};
} catch (error) {
// If preset application fails (not enough windows, etc.), return unchanged
console.error("[Layout] Failed to apply preset:", error);
return state;
}
};

View File

@@ -240,6 +240,12 @@ export const useGrimoire = () => {
[setState],
);
const applyPresetLayout = useCallback(
(preset: any) =>
setState((prev) => Logic.applyPresetLayout(prev, preset)),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
@@ -254,5 +260,6 @@ export const useGrimoire = () => {
setActiveAccount,
setActiveAccountRelays,
updateWorkspaceLayoutConfig,
applyPresetLayout,
};
};

39
src/lib/layout-parser.ts Normal file
View File

@@ -0,0 +1,39 @@
import { getPreset, getAllPresets } from "./layout-presets";
export interface LayoutCommandResult {
/** The preset ID to apply, or undefined to show preset list */
presetId?: string;
/** Error message if parsing failed */
error?: string;
}
/**
* Parses the /layout command arguments
*
* Usage:
* /layout - Show all available presets
* /layout side-by-side - Apply the side-by-side preset
* /layout grid - Apply the grid preset
*/
export function parseLayoutCommand(args: string[]): LayoutCommandResult {
// No arguments - show preset list
if (args.length === 0) {
return {};
}
// Get the preset ID (first argument)
const presetId = args[0].toLowerCase();
// Validate preset exists
const preset = getPreset(presetId);
if (!preset) {
const availablePresets = getAllPresets()
.map((p) => p.id)
.join(", ");
return {
error: `Unknown preset "${presetId}". Available presets: ${availablePresets}`,
};
}
return { presetId };
}

166
src/lib/layout-presets.ts Normal file
View File

@@ -0,0 +1,166 @@
import type { MosaicNode } from "react-mosaic-component";
/**
* A layout preset template with null values that get filled with window IDs
*/
export interface LayoutPreset {
/** Unique identifier for the preset */
id: string;
/** Display name for the preset */
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;
}
/**
* Built-in layout presets
*/
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,
},
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,
},
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,
},
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
*/
export function collectWindowIds(
layout: MosaicNode<string> | null
): string[] {
if (layout === null) {
return [];
}
if (typeof layout === "string") {
return [layout];
}
return [...collectWindowIds(layout.first), ...collectWindowIds(layout.second)];
}
/**
* Applies a preset layout to existing windows
* Takes the first N windows from the current layout and arranges them according to the preset
*/
export function applyPresetToLayout(
currentLayout: MosaicNode<string> | null,
preset: LayoutPreset
): MosaicNode<string> {
// Collect all window IDs from current layout
const windowIds = collectWindowIds(currentLayout);
// Check if we have enough windows
if (windowIds.length < preset.slots) {
throw new Error(
`Preset "${preset.name}" requires ${preset.slots} windows but only ${windowIds.length} available`
);
}
// Take first N windows for the preset
const windowsToUse = windowIds.slice(0, preset.slots);
// Fill template with window IDs
return fillLayoutTemplate(preset.template, windowsToUse);
}
/**
* Get a preset by ID
*/
export function getPreset(presetId: string): LayoutPreset | undefined {
return BUILT_IN_PRESETS[presetId];
}
/**
* Get all available presets
*/
export function getAllPresets(): LayoutPreset[] {
return Object.values(BUILT_IN_PRESETS);
}

View File

@@ -15,7 +15,8 @@ export type AppId =
| "decode"
| "relay"
| "debug"
| "conn";
| "conn"
| "layout";
export interface WindowInstance {
id: string;

View File

@@ -4,6 +4,7 @@ import type { AppId } from "./app";
import { parseOpenCommand } from "@/lib/open-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { parseLayoutCommand } from "@/lib/layout-parser";
import { resolveNip05Batch } from "@/lib/nip05";
export interface ManPageEntry {
@@ -457,4 +458,30 @@ export const manPages: Record<string, ManPageEntry> = {
category: "System",
defaultProps: {},
},
layout: {
name: "layout",
section: "1",
synopsis: "layout [preset-name]",
description:
"Apply a preset layout to reorganize windows in the current workspace. Presets provide common layout arrangements like side-by-side splits, main+sidebar configurations, and grid layouts. Running without arguments shows all available presets.",
options: [
{
flag: "[preset-name]",
description: "Preset layout to apply (side-by-side, main-sidebar, grid)",
},
],
examples: [
"layout View all available presets",
"layout side-by-side Apply 50/50 horizontal split (2 windows)",
"layout main-sidebar Apply 70/30 horizontal split (2 windows)",
"layout grid Apply 2×2 grid layout (4 windows)",
],
seeAlso: ["man"],
appId: "layout",
category: "System",
argParser: (args: string[]) => {
const result = parseLayoutCommand(args);
return result;
},
},
};