Skip to content

Implement mouse event handling #8

@gregpriday

Description

@gregpriday

Summary

Implement comprehensive mouse event handling for the Yellowwood file tree, including left-click navigation/opening, right-click context menus, and scroll wheel support. This provides a complete mouse-driven workflow alongside keyboard navigation, making Yellowwood feel native to mouse users while maintaining keyboard ergonomics.

Context

Yellowwood must support both mouse and keyboard workflows seamlessly. Mouse support includes:

  1. Left-click on folders: Toggle expansion/collapse
  2. Left-click on files: Open with default editor or select (configurable)
  3. Right-click: Open context menu (where terminals support it)
  4. Scroll wheel: Scroll the tree viewport
  5. Click handling: Translate terminal mouse coordinates to tree row selection

Current State:

  • src/types/index.ts defines YellowwoodConfig with ui.leftClickAction (not yet in types - will be added)
  • No mouse handling exists yet
  • Ink 6.5 supports mouse events via Box component props
  • Terminal mouse support varies (most modern terminals support it)

How This Will Be Used:

// In TreeView component
const { handleClick, handleScroll } = useMouse({
  fileTree: flattenedTree,
  selectedPath,
  onSelect,
  onToggle,
  onOpen,
  onContextMenu,
  config,
});

return (
  <Box flexDirection="column" onMouseDown={handleClick} onScroll={handleScroll}>
    {visibleNodes.map((node, index) => (
      <TreeNode key={node.path} node={node} index={index} />
    ))}
  </Box>
);

Why Ink mouse handling?

  • Ink provides mouse event abstractions over terminal escape sequences
  • Works with terminals that support mouse reporting (most modern terminals)
  • Provides coordinates, button info, and scroll events
  • Falls back gracefully when mouse not supported
  • No need to parse raw escape sequences

Terminal Compatibility:

  • ✅ iTerm2, Ghostty, kitty, WezTerm, Windows Terminal, Alacritty
  • ✅ VS Code integrated terminal
  • ⚠️ Some terminals may require mouse reporting mode enabled
  • ❌ Very old terminals (pre-xterm mouse support)

Deliverables

Code Changes

Files to Create:

  • src/hooks/useMouse.ts - Mouse event handling hook

Hook Interface:

interface UseMouseOptions {
  fileTree: TreeNode[];           // Flattened tree for index-to-node mapping
  selectedPath: string | null;    // Currently selected path
  scrollOffset: number;           // Current scroll offset
  viewportHeight: number;         // Number of visible rows
  onSelect: (path: string) => void;         // Select a file/folder
  onToggle: (path: string) => void;         // Toggle folder expansion
  onOpen: (path: string) => void;           // Open file with editor
  onContextMenu: (path: string, position: { x: number; y: number }) => void;
  config: YellowwoodConfig;       // For ui.leftClickAction setting
}

interface UseMouseReturn {
  handleClick: (event: MouseEvent) => void;
  handleScroll: (event: ScrollEvent) => void;
  handleRightClick: (event: MouseEvent) => void;
}

export function useMouse(options: UseMouseOptions): UseMouseReturn

Implementation Details

Step-by-Step Implementation:

  1. Set up hook structure:

    • Import types and Ink mouse event types
    • Define options and return interfaces
    • Set up event handler functions
  2. Implement coordinate-to-row mapping:

    • Mouse events provide terminal coordinates (x, y)
    • Convert y coordinate to tree row index
    • Account for scroll offset (y + scrollOffset)
    • Account for header rows (y - headerHeight)
    • Bounds check to prevent out-of-range access
  3. Implement left-click handling:

    • Get clicked row index from coordinates
    • Look up TreeNode in flattened tree
    • Check node type (file vs directory)
    • For directories: always toggle expansion
    • For files: respect config.ui.leftClickAction
      • 'open': Call onOpen(path)
      • 'select': Call onSelect(path)
    • Handle clicks on empty space (no-op)
  4. Implement right-click handling:

    • Get clicked row and node
    • Call onContextMenu with path and mouse position
    • Position used to place context menu near cursor
    • Keyboard fallback: 'm' key can trigger context menu at current selection
  5. Implement scroll handling:

    • Scroll events provide delta (positive or negative)
    • Translate to viewport offset changes
    • Clamp to valid range (0 to maxScroll)
    • Caller updates scroll state and re-renders
  6. Add terminal compatibility detection:

    • Check if terminal supports mouse events
    • Provide fallback message if mouse not supported
    • Log warning once on first attempt if unsupported

