From 1815178aaaef6f3a392e5b4341d8c6c1367d7df6 Mon Sep 17 00:00:00 2001 From: Ryder Date: Mon, 12 May 2025 15:45:37 +0800 Subject: [PATCH 1/4] fix: handle tab on tmux next3.6 --- .gitignore | 2 ++ CLAUDE.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ easymotion.py | 40 ++++++++++++++++++++++++-------- test_easymotion.py | 16 ++++++++++++- 4 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md 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 new file mode 100644 index 0000000..73e1125 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# 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 inspired by vim-easymotion that provides a quick way to navigate and jump between positions in tmux panes. The key feature is the ability to jump between panes, not just within a single pane. + +## Code Architecture + +- **easymotion.tmux**: Main shell script that sets up the tmux key bindings and configuration options +- **easymotion.py**: Python implementation of the easymotion functionality + - Uses two display methods: ANSI sequences or curses + - Implements a hints system to quickly navigate to characters + - Handles smart matching features like case sensitivity and smartsign + +## Key Concepts + +1. **Hint Generation**: Creates single or double character hints for navigation +2. **Smart Matching**: Supports case-insensitive matching and "smartsign" (matching symbol pairs) +3. **Pane Navigation**: Can jump between panes, not just within one pane +4. **Visual Width Handling**: Properly handles wide characters (CJK, etc.) + +## Running Tests + +To run the tests: + +```bash +pytest test_easymotion.py -v --cache-clear +``` + +## Configuration Options + +The plugin supports several configuration options set in tmux.conf: + +- Hint characters +- Border style +- Display method (ANSI or curses) +- Case sensitivity +- Smartsign feature +- Debug and performance logging + +## Common Development Tasks + +When working on this plugin, you may need to: + +1. Debug the easymotion behavior by enabling debug logging: + ``` + set -g @easymotion-debug 'true' + ``` + +2. Measure performance using the perf logging option: + ``` + set -g @easymotion-perf 'true' + ``` + +Both debug and perf logs are written to `~/easymotion.log`. \ No newline at end of file diff --git a/easymotion.py b/easymotion.py index c6a44c9..5c06961 100755 --- a/easymotion.py +++ b/easymotion.py @@ -214,24 +214,38 @@ 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) + """ + if char == '\t': + return calculate_tab_width(position) 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 @@ -547,12 +561,18 @@ def find_matches(panes, search_ch): for ch in search_chars: if CASE_SENSITIVE: if pos < len(line) and line[pos] == ch: - visual_col = sum(get_char_width(c) for c in line[:pos]) - matches.append((pane, line_num, visual_col)) + # Calculate visual position accounting for tab width based on position + visual_pos = 0 + for i in range(pos): + visual_pos += get_char_width(line[i], visual_pos) + matches.append((pane, line_num, visual_pos)) else: if pos < len(line) and line[pos].lower() == ch.lower(): - visual_col = sum(get_char_width(c) for c in line[:pos]) - matches.append((pane, line_num, visual_col)) + # Calculate visual position accounting for tab width based on position + visual_pos = 0 + for i in range(pos): + visual_pos += get_char_width(line[i], visual_pos) + matches.append((pane, line_num, visual_pos)) return matches diff --git a/test_easymotion.py b/test_easymotion.py index adc530b..ac98c7c 100644 --- a/test_easymotion.py +++ b/test_easymotion.py @@ -1,5 +1,5 @@ from easymotion import (generate_hints, get_char_width, get_string_width, - get_true_position) + get_true_position, calculate_tab_width) def test_get_char_width(): @@ -9,6 +9,9 @@ def test_get_char_width(): assert get_char_width('한') == 2 # Korean character (wide) assert get_char_width(' ') == 1 # Space assert get_char_width('\n') == 1 # Newline + assert get_char_width('\t', 0) == 8 # Tab at position 0 + assert get_char_width('\t', 1) == 7 # Tab at position 1 + assert get_char_width('\t', 7) == 1 # Tab at position 7 def test_get_string_width(): @@ -17,12 +20,23 @@ def test_get_string_width(): assert get_string_width('hello こんにちは') == 16 assert get_string_width('') == 0 + # Need to manually calculate tab width examples to match our implementation + assert get_string_width('\t') == 8 # Tab at position 0 = 8 spaces + 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 position 1 (7) + 'b' (1) + Tab at position 9=1 (7) = 16 + def test_get_true_position(): 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 + 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' def test_generate_hints(): From ed9155873b6935272545fb2faf143c33b571e80c Mon Sep 17 00:00:00 2001 From: Matthias Putz Date: Thu, 31 Jul 2025 14:48:54 +0200 Subject: [PATCH 2/4] fix: save temporary keystroke file in tmp dir --- easymotion.tmux | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/easymotion.tmux b/easymotion.tmux index 71de8ae..31d6492 100755 --- a/easymotion.tmux +++ b/easymotion.tmux @@ -23,7 +23,8 @@ PERF=$(get_tmux_option "@easymotion-perf" "false") CASE_SENSITIVE=$(get_tmux_option "@easymotion-case-sensitive" "false") SMARTSIGN=$(get_tmux_option "@easymotion-smartsign" "false") -tmp_file=$CURRENT_DIR/.keystroke +tmp_file=$(mktemp -t tmux-easymotion_keystroke-XXXXXXX) + # Execute Python script with environment variables tmux bind $(get_tmux_option "@easymotion-key" "s") run-shell "\ printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ From 780c64dc974a187fd4dfb248609a1d0aedfd5bc0 Mon Sep 17 00:00:00 2001 From: Steven Xu Date: Mon, 29 Sep 2025 16:17:57 +1000 Subject: [PATCH 3/4] fix: escape hints --- easymotion.tmux | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/easymotion.tmux b/easymotion.tmux index 31d6492..2cf1ee9 100755 --- a/easymotion.tmux +++ b/easymotion.tmux @@ -26,10 +26,11 @@ SMARTSIGN=$(get_tmux_option "@easymotion-smartsign" "false") tmp_file=$(mktemp -t tmux-easymotion_keystroke-XXXXXXX) # Execute Python script with environment variables +HINTS_ESCAPED="${HINTS/;/\";\"}" tmux bind $(get_tmux_option "@easymotion-key" "s") run-shell "\ - printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ - neww -d '\ - TMUX_EASYMOTION_HINTS=$HINTS \ + printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ + neww -d '\ + TMUX_EASYMOTION_HINTS=$HINTS_ESCAPED \ TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ From b98c76fdb56e2c587cdaf1dd9d328a427f24df10 Mon Sep 17 00:00:00 2001 From: Ryder Date: Mon, 27 Oct 2025 16:24:58 +0800 Subject: [PATCH 4/4] feat: auto detect tmux version --- easymotion.py | 59 ++++++++++++++- test_easymotion.py | 175 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 220 insertions(+), 14 deletions(-) diff --git a/easymotion.py b/easymotion.py index 2f6a4b6..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 @@ -226,9 +273,19 @@ def get_char_width(char: str, position: int = 0) -> int: 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': - return calculate_tab_width(position) + # 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 diff --git a/test_easymotion.py b/test_easymotion.py index 99e8519..0defe42 100644 --- a/test_easymotion.py +++ b/test_easymotion.py @@ -16,40 +16,74 @@ 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) assert get_char_width('한') == 2 # Korean character (wide) assert get_char_width(' ') == 1 # Space assert get_char_width('\n') == 1 # Newline - assert get_char_width('\t', 0) == 8 # Tab at position 0 - assert get_char_width('\t', 1) == 7 # Tab at position 1 - assert get_char_width('\t', 7) == 1 # Tab at position 7 + + # 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 - # Need to manually calculate tab width examples to match our implementation - assert get_string_width('\t') == 8 # Tab at position 0 = 8 spaces - 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 position 1 (7) + 'b' (1) + Tab at position 9=1 (7) = 16 + # 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 - 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' + + # 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(): @@ -898,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}"