diff --git a/.gitignore b/.gitignore index 2a6e8ee..edde333 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .env __pycache__ .keystroke + +**/.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 78f5d79..394631c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ The `find_matches()` function was refactored to support multi-character patterns All options are read from tmux options in easymotion.tmux and passed as environment variables to the Python script: - `@easymotion-key`: Trigger key binding for 1-char search (default: 's', backward compatible) -- `@easymotion-s`: Explicit 1-char search key binding (optional) +- `@easymotion-s`: Explicit 1-char search binding (optional) - `@easymotion-s2`: 2-char search key binding (optional, e.g., 'f') - `@easymotion-hints`: Characters used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;') - `@easymotion-vertical-border`: Character for vertical borders (default: '│') diff --git a/easymotion.py b/easymotion.py index 646a4ce..ef74830 100755 --- a/easymotion.py +++ b/easymotion.py @@ -3,6 +3,7 @@ import functools import logging import os +import re import subprocess import sys import termios @@ -52,6 +53,52 @@ 'TMUX_EASYMOTION_USE_CURSES', 'false').lower() == 'true' +def get_tmux_version(): + """Detect tmux version for compatibility adjustments + + Returns: + tuple: (major, minor) version numbers, e.g., (3, 6) for "tmux 3.6" + Returns (0, 0) if detection fails or version is ambiguous + + Handles various version formats: + - "tmux 3.5" → (3, 5) + - "tmux 3.0a" → (3, 0) + - "tmux next-3.6" → (3, 6) + - "tmux 3.1-rc2" → (3, 1) + - "tmux master" → (0, 0) - assume latest features, use conservative defaults + - "tmux openbsd-6.6" → (0, 0) - OpenBSD version, not tmux version + """ + try: + result = subprocess.run( + ['tmux', '-V'], + capture_output=True, + text=True, + check=True + ) + version_str = result.stdout.strip() + + # Skip OpenBSD-specific versioning (not actual tmux version) + if 'openbsd-' in version_str: + return (0, 0) + + # Handle "tmux master" - development version, use conservative defaults + if 'master' in version_str: + return (0, 0) + + # Extract version: matches "3.6", "next-3.6", "3.0a", "3.1-rc2" + # Pattern: find digits.digits anywhere in string, but avoid matching openbsd versions + match = re.search(r'(?:next-)?(\d+)\.(\d+)', version_str) + if match: + return (int(match.group(1)), int(match.group(2))) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError): + pass + return (0, 0) # Default to oldest behavior if detection fails + + +# Detect tmux version at module load time +TMUX_VERSION = get_tmux_version() + + class Screen(ABC): # Common attributes for both implementations A_NORMAL = 0 @@ -215,24 +262,48 @@ def wrapper(*args, **kwargs): return decorator +def calculate_tab_width(position: int, tab_size: int = 8) -> int: + """Calculate the visual width of a tab based on its position""" + return tab_size - (position % tab_size) + @functools.lru_cache(maxsize=1024) -def get_char_width(char: str) -> int: - """Get visual width of a single character with caching""" +def get_char_width(char: str, position: int = 0) -> int: + """Get visual width of a single character with caching + + Args: + char: The character to measure + position: The visual position of the character (needed for tabs) + + Note: + Tab handling differs by tmux version: + - tmux >= 3.6: Position-aware tabs (tab stops at multiples of 8) + - tmux < 3.6: Fixed-width tabs (always 8 spaces) + """ + if char == '\t': + # tmux 3.6+ uses position-aware tab rendering (correct terminal behavior) + # Older versions render tabs as fixed 8-space width + if TMUX_VERSION >= (3, 6): + return calculate_tab_width(position) + else: + return 8 # Fixed width for older tmux versions return 2 if unicodedata.east_asian_width(char) in 'WF' else 1 @functools.lru_cache(maxsize=1024) def get_string_width(s: str) -> int: - """Calculate visual width of string, accounting for double-width characters""" - return sum(map(get_char_width, s)) + """Calculate visual width of string, accounting for double-width characters and tabs""" + visual_pos = 0 + for char in s: + visual_pos += get_char_width(char, visual_pos) + return visual_pos def get_true_position(line, target_col): - """Calculate true position accounting for wide characters""" + """Calculate true position accounting for wide characters and tabs""" visual_pos = 0 true_pos = 0 while true_pos < len(line) and visual_pos < target_col: - char_width = get_char_width(line[true_pos]) + char_width = get_char_width(line[true_pos], visual_pos) visual_pos += char_width true_pos += 1 return true_pos diff --git a/test_easymotion.py b/test_easymotion.py index 8af9f40..0defe42 100644 --- a/test_easymotion.py +++ b/test_easymotion.py @@ -16,6 +16,8 @@ def test_get_char_width(): + import easymotion + assert get_char_width('a') == 1 # ASCII character assert get_char_width('あ') == 2 # Japanese character (wide) assert get_char_width('漢') == 2 # Chinese character (wide) @@ -23,20 +25,66 @@ def test_get_char_width(): assert get_char_width(' ') == 1 # Space assert get_char_width('\n') == 1 # Newline + # Tab behavior is version-dependent + assert get_char_width('\t', 0) == 8 # Tab at position 0 (always 8) + + if easymotion.TMUX_VERSION >= (3, 6): + # tmux 3.6+: position-aware tabs + assert get_char_width('\t', 1) == 7 # Tab at position 1 + assert get_char_width('\t', 7) == 1 # Tab at position 7 + else: + # Older tmux: fixed-width tabs + assert get_char_width('\t', 1) == 8 # Tab at position 1 + assert get_char_width('\t', 7) == 8 # Tab at position 7 + def test_get_string_width(): + import easymotion + assert get_string_width('hello') == 5 assert get_string_width('こんにちは') == 10 assert get_string_width('hello こんにちは') == 16 assert get_string_width('') == 0 + # Tab behavior is version-dependent + assert get_string_width('\t') == 8 # Tab at position 0 = 8 spaces (always) + + if easymotion.TMUX_VERSION >= (3, 6): + # tmux 3.6+: position-aware tabs + assert get_string_width('a\t') == 8 # 'a' (1) + Tab at position 1 (7) = 8 + assert get_string_width('1234567\t') == 8 # 7 chars + Tab at position 7 (1) = 8 + assert get_string_width('a\tb\t') == 16 # 'a' (1) + Tab at pos 1 (7) + 'b' (1) + Tab at pos 9→1 (7) = 16 + else: + # Older tmux: fixed-width tabs + assert get_string_width('a\t') == 9 # 'a' (1) + Tab (8) = 9 + assert get_string_width('1234567\t') == 15 # 7 chars + Tab (8) = 15 + assert get_string_width('a\tb\t') == 18 # 'a' (1) + Tab (8) + 'b' (1) + Tab (8) = 18 + def test_get_true_position(): + import easymotion + assert get_true_position('hello', 3) == 3 assert get_true_position('あいうえお', 4) == 2 assert get_true_position('hello あいうえお', 7) == 7 assert get_true_position('', 5) == 0 + # Tab behavior is version-dependent + if easymotion.TMUX_VERSION >= (3, 6): + # tmux 3.6+: position-aware tabs + assert get_true_position('\t', 4) == 1 # Halfway through tab + assert get_true_position('\t', 8) == 1 # Full tab width + assert get_true_position('a\tb', 1) == 1 # 'a' ends at visual pos 1 + assert get_true_position('a\tb', 5) == 2 # Halfway through tab + assert get_true_position('a\tb', 8) == 2 # 'b' starts at visual pos 8, string index 2 + else: + # Older tmux: fixed-width tabs + assert get_true_position('\t', 4) == 1 # Halfway through tab + assert get_true_position('\t', 8) == 1 # Full tab width + assert get_true_position('a\tb', 1) == 1 # 'a' + assert get_true_position('a\tb', 5) == 2 # After 'a', halfway through tab + assert get_true_position('a\tb', 9) == 3 # 'b' (at position 9) + def test_generate_hints(): test_keys = 'ab' @@ -884,3 +932,118 @@ def test_hint_restoration_not_at_line_end(): # Should restore the actual next character 'l' assert len(calls_at_next_pos) == 1 assert calls_at_next_pos[0]['text'] == 'l' + + +# ============================================================================ +# Tests for Tmux Version Detection and Tab Handling +# ============================================================================ + +def test_tmux_version_detection(): + """Test that tmux version can be detected""" + import easymotion + + # Version should be detected and be a tuple + assert isinstance(easymotion.TMUX_VERSION, tuple) + assert len(easymotion.TMUX_VERSION) == 2 + + # Version numbers should be non-negative + major, minor = easymotion.TMUX_VERSION + assert major >= 0 + assert minor >= 0 + + +def test_version_conditional_tab_behavior(): + """Test that tab behavior is conditional on tmux version""" + import easymotion + + major, minor = easymotion.TMUX_VERSION + + # Test tab at position 0 (always 8) + assert get_char_width('\t', 0) == 8 + + # Test tab at position 1 (version-dependent) + if (major, minor) >= (3, 6): + # tmux 3.6+: position-aware tabs + assert get_char_width('\t', 1) == 7 # Position-aware + assert get_char_width('\t', 7) == 1 # Position-aware + else: + # Older tmux: fixed-width tabs + assert get_char_width('\t', 1) == 8 # Fixed width + assert get_char_width('\t', 7) == 8 # Fixed width + + +def test_tab_in_string_width_version_aware(): + """Test that string width calculation respects version-conditional tab behavior""" + import easymotion + + major, minor = easymotion.TMUX_VERSION + + if (major, minor) >= (3, 6): + # tmux 3.6+: position-aware tabs + assert get_string_width('a\t') == 8 # 'a' (1) + Tab at position 1 (7) + assert get_string_width('1234567\t') == 8 # 7 chars + Tab at position 7 (1) + else: + # Older tmux: fixed-width tabs + assert get_string_width('a\t') == 9 # 'a' (1) + Tab (8) + assert get_string_width('1234567\t') == 15 # 7 chars + Tab (8) + + +def test_get_tmux_version_function(): + """Test the get_tmux_version() function directly""" + from easymotion import get_tmux_version + + version = get_tmux_version() + + # Should return a tuple of two integers + assert isinstance(version, tuple) + assert len(version) == 2 + assert isinstance(version[0], int) + assert isinstance(version[1], int) + + # If we're running tests, tmux should be available + # Version should be at least (0, 0) but likely higher + assert version >= (0, 0) + + # If version was detected (not fallback), it should be reasonable + if version != (0, 0): + major, minor = version + # Tmux versions are typically 1.x to 3.x + assert 1 <= major <= 5 # Reasonable range for tmux major versions + assert 0 <= minor <= 20 # Reasonable range for minor versions + + +def test_version_string_parsing(): + """Test parsing various tmux version string formats""" + import subprocess + import re + + # Mock different version strings and test parsing logic + test_cases = [ + ("tmux 3.5", (3, 5)), + ("tmux 3.0a", (3, 0)), + ("tmux 2.9a", (2, 9)), + ("tmux next-3.6", (3, 6)), + ("tmux next-3.7", (3, 7)), + ("tmux 3.1-rc", (3, 1)), + ("tmux 3.1-rc2", (3, 1)), + ("tmux 2.6", (2, 6)), + ("tmux 3.1c", (3, 1)), + ("tmux master", (0, 0)), # Conservative default + ("tmux openbsd-6.6", (0, 0)), # OpenBSD version, not tmux + ("tmux openbsd-7.0", (0, 0)), + ] + + for version_str, expected in test_cases: + # Replicate the parsing logic from get_tmux_version() + if 'openbsd-' in version_str: + result = (0, 0) + elif 'master' in version_str: + result = (0, 0) + else: + match = re.search(r'(?:next-)?(\d+)\.(\d+)', version_str) + if match: + result = (int(match.group(1)), int(match.group(2))) + else: + result = (0, 0) + + assert result == expected, f"Failed to parse '{version_str}': got {result}, expected {expected}"