Complete Implementation:

import { useCallback } from 'react';
import type { TreeNode, YellowwoodConfig } from '../types/index.js';

// Ink's mouse event types (from Ink 6.5)
interface MouseEvent {
  x: number;          // Column (0-based)
  y: number;          // Row (0-based)
  button: 'left' | 'right' | 'middle';
  shift: boolean;
  ctrl: boolean;
  meta: boolean;
}

interface ScrollEvent {
  x: number;
  y: number;
  deltaY: number;     // Scroll amount (positive = down, negative = up)
}

interface UseMouseOptions {
  fileTree: TreeNode[];           // Flattened tree (visible rows)
  selectedPath: string | null;
  scrollOffset: number;           // Current scroll position
  viewportHeight: number;         // Visible rows
  headerHeight: number;           // Rows before tree starts (Header component)
  onSelect: (path: string) => void;
  onToggle: (path: string) => void;
  onOpen: (path: string) => void;
  onContextMenu: (path: string, position: { x: number; y: number }) => void;
  onScrollChange: (newOffset: number) => void;
  config: YellowwoodConfig;
}

interface UseMouseReturn {
  handleClick: (event: MouseEvent) => void;
  handleScroll: (event: ScrollEvent) => void;
}

/**
 * Mouse event handling hook for tree navigation.
 * 
 * Provides handlers for:
 * - Left-click: Select/open files, toggle folders
 * - Right-click: Context menu
 * - Scroll: Navigate tree viewport
 * 
 * @param options - Configuration and callbacks
 * @returns Event handlers for mouse interactions
 */
export function useMouse(options: UseMouseOptions): UseMouseReturn {
  const {
    fileTree,
    selectedPath,
    scrollOffset,
    viewportHeight,
    headerHeight = 1,
    onSelect,
    onToggle,
    onOpen,
    onContextMenu,
    onScrollChange,
    config,
  } = options;

  /**
   * Convert mouse y coordinate to tree row index.
   * Accounts for header offset and scroll position.
   */
  const getRowIndexFromY = useCallback((y: number): number | null => {
    // Subtract header rows to get relative tree row
    const relativeY = y - headerHeight;
    
    // Check if click is in header or above tree
    if (relativeY < 0) {
      return null;
    }

    // Add scroll offset to get absolute tree index
    const rowIndex = relativeY + scrollOffset;

    // Bounds check
    if (rowIndex < 0 || rowIndex >= fileTree.length) {
      return null;
    }

    return rowIndex;
  }, [headerHeight, scrollOffset, fileTree.length]);

  /**
   * Handle left and right mouse clicks.
   */
  const handleClick = useCallback((event: MouseEvent) => {
    const rowIndex = getRowIndexFromY(event.y);
    
    // Click on empty space or header
    if (rowIndex === null) {
      return;
    }

    const node = fileTree[rowIndex];
    if (!node) {
      return;
    }

    // Right-click: context menu
    if (event.button === 'right') {
      onContextMenu(node.path, { x: event.x, y: event.y });
      return;
    }

    // Left-click behavior depends on node type and config
    if (event.button === 'left') {
      if (node.type === 'directory') {
        // Directories: always toggle expansion
        onToggle(node.path);
      } else {
        // Files: respect config.ui.leftClickAction
        const action = config.ui?.leftClickAction || 'open';
        
        if (action === 'open') {
          onOpen(node.path);
        } else {
          onSelect(node.path);
        }
      }
    }

    // Middle-click: ignore for now (future: could open in new pane)
  }, [getRowIndexFromY, fileTree, onToggle, onOpen, onSelect, onContextMenu, config]);

  /**
   * Handle scroll wheel events.
   */
  const handleScroll = useCallback((event: ScrollEvent) => {
    // deltaY is typically in increments of mouse wheel notches
    // Positive = scroll down (increase offset)
    // Negative = scroll up (decrease offset)
    
    const scrollLines = Math.sign(event.deltaY) * 3; // Scroll 3 lines per notch
    const newOffset = scrollOffset + scrollLines;

    // Calculate max scroll (total rows - viewport height)
    const maxScroll = Math.max(0, fileTree.length - viewportHeight);

    // Clamp to valid range
    const clampedOffset = Math.max(0, Math.min(newOffset, maxScroll));

    if (clampedOffset !== scrollOffset) {
      onScrollChange(clampedOffset);
    }
  }, [scrollOffset, fileTree.length, viewportHeight, onScrollChange]);

  return {
    handleClick,
    handleScroll,
  };
}

