From 52f39a8073df0a3b51bd82f1a8dff4cdb98d1414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 18 Dec 2025 12:13:28 +0100 Subject: [PATCH] feat: add layout presets system with /layout command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/LayoutViewer.tsx | 216 ++++++++++++++++++++++++++++++ src/components/WindowRenderer.tsx | 11 ++ src/core/logic.ts | 33 +++++ src/core/state.ts | 7 + src/lib/layout-parser.ts | 39 ++++++ src/lib/layout-presets.ts | 166 +++++++++++++++++++++++ src/types/app.ts | 3 +- src/types/man.ts | 27 ++++ 8 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 src/components/LayoutViewer.tsx create mode 100644 src/lib/layout-parser.ts create mode 100644 src/lib/layout-presets.ts diff --git a/src/components/LayoutViewer.tsx b/src/components/LayoutViewer.tsx new file mode 100644 index 0000000..84d095e --- /dev/null +++ b/src/components/LayoutViewer.tsx @@ -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(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 ; + case "main-sidebar": + return ; + case "grid": + return ; + default: + return ; + } + }; + + const getPresetDiagram = (preset: LayoutPreset) => { + // Visual representation of the layout + switch (preset.id) { + case "side-by-side": + return ( +
+
+
+
+ ); + case "main-sidebar": + return ( +
+
+
+
+ ); + case "grid": + return ( +
+
+
+
+
+
+ ); + default: + return null; + } + }; + + return ( +
+
+ {/* Header */} +
+

Layout Presets

+

+ Apply preset layouts to reorganize windows in workspace{" "} + {activeWorkspace.number} +

+
+ Current windows: + {windowCount} +
+
+ + {/* Command-line specified preset with error */} + {error && ( +
+ +
+
+ Command Error +
+
{error}
+
+
+ )} + + {/* Command-line specified preset (valid) */} + {specifiedPreset && !error && ( +
+ +
+
+ Preset: {specifiedPreset.name} +
+
+ {specifiedPreset.description} +
+ {windowCount < specifiedPreset.slots ? ( +
+ ⚠️ Not enough windows (requires {specifiedPreset.slots}, have{" "} + {windowCount}) +
+ ) : ( + + )} +
+
+ )} + + {/* Preset Gallery */} +
+ {presets.map((preset) => { + const canApply = windowCount >= preset.slots; + const isApplying = applying === preset.id; + + return ( +
+ {/* Icon and Title */} +
+
+ {getPresetIcon(preset.id)} +
+
+
{preset.name}
+
+ {preset.slots} windows +
+
+
+ + {/* Description */} +

+ {preset.description} +

+ + {/* Visual Diagram */} +
{getPresetDiagram(preset)}
+ + {/* Apply Button */} + {canApply ? ( + + ) : ( +
+ Requires {preset.slots} windows (have {windowCount}) +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 91ab763..19ce20e 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 = ; break; + case "layout": + content = ( + + ); + break; default: content = (
diff --git a/src/core/logic.ts b/src/core/logic.ts index 656e7f7..39d39c5 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -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; + } +}; diff --git a/src/core/state.ts b/src/core/state.ts index 4773892..e626a7c 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -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, }; }; diff --git a/src/lib/layout-parser.ts b/src/lib/layout-parser.ts new file mode 100644 index 0000000..0d4eb57 --- /dev/null +++ b/src/lib/layout-parser.ts @@ -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 }; +} diff --git a/src/lib/layout-presets.ts b/src/lib/layout-presets.ts new file mode 100644 index 0000000..042268c --- /dev/null +++ b/src/lib/layout-presets.ts @@ -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; + /** Number of windows required for this preset */ + slots: number; +} + +/** + * Built-in layout presets + */ +export const BUILT_IN_PRESETS: Record = { + "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, + windowIds: string[] +): MosaicNode { + let windowIndex = 0; + + const fill = (node: MosaicNode): MosaicNode => { + // 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 | 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 | null, + preset: LayoutPreset +): MosaicNode { + // 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); +} diff --git a/src/types/app.ts b/src/types/app.ts index 1dd4f7e..26db81d 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -15,7 +15,8 @@ export type AppId = | "decode" | "relay" | "debug" - | "conn"; + | "conn" + | "layout"; export interface WindowInstance { id: string; diff --git a/src/types/man.ts b/src/types/man.ts index 45602df..43cc20e 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -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 = { 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; + }, + }, };