From b57ff31907fec6ea2c19f202e8f509899ea48ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 18 Dec 2025 13:47:40 +0100 Subject: [PATCH] feat: balance layout and kbd workspace navigation --- TODO.md | 61 ++++++++++++++++++++++++ src/components/LayoutControls.tsx | 44 ++++++++++++++++- src/components/TabBar.tsx | 22 ++++++++- src/core/logic.ts | 31 +++++++++++- src/core/state.ts | 6 +++ src/lib/layout-presets.test.ts | 79 +++++++++++++++++++++++++++++++ src/lib/layout-presets.ts | 25 ++++++++++ 7 files changed, 265 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index a12deb3..0078fd6 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,67 @@ All event renderers now protected with error boundaries: - Retry button and collapsible details for debugging - Auto-resets when event changes +### Layout System Enhancements +**Completed**: 2024-12-18 +**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx`, `src/core/logic.ts`, `src/core/state.ts` + +Quick-win improvements to window management: +- **Balance Splits**: New action to equalize all split percentages to 50/50 after manual resizing + - Recursive tree traversal preserves window IDs and directions + - Added to Actions section in LayoutControls dropdown + - Smooth animation on balance operation +- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by number + - Browser-safe shortcuts (prevents default browser behavior) + - Significantly faster workflow for power users +- Comprehensive test coverage for balanceLayout function + +## Window Management Improvements + +### Fullscreen Mode +**Priority**: High | **Effort**: Medium (2-3 hours) +**Description**: Toggle window to fill entire workspace with minimal chrome +**Implementation**: +- CSS-based approach (hide siblings, expand target) +- Keep workspace tabs visible for navigation +- Toolbar button + right-click menu to enter fullscreen +- ESC or button click to exit +- Add `fullscreenWindowId` to workspace state +- Smooth animation on enter/exit + +**Use Case**: Reading long-form content, focused analysis of single event/profile + +### Move Window to Different Workspace +**Priority**: Medium | **Effort**: High (3-4 hours) +**Description**: Reorganize windows by moving them between workspaces +**Implementation**: +- Right-click window → "Move to Workspace N" submenu +- Extract window from current layout tree +- Insert into target workspace layout +- Handle edge cases (last window, invalid workspace) + +**Use Case**: "This profile is actually relevant to workspace 2's topic" + +### Rotate/Mirror Layout +**Priority**: Low | **Effort**: Medium (2 hours) +**Description**: Swap all row↔column directions in layout tree +**Implementation**: +- Recursive tree traversal +- Swap `direction: "row"` ↔ `direction: "column"` +- Keep split percentages unchanged +- Add to Actions section in LayoutControls + +**Use Case**: "This arrangement works better vertically than horizontally" + +### Tab Navigation Between Windows +**Priority**: Low | **Effort**: Low (1 hour) +**Description**: Keyboard navigation within workspace +**Implementation**: +- Tab/Shift+Tab to cycle focus between windows +- Focus management via mosaic window refs +- Visual focus indicator (border highlight) + +**Use Case**: Keyboard-driven workflow without mouse + ## Planned Improvements - **App-wide error boundary** - Splash crash screen for unhandled errors (separate from event-level boundaries) diff --git a/src/components/LayoutControls.tsx b/src/components/LayoutControls.tsx index a54b00d..e1113e1 100644 --- a/src/components/LayoutControls.tsx +++ b/src/components/LayoutControls.tsx @@ -6,6 +6,7 @@ import { Sparkles, SplitSquareHorizontal, SplitSquareVertical, + Scale, } from "lucide-react"; import { Button } from "./ui/button"; import { useGrimoire } from "@/core/state"; @@ -22,7 +23,8 @@ import { toast } from "sonner"; import type { LayoutConfig } from "@/types/app"; export function LayoutControls() { - const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire(); + const { state, applyPresetLayout, balanceLayout, updateLayoutConfig } = + useGrimoire(); const { workspaces, activeWorkspaceId, layoutConfig } = state; const activeWorkspace = workspaces[activeWorkspaceId]; @@ -97,6 +99,26 @@ export function LayoutControls() { updateLayoutConfig({ splitPercentage: newValue }); }; + const handleBalance = () => { + try { + // Enable animations for smooth transition + document.body.classList.add("animating-layout"); + + balanceLayout(); + + // 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 balance layout`, { + description: + error instanceof Error ? error.message : "Unknown error occurred", + }); + } + }; + return ( @@ -214,6 +236,26 @@ export function LayoutControls() { + + + + {/* Actions Section */} +
+ Actions +
+ + + Balance Splits + {windowCount < 2 && ( + + Need 2+ windows + + )} +
); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 3682c03..aca42a6 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -3,6 +3,7 @@ import { Button } from "./ui/button"; import { useGrimoire } from "@/core/state"; import { cn } from "@/lib/utils"; import { LayoutControls } from "./LayoutControls"; +import { useEffect } from "react"; export function TabBar() { const { state, setActiveWorkspace, createWorkspace } = useGrimoire(); @@ -12,11 +13,30 @@ export function TabBar() { createWorkspace(); }; - // Sort workspaces by number + // Sort workspaces by number (for both rendering and keyboard shortcuts) const sortedWorkspaces = Object.values(workspaces).sort( (a, b) => a.number - b.number, ); + // Keyboard shortcut: Cmd+1-9 to switch workspaces by position + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check for Cmd/Ctrl + number key (1-9) + if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") { + const index = Number.parseInt(e.key, 10) - 1; // Convert key to array index + const targetWorkspace = sortedWorkspaces[index]; + + if (targetWorkspace) { + e.preventDefault(); // Prevent browser default (like Cmd+1 = first tab) + setActiveWorkspace(targetWorkspace.id); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [sortedWorkspaces, setActiveWorkspace]); + return ( <>
diff --git a/src/core/logic.ts b/src/core/logic.ts index a719d6a..beaff9a 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -2,7 +2,11 @@ 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"; +import { + applyPresetToLayout, + balanceLayout, + type LayoutPreset, +} from "@/lib/layout-presets"; /** * Finds the lowest available workspace number. @@ -394,3 +398,28 @@ export const applyPresetLayout = ( return state; } }; + +/** + * Balances all split percentages in the active workspace to 50/50. + * Useful for equalizing splits after manual resizing. + */ +export const balanceLayoutInWorkspace = ( + state: GrimoireState, +): GrimoireState => { + const activeId = state.activeWorkspaceId; + const ws = state.workspaces[activeId]; + + // Balance the layout tree + const balancedLayout = balanceLayout(ws.layout); + + return { + ...state, + workspaces: { + ...state.workspaces, + [activeId]: { + ...ws, + layout: balancedLayout, + }, + }, + }; +}; diff --git a/src/core/state.ts b/src/core/state.ts index 609bfd3..667e5d5 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -241,6 +241,11 @@ export const useGrimoire = () => { [setState], ); + const balanceLayout = useCallback( + () => setState((prev) => Logic.balanceLayoutInWorkspace(prev)), + [setState], + ); + return { state, locale: state.locale || browserLocale, @@ -256,5 +261,6 @@ export const useGrimoire = () => { setActiveAccountRelays, updateLayoutConfig, applyPresetLayout, + balanceLayout, }; }; diff --git a/src/lib/layout-presets.test.ts b/src/lib/layout-presets.test.ts index beacdd2..e12aa36 100644 --- a/src/lib/layout-presets.test.ts +++ b/src/lib/layout-presets.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { collectWindowIds, applyPresetToLayout, + balanceLayout, BUILT_IN_PRESETS, } from "./layout-presets"; import type { MosaicNode } from "react-mosaic-component"; @@ -193,6 +194,84 @@ describe("layout-presets", () => { }); }); + describe("balanceLayout", () => { + it("returns null for null layout", () => { + expect(balanceLayout(null)).toBeNull(); + }); + + it("returns single window unchanged", () => { + expect(balanceLayout("w1")).toBe("w1"); + }); + + it("balances a simple binary split", () => { + const unbalanced: MosaicNode = { + direction: "row", + first: "w1", + second: "w2", + splitPercentage: 70, + }; + const balanced = balanceLayout(unbalanced); + expect(balanced).toEqual({ + direction: "row", + first: "w1", + second: "w2", + splitPercentage: 50, + }); + }); + + it("balances nested splits recursively", () => { + const unbalanced: MosaicNode = { + direction: "row", + first: { + direction: "column", + first: "w1", + second: "w2", + splitPercentage: 30, + }, + second: { + direction: "column", + first: "w3", + second: "w4", + splitPercentage: 80, + }, + splitPercentage: 60, + }; + const balanced = balanceLayout(unbalanced); + + // All splits should be 50% + expect(balanced).toMatchObject({ + splitPercentage: 50, + first: { splitPercentage: 50 }, + second: { splitPercentage: 50 }, + }); + }); + + it("preserves window IDs and directions", () => { + const original: MosaicNode = { + direction: "column", + first: "w1", + second: { + direction: "row", + first: "w2", + second: "w3", + splitPercentage: 75, + }, + splitPercentage: 25, + }; + const balanced = balanceLayout(original); + const windowIds = collectWindowIds(balanced); + expect(windowIds).toEqual(["w1", "w2", "w3"]); + + // Check directions preserved + if (balanced && typeof balanced !== "string") { + expect(balanced.direction).toBe("column"); + if (typeof balanced.second !== "string") { + expect(balanced.second.direction).toBe("row"); + } + } + }); + }); + describe("applyPresetToLayout", () => { it("throws error if too few windows", () => { const layout: MosaicNode = "w1"; diff --git a/src/lib/layout-presets.ts b/src/lib/layout-presets.ts index 247daa3..ed84ae9 100644 --- a/src/lib/layout-presets.ts +++ b/src/lib/layout-presets.ts @@ -203,6 +203,31 @@ export function applyPresetToLayout( return preset.generate(windowIds); } +/** + * Balances all split percentages in a layout tree to 50/50 + * Useful for equalizing splits after manual resizing + */ +export function balanceLayout( + layout: MosaicNode | null +): MosaicNode | null { + if (layout === null) { + return null; + } + + // Leaf node (window ID), return as-is + if (typeof layout === "string") { + return layout; + } + + // Branch node, balance this split and recurse + return { + direction: layout.direction, + first: balanceLayout(layout.first), + second: balanceLayout(layout.second), + splitPercentage: 50, + }; +} + /** * Get a preset by ID */