Mouse Event Flow:

  1. User clicks in terminal:

    • Terminal sends mouse event escape sequence
    • Ink captures and parses into MouseEvent
    • Ink calls onMouseDown prop on Box component
    • Our handleClick receives normalized event
  2. Coordinate mapping:

    • Event.y is absolute terminal row (0-based)
    • Subtract headerHeight to get tree-relative row
    • Add scrollOffset to get index in flattened tree
    • Look up TreeNode at that index
  3. Action dispatch:

    • Folder + left-click → onToggle
    • File + left-click + config='open' → onOpen
    • File + left-click + config='select' → onSelect
    • Any + right-click → onContextMenu
  4. Scroll handling:

    • Scroll wheel sends ScrollEvent with deltaY
    • Convert to line-based scrolling (3 lines per notch)
    • Clamp to valid range (0 to maxScroll)
    • Call onScrollChange to update state

Configuration Integration:

The ui.leftClickAction config option (to be added to types):

// In src/types/index.ts - add to YellowwoodConfig
export interface YellowwoodConfig {
  // ... existing fields ...
  ui?: {
    leftClickAction?: 'open' | 'select';  // Default: 'open'
    compactMode?: boolean;
    showStatusBar?: boolean;
  };
}

// Update DEFAULT_CONFIG
export const DEFAULT_CONFIG: YellowwoodConfig = {
  // ... existing fields ...
  ui: {
    leftClickAction: 'open',
    compactMode: true,
    showStatusBar: true,
  },
};

Error Handling:

  1. Invalid coordinates:

    • Out of bounds y coordinate → return null, no-op
    • Click on header or empty space → return null, no-op
  2. Mouse not supported:

    • Ink handles detection
    • Events simply won't fire
    • Keyboard navigation still works (graceful degradation)
  3. Rapid clicking:

    • Each click processes independently
    • No debouncing needed (clicks are intentional)
    • Double-click = two single clicks (intended)
  4. Scroll overflow:

    • Clamping prevents scroll beyond tree bounds
    • Negative offset clamped to 0
    • Excessive offset clamped to maxScroll

Performance Considerations:

  • Mouse events are frequent (every click, scroll notch)
  • useCallback prevents handler recreation on every render
  • Row index calculation is O(1)
  • Node lookup in flattened tree is O(1) (array access)
  • No expensive operations in event handlers

Tests

Unit Tests:

Create tests/hooks/test_useMouse.ts:

import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMouse } from '../../src/hooks/useMouse.js';
import type { TreeNode, YellowwoodConfig } from '../../src/types/index.js';
import { DEFAULT_CONFIG } from '../../src/types/index.js';

describe('useMouse', () => {
  const mockTree: TreeNode[] = [
    { name: 'src', path: 'src', type: 'directory', depth: 0, expanded: true },
    { name: 'App.tsx', path: 'src/App.tsx', type: 'file', depth: 1 },
    { name: 'utils', path: 'src/utils', type: 'directory', depth: 1, expanded: false },
    { name: 'README.md', path: 'README.md', type: 'file', depth: 0 },
  ];

  const createOptions = (overrides = {}) => ({
    fileTree: mockTree,
    selectedPath: null,
    scrollOffset: 0,
    viewportHeight: 10,
    headerHeight: 1,
    onSelect: vi.fn(),
    onToggle: vi.fn(),
    onOpen: vi.fn(),
    onContextMenu: vi.fn(),
    onScrollChange: vi.fn(),
    config: DEFAULT_CONFIG,
    ...overrides,
  });

  describe('handleClick', () => {
    it('toggles folder on left-click', () => {
      const onToggle = vi.fn();
      const { result } = renderHook(() => useMouse(createOptions({ onToggle })));

      // Click on first row (src folder) after 1-row header
      result.current.handleClick({
        x: 0,
        y: 1, // Header is row 0, first tree row is 1
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onToggle).toHaveBeenCalledWith('src');
    });

    it('opens file on left-click when config is "open"', () => {
      const onOpen = vi.fn();
      const config = {
        ...DEFAULT_CONFIG,
        ui: { leftClickAction: 'open' as const },
      };

      const { result } = renderHook(() => useMouse(createOptions({ onOpen, config })));

      // Click on second row (App.tsx file)
      result.current.handleClick({
        x: 0,
        y: 2,
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onOpen).toHaveBeenCalledWith('src/App.tsx');
    });

    it('selects file on left-click when config is "select"', () => {
      const onSelect = vi.fn();
      const config = {
        ...DEFAULT_CONFIG,
        ui: { leftClickAction: 'select' as const },
      };

      const { result } = renderHook(() => useMouse(createOptions({ onSelect, config })));

      // Click on file
      result.current.handleClick({
        x: 0,
        y: 2,
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onSelect).toHaveBeenCalledWith('src/App.tsx');
    });

    it('opens context menu on right-click', () => {
      const onContextMenu = vi.fn();
      const { result } = renderHook(() => useMouse(createOptions({ onContextMenu })));

      result.current.handleClick({
        x: 10,
        y: 2,
        button: 'right',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onContextMenu).toHaveBeenCalledWith('src/App.tsx', { x: 10, y: 2 });
    });

    it('ignores clicks on header', () => {
      const onToggle = vi.fn();
      const onOpen = vi.fn();
      const onSelect = vi.fn();

      const { result } = renderHook(() =>
        useMouse(createOptions({ onToggle, onOpen, onSelect }))
      );

      // Click on row 0 (header)
      result.current.handleClick({
        x: 0,
        y: 0,
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onToggle).not.toHaveBeenCalled();
      expect(onOpen).not.toHaveBeenCalled();
      expect(onSelect).not.toHaveBeenCalled();
    });

    it('ignores clicks beyond tree length', () => {
      const onToggle = vi.fn();
      const { result } = renderHook(() => useMouse(createOptions({ onToggle })));

      // Click on row 100 (way beyond tree)
      result.current.handleClick({
        x: 0,
        y: 100,
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onToggle).not.toHaveBeenCalled();
    });

    it('accounts for scroll offset when mapping coordinates', () => {
      const onToggle = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({ onToggle, scrollOffset: 2 }))
      );

      // Click on row 1 (first tree row on screen)
      // With scrollOffset=2, this is actually tree index 2 (src/utils folder)
      result.current.handleClick({
        x: 0,
        y: 1,
        button: 'left',
        shift: false,
        ctrl: false,
        meta: false,
      });

      expect(onToggle).toHaveBeenCalledWith('src/utils');
    });
  });

  describe('handleScroll', () => {
    it('scrolls down on positive deltaY', () => {
      const onScrollChange = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({ onScrollChange, scrollOffset: 0 }))
      );

      result.current.handleScroll({
        x: 0,
        y: 5,
        deltaY: 1, // One notch down
      });

      // Should scroll down by 3 lines (default)
      expect(onScrollChange).toHaveBeenCalledWith(3);
    });

    it('scrolls up on negative deltaY', () => {
      const onScrollChange = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({ onScrollChange, scrollOffset: 5 }))
      );

      result.current.handleScroll({
        x: 0,
        y: 5,
        deltaY: -1, // One notch up
      });

      // Should scroll up by 3 lines
      expect(onScrollChange).toHaveBeenCalledWith(2); // 5 - 3 = 2
    });

    it('clamps scroll to 0 minimum', () => {
      const onScrollChange = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({ onScrollChange, scrollOffset: 1 }))
      );

      result.current.handleScroll({
        x: 0,
        y: 5,
        deltaY: -5, // Large negative delta
      });

      // Should clamp to 0
      expect(onScrollChange).toHaveBeenCalledWith(0);
    });

    it('clamps scroll to max scroll', () => {
      const onScrollChange = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({
          onScrollChange,
          scrollOffset: 0,
          viewportHeight: 2,
          // fileTree has 4 items, viewport is 2, so maxScroll = 2
        }))
      );

      result.current.handleScroll({
        x: 0,
        y: 5,
        deltaY: 10, // Large positive delta
      });

      // Should clamp to maxScroll = 4 - 2 = 2
      expect(onScrollChange).toHaveBeenCalledWith(2);
    });

    it('does not call onScrollChange if offset unchanged', () => {
      const onScrollChange = vi.fn();
      const { result } = renderHook(() =>
        useMouse(createOptions({
          onScrollChange,
          scrollOffset: 0,
          viewportHeight: 10,
        }))
      );

      // Try to scroll up when already at top
      result.current.handleScroll({
        x: 0,
        y: 5,
        deltaY: -1,
      });

      // Should not call because clamping results in same offset
      expect(onScrollChange).not.toHaveBeenCalled();
    });
  });
});

