From 2928642e162f9fb9af80795956cca6eb800465a2 Mon Sep 17 00:00:00 2001 From: Ryder Date: Sun, 26 Oct 2025 20:52:49 +0800 Subject: [PATCH 1/3] feat: easymotion-s2 mode --- CLAUDE.md | 58 +++++- README.md | 32 +++- easymotion.py | 157 +++++++++++---- easymotion.tmux | 55 ++++-- test_easymotion.py | 468 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 721 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1fb292c..78f5d79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,17 +68,71 @@ Enable performance logging: set -g @easymotion-perf 'true' ``` +## Motion Types + +The plugin supports multiple motion types, controlled by the `TMUX_EASYMOTION_MOTION_TYPE` environment variable: + +- **s** (default): 1-character search - prompts for a single character and shows hints for all matches +- **s2**: 2-character search - prompts for two consecutive characters for more precise matching + +### Motion Type Implementation + +Each motion type is set up via different tmux key bindings in easymotion.tmux: + +- **@easymotion-key**: Legacy binding (backward compatible, uses 's' mode) +- **@easymotion-s**: Explicit 1-char search binding +- **@easymotion-s2**: 2-char search binding (uses two sequential `command-prompt` calls) + +The `main()` function in easymotion.py reads the `MOTION_TYPE` environment variable and: +1. For 's': reads 1 character from the temp file +2. For 's2': reads 2 characters from the temp file +3. Calls `find_matches()` with the search pattern + +### 2-Character Search Details + +The `find_matches()` function was refactored to support multi-character patterns: +- Accepts `search_pattern` (1+ characters) instead of `search_ch` (single character) +- For multi-char patterns, checks substring matches at each position +- Handles wide character boundaries - skips matches that would split a wide (CJK) character +- Smartsign applies to **all pattern lengths** via `generate_smartsign_patterns()` + +### Smartsign Architecture + +**Design Principle**: Smartsign is a **generic transformation layer** that works independently of search mode. + +**Key Components**: + +1. **`generate_smartsign_patterns(pattern)`**: Generic pattern generator + - Works for patterns of **any length** (1-char, 2-char, 3-char, etc.) + - Each character position is independently expanded if it has a smartsign mapping + - Returns all possible combinations using Cartesian product + - Example: `"3,"` → `["3,", "#,", "3<", "#<"]` (4 combinations) + +2. **`find_matches(panes, search_pattern)`**: Pattern-agnostic matching engine + - Calls `generate_smartsign_patterns()` to get all pattern variants + - Performs matching logic once for all variants + - No mode-specific smartsign logic needed + +3. **Extensibility**: New search modes automatically get smartsign support + - Mode determines **what to search** (user input, word boundaries, etc.) + - Smartsign determines **how to expand the pattern** + - Matching logic is unified + +**Performance**: For 2-char search with both chars having mappings, maximum 4 pattern variants. For 3-char, maximum 8 variants. This is acceptable overhead. + ## 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-key`: Trigger key binding for 1-char search (default: 's', backward compatible) +- `@easymotion-s`: Explicit 1-char search key 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: '│') - `@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') +- `@easymotion-smartsign`: Enable smartsign feature to match shifted symbols (default: 'false', works with all search modes) ## Important Implementation Notes diff --git a/README.md b/README.md index a0735ed..50173d9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,30 @@ Press `prefix` + `I` to install ### Options: ```bash +# ============================================================================ +# Key Bindings - Motion Types +# ============================================================================ + +# 1-Character Search (Traditional) +# Usage: prefix + → type a character → hints appear at all occurrences +# Use case: Quick jumps when the target character is unique or easy to spot +# set -g @easymotion-key 's' # Legacy 1-char search (default: 's', backward compatible) +set -g @easymotion-s 's' + +# 2-Character Search (similar to leap.nvim) +# Usage: prefix + → type 2 chars → hints appear only where both match consecutively +# Use case: Reduce screen clutter by narrowing down matches with 2 characters +# Benefits: +# - Fewer hints on screen = easier to read +# - More precise targeting +# - Supports CJK (wide) characters +# - Works with case-sensitivity and smartsign options +set -g @easymotion-s2 'f' + +# ============================================================================ +# Other Configuration Options +# ============================================================================ + # Keys used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;') set -g @easymotion-hints 'asdfghjkl;' @@ -58,6 +82,7 @@ set -g @easymotion-perf 'true' set -g @easymotion-case-sensitive 'true' # Enable smartsign feature (default: false) +# Works with all search modes (s, s2, etc.) set -g @easymotion-smartsign 'true' ``` @@ -72,9 +97,12 @@ bind-key -T copy-mode-vi V send-keys -X select-line; ``` -### Usage -`prefix` + `s` -> hit a character -> hit hints (jump to position) -> press `ve` and `y` to copy +### Usage Examples + +**Copy a word:** +`prefix` + `s` → type character → select hint → press `ve` and `y` to copy +**Paste:** `prefix` + `]` to paste diff --git a/easymotion.py b/easymotion.py index c6a44c9..d331ebe 100755 --- a/easymotion.py +++ b/easymotion.py @@ -18,6 +18,7 @@ 'TMUX_EASYMOTION_CASE_SENSITIVE', 'false').lower() == 'true' SMARTSIGN = os.environ.get( 'TMUX_EASYMOTION_SMARTSIGN', 'false').lower() == 'true' +MOTION_TYPE = os.environ.get('TMUX_EASYMOTION_MOTION_TYPE', 's') # Smartsign mapping table SMARTSIGN_TABLE = { @@ -341,12 +342,12 @@ def get_terminal_size(): return width, height - 1 # Subtract 1 from height -def getch(input_file=None): - """Get a single character from terminal or file +def getch(input_file=None, num_chars=1): + """Get character(s) from terminal or file Args: input_file: Optional filename to read from. If None, read from stdin. - File will be deleted after reading if specified. + num_chars: Number of characters to read (default: 1) """ if input_file is None: # Read from stdin @@ -354,19 +355,23 @@ def getch(input_file=None): old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) - ch = sys.stdin.read(1) + ch = sys.stdin.read(num_chars) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) else: - # Read from file and delete it + # Read from file try: with open(input_file, 'r') as f: - ch = f.read(1) + ch = f.read(num_chars) except FileNotFoundError: - return '\x03' # Return Ctrl+C if file not found + logging.info("File not found") + exit(1) except Exception as e: logging.error(f"Error reading from file: {str(e)}") - return '\x03' + exit(1) + if ch == '\x03': + logging.info("Operation cancelled by user") + exit(1) return ch @@ -530,29 +535,96 @@ def draw_all_panes(panes, max_x, padding_cache, terminal_height, screen): screen.refresh() +def generate_smartsign_patterns(pattern): + """Generate all smartsign variants for ANY pattern + + This is a generic function that works for patterns of any length. + Each character position is independently expanded if it has a smartsign mapping. + This enables smartsign support for all search modes (s, s2, s3, etc.) + + Args: + pattern: String of any length + + Returns: + List of pattern variants (includes original pattern) + + Examples: + "3" -> ["3", "#"] + "3," -> ["3,", "#,", "3<", "#<"] + "ab" -> ["ab"] + "3x5" -> ["3x5", "#x5", "3x%", "#x%"] # Future: 3-char support + """ + if not SMARTSIGN: + return [pattern] + + import itertools + + # For each character position, collect possible characters + char_options = [] + for ch in pattern: + options = [ch] + # Add smartsign variant if exists + if ch in SMARTSIGN_TABLE: + options.append(SMARTSIGN_TABLE[ch]) + char_options.append(options) + + # Generate all combinations (Cartesian product) + patterns = [''.join(combo) for combo in itertools.product(*char_options)] + return patterns + + @perf_timer("Finding matches") -def find_matches(panes, search_ch): - """Find all matches and return match list""" +def find_matches(panes, search_pattern): + """Generic pattern matching with smartsign support + + This function is pattern-agnostic - it works for any search pattern, + regardless of how that pattern was generated (s, s2, bd-w, etc.) + Smartsign is automatically applied via generate_smartsign_patterns(). + + Args: + panes: List of PaneInfo objects + search_pattern: String to search for (1 or more characters) + """ matches = [] + pattern_length = len(search_pattern) - # If smartsign is enabled, add corresponding symbol - search_chars = [search_ch] - if SMARTSIGN and search_ch in SMARTSIGN_TABLE: - search_chars.append(SMARTSIGN_TABLE[search_ch]) + # GENERIC: Apply smartsign transformation (works for any pattern length) + search_patterns = generate_smartsign_patterns(search_pattern) for pane in panes: for line_num, line in enumerate(pane.lines): - # 對每個字符位置檢查所有可能的匹配 + # Check each position in the line for pos in range(len(line)): - for ch in search_chars: + # For multi-char search, make sure we have enough characters + if pos + pattern_length > len(line): + continue + + # Get substring at current position + substring = line[pos:pos + pattern_length] + + # Skip if substring would split a wide character + if pattern_length > 1: + # Check if we're in the middle of a wide char + if pos > 0 and get_char_width(line[pos - 1]) == 2: + # Check if previous char's visual position overlaps with current pos + visual_before = sum(get_char_width(c) for c in line[:pos - 1]) + visual_at_pos = sum(get_char_width(c) for c in line[:pos]) + if visual_at_pos - visual_before == 1: + # We're at the second half of a wide char, skip + continue + + # Check against all search patterns + for pattern in search_patterns: + matched = False 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)) + matched = (substring == pattern) 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)) + matched = (substring.lower() == pattern.lower()) + + if matched: + visual_col = sum(get_char_width(c) for c in line[:pos]) + matches.append((pane, line_num, visual_col)) + break # Found match, no need to check other patterns return matches @@ -565,8 +637,10 @@ def update_hints_display(screen, positions, current_key): if hint.startswith(current_key): next_x = screen_x + get_char_width(char) if next_x < pane_right_edge: - logging.debug(f"Restoring next char {next_x} {next_char}") - screen.addstr(screen_y, next_x, next_char) + # Use space if next_char is empty (end of line case) + restore_char = next_char if next_char else ' ' + logging.debug(f"Restoring next char {next_x} {restore_char}") + screen.addstr(screen_y, next_x, restore_char) else: logging.debug(f"Non-matching hint {screen_x} {screen_y} {char}") # Restore original character for non-matching hints @@ -574,8 +648,10 @@ def update_hints_display(screen, positions, current_key): # Always restore second character next_x = screen_x + get_char_width(char) if next_x < pane_right_edge: - logging.debug(f"Restoring next char {next_x} {next_char}") - screen.addstr(screen_y, next_x, next_char) + # Use space if next_char is empty (end of line case) + restore_char = next_char if next_char else ' ' + logging.debug(f"Restoring next char {next_x} {restore_char}") + screen.addstr(screen_y, next_x, restore_char) continue # For matching hints: @@ -588,7 +664,9 @@ def update_hints_display(screen, positions, current_key): screen.addstr(screen_y, screen_x, char) next_x = screen_x + get_char_width(char) if next_x < pane_right_edge: - screen.addstr(screen_y, next_x, next_char) + # Use space if next_char is empty (end of line case) + restore_char = next_char if next_char else ' ' + screen.addstr(screen_y, next_x, restore_char) screen.refresh() @@ -616,11 +694,26 @@ def main(screen: Screen): setup_logging() panes, max_x, padding_cache = init_panes() - # Read character from temporary file - search_ch = getch(sys.argv[1]) - if search_ch == '\x03': - return - matches = find_matches(panes, search_ch) + # Determine search mode and find matches + if MOTION_TYPE == 's': + # 1 char search + search_pattern = getch(sys.argv[1], 1) + search_pattern = search_pattern.replace('\n', '').replace('\r', '') + if not search_pattern: + return + matches = find_matches(panes, search_pattern) + elif MOTION_TYPE == 's2': + # 2 char search + search_pattern = getch(sys.argv[1], 2) + search_pattern = search_pattern.replace('\n', '').replace('\r', '') + if not search_pattern: + return + matches = find_matches(panes, search_pattern) + else: + logging.error(f"Invalid motion type: {MOTION_TYPE}") + exit(1) + + # Check for matches if len(matches) == 0: sh(['tmux', 'display-message', 'no match']) return diff --git a/easymotion.tmux b/easymotion.tmux index 2cf1ee9..39ce7bc 100755 --- a/easymotion.tmux +++ b/easymotion.tmux @@ -25,17 +25,46 @@ SMARTSIGN=$(get_tmux_option "@easymotion-smartsign" "false") tmp_file=$(mktemp -t tmux-easymotion_keystroke-XXXXXXX) -# Execute Python script with environment variables +# Escape semicolon in hints (if present) 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_ESCAPED \ - TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ - TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ - TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ - TMUX_EASYMOTION_DEBUG=$DEBUG \ - TMUX_EASYMOTION_PERF=$PERF \ - TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ - TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN \ - $CURRENT_DIR/easymotion.py $tmp_file'" + +# Build environment variables string for passing to neww -d +# This must be done because neww -d does not inherit exported variables +ENV_VARS="\ +TMUX_EASYMOTION_HINTS=$HINTS_ESCAPED \ +TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ +TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ +TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ +TMUX_EASYMOTION_DEBUG=$DEBUG \ +TMUX_EASYMOTION_PERF=$PERF \ +TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ +TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN" + +# ============================================================================ +# 1-Character Search Key Binding +# ============================================================================ +# Prefer new naming (@easymotion-s), fallback to legacy (@easymotion-key) +S_KEY=$(get_tmux_option "@easymotion-s" "") +if [ -z "$S_KEY" ]; then + # Fallback to legacy naming (for backward compatibility) + S_KEY=$(get_tmux_option "@easymotion-key" "s") +fi + +# Setup 1-char search binding +if [ -n "$S_KEY" ]; then + tmux bind "$S_KEY" run-shell "\ + printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ + neww -d '$ENV_VARS TMUX_EASYMOTION_MOTION_TYPE=s $CURRENT_DIR/easymotion.py $tmp_file'" +fi + +# ============================================================================ +# 2-Character Search Key Binding +# ============================================================================ +S2_KEY=$(get_tmux_option "@easymotion-s2" "") +if [ -n "$S2_KEY" ]; then + tmux bind "$S2_KEY" run-shell "\ + printf '\x03' > $tmp_file && \ + tmux command-prompt -1 -p 'easymotion char 1:' 'run-shell \"printf %s \\\"%1\\\" > $tmp_file\"' \; \ + command-prompt -1 -p 'easymotion char 2:' 'run-shell \"printf %s\\\\n \\\"%1\\\" >> $tmp_file\"' \; \ + neww -d '$ENV_VARS TMUX_EASYMOTION_MOTION_TYPE=s2 $CURRENT_DIR/easymotion.py $tmp_file'" +fi diff --git a/test_easymotion.py b/test_easymotion.py index f27e511..8af9f40 100644 --- a/test_easymotion.py +++ b/test_easymotion.py @@ -7,9 +7,11 @@ assign_hints_by_distance, find_matches, generate_hints, + generate_smartsign_patterns, get_char_width, get_string_width, get_true_position, + update_hints_display, ) @@ -229,6 +231,182 @@ def test_find_matches_smartsign(): easymotion.SMARTSIGN = original_smartsign +def test_smartsign_key_mappings(): + """Test smartsign with key number-to-symbol mappings (issue reported: '3' not matching '#')""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + + # Test '3' -> '#' mapping (user reported issue) + pane.lines = ['test 3# code'] + matches = find_matches([pane], '3') + assert len(matches) == 2 # Should find both '3' and '#' + + # Test '1' -> '!' mapping + pane.lines = ['test 1! code'] + matches = find_matches([pane], '1') + assert len(matches) == 2 # Should find both '1' and '!' + + # Test '2' -> '@' mapping + pane.lines = ['email 2@ test'] + matches = find_matches([pane], '2') + assert len(matches) == 2 # Should find both '2' and '@' + + # Test '8' -> '*' mapping + pane.lines = ['star 8* test'] + matches = find_matches([pane], '8') + assert len(matches) == 2 # Should find both '8' and '*' + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_smartsign_with_case_insensitive(): + """Test smartsign combined with case insensitive mode""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + original_case_sensitive = easymotion.CASE_SENSITIVE + + try: + easymotion.SMARTSIGN = True + easymotion.CASE_SENSITIVE = False + + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + + # Smartsign should work with case insensitive mode + pane.lines = ['test 3# CODE'] + matches = find_matches([pane], '3') + assert len(matches) == 2 # Should find both '3' and '#' + + finally: + easymotion.SMARTSIGN = original_smartsign + easymotion.CASE_SENSITIVE = original_case_sensitive + + +def test_smartsign_reverse_search(): + """Test that searching for symbol itself (not number) works correctly""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + pane = PaneInfo( + pane_id='%1', active=True, start_y=0, height=10, start_x=0, width=80 + ) + + # Searching '#' should only find '#', not '3' + # because '#' is not a key in SMARTSIGN_TABLE + pane.lines = ['test 3# code'] + matches = find_matches([pane], '#') + assert len(matches) == 1 # Should only find '#' + + # Searching '!' should only find '!' + pane.lines = ['test 1! code'] + matches = find_matches([pane], '!') + assert len(matches) == 1 # Should only find '!' + + finally: + easymotion.SMARTSIGN = original_smartsign + + +# ============================================================================ +# Tests for Generic Smartsign Pattern Generation +# ============================================================================ + +def test_generate_smartsign_patterns_disabled(): + """Test that pattern generation returns original when SMARTSIGN is disabled""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = False + + # Should return only the original pattern + assert generate_smartsign_patterns("3") == ["3"] + assert generate_smartsign_patterns("3,") == ["3,"] + assert generate_smartsign_patterns("abc") == ["abc"] + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_generate_smartsign_patterns_1char(): + """Test 1-character smartsign pattern generation""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + # Character with mapping + patterns = generate_smartsign_patterns("3") + assert set(patterns) == {"3", "#"} + + # Character without mapping + patterns = generate_smartsign_patterns("x") + assert patterns == ["x"] + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_generate_smartsign_patterns_2char(): + """Test 2-character smartsign pattern generation (all combinations)""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + # Both characters have mappings: '3' -> '#', ',' -> '<' + patterns = generate_smartsign_patterns("3,") + assert set(patterns) == {"3,", "#,", "3<", "#<"} + + # Only first character has mapping + patterns = generate_smartsign_patterns("3x") + assert set(patterns) == {"3x", "#x"} + + # Only second character has mapping + patterns = generate_smartsign_patterns("x,") + assert set(patterns) == {"x,", "x<"} + + # Neither character has mapping + patterns = generate_smartsign_patterns("ab") + assert patterns == ["ab"] + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_generate_smartsign_patterns_3char(): + """Test 3-character pattern generation (verifies extensibility)""" + import easymotion + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + # All three have mappings: '1' -> '!', '2' -> '@', '3' -> '#' + patterns = generate_smartsign_patterns("123") + # Should generate 2^3 = 8 combinations + expected = {"123", "!23", "1@3", "12#", "!@3", "!2#", "1@#", "!@#"} + assert set(patterns) == expected + + # Mixed: first and last have mappings + patterns = generate_smartsign_patterns("1x3") + assert set(patterns) == {"1x3", "!x3", "1x#", "!x#"} + + 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') @@ -416,3 +594,293 @@ def test_search_to_hint_integration(simple_pane): # Verify hint is valid assert len(hint) in [1, 2] # Should be 1 or 2 characters + + +# ============================================================================ +# Tests for 2-Character Search (Issue #6) +# ============================================================================ + +def test_find_matches_2char_basic(simple_pane): + """Test 2-character search with basic patterns""" + simple_pane.lines = ['hello world', 'foo bar baz', 'test line'] + + # Search for 'wo' + matches = find_matches([simple_pane], 'wo') + assert len(matches) >= 1 + # Should find 'wo' in "world" + pane, line_num, visual_col = matches[0] + assert line_num == 0 + true_pos = get_true_position(simple_pane.lines[line_num], visual_col) + assert simple_pane.lines[line_num][true_pos:true_pos+2] == 'wo' + + +def test_find_matches_2char_multiple(simple_pane): + """Test 2-character search with multiple matches""" + simple_pane.lines = ['hello hello', 'test hello'] + + # Search for 'he' + matches = find_matches([simple_pane], 'he') + # Should find 'he' three times + assert len(matches) == 3 + + +def test_find_matches_2char_case_insensitive(simple_pane): + """Test 2-character search with case insensitivity""" + import easymotion + original_case_sensitive = easymotion.CASE_SENSITIVE + + try: + easymotion.CASE_SENSITIVE = False + simple_pane.lines = ['Hello HELLO heLLo'] + + # Search for 'he' should match 'He', 'HE', 'he' + matches = find_matches([simple_pane], 'he') + assert len(matches) == 3 + + # Search for 'HE' should also match all + matches_upper = find_matches([simple_pane], 'HE') + assert len(matches_upper) == 3 + + finally: + easymotion.CASE_SENSITIVE = original_case_sensitive + + +def test_find_matches_2char_wide_characters(wide_char_pane): + """Test 2-character search with wide characters""" + # Search for 'ld' in "world" + matches = find_matches([wide_char_pane], 'ld') + assert len(matches) >= 1 + + +def test_find_matches_2char_no_match(simple_pane): + """Test 2-character search with no matches""" + simple_pane.lines = ['hello world'] + + # Search for pattern that doesn't exist + matches = find_matches([simple_pane], 'xy') + assert len(matches) == 0 + + +def test_find_matches_2char_partial_match(simple_pane): + """Test that partial matches don't count""" + simple_pane.lines = ['hello'] + + # Search for 'lo' - should find only one match at the end + matches = find_matches([simple_pane], 'lo') + assert len(matches) == 1 + + +def test_s2_smartsign_single_char_mapping(): + """Test s2 mode with smartsign when only one character has mapping""" + import easymotion + + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + pane = PaneInfo('%1', True, 0, 3, 0, 40) + pane.lines = ['test 3x and #x code'] + + # Search for '3x' should match both '3x' and '#x' + matches = find_matches([pane], '3x') + assert len(matches) == 2 + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_s2_smartsign_both_chars_mapping(): + """Test s2 mode with smartsign when both characters have mappings""" + import easymotion + + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + pane = PaneInfo('%1', True, 0, 3, 0, 60) + # '3' -> '#', ',' -> '<' + pane.lines = ['3, #, 3< #< test'] + + # Search for '3,' should match all 4 combinations + matches = find_matches([pane], '3,') + assert len(matches) == 4 + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_s2_smartsign_no_mapping(): + """Test s2 mode with smartsign when no characters have mappings""" + import easymotion + + original_smartsign = easymotion.SMARTSIGN + + try: + easymotion.SMARTSIGN = True + + pane = PaneInfo('%1', True, 0, 3, 0, 40) + pane.lines = ['test ab and cd code'] + + # Search for 'ab' should only match 'ab' (no mappings) + matches = find_matches([pane], 'ab') + assert len(matches) == 1 + + finally: + easymotion.SMARTSIGN = original_smartsign + + +def test_s2_smartsign_with_case_insensitive(): + """Test s2 mode with smartsign + case insensitive combination""" + import easymotion + + original_smartsign = easymotion.SMARTSIGN + original_case_sensitive = easymotion.CASE_SENSITIVE + + try: + easymotion.SMARTSIGN = True + easymotion.CASE_SENSITIVE = False + + pane = PaneInfo('%1', True, 0, 3, 0, 40) + pane.lines = ['3X #X 3x #x test'] + + # Should match all case variations + smartsign variants + matches = find_matches([pane], '3x') + # Matches: 3X, #X, 3x, #x (4 total) + assert len(matches) == 4 + + finally: + easymotion.SMARTSIGN = original_smartsign + easymotion.CASE_SENSITIVE = original_case_sensitive + + +def test_find_matches_2char_at_line_end(simple_pane): + """Test 2-character search at end of line""" + simple_pane.lines = ['hello'] + + # Search for 'lo' at end of line + matches = find_matches([simple_pane], 'lo') + assert len(matches) == 1 + pane, line_num, visual_col = matches[0] + assert line_num == 0 + true_pos = get_true_position(simple_pane.lines[line_num], visual_col) + assert simple_pane.lines[line_num][true_pos:true_pos+2] == 'lo' + + +# ============================================================================ +# Tests for Line-End Hint Restoration Bug Fix +# ============================================================================ + +def test_positions_construction_at_line_end(simple_pane): + """Test that positions are correctly constructed when match is at line end""" + simple_pane.lines = ['hello'] + + # Find match for 'o' at end of line (position 4) + matches = find_matches([simple_pane], 'o') + assert len(matches) == 1 + + pane, line_num, visual_col = matches[0] + line = pane.lines[line_num] + true_col = get_true_position(line, visual_col) + + # At line end, true_col should be the last character + assert true_col == 4 # 'o' is at index 4 + assert line[true_col] == 'o' + + # next_char should be empty because we're at line end + next_char = line[true_col + 1] if true_col + 1 < len(line) else '' + assert next_char == '' + + # But next_x should still be within pane bounds (for padding area) + next_x = simple_pane.start_x + visual_col + get_char_width('o') + pane_right_edge = simple_pane.start_x + simple_pane.width + assert next_x < pane_right_edge # Should be within pane for padding + + +class MockScreen: + """Mock Screen class to record addstr calls for testing""" + + # Attribute constants matching real Screen class + A_NORMAL = 0 + A_DIM = 1 + A_HINT1 = 2 + A_HINT2 = 3 + + def __init__(self): + self.calls = [] + self.refresh_called = False + + def addstr(self, y, x, text, attr=0): + """Record all addstr calls""" + self.calls.append({ + 'y': y, + 'x': x, + 'text': text, + 'attr': attr + }) + + def refresh(self): + """Record refresh call""" + self.refresh_called = True + + def get_calls_at_position(self, x): + """Helper to get all calls at a specific x position""" + return [call for call in self.calls if call['x'] == x] + + +def test_hint_restoration_at_line_end(): + """Test that hint at line end is properly restored when first char is pressed""" + # Create a pane with line ending at 'o' + pane = PaneInfo('%1', True, 0, 1, 0, 20) + pane.lines = ['hello'] + + # Simulate a two-character hint 'ab' at the last character 'o' (position 4) + # screen_y, screen_x, pane_right_edge, char, next_char, hint + positions = [ + (0, 4, 20, 'o', '', 'ab') # next_char is empty (line end) + ] + + # Create mock screen + screen = MockScreen() + + # Simulate user pressing first hint character 'a' + update_hints_display(screen, positions, 'a') + + # Verify that refresh was called + assert screen.refresh_called + + # Get calls at position 5 (next_x = 4 + get_char_width('o') = 5) + calls_at_next_pos = screen.get_calls_at_position(5) + + # Should have one call to restore the second position + assert len(calls_at_next_pos) == 1 + + # The restored character should be a space, not empty string + assert calls_at_next_pos[0]['text'] == ' ' + assert calls_at_next_pos[0]['text'] != '' # Bug fix: was empty before + + +def test_hint_restoration_not_at_line_end(): + """Test that hint restoration works correctly when NOT at line end""" + # Create a pane + pane = PaneInfo('%1', True, 0, 1, 0, 20) + pane.lines = ['hello world'] + + # Simulate a two-character hint 'ab' at 'e' (position 1), next_char is 'l' + positions = [ + (0, 1, 20, 'e', 'l', 'ab') # next_char is 'l' (not empty) + ] + + # Create mock screen + screen = MockScreen() + + # Simulate user pressing first hint character 'a' + update_hints_display(screen, positions, 'a') + + # Get calls at position 2 (next_x = 1 + get_char_width('e') = 2) + calls_at_next_pos = screen.get_calls_at_position(2) + + # Should restore the actual next character 'l' + assert len(calls_at_next_pos) == 1 + assert calls_at_next_pos[0]['text'] == 'l' From 33e9d173001c10299d4f602abb64d238adcc2c32 Mon Sep 17 00:00:00 2001 From: Ryder Date: Sun, 26 Oct 2025 21:55:28 +0800 Subject: [PATCH 2/3] refactor: extract different mode sh --- common.sh | 46 ++++++++++++++++++++++++++++++++++++++++++++++ easymotion.py | 6 ++++-- easymotion.tmux | 49 ++++--------------------------------------------- mode-s.sh | 15 +++++++++++++++ mode-s2.sh | 23 +++++++++++++++++++++++ 5 files changed, 92 insertions(+), 47 deletions(-) create mode 100644 common.sh create mode 100755 mode-s.sh create mode 100755 mode-s2.sh diff --git a/common.sh b/common.sh new file mode 100644 index 0000000..ce97ef2 --- /dev/null +++ b/common.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Common functions and configuration for easymotion modes + +# Read configuration from tmux options +get_tmux_option() { + local option=$1 + local default_value=$2 + local option_value=$(tmux show-option -gqv "$option") + if [ -z $option_value ]; then + echo $default_value + else + echo $option_value + fi +} + +# Get all configuration options +HINTS=$(get_tmux_option "@easymotion-hints" "asdghklqwertyuiopzxcvbnmfj;") +VERTICAL_BORDER=$(get_tmux_option "@easymotion-vertical-border" "│") +HORIZONTAL_BORDER=$(get_tmux_option "@easymotion-horizontal-border" "─") +USE_CURSES=$(get_tmux_option "@easymotion-use-curses" "false") +DEBUG=$(get_tmux_option "@easymotion-debug" "false") +PERF=$(get_tmux_option "@easymotion-perf" "false") +CASE_SENSITIVE=$(get_tmux_option "@easymotion-case-sensitive" "false") +SMARTSIGN=$(get_tmux_option "@easymotion-smartsign" "false") + +# Create temporary input file with reset character +create_input_file() { + local tmp_file=$(mktemp -t tmux-easymotion_keystroke-XXXXXXX) + printf '\x03' > "$tmp_file" + echo "$tmp_file" +} + +# Build environment variables string for neww -d +build_env_vars() { + local motion_type=$1 + echo "TMUX_EASYMOTION_HINTS=$HINTS \ +TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ +TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ +TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ +TMUX_EASYMOTION_DEBUG=$DEBUG \ +TMUX_EASYMOTION_PERF=$PERF \ +TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ +TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN \ +TMUX_EASYMOTION_MOTION_TYPE=$motion_type" +} diff --git a/easymotion.py b/easymotion.py index d331ebe..646a4ce 100755 --- a/easymotion.py +++ b/easymotion.py @@ -704,8 +704,10 @@ def main(screen: Screen): matches = find_matches(panes, search_pattern) elif MOTION_TYPE == 's2': # 2 char search - search_pattern = getch(sys.argv[1], 2) - search_pattern = search_pattern.replace('\n', '').replace('\r', '') + raw_input = getch(sys.argv[1], 2) + logging.debug(f"Raw input (s2): {repr(raw_input)}") + search_pattern = raw_input.replace('\n', '').replace('\r', '') + logging.debug(f"Search pattern (s2): {repr(search_pattern)}") if not search_pattern: return matches = find_matches(panes, search_pattern) diff --git a/easymotion.tmux b/easymotion.tmux index 39ce7bc..c24c8fb 100755 --- a/easymotion.tmux +++ b/easymotion.tmux @@ -1,44 +1,9 @@ #!/usr/bin/env bash -get_tmux_option() { - local option=$1 - local default_value=$2 - local option_value=$(tmux show-option -gqv "$option") - if [ -z $option_value ]; then - echo $default_value - else - echo $option_value - fi -} - CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -# Define all options and their default values -HINTS=$(get_tmux_option "@easymotion-hints" "asdghklqwertyuiopzxcvbnmfj;") -VERTICAL_BORDER=$(get_tmux_option "@easymotion-vertical-border" "│") -HORIZONTAL_BORDER=$(get_tmux_option "@easymotion-horizontal-border" "─") -USE_CURSES=$(get_tmux_option "@easymotion-use-curses" "false") -DEBUG=$(get_tmux_option "@easymotion-debug" "false") -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=$(mktemp -t tmux-easymotion_keystroke-XXXXXXX) - -# Escape semicolon in hints (if present) -HINTS_ESCAPED="${HINTS/;/\";\"}" - -# Build environment variables string for passing to neww -d -# This must be done because neww -d does not inherit exported variables -ENV_VARS="\ -TMUX_EASYMOTION_HINTS=$HINTS_ESCAPED \ -TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ -TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ -TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ -TMUX_EASYMOTION_DEBUG=$DEBUG \ -TMUX_EASYMOTION_PERF=$PERF \ -TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ -TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN" +# Load common functions +source "$CURRENT_DIR/common.sh" # ============================================================================ # 1-Character Search Key Binding @@ -52,9 +17,7 @@ fi # Setup 1-char search binding if [ -n "$S_KEY" ]; then - tmux bind "$S_KEY" run-shell "\ - printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ - neww -d '$ENV_VARS TMUX_EASYMOTION_MOTION_TYPE=s $CURRENT_DIR/easymotion.py $tmp_file'" + tmux bind "$S_KEY" run-shell "$CURRENT_DIR/mode-s.sh" fi # ============================================================================ @@ -62,9 +25,5 @@ fi # ============================================================================ S2_KEY=$(get_tmux_option "@easymotion-s2" "") if [ -n "$S2_KEY" ]; then - tmux bind "$S2_KEY" run-shell "\ - printf '\x03' > $tmp_file && \ - tmux command-prompt -1 -p 'easymotion char 1:' 'run-shell \"printf %s \\\"%1\\\" > $tmp_file\"' \; \ - command-prompt -1 -p 'easymotion char 2:' 'run-shell \"printf %s\\\\n \\\"%1\\\" >> $tmp_file\"' \; \ - neww -d '$ENV_VARS TMUX_EASYMOTION_MOTION_TYPE=s2 $CURRENT_DIR/easymotion.py $tmp_file'" + tmux bind "$S2_KEY" run-shell "$CURRENT_DIR/mode-s2.sh" fi diff --git a/mode-s.sh b/mode-s.sh new file mode 100755 index 0000000..4a19076 --- /dev/null +++ b/mode-s.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Get the directory where this script is located +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Load common configuration and functions +source "$CURRENT_DIR/common.sh" + +# Create temporary input file +tmp_file=$(create_input_file) + +# Prompt for single character +ENV_VARS=$(build_env_vars "s") +tmux command-prompt -1 -p 'easymotion:' "run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"; \ + neww -d '$ENV_VARS $CURRENT_DIR/easymotion.py $tmp_file'" diff --git a/mode-s2.sh b/mode-s2.sh new file mode 100755 index 0000000..2c4f0c3 --- /dev/null +++ b/mode-s2.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Get the directory where this script is located +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Load common configuration and functions +source "$CURRENT_DIR/common.sh" + +# Create temporary input file +tmp_file=$(create_input_file) + +# Build environment variables +ENV_VARS=$(build_env_vars "s2") + +# First prompt: get first character +tmux command-prompt -1 -p 'easymotion char 1:' \ + "run-shell \"printf '%1' > $tmp_file\"; \ + set-option -g @_easymotion_tmp_char1 '%1'" + +# Second prompt: get second character and launch easymotion +tmux command-prompt -1 -p 'easymotion char 2: #{@_easymotion_tmp_char1}' \ + "run-shell \"printf '%1' >> $tmp_file && echo >> $tmp_file\"; \ + neww -d '$ENV_VARS $CURRENT_DIR/easymotion.py $tmp_file'" From 4376ebb636da18c41baeaabf60bd5e8601ac7943 Mon Sep 17 00:00:00 2001 From: Ryder Date: Sun, 26 Oct 2025 22:11:36 +0800 Subject: [PATCH 3/3] fix: quote --- common.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/common.sh b/common.sh index ce97ef2..3e48661 100644 --- a/common.sh +++ b/common.sh @@ -34,13 +34,13 @@ create_input_file() { # Build environment variables string for neww -d build_env_vars() { local motion_type=$1 - echo "TMUX_EASYMOTION_HINTS=$HINTS \ -TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ -TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ -TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ -TMUX_EASYMOTION_DEBUG=$DEBUG \ -TMUX_EASYMOTION_PERF=$PERF \ -TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ -TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN \ -TMUX_EASYMOTION_MOTION_TYPE=$motion_type" + echo "TMUX_EASYMOTION_HINTS=\"$HINTS\" \ +TMUX_EASYMOTION_VERTICAL_BORDER=\"$VERTICAL_BORDER\" \ +TMUX_EASYMOTION_HORIZONTAL_BORDER=\"$HORIZONTAL_BORDER\" \ +TMUX_EASYMOTION_USE_CURSES=\"$USE_CURSES\" \ +TMUX_EASYMOTION_DEBUG=\"$DEBUG\" \ +TMUX_EASYMOTION_PERF=\"$PERF\" \ +TMUX_EASYMOTION_CASE_SENSITIVE=\"$CASE_SENSITIVE\" \ +TMUX_EASYMOTION_SMARTSIGN=\"$SMARTSIGN\" \ +TMUX_EASYMOTION_MOTION_TYPE=\"$motion_type\"" }