diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..df3a993 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + pull_request: + branches: [ master, main ] + push: + branches: [ master, main ] + +jobs: + test: + name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'requirements-dev.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: | + pytest test_easymotion.py -v --cache-clear + + - name: Test summary + if: always() + run: | + echo "Tests completed for Python ${{ matrix.python-version }} on ${{ matrix.os }}" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1fb292c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +tmux-easymotion is a tmux plugin that provides vim-easymotion-like functionality for quickly jumping to visible text positions across tmux panes. The key feature that distinguishes this plugin is its ability to jump between split panes within a tmux window. + +## Architecture + +The plugin consists of three main components: + +1. **easymotion.tmux** (bash): TPM plugin entry point that reads tmux options and sets up key bindings. It spawns a background tmux window that runs the Python script with environment variables for configuration. + +2. **easymotion.py** (Python): Core implementation that: + - Uses either curses or ANSI escape sequences for rendering (configurable) + - Captures all visible pane contents via `tmux capture-pane` + - Implements hint generation and assignment based on distance from cursor + - Handles wide (CJK) characters with proper width calculations + - Supports both normal mode and copy mode cursor positioning + +3. **Rendering Strategy**: The plugin uses an abstract `Screen` class with two implementations: + - `Curses`: Standard curses-based rendering (opt-in via `@easymotion-use-curses`) + - `AnsiSequence`: ANSI escape sequence rendering (default, more portable) + +## Key Technical Details + +### Wide Character Handling +The codebase has special handling for double-width characters (CJK characters). Functions like `get_char_width()`, `get_string_width()`, and `get_true_position()` convert between visual columns and string indices. When modifying character position logic, always use `get_true_position()` to convert visual columns to true string positions. + +### Hint Generation Algorithm +The `generate_hints()` function dynamically balances single-character and double-character hints to minimize keystrokes. It ensures double-character hints never start with characters used as single-character hints. The `assign_hints_by_distance()` function sorts matches by Euclidean distance from the cursor. + +### Pane Information Gathering +`get_initial_tmux_info()` makes a single tmux call to batch-fetch all pane information (positions, dimensions, cursor state, scroll position) for performance. It handles zoomed windows by filtering out non-active panes. + +### Input Flow +User input flows through a temporary file (created by `mktemp`) to handle the initial search character, then switches to direct stdin reading via `getch()` for hint selection. This avoids conflicts with tmux's command-prompt. + +## Development Commands + +### Running Tests +```bash +# First time: Install development dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest test_easymotion.py -v --cache-clear +``` + +### Testing in tmux +After making changes, reload the plugin in tmux: +```bash +# In tmux, press prefix + I to reload TPM plugins +# Or source the config manually: +tmux source-file ~/.tmux.conf +``` + +### Debugging +Enable debug logging by setting in ~/.tmux.conf: +```bash +set -g @easymotion-debug 'true' +``` +Logs are written to ~/easymotion.log + +Enable performance logging: +```bash +set -g @easymotion-perf 'true' +``` + +## Configuration Options (tmux.conf) + +All options are read from tmux options in easymotion.tmux and passed as environment variables to the Python script: + +- `@easymotion-key`: Trigger key binding (default: 's') +- `@easymotion-hints`: Characters used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;') +- `@easymotion-vertical-border`: Character for vertical borders (default: '│') +- `@easymotion-horizontal-border`: Character for horizontal borders (default: '─') +- `@easymotion-use-curses`: Use curses instead of ANSI sequences (default: 'false') +- `@easymotion-case-sensitive`: Case-sensitive search (default: 'false') +- `@easymotion-smartsign`: Enable smartsign feature to match shifted symbols (default: 'false') + +## Important Implementation Notes + +- The Python script runs in a detached tmux window (`neww -d`) to avoid interfering with the user's session +- Cursor position differs between normal mode and copy mode - check `pane.copy_mode` flag +- The `__slots__` optimization on `PaneInfo` reduces memory overhead +- Functions decorated with `@perf_timer()` only log timing when `TMUX_EASYMOTION_PERF` is enabled +- The `@functools.lru_cache` on width calculation functions significantly improves performance with repeated characters diff --git a/README.md b/README.md index 7cb8850..2fc8670 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # TMUX Easymotion +[![Tests](https://github.com/ddzero2c/tmux-easymotion/actions/workflows/test.yml/badge.svg)](https://github.com/ddzero2c/tmux-easymotion/actions/workflows/test.yml) + - Tmux prefix is `Ctrl+q`: - Trigger key is `s` @@ -68,6 +70,10 @@ bind-key -T copy-mode-vi V send-keys -X select-line; ### Run tests ```bash +# Install development dependencies +pip install -r requirements-dev.txt + +# Run tests pytest test_easymotion.py -v --cache-clear ``` diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f77c910 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=7.0.0 diff --git a/test_easymotion.py b/test_easymotion.py index adc530b..f27e511 100644 --- a/test_easymotion.py +++ b/test_easymotion.py @@ -1,5 +1,16 @@ -from easymotion import (generate_hints, get_char_width, get_string_width, - get_true_position) +import os + +import pytest + +from easymotion import ( + PaneInfo, + assign_hints_by_distance, + find_matches, + generate_hints, + get_char_width, + get_string_width, + get_true_position, +) def test_get_char_width(): @@ -48,8 +59,8 @@ def test_generate_hints_no_duplicates(): double_chars = [h for h in hints if len(h) == 2] if double_chars: for double_char in double_chars: - assert double_char[0] not in single_chars, f"Double char hint { - double_char} starts with single char hint" + assert double_char[0] not in single_chars, \ + f"Double char hint {double_char} starts with single char hint" # Check all characters are from the key set assert all(c in keys for h in hints for c in h), \ @@ -110,3 +121,298 @@ def test_generate_hints_distribution(): assert all(len(hint) == 2 for hint in hints) # For all double chars case, just ensure no duplicate combinations assert len(hints) == len(set(hints)) + + +# ============================================================================ +# Fixtures for reusable test data +# ============================================================================ + +@pytest.fixture +def simple_pane(): + """Single pane with basic ASCII content""" + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + pane.lines = ['hello world', 'foo bar baz', 'test line'] + return pane + + +@pytest.fixture +def wide_char_pane(): + """Pane with CJK (wide) characters""" + pane = PaneInfo( + pane_id='%2', active=True, start_y=0, height=10, start_x=0, width=80 + ) + pane.lines = ['こんにちは world', '你好 hello', 'test 테스트'] + return pane + + +@pytest.fixture +def multi_pane(): + """Multiple panes for cross-pane testing""" + pane1 = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=40 + ) + pane1.lines = ['left pane', 'aaa bbb'] + + pane2 = PaneInfo( + pane_id='%2', active=False, start_y=0, height=10, start_x=40, width=40 + ) + pane2.lines = ['right pane', 'ccc ddd'] + + return [pane1, pane2] + + +# ============================================================================ +# Tests for find_matches() +# ============================================================================ + +@pytest.mark.parametrize("search_char,expected_min_count", [ + ('o', 4), # 'o' in "hello", "world", "foo" + ('l', 3), # 'l' in "hello", "world" + ('b', 2), # 'b' in "bar", "baz" + ('x', 0), # no matches +]) +def test_find_matches_basic(simple_pane, search_char, expected_min_count): + """Test basic character matching with various characters""" + matches = find_matches([simple_pane], search_char) + assert len(matches) >= expected_min_count + + +def test_find_matches_case_insensitive(simple_pane): + """Test case-insensitive matching (default behavior)""" + # Mock the CASE_SENSITIVE environment variable + import easymotion + original_case_sensitive = easymotion.CASE_SENSITIVE + + try: + easymotion.CASE_SENSITIVE = False + + # Add a line with uppercase + simple_pane.lines = ['Hello World'] + + # Should match both 'h' and 'H' + matches_lower = find_matches([simple_pane], 'h') + matches_upper = find_matches([simple_pane], 'H') + + # Both should find the 'H' in "Hello" + assert len(matches_lower) >= 1 + assert len(matches_upper) >= 1 + + finally: + easymotion.CASE_SENSITIVE = original_case_sensitive + + +def test_find_matches_smartsign(): + """Test SMARTSIGN feature - searching ',' also finds '<'""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + pane.lines = ['hello, world < test'] + + # With SMARTSIGN enabled, searching ',' should also find '<' + easymotion.SMARTSIGN = True + matches = find_matches([pane], ',') + # Should find both ',' and '<' + assert len(matches) >= 2 + + # Without SMARTSIGN, should only find ',' + easymotion.SMARTSIGN = False + matches = find_matches([pane], ',') + assert len(matches) == 1 + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_find_matches_wide_characters(wide_char_pane): + """Test matching with wide characters and correct visual position""" + matches = find_matches([wide_char_pane], 'w') + + # Should find 'w' in "world" on first line + assert len(matches) >= 1 + + # Check that visual column accounts for wide characters + # 'こんにちは' = 5 chars * 2 width = 10, plus 1 space = 11 + pane, line_num, visual_col = matches[0] + assert line_num == 0 + assert visual_col == 11 # After wide chars and space + + +def test_find_matches_multiple_panes(multi_pane): + """Test finding matches across multiple panes""" + matches = find_matches(multi_pane, 'a') + + # Should find 'a' in both panes: "pane" (twice), "aaa" (3 times) = 5+ total + assert len(matches) >= 5 + + # Verify matches come from both panes + pane_ids = {match[0].pane_id for match in matches} + assert '%1' in pane_ids + assert '%2' in pane_ids + + +def test_find_matches_edge_cases(): + """Test edge cases: empty pane, no matches""" + # Empty pane + empty_pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + empty_pane.lines = [] + + matches = find_matches([empty_pane], 'a') + assert len(matches) == 0 + + # Pane with content but no matches + pane = PaneInfo( + pane_id='%2', active=True, start_y=0, height=10, start_x=0, width=80 + ) + pane.lines = ['hello world'] + + matches = find_matches([pane], 'z') + assert len(matches) == 0 + + +# ============================================================================ +# Tests for assign_hints_by_distance() +# ============================================================================ + +def test_assign_hints_by_distance_basic(simple_pane): + """Test that hints are assigned based on distance from cursor""" + simple_pane.lines = ['hello world'] + + matches = [ + (simple_pane, 0, 0), # 'h' at position (0, 0) + (simple_pane, 0, 6), # 'w' at position (0, 6) + ] + + # Cursor at (0, 0) - closer to first match + hint_mapping = assign_hints_by_distance(matches, cursor_y=0, cursor_x=0) + + # Should have 2 hints + assert len(hint_mapping) == 2 + + # All matches should be in the mapping + mapped_matches = list(hint_mapping.values()) + assert all(match in mapped_matches for match in matches) + + +def test_assign_hints_by_distance_priority(): + """Test that closer matches get simpler (shorter) hints""" + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + pane.lines = ['a' * 80] + + matches = [ + (pane, 0, 50), # Far from cursor + (pane, 0, 2), # Close to cursor + (pane, 0, 25), # Medium distance + ] + + # Cursor at (0, 0) + import easymotion + original_hints = easymotion.HINTS + + try: + easymotion.HINTS = 'abc' + hint_mapping = assign_hints_by_distance(matches, cursor_y=0, cursor_x=0) + + # Find hint for closest match + closest_match = (pane, 0, 2) + closest_hint = [k for k, v in hint_mapping.items() if v == closest_match][0] + + # Closest match should get shortest hint + all_hint_lengths = [len(h) for h in hint_mapping.keys()] + assert len(closest_hint) == min(all_hint_lengths) + + finally: + easymotion.HINTS = original_hints + + +def test_assign_hints_by_distance_multi_pane(multi_pane): + """Test hint assignment across multiple panes""" + matches = [ + (multi_pane[0], 0, 0), # Left pane at screen x=0 + (multi_pane[1], 0, 0), # Right pane at screen x=40 + ] + + # Cursor in left pane at (0, 0) + hint_mapping = assign_hints_by_distance(matches, cursor_y=0, cursor_x=0) + + assert len(hint_mapping) == 2 + + # Verify both matches are assigned hints + mapped_matches = list(hint_mapping.values()) + assert matches[0] in mapped_matches + assert matches[1] in mapped_matches + + +# ============================================================================ +# Tests for PaneInfo +# ============================================================================ + +def test_pane_info_initialization(): + """Test PaneInfo initialization with correct defaults""" + pane = PaneInfo( + pane_id='%1', + active=True, + start_y=5, + height=20, + start_x=10, + width=80 + ) + + # Check provided values + assert pane.pane_id == '%1' + assert pane.active is True + assert pane.start_y == 5 + assert pane.height == 20 + assert pane.start_x == 10 + assert pane.width == 80 + + # Check defaults + assert pane.lines == [] + assert pane.positions == [] + assert pane.copy_mode is False + assert pane.scroll_position == 0 + assert pane.cursor_y == 0 + assert pane.cursor_x == 0 + + +# ============================================================================ +# Integration Test +# ============================================================================ + +def test_search_to_hint_integration(simple_pane): + """Integration test: search → find matches → assign hints → verify positions""" + simple_pane.lines = ['hello world test'] + + # Step 1: Find matches for 'e' + matches = find_matches([simple_pane], 'e') + + # Should find 'e' in "hello" and "test" + assert len(matches) >= 2 + + # Step 2: Assign hints based on distance from cursor + cursor_y = simple_pane.start_y + 0 # First line + cursor_x = simple_pane.start_x + 0 # Start of line + + hint_mapping = assign_hints_by_distance(matches, cursor_y, cursor_x) + + # Step 3: Verify hints are assigned to all matches + assert len(hint_mapping) == len(matches) + + # Step 4: Verify positions can be extracted from matches + for hint, (pane, line_num, visual_col) in hint_mapping.items(): + assert pane == simple_pane + assert line_num == 0 # All matches on first line + assert visual_col >= 0 + assert visual_col < len(simple_pane.lines[line_num]) + + # Verify hint is valid + assert len(hint) in [1, 2] # Should be 1 or 2 characters