feat: improve layout animations and consolidate controls

Animation improvements:
- Use professional easing curve: cubic-bezier(0.25, 0.1, 0.25, 1)
- Reduce duration from 200ms to 150ms for snappier feel
- Enable animations only during preset application (not manual resize/drag)
- Add CSS layout containment for better performance
- Add/remove 'animating-layout' class to control when animations occur

UI consolidation:
- Merge layout preset dropdown and insertion settings into single control
- Create unified LayoutControls component with sections:
  * Presets (apply existing layouts)
  * Insert Mode (balanced/horizontal/vertical)
  * Split ratio slider with +/- buttons
- Remove separate icons, now just one SlidersHorizontal button
- Cleaner, more discoverable interface

Benefits:
- Smoother, more natural-feeling animations
- No animation jank during manual operations
- Single unified control reduces UI clutter
- All layout settings in one place

🤖 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:02:19 +01:00
parent 4c19ad2fd3
commit b81ac599a2
3 changed files with 213 additions and 108 deletions

View File

@@ -0,0 +1,201 @@
import {
SlidersHorizontal,
Grid2X2,
Columns2,
Split,
Sparkles,
SplitSquareHorizontal,
SplitSquareVertical,
} from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { getAllPresets } from "@/lib/layout-presets";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { toast } from "sonner";
import type { LayoutConfig } from "@/types/app";
export function LayoutControls() {
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
const { workspaces, activeWorkspaceId, layoutConfig } = state;
const activeWorkspace = workspaces[activeWorkspaceId];
const windowCount = activeWorkspace?.windowIds.length || 0;
const presets = getAllPresets();
const handleApplyPreset = (presetId: string) => {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
if (windowCount < preset.slots) {
toast.error(`Not enough windows`, {
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
});
return;
}
try {
// Enable animations for smooth layout transition
document.body.classList.add("animating-layout");
applyPresetLayout(preset);
// Remove animation class after transition completes
setTimeout(() => {
document.body.classList.remove("animating-layout");
}, 180);
} catch (error) {
document.body.classList.remove("animating-layout");
toast.error(`Failed to apply layout`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
};
const getPresetIcon = (presetId: string) => {
switch (presetId) {
case "side-by-side":
return <Columns2 className="h-4 w-4" />;
case "main-sidebar":
return <Split className="h-4 w-4" />;
case "grid":
return <Grid2X2 className="h-4 w-4" />;
default:
return <Grid2X2 className="h-4 w-4" />;
}
};
const insertionModes: Array<{
id: LayoutConfig["insertionMode"];
label: string;
icon: React.ComponentType<{ className?: string }>;
}> = [
{ id: "smart", label: "Balanced", icon: Sparkles },
{ id: "row", label: "Horizontal", icon: SplitSquareHorizontal },
{ id: "column", label: "Vertical", icon: SplitSquareVertical },
];
const handleSplitChange = (increment: number) => {
const newValue = Math.max(
20,
Math.min(80, layoutConfig.splitPercentage + increment)
);
updateLayoutConfig({ splitPercentage: newValue });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Presets Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Presets
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => handleApplyPreset(preset.id)}
disabled={!canApply}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<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})`}
</div>
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Insertion Mode Section */}
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Insert Mode
</div>
{insertionModes.map((mode) => {
const Icon = mode.icon;
const isActive = layoutConfig.insertionMode === mode.id;
return (
<DropdownMenuItem
key={mode.id}
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
className="flex items-center gap-2 cursor-pointer"
>
<Icon className="h-3.5 w-3.5" />
<span className="flex-1">{mode.label}</span>
{isActive && (
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
{/* Split Ratio Section */}
<div className="px-2 py-2 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-muted-foreground">Split</span>
<span className="text-foreground">
{layoutConfig.splitPercentage}/
{100 - layoutConfig.splitPercentage}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleSplitChange(-10)}
>
-
</Button>
<input
type="range"
min="20"
max="80"
value={layoutConfig.splitPercentage}
onChange={(e) =>
updateLayoutConfig({
splitPercentage: Number(e.target.value),
})
}
className="flex-1 h-1.5 bg-muted rounded-lg appearance-none cursor-pointer accent-accent"
/>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleSplitChange(10)}
>
+
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,66 +1,17 @@
import { Plus, SlidersHorizontal, Grid2X2, Columns2, Split } from "lucide-react";
import { Plus } from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { WorkspaceSettings } from "./WorkspaceSettings";
import { getAllPresets } from "@/lib/layout-presets";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { toast } from "sonner";
import { useState } from "react";
import { LayoutControls } from "./LayoutControls";
export function TabBar() {
const { state, setActiveWorkspace, createWorkspace, applyPresetLayout } = useGrimoire();
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
const [settingsOpen, setSettingsOpen] = useState(false);
const activeWorkspace = workspaces[activeWorkspaceId];
const windowCount = activeWorkspace?.windowIds.length || 0;
const presets = getAllPresets();
const handleNewTab = () => {
createWorkspace();
};
const handleApplyPreset = (presetId: string) => {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
if (windowCount < preset.slots) {
toast.error(`Not enough windows`, {
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
});
return;
}
try {
applyPresetLayout(preset);
} catch (error) {
toast.error(`Failed to apply layout`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
};
const getPresetIcon = (presetId: string) => {
switch (presetId) {
case "side-by-side":
return <Columns2 className="h-4 w-4" />;
case "main-sidebar":
return <Split className="h-4 w-4" />;
case "grid":
return <Grid2X2 className="h-4 w-4" />;
default:
return <Grid2X2 className="h-4 w-4" />;
}
};
// Sort workspaces by number
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number,
@@ -104,60 +55,7 @@ export function TabBar() {
{/* Right side: Layout controls */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Layout Preset Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label="Apply layout preset"
>
<Grid2X2 className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Apply Layout Preset
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => handleApplyPreset(preset.id)}
disabled={!canApply}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<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})`}
</div>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Window/Layout Settings */}
<WorkspaceSettings
open={settingsOpen}
onOpenChange={setSettingsOpen}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3" />
</Button>
</WorkspaceSettings>
<LayoutControls />
</div>
</div>
</>

View File

@@ -124,8 +124,14 @@
}
/* Smooth animations for window resizing and repositioning */
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-tile {
transition: width 0.2s ease-out, height 0.2s ease-out, top 0.2s ease-out, left 0.2s ease-out;
/* Only animate during preset application, not manual resize/drag */
body.animating-layout .mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-tile {
transition:
width 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
height 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
top 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
left 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
contain: layout; /* Isolate layout calculations for better performance */
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme