mutiny-diff 0.1.22

TUI git diff viewer with worktree management
# Spec: Mouse Support for Navigation

**Priority**: P2
**Status**: Ready for implementation
**Estimated effort**: Small-Medium (3-5 files changed)

## Problem

mdiff captures mouse events in `event.rs` (`Event::Mouse(MouseEvent)`) but does not process them. While the keyboard-driven workflow is mdiff's core UX, many developers use the mouse as a secondary input for quick navigation — clicking a file in a list, scrolling with the wheel, or clicking a line to select it. Competitors like lazygit and VS Code support mouse interaction alongside keyboard shortcuts. Not supporting mouse feels like a gap, especially for users new to the tool.

## Solution

Add mouse support for the three most impactful interactions:
1. **Scroll wheel** in diff view and navigator (most requested)
2. **Click to select** files in the navigator
3. **Click to position cursor** in the diff view

This is intentionally scoped to navigation only — annotation and editing flows remain keyboard-driven to preserve the structured feedback workflow.

## Architecture

### Event Handling (src/event.rs)

Currently, `Event::Mouse(MouseEvent)` is captured but not mapped to actions. Add a new function:

```rust
use crossterm::event::{MouseEventKind, MouseButton};

/// Map a mouse event to an action based on current app context.
pub fn map_mouse_to_action(mouse: MouseEvent, ctx: &MouseContext) -> Option<Action> {
    match mouse.kind {
        // Scroll wheel
        MouseEventKind::ScrollUp => {
            match ctx.panel_at(mouse.column, mouse.row) {
                Some(Panel::Navigator) => Some(Action::NavigatorUp),
                Some(Panel::DiffView) => Some(Action::ScrollUp),
                _ => None,
            }
        }
        MouseEventKind::ScrollDown => {
            match ctx.panel_at(mouse.column, mouse.row) {
                Some(Panel::Navigator) => Some(Action::NavigatorDown),
                Some(Panel::DiffView) => Some(Action::ScrollDown),
                _ => None,
            }
        }
        // Left click
        MouseEventKind::Down(MouseButton::Left) => {
            match ctx.panel_at(mouse.column, mouse.row) {
                Some(Panel::Navigator) => {
                    let file_index = ctx.navigator_row_to_index(mouse.row);
                    file_index.map(Action::SelectFile)
                }
                Some(Panel::DiffView) => {
                    // Click to focus diff view + position cursor
                    Some(Action::FocusDiffView)
                }
                _ => None,
            }
        }
        _ => None,
    }
}
```

### New Types

```rust
/// Context for mouse event mapping.
pub struct MouseContext {
    pub navigator_rect: Rect,
    pub diff_view_rect: Rect,
    pub navigator_scroll_offset: usize,
    pub navigator_item_count: usize,
}

enum Panel {
    Navigator,
    DiffView,
}

impl MouseContext {
    /// Determine which panel a screen coordinate falls in.
    fn panel_at(&self, col: u16, row: u16) -> Option<Panel> {
        if self.navigator_rect.contains((col, row).into()) {
            Some(Panel::Navigator)
        } else if self.diff_view_rect.contains((col, row).into()) {
            Some(Panel::DiffView)
        } else {
            None
        }
    }

    /// Convert a mouse row in the navigator area to a file index.
    fn navigator_row_to_index(&self, row: u16) -> Option<usize> {
        let relative_row = row.saturating_sub(self.navigator_rect.y + 1); // +1 for border
        let index = self.navigator_scroll_offset + relative_row as usize;
        if index < self.navigator_item_count {
            Some(index)
        } else {
            None
        }
    }
}
```

### App Integration (src/app.rs)

In the main event loop where `Event::Mouse` is currently a no-op:

```rust
Event::Mouse(mouse) => {
    let ctx = MouseContext {
        navigator_rect: self.last_navigator_rect,
        diff_view_rect: self.last_diff_view_rect,
        navigator_scroll_offset: self.state.navigator.scroll_offset,
        navigator_item_count: self.state.navigator.entries.len(),
    };
    if let Some(action) = map_mouse_to_action(mouse, &ctx) {
        self.handle_action(action);
    }
}
```

This requires storing the layout rects from the last render pass. Add fields to `App`:

```rust
pub struct App {
    // ... existing fields ...
    last_navigator_rect: Rect,
    last_diff_view_rect: Rect,
}
```

Update the render function to save these rects after layout computation.

### Configuration (config.toml)

```toml
[mouse]
enabled = true  # Default: true. Set to false to disable mouse handling entirely.
```

### Scroll Acceleration

For scroll wheel events, map multiple scroll ticks to faster scrolling:
- Single scroll tick: Move 1 line (NavigatorUp/Down or ScrollUp/Down)
- This matches the existing keyboard behavior and feels natural

Future enhancement: Hold Shift + scroll for page-level scrolling.

## Keybindings / Actions

No new actions needed — mouse events map to existing actions:
- `ScrollUp/Down` for wheel in diff
- `NavigatorUp/Down` for wheel in navigator
- `SelectFile(index)` for click in navigator
- `FocusDiffView` / `FocusNavigator` for click to focus

## Edge Cases

- Mouse events during modal dialogs (commit, comment editor, etc.): Ignore mouse events when any modal is open
- Mouse events during search mode: Ignore (keyboard takes priority)
- Terminal resizing while mouse is in use: Rects update on next render, mouse coordinates may be stale for one frame — acceptable
- Terminals that don't support mouse: crossterm handles this gracefully; mouse events simply won't arrive
- Click on panel borders: Treat as no-op (panel_at returns None for border pixels)

## Testing

- Manual test: Scroll wheel in navigator scrolls file list
- Manual test: Scroll wheel in diff view scrolls diff content
- Manual test: Click file in navigator selects it and shows diff
- Manual test: Mouse disabled in config.toml suppresses all mouse handling
- Manual test: Mouse during modal dialogs is properly ignored