Test Coverage:

  • ✓ Left-click on folder toggles expansion
  • ✓ Left-click on file opens (when config='open')
  • ✓ Left-click on file selects (when config='select')
  • ✓ Right-click opens context menu with position
  • ✓ Clicks on header are ignored
  • ✓ Clicks beyond tree bounds are ignored
  • ✓ Scroll offset is accounted for in coordinate mapping
  • ✓ Scroll down increases offset
  • ✓ Scroll up decreases offset
  • ✓ Scroll clamped to minimum (0)
  • ✓ Scroll clamped to maximum (tree length - viewport)
  • ✓ No-op scroll doesn't trigger state update

Integration Testing:

Manual testing with real terminal:

# Build and run
npm run dev

# Test mouse interactions:
# 1. Left-click folders - should toggle
# 2. Left-click files - should open in editor
# 3. Right-click - should show context menu
# 4. Scroll wheel - should scroll tree
# 5. Click header - should do nothing
# 6. Scroll at top/bottom - should clamp

# Test in different terminals:
# - iTerm2 / Ghostty / kitty
# - VS Code integrated terminal
# - Windows Terminal
# - SSH session (mouse may not work)

Technical Specifications

Footprint:

  • Modules touched: mouse-handler
  • Files created: src/hooks/useMouse.ts, tests/hooks/test_useMouse.ts
  • Files modified: src/types/index.ts (add ui config section)
  • Dependencies: None (uses Ink's built-in mouse support)

Ink Mouse Event Types:

// From Ink 6.5
interface MouseEvent {
  x: number;          // Column (0-indexed)
  y: number;          // Row (0-indexed)
  button: 'left' | 'right' | 'middle';
  shift: boolean;     // Modifier keys
  ctrl: boolean;
  meta: boolean;
}

interface ScrollEvent {
  x: number;
  y: number;
  deltaY: number;     // Scroll delta (positive = down)
}

Box Component Mouse Props:

<Box
  onMouseDown={(event: MouseEvent) => void}
  onScroll={(event: ScrollEvent) => void}
>

Size Estimate:

  • Size: S (2 points)
  • Effort: 4-6 hours total
    • Research Ink mouse API: 30 min
    • Implementation: 2 hours
    • Testing: 1.5-2 hours
    • Manual testing across terminals: 1 hour

Dependencies

Strict Dependencies (Blocking):

Flexible Dependencies (Informational):

Assumptions:

  • Ink 6.5 is installed with mouse support (✓ verified)
  • Terminal supports mouse reporting (most modern terminals do)
  • Flattened tree is provided by caller (TreeView component)
  • Config includes ui.leftClickAction option

Acceptance Criteria

  • useMouse hook provides handleClick and handleScroll handlers
  • Left-click on folders toggles expansion
  • Left-click on files opens them (when config.ui.leftClickAction='open')
  • Left-click on files selects them (when config.ui.leftClickAction='select')
  • Right-click triggers context menu with correct path and position
  • Scroll wheel scrolls viewport up/down
  • Scroll clamped to valid range (0 to maxScroll)
  • Header clicks are ignored (no action)
  • Out-of-bounds clicks are ignored
  • Scroll offset correctly maps screen coordinates to tree indices
  • Multi-row header offset is handled correctly
  • All unit tests pass (12+ test cases)
  • Manual testing in 3+ terminals confirms functionality
  • Mouse events don't interfere with keyboard navigation

Implementation Guidance

Recommended Approach:

  1. Research Ink mouse API (30 min)

    • Read Ink documentation on mouse events
    • Test mouse events in simple Ink app
    • Understand coordinate system (0-indexed)
    • Test in multiple terminals
  2. Add ui config to types (15 min)

    • Update YellowwoodConfig interface
    • Add ui object with leftClickAction
    • Update DEFAULT_CONFIG
  3. Implement coordinate mapping (30 min)

    • Create getRowIndexFromY helper
    • Account for header offset
    • Account for scroll offset
    • Add bounds checking
  4. Implement handleClick (1 hour)

    • Handle left-click for folders (toggle)
    • Handle left-click for files (open or select)
    • Handle right-click (context menu)
    • Test each path
  5. Implement handleScroll (30 min)

    • Convert deltaY to line scroll
    • Calculate new offset
    • Clamp to valid range
    • Call onScrollChange
  6. Write comprehensive tests (1.5 hours)

    • Test each click type
    • Test coordinate mapping
    • Test scroll clamping
    • Test config variations
  7. Manual verification (1 hour)

    • Test in multiple terminals
    • Verify smooth scrolling
    • Verify click accuracy
    • Test edge cases (empty tree, small tree)

Patterns to Follow:

  • useCallback: Wrap all handlers to prevent recreation
  • Bounds checking: Always validate coordinates before array access
  • Graceful degradation: Handle missing mouse support (no-op)
  • Type safety: Use Ink's MouseEvent and ScrollEvent types
  • Immutability: Don't mutate event objects

Patterns to Avoid:

  • Don't track mouse position state (events are stateless)
  • Don't implement double-click detection (two singles is fine)
  • Don't add drag-and-drop (not in MVP)
  • Don't implement hover effects (terminal limitations)
  • Don't debounce clicks (each click is intentional)

Code Quality Checklist:

  • All functions have JSDoc comments
  • All handlers use useCallback
  • Coordinate mapping is well-documented
  • TypeScript strict mode passes
  • No console.log (only console.warn for unsupported terminals)
  • Use .js extensions in imports

Edge Cases & Gotchas

Terminal coordinate systems:

  • Ink provides 0-indexed coordinates (top-left is 0,0)
  • Some terminals use 1-indexed (xterm)
  • Ink normalizes this - we always get 0-indexed
  • No special handling needed

Header height variability:

  • Header might be multiple rows
  • Always pass headerHeight as parameter
  • Don't hardcode to 1

Scroll offset boundaries:

  • Empty tree: maxScroll = 0
  • Small tree (fits in viewport): maxScroll = 0
  • Large tree: maxScroll = treeLength - viewportHeight
  • Always clamp before updating state

Mouse not supported:

  • Some terminals don't support mouse reporting
  • Ink detects this - events won't fire
  • Provide keyboard fallbacks
  • Don't show error (graceful degradation)

Terminal focus required:

  • Mouse events only fire when terminal has focus
  • Obvious to user (terminal window is active)
  • No special handling needed

Click accuracy at edges:

  • Last row of viewport
  • First row after header
  • Test edge cases explicitly

Rapid scrolling:

  • User scrolls quickly with wheel
  • Each notch is separate event
  • State updates accumulate correctly
  • No throttling needed (terminal limits rate)

Context menu positioning:

  • Pass actual mouse coordinates
  • Context menu component uses them for placement
  • Might adjust if near screen edge (future)

Multi-column layouts:

  • x coordinate currently unused
  • Could detect clicks on git status column (future)
  • For now, any x in row triggers action

Right-click on macOS:

  • Two-finger tap or Ctrl+click
  • Terminals handle translation
  • We just check event.button === 'right'

Scroll direction:

  • Positive deltaY = scroll down (increase offset)
  • Negative deltaY = scroll up (decrease offset)
  • This matches natural scroll direction

Flattened tree changes:

  • Expansion/collapse changes flattened tree
  • Indices shift
  • Hook gets new tree on each render
  • Old click handlers are discarded (useCallback dependencies)

Resources

External Documentation:

Internal References:

  • SPEC.md section 5.2 "Mouse Support" - Full requirements
  • SPEC.md section 5.5 "Keyboard Navigation" - Keyboard fallbacks
  • src/types/index.ts:6-16 - TreeNode interface
  • src/types/index.ts:24-44 - YellowwoodConfig interface

Similar Implementations:

  • VS Code tree view mouse handling
  • ranger (CLI file manager) mouse support
  • Midnight Commander mouse support

Notes

Why this matters:
Many developers prefer mouse workflows, especially when working in narrow panes. Supporting mouse makes Yellowwood feel native and modern, not just keyboard-only.

Design philosophy:

  • Mouse and keyboard are equal citizens (not mouse-first or keyboard-first)
  • All mouse actions have keyboard equivalents
  • Mouse provides spatial navigation, keyboard provides fast navigation
  • Graceful degradation when mouse unavailable

Terminal compatibility:
Testing across terminals is critical. Priority terminals:

  1. Ghostty - Target terminal for narrow panes
  2. iTerm2 - Popular macOS terminal
  3. kitty - Fast, feature-rich
  4. VS Code integrated - Common for development
  5. Windows Terminal - Windows users

Future enhancements (NOT in this issue):

  • Drag-and-drop file operations
  • Double-click to open (distinct from single-click)
  • Hover effects (if terminal supports)
  • Click-and-drag selection (multi-select)
  • Mouse gesture support (swipe to scroll)
  • Horizontal scrolling for long filenames
  • Click on git status column to filter
  • Click on worktree indicator to switch
  • Touch support for terminals (iPad, etc.)

Performance considerations:

  • Mouse events are frequent
  • useCallback prevents handler recreation
  • Coordinate mapping is O(1)
  • No DOM-style event bubbling
  • Terminal rate-limits events naturally

Accessibility:

  • Mouse is optional, not required
  • All actions have keyboard equivalents
  • Provides choice for users with different preferences
  • No mouse-only features

Integration notes:
This hook will be used by:

Keep hook stateless - caller manages all state (selection, scroll, expansion). Hook only translates events to actions.

Testing strategy:

  • Unit tests cover logic and edge cases
  • Integration tests verify terminal compatibility
  • Manual testing essential for feel/smoothness
  • Test rapid clicking/scrolling
  • Test with large trees (100+ files)

Common pitfalls:

  • Forgetting to account for scroll offset
  • Hardcoding header height
  • Not clamping scroll values
  • Assuming mouse always works
  • Not using useCallback (performance)
  • Mutating event objects

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions