diff --git a/docs/KITTY_LAYOUTS.md b/docs/KITTY_LAYOUTS.md new file mode 100644 index 0000000..9aa45f1 --- /dev/null +++ b/docs/KITTY_LAYOUTS.md @@ -0,0 +1,227 @@ +# Kitty-Inspired Layouts + +> Window layout system for the Workspace IDE, inspired by the [kitty terminal emulator](https://sw.kovidgoyal.net/kitty/overview/#layouts). + +--- + +## Overview + +The Workspace IDE supports **7 kitty-inspired layout modes** that control how panes are arranged within a workspace window. You can switch layouts via the layout selector in the top bar or by pressing **Ctrl+Shift+L** to cycle through them. + +All layouts dynamically rearrange existing panes — no panes are created or destroyed when switching layouts. + +--- + +## Available Layouts + +### Stack + +Displays a **single pane** using all available space. Other panes are hidden behind it and accessible via pane tabs at the top. + +``` +┌──────────────────────────────────┐ +│ │ +│ │ +│ Active Pane │ +│ │ +│ │ +└──────────────────────────────────┘ +``` + +**Use case:** Focus on a single task without distraction. + +--- + +### Tall + +Displays one (or more) **full-height pane(s)** on the left. Remaining panes are stacked vertically on the right. + +``` +┌──────────────┬───────────────┐ +│ │ │ +│ │ │ +│ ├───────────────┤ +│ Main │ Side 1 │ +│ ├───────────────┤ +│ │ Side 2 │ +│ │ │ +└──────────────┴───────────────┘ +``` + +**Options:** +| Option | Default | Description | +|--------|---------|-------------| +| `bias` | 50 | Width percentage for main pane(s), 10–90 | +| `fullSize` | 1 | Number of full-height main panes | +| `mirrored` | false | Place main pane(s) on the right | + +**Use case:** Code editor on the left with preview/console stacked on the right. + +--- + +### Fat + +Displays one (or more) **full-width pane(s)** on top. Remaining panes are tiled horizontally on the bottom. + +``` +┌──────────────────────────────┐ +│ Main │ +│ │ +├─────────┬──────────┬─────────┤ +│ Side 1 │ Side 2 │ Side 3 │ +│ │ │ │ +└─────────┴──────────┴─────────┘ +``` + +**Options:** Same as Tall layout (bias, fullSize, mirrored). + +**Use case:** Wide editor on top with terminal, console, and preview below. + +--- + +### Grid + +Displays panes in a **balanced grid** with all panes roughly the same size. + +``` +┌─────────┬──────────┬─────────┐ +│ │ │ │ +│ │ │ │ +├─────────┼──────────┼─────────┤ +│ │ │ │ +│ │ │ │ +└─────────┴──────────┴─────────┘ +``` + +The grid automatically computes the optimal number of rows and columns based on the pane count. + +**Use case:** Monitoring multiple outputs simultaneously (editor, preview, console, shell). + +--- + +### Splits + +The most **flexible layout**. Panes are arranged using the existing recursive split tree. This is the default layout and preserves manual split arrangements. + +``` +┌──────────────┬───────────────┐ +│ │ │ +│ ├───────┬───────┤ +│ │ │ │ +│ ├───────┴───────┤ +│ │ │ +└──────────────┴───────────────┘ +``` + +**Options:** +| Option | Default | Description | +|--------|---------|-------------| +| `splitAxis` | horizontal | Default split direction: `horizontal`, `vertical`, or `auto` | + +**Use case:** Custom arrangements for complex workflows. + +--- + +### Horizontal + +All panes shown **side by side** (columns). + +``` +┌─────────┬──────────┬─────────┐ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +└─────────┴──────────┴─────────┘ +``` + +**Use case:** Comparing files or outputs side by side. + +--- + +### Vertical + +All panes shown **one below the other** (rows). + +``` +┌──────────────────────────────┐ +│ │ +├──────────────────────────────┤ +│ │ +├──────────────────────────────┤ +│ │ +└──────────────────────────────┘ +``` + +**Use case:** Stacking editor, output, and terminal in a single column. + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+Shift+L` | Cycle to next layout | + +--- + +## Configuration + +See [`kitty.conf`](../kitty.conf) in the project root for the full configuration reference, including: + +- Enabling/disabling specific layouts +- Layout-specific options (bias, full_size, mirrored) +- Keyboard shortcut mappings +- Window resizing controls +- Split management shortcuts + +--- + +## Architecture + +### Layout Engine (`src/layouts/kittyLayouts.ts`) + +The layout engine converts between a flat list of panes and a `PaneSplit` tree: + +1. **`collectPanes(layout)`** — Recursively flattens a layout tree into an ordered list of panes. +2. **`applyKittyLayout(layout, config)`** — Takes the current layout tree and a `KittyLayoutConfig`, extracts all panes, and rebuilds the tree according to the selected layout algorithm. + +### State Management + +Layout state is managed in the Zustand store (`workspaceStore.ts`): + +- `kittyLayout: KittyLayoutConfig` — Current layout configuration +- `enabledLayouts: KittyLayoutType[]` — List of available layouts +- `activePaneIndex: number` — Active pane index for Stack layout +- `setKittyLayout(type)` — Switch to a specific layout +- `updateKittyLayoutConfig(config)` — Update layout options (bias, mirrored, etc.) +- `cycleLayout()` — Advance to the next enabled layout + +### Components + +- **`LayoutSelector`** (`src/components/layout/LayoutSelector.tsx`) — Dropdown in the top bar for selecting layouts and configuring options. +- **`WorkspaceLayout`** (`src/components/layout/WorkspaceLayout.tsx`) — Renders the layout tree; handles Stack mode by showing only the active pane. + +--- + +## Types + +```typescript +type KittyLayoutType = + | 'stack' + | 'tall' + | 'fat' + | 'grid' + | 'splits' + | 'horizontal' + | 'vertical'; + +interface KittyLayoutConfig { + type: KittyLayoutType; + bias: number; // 10–90, percentage split for Tall/Fat + fullSize: number; // Number of full-size panes for Tall/Fat + mirrored: boolean; // Reverse main/side positions for Tall/Fat + splitAxis: 'horizontal' | 'vertical' | 'auto'; // Default axis for Splits +} +``` diff --git a/kitty.conf b/kitty.conf new file mode 100644 index 0000000..b25de25 --- /dev/null +++ b/kitty.conf @@ -0,0 +1,164 @@ +# Workspace IDE - Kitty-Inspired Layout Configuration +# =================================================== +# +# This file configures the kitty-inspired layout system for the Workspace IDE. +# Layouts define how panes are arranged within a workspace window. +# +# Reload configuration: The IDE reads this on startup; changes take effect +# after reloading the page or switching layouts. +# +# Syntax: key value +# Comments start with # + +# ─── Enabled Layouts ──────────────────────────────────────────────── +# Comma-separated list of layout names. The first layout is the default. +# Available: stack, tall, fat, grid, splits, horizontal, vertical +# Use "all" to enable every layout. +enabled_layouts all + +# ─── Layout Cycling ───────────────────────────────────────────────── +# Keyboard shortcut to cycle through enabled layouts. +# Default: ctrl+shift+l +map ctrl+shift+l next_layout + +# ─── Stack Layout ─────────────────────────────────────────────────── +# Displays a single pane using all available space. +# Other panes are hidden behind it. Switch between them via pane tabs. +# +# ┌──────────────────────────────────┐ +# │ │ +# │ Active Pane │ +# │ │ +# └──────────────────────────────────┘ + +# ─── Tall Layout ──────────────────────────────────────────────────── +# Full-height pane(s) on the left, remaining panes stacked on the right. +# +# Options: +# bias - Percentage of width for the main pane(s) (10-90, default: 50) +# full_size - Number of full-height panes (default: 1) +# mirrored - If true, main pane(s) on the right (default: false) +# +# enabled_layouts tall:bias=50;full_size=1;mirrored=false +# +# ┌──────────────┬───────────────┐ +# │ │ │ +# │ │ │ +# │ ├───────────────┤ +# │ Main │ Side 1 │ +# │ ├───────────────┤ +# │ │ Side 2 │ +# │ │ │ +# └──────────────┴───────────────┘ + +# ─── Fat Layout ───────────────────────────────────────────────────── +# Full-width pane(s) on top, remaining panes tiled horizontally on bottom. +# +# Options: +# bias - Percentage of height for the main pane(s) (10-90, default: 50) +# full_size - Number of full-width panes (default: 1) +# mirrored - If true, main pane(s) on the bottom (default: false) +# +# enabled_layouts fat:bias=50;full_size=1;mirrored=false +# +# ┌──────────────────────────────┐ +# │ Main │ +# │ │ +# ├─────────┬──────────┬─────────┤ +# │ Side 1 │ Side 2 │ Side 3 │ +# │ │ │ │ +# └─────────┴──────────┴─────────┘ + +# ─── Grid Layout ──────────────────────────────────────────────────── +# Balanced grid with all panes the same size. +# +# ┌─────────┬──────────┬─────────┐ +# │ │ │ │ +# │ │ │ │ +# ├─────────┼──────────┼─────────┤ +# │ │ │ │ +# │ │ │ │ +# └─────────┴──────────┴─────────┘ + +# ─── Splits Layout ────────────────────────────────────────────────── +# The most flexible layout. Panes are arranged via recursive splits. +# This is the default layout for manual pane management. +# +# Options: +# split_axis - Default split direction: horizontal, vertical, or auto +# +# enabled_layouts splits:split_axis=horizontal +# +# ┌──────────────┬───────────────┐ +# │ │ │ +# │ ├───────┬───────┤ +# │ │ │ │ +# │ ├───────┴───────┤ +# │ │ │ +# └──────────────┴───────────────┘ + +# ─── Horizontal Layout ────────────────────────────────────────────── +# All panes shown side by side (columns). +# +# ┌─────────┬──────────┬─────────┐ +# │ │ │ │ +# │ │ │ │ +# │ │ │ │ +# │ │ │ │ +# └─────────┴──────────┴─────────┘ + +# ─── Vertical Layout ──────────────────────────────────────────────── +# All panes shown one below the other (rows). +# +# ┌──────────────────────────────┐ +# │ │ +# ├──────────────────────────────┤ +# │ │ +# ├──────────────────────────────┤ +# │ │ +# └──────────────────────────────┘ + +# ─── Layout Action Shortcuts ──────────────────────────────────────── +# These shortcuts control layout-specific behavior. + +# Decrease/increase the number of full-size panes (Tall/Fat layouts) +map ctrl+[ layout_action decrease_num_full_size_windows +map ctrl+] layout_action increase_num_full_size_windows + +# Toggle or set mirrored mode (Tall/Fat layouts) +map ctrl+/ layout_action mirror toggle + +# Adjust bias (Tall/Fat layouts) - cycles through percentages +map ctrl+. layout_action bias 50 62 70 +map ctrl+, layout_action bias 62 + +# ─── Window Resizing ──────────────────────────────────────────────── +# Resize the active pane within the current layout. +map ctrl+left resize_window narrower +map ctrl+right resize_window wider +map ctrl+up resize_window taller +map ctrl+down resize_window shorter + +# Reset all panes to default sizes +map ctrl+home resize_window reset + +# ─── Split Management (Splits Layout) ────────────────────────────── +# Create new panes by splitting the current one. +map f5 launch --location=hsplit +map f6 launch --location=vsplit +map f4 launch --location=split + +# Rotate the current split axis +map f7 layout_action rotate + +# Move the active pane in the indicated direction +map shift+up move_window up +map shift+left move_window left +map shift+right move_window right +map shift+down move_window down + +# Switch focus to the neighboring pane +map ctrl+left neighboring_window left +map ctrl+right neighboring_window right +map ctrl+up neighboring_window up +map ctrl+down neighboring_window down diff --git a/src/App.tsx b/src/App.tsx index 0319d27..666f542 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ export default function App() { searchOpen, spotlightOpen, setSearchOpen, + cycleLayout, } = useWorkspaceStore(); const activeWindow = windows.find((w) => w.id === activeWindowId); @@ -25,10 +26,14 @@ export default function App() { e.preventDefault(); setSearchOpen(true); } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'L') { + e.preventDefault(); + cycleLayout(); + } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, [setSearchOpen]); + }, [setSearchOpen, cycleLayout]); return (
diff --git a/src/components/layout/LayoutSelector.tsx b/src/components/layout/LayoutSelector.tsx new file mode 100644 index 0000000..958bf9d --- /dev/null +++ b/src/components/layout/LayoutSelector.tsx @@ -0,0 +1,148 @@ +import { useState, useRef, useEffect } from 'react'; +import { + LayoutGrid, + Columns, + Rows, + Square, + PanelLeft, + PanelTop, + SplitSquareHorizontal, + ChevronDown, + type LucideIcon, +} from 'lucide-react'; +import { useWorkspaceStore } from '../../stores/workspaceStore'; +import type { KittyLayoutType } from '../../types/workspace'; +import { KITTY_LAYOUT_LABELS } from '../../types/workspace'; + +const LAYOUT_ICONS: Record = { + stack: Square, + tall: PanelLeft, + fat: PanelTop, + grid: LayoutGrid, + splits: SplitSquareHorizontal, + horizontal: Columns, + vertical: Rows, +}; + +const LAYOUT_DESCRIPTIONS: Record = { + stack: 'Single window, others hidden', + tall: 'Full-height left + stacked right', + fat: 'Full-width top + tiled bottom', + grid: 'Balanced grid of equal windows', + splits: 'Manual split arrangement', + horizontal: 'All windows side by side', + vertical: 'All windows stacked vertically', +}; + +export function LayoutSelector() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { kittyLayout, setKittyLayout, updateKittyLayoutConfig, enabledLayouts } = + useWorkspaceStore(); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const CurrentIcon = LAYOUT_ICONS[kittyLayout.type]; + const showBiasControls = + kittyLayout.type === 'tall' || kittyLayout.type === 'fat'; + + return ( +
+ + + {open && ( +
+
Kitty Layouts
+ {enabledLayouts.map((layoutType) => { + const Icon = LAYOUT_ICONS[layoutType]; + const isActive = kittyLayout.type === layoutType; + return ( + + ); + })} + + {showBiasControls && ( + <> +
+
Layout Options
+
+ + + updateKittyLayoutConfig({ bias: Number(e.target.value) }) + } + /> +
+
+ + + updateKittyLayoutConfig({ fullSize: Number(e.target.value) }) + } + /> +
+
+ +
+ + )} +
+ )} +
+ ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 54515c1..643dcf5 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,5 +1,6 @@ import { Plus, X, Search, Play, Square } from 'lucide-react'; import { useWorkspaceStore } from '../../stores/workspaceStore'; +import { LayoutSelector } from './LayoutSelector'; export function TopBar() { const { @@ -40,6 +41,8 @@ export function TopBar() {
+ + + ); + })} +
+ )} +
+ +
+ + ); +} + interface WorkspaceLayoutProps { layout: Pane | PaneSplit; } export function WorkspaceLayout({ layout }: WorkspaceLayoutProps) { + const { kittyLayout } = useWorkspaceStore(); + const isStack = kittyLayout.type === 'stack'; + return (
- + {isStack ? ( + + ) : ( + + )}
); } diff --git a/src/index.css b/src/index.css index 1732212..4839131 100644 --- a/src/index.css +++ b/src/index.css @@ -1079,6 +1079,176 @@ html, body, #root { border-color: var(--accent); } +/* Layout Selector */ +.layout-selector { + position: relative; + flex-shrink: 0; +} + +.layout-selector-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.layout-selector-trigger:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--accent); +} + +.layout-selector-label { + font-weight: 500; +} + +.layout-selector-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + min-width: 260px; + padding: 6px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); + z-index: 600; +} + +.layout-selector-header { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 10px 4px; +} + +.layout-selector-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: background 0.1s; + color: var(--text-secondary); + border: none; + background: none; + width: 100%; + text-align: left; +} + +.layout-selector-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.layout-selector-item.active { + background: rgba(137, 180, 250, 0.12); + color: var(--accent); +} + +.layout-selector-item-text { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; +} + +.layout-selector-item-name { + font-weight: 500; +} + +.layout-selector-item-desc { + font-size: 11px; + color: var(--text-muted); +} + +.layout-selector-active-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.layout-selector-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +.layout-selector-option { + padding: 6px 10px; + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 4px; +} + +.layout-selector-option label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + cursor: pointer; +} + +.layout-selector-option input[type="range"] { + width: 100%; + accent-color: var(--accent); + height: 4px; +} + +.layout-selector-option input[type="checkbox"] { + accent-color: var(--accent); +} + +/* Stack layout pane tabs */ +.stack-pane-tabs { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; + padding: 0 4px; + gap: 2px; +} + +.stack-pane-tab { + padding: 6px 14px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + color: var(--text-muted); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + transition: all 0.15s; + white-space: nowrap; +} + +.stack-pane-tab:hover { + color: var(--text-secondary); + background: var(--bg-hover); +} + +.stack-pane-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + /* Floating pane */ .floating-pane { position: fixed; diff --git a/src/layouts/kittyLayouts.ts b/src/layouts/kittyLayouts.ts new file mode 100644 index 0000000..73eed99 --- /dev/null +++ b/src/layouts/kittyLayouts.ts @@ -0,0 +1,206 @@ +import type { Pane, PaneSplit, KittyLayoutConfig } from '../types/workspace'; + +type LayoutNode = Pane | PaneSplit; + +let splitCounter = 1000; +const genSplitId = () => `ksplit-${++splitCounter}`; + +function isPaneSplit(node: LayoutNode): node is PaneSplit { + return 'direction' in node && 'children' in node; +} + +export function collectPanes(layout: LayoutNode): Pane[] { + if (!isPaneSplit(layout)) return [layout]; + const panes: Pane[] = []; + for (const child of layout.children) { + panes.push(...collectPanes(child)); + } + return panes; +} + +function makeSplit( + direction: 'horizontal' | 'vertical', + children: LayoutNode[], + sizes: number[], +): PaneSplit { + return { + id: genSplitId(), + direction, + children, + sizes, + }; +} + +function equalSizes(count: number): number[] { + const size = Math.round(100 / count); + return Array.from({ length: count }, (_, i) => + i === count - 1 ? 100 - size * (count - 1) : size, + ); +} + +function buildStack(panes: Pane[]): LayoutNode { + if (panes.length === 0) return panes[0]; + return makeSplit('horizontal', panes, equalSizes(panes.length)); +} + +function buildTall(panes: Pane[], config: KittyLayoutConfig): LayoutNode { + if (panes.length <= 1) return panes[0]; + + const fullSize = Math.min(config.fullSize, panes.length); + const mainPanes = panes.slice(0, fullSize); + const sidePanes = panes.slice(fullSize); + + if (sidePanes.length === 0) { + if (mainPanes.length === 1) return mainPanes[0]; + return makeSplit('horizontal', mainPanes, equalSizes(mainPanes.length)); + } + + const mainSection: LayoutNode = + mainPanes.length === 1 + ? mainPanes[0] + : makeSplit('horizontal', mainPanes, equalSizes(mainPanes.length)); + + const sideSection: LayoutNode = + sidePanes.length === 1 + ? sidePanes[0] + : makeSplit('vertical', sidePanes, equalSizes(sidePanes.length)); + + const children: LayoutNode[] = config.mirrored + ? [sideSection, mainSection] + : [mainSection, sideSection]; + + const sizes = config.mirrored + ? [100 - config.bias, config.bias] + : [config.bias, 100 - config.bias]; + + return makeSplit('horizontal', children, sizes); +} + +function buildFat(panes: Pane[], config: KittyLayoutConfig): LayoutNode { + if (panes.length <= 1) return panes[0]; + + const fullSize = Math.min(config.fullSize, panes.length); + const mainPanes = panes.slice(0, fullSize); + const sidePanes = panes.slice(fullSize); + + if (sidePanes.length === 0) { + if (mainPanes.length === 1) return mainPanes[0]; + return makeSplit('vertical', mainPanes, equalSizes(mainPanes.length)); + } + + const mainSection: LayoutNode = + mainPanes.length === 1 + ? mainPanes[0] + : makeSplit('vertical', mainPanes, equalSizes(mainPanes.length)); + + const sideSection: LayoutNode = + sidePanes.length === 1 + ? sidePanes[0] + : makeSplit('horizontal', sidePanes, equalSizes(sidePanes.length)); + + const children: LayoutNode[] = config.mirrored + ? [sideSection, mainSection] + : [mainSection, sideSection]; + + const sizes = config.mirrored + ? [100 - config.bias, config.bias] + : [config.bias, 100 - config.bias]; + + return makeSplit('vertical', children, sizes); +} + +function buildGrid(panes: Pane[]): LayoutNode { + if (panes.length <= 1) return panes[0]; + if (panes.length === 2) { + return makeSplit('horizontal', panes, [50, 50]); + } + + const cols = Math.ceil(Math.sqrt(panes.length)); + const rows = Math.ceil(panes.length / cols); + + const rowNodes: LayoutNode[] = []; + for (let r = 0; r < rows; r++) { + const rowPanes = panes.slice(r * cols, (r + 1) * cols); + if (rowPanes.length === 1) { + rowNodes.push(rowPanes[0]); + } else { + rowNodes.push(makeSplit('horizontal', rowPanes, equalSizes(rowPanes.length))); + } + } + + if (rowNodes.length === 1) return rowNodes[0]; + return makeSplit('vertical', rowNodes, equalSizes(rowNodes.length)); +} + +function buildHorizontal(panes: Pane[]): LayoutNode { + if (panes.length <= 1) return panes[0]; + return makeSplit('horizontal', panes, equalSizes(panes.length)); +} + +function buildVertical(panes: Pane[]): LayoutNode { + if (panes.length <= 1) return panes[0]; + return makeSplit('vertical', panes, equalSizes(panes.length)); +} + +function buildSplits(panes: Pane[], config: KittyLayoutConfig): LayoutNode { + if (panes.length <= 1) return panes[0]; + + const direction = + config.splitAxis === 'auto' + ? 'horizontal' + : config.splitAxis === 'horizontal' + ? 'horizontal' + : 'vertical'; + + if (panes.length === 2) { + return makeSplit(direction, panes, [50, 50]); + } + + const first = panes[0]; + const rest = panes.slice(1); + const altDirection = direction === 'horizontal' ? 'vertical' : 'horizontal'; + const restNode: LayoutNode = + rest.length === 1 + ? rest[0] + : makeSplit(altDirection, rest, equalSizes(rest.length)); + + return makeSplit(direction, [first, restNode], [50, 50]); +} + +export function applyKittyLayout( + currentLayout: LayoutNode, + config: KittyLayoutConfig, +): LayoutNode { + const panes = collectPanes(currentLayout); + if (panes.length === 0) return currentLayout; + if (panes.length === 1) return panes[0]; + + switch (config.type) { + case 'stack': + return buildStack(panes); + case 'tall': + return buildTall(panes, config); + case 'fat': + return buildFat(panes, config); + case 'grid': + return buildGrid(panes); + case 'horizontal': + return buildHorizontal(panes); + case 'vertical': + return buildVertical(panes); + case 'splits': + return buildSplits(panes, config); + default: + return currentLayout; + } +} + +export function getDefaultLayoutConfig(): KittyLayoutConfig { + return { + type: 'splits', + bias: 50, + fullSize: 1, + mirrored: false, + splitAxis: 'horizontal', + }; +} diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index 82532ad..33e174e 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -9,7 +9,11 @@ import type { SpotlightConfig, SplitDirection, PaneSplit, + KittyLayoutType, + KittyLayoutConfig, } from '../types/workspace'; +import { KITTY_LAYOUT_ORDER } from '../types/workspace'; +import { applyKittyLayout, getDefaultLayoutConfig } from '../layouts/kittyLayouts'; let idCounter = 0; const genId = (prefix: string) => `${prefix}-${++idCounter}`; @@ -118,6 +122,14 @@ interface WorkspaceState { searchOpen: boolean; spotlightOpen: boolean; secrets: { key: string; name: string; value?: string; masked: boolean }[]; + kittyLayout: KittyLayoutConfig; + enabledLayouts: KittyLayoutType[]; + activePaneIndex: number; + + setKittyLayout: (type: KittyLayoutType) => void; + updateKittyLayoutConfig: (config: Partial) => void; + cycleLayout: () => void; + setActivePaneIndex: (index: number) => void; addWindow: () => void; removeWindow: (id: string) => void; @@ -288,6 +300,48 @@ export const useWorkspaceStore = create((set, get) => ({ { key: 's1', name: 'OPENAI_API_KEY', masked: true }, { key: 's2', name: 'DATABASE_URL', masked: true }, ], + kittyLayout: getDefaultLayoutConfig(), + enabledLayouts: KITTY_LAYOUT_ORDER, + activePaneIndex: 0, + + setKittyLayout: (type) => { + set((s) => { + const newConfig: KittyLayoutConfig = { ...s.kittyLayout, type }; + const win = s.windows.find((w) => w.isActive); + if (!win) return { kittyLayout: newConfig }; + const newLayout = applyKittyLayout(win.layout, newConfig); + return { + kittyLayout: newConfig, + windows: s.windows.map((w) => + w.isActive ? { ...w, layout: newLayout } : w, + ), + }; + }); + }, + + updateKittyLayoutConfig: (config) => { + set((s) => { + const newConfig: KittyLayoutConfig = { ...s.kittyLayout, ...config }; + const win = s.windows.find((w) => w.isActive); + if (!win) return { kittyLayout: newConfig }; + const newLayout = applyKittyLayout(win.layout, newConfig); + return { + kittyLayout: newConfig, + windows: s.windows.map((w) => + w.isActive ? { ...w, layout: newLayout } : w, + ), + }; + }); + }, + + cycleLayout: () => { + const state = get(); + const currentIndex = state.enabledLayouts.indexOf(state.kittyLayout.type); + const nextIndex = (currentIndex + 1) % state.enabledLayouts.length; + state.setKittyLayout(state.enabledLayouts[nextIndex]); + }, + + setActivePaneIndex: (index) => set({ activePaneIndex: index }), addWindow: () => { const tab = createTab('editor', 'Untitled'); diff --git a/src/types/workspace.ts b/src/types/workspace.ts index 0079b3d..902df06 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -77,3 +77,40 @@ export interface SpotlightConfig { isPublic: boolean; shareLink?: string; } + +export type KittyLayoutType = + | 'stack' + | 'tall' + | 'fat' + | 'grid' + | 'splits' + | 'horizontal' + | 'vertical'; + +export interface KittyLayoutConfig { + type: KittyLayoutType; + bias: number; + fullSize: number; + mirrored: boolean; + splitAxis: 'horizontal' | 'vertical' | 'auto'; +} + +export const KITTY_LAYOUT_LABELS: Record = { + stack: 'Stack', + tall: 'Tall', + fat: 'Fat', + grid: 'Grid', + splits: 'Splits', + horizontal: 'Horizontal', + vertical: 'Vertical', +}; + +export const KITTY_LAYOUT_ORDER: KittyLayoutType[] = [ + 'stack', + 'tall', + 'fat', + 'grid', + 'splits', + 'horizontal', + 'vertical', +];