Skip to content

Commit 2928642

Browse files
committed
feat: easymotion-s2 mode
1 parent 96b18cd commit 2928642

File tree

5 files changed

+721
-49
lines changed

5 files changed

+721
-49
lines changed

CLAUDE.md

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,71 @@ Enable performance logging:
6868
set -g @easymotion-perf 'true'
6969
```
7070

71+
## Motion Types
72+
73+
The plugin supports multiple motion types, controlled by the `TMUX_EASYMOTION_MOTION_TYPE` environment variable:
74+
75+
- **s** (default): 1-character search - prompts for a single character and shows hints for all matches
76+
- **s2**: 2-character search - prompts for two consecutive characters for more precise matching
77+
78+
### Motion Type Implementation
79+
80+
Each motion type is set up via different tmux key bindings in easymotion.tmux:
81+
82+
- **@easymotion-key**: Legacy binding (backward compatible, uses 's' mode)
83+
- **@easymotion-s**: Explicit 1-char search binding
84+
- **@easymotion-s2**: 2-char search binding (uses two sequential `command-prompt` calls)
85+
86+
The `main()` function in easymotion.py reads the `MOTION_TYPE` environment variable and:
87+
1. For 's': reads 1 character from the temp file
88+
2. For 's2': reads 2 characters from the temp file
89+
3. Calls `find_matches()` with the search pattern
90+
91+
### 2-Character Search Details
92+
93+
The `find_matches()` function was refactored to support multi-character patterns:
94+
- Accepts `search_pattern` (1+ characters) instead of `search_ch` (single character)
95+
- For multi-char patterns, checks substring matches at each position
96+
- Handles wide character boundaries - skips matches that would split a wide (CJK) character
97+
- Smartsign applies to **all pattern lengths** via `generate_smartsign_patterns()`
98+
99+
### Smartsign Architecture
100+
101+
**Design Principle**: Smartsign is a **generic transformation layer** that works independently of search mode.
102+
103+
**Key Components**:
104+
105+
1. **`generate_smartsign_patterns(pattern)`**: Generic pattern generator
106+
- Works for patterns of **any length** (1-char, 2-char, 3-char, etc.)
107+
- Each character position is independently expanded if it has a smartsign mapping
108+
- Returns all possible combinations using Cartesian product
109+
- Example: `"3,"``["3,", "#,", "3<", "#<"]` (4 combinations)
110+
111+
2. **`find_matches(panes, search_pattern)`**: Pattern-agnostic matching engine
112+
- Calls `generate_smartsign_patterns()` to get all pattern variants
113+
- Performs matching logic once for all variants
114+
- No mode-specific smartsign logic needed
115+
116+
3. **Extensibility**: New search modes automatically get smartsign support
117+
- Mode determines **what to search** (user input, word boundaries, etc.)
118+
- Smartsign determines **how to expand the pattern**
119+
- Matching logic is unified
120+
121+
**Performance**: For 2-char search with both chars having mappings, maximum 4 pattern variants. For 3-char, maximum 8 variants. This is acceptable overhead.
122+
71123
## Configuration Options (tmux.conf)
72124

73125
All options are read from tmux options in easymotion.tmux and passed as environment variables to the Python script:
74126

75-
- `@easymotion-key`: Trigger key binding (default: 's')
127+
- `@easymotion-key`: Trigger key binding for 1-char search (default: 's', backward compatible)
128+
- `@easymotion-s`: Explicit 1-char search key binding (optional)
129+
- `@easymotion-s2`: 2-char search key binding (optional, e.g., 'f')
76130
- `@easymotion-hints`: Characters used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;')
77131
- `@easymotion-vertical-border`: Character for vertical borders (default: '│')
78132
- `@easymotion-horizontal-border`: Character for horizontal borders (default: '─')
79133
- `@easymotion-use-curses`: Use curses instead of ANSI sequences (default: 'false')
80134
- `@easymotion-case-sensitive`: Case-sensitive search (default: 'false')
81-
- `@easymotion-smartsign`: Enable smartsign feature to match shifted symbols (default: 'false')
135+
- `@easymotion-smartsign`: Enable smartsign feature to match shifted symbols (default: 'false', works with all search modes)
82136

83137
## Important Implementation Notes
84138

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ Press `prefix` + `I` to install
3838
### Options:
3939

4040
```bash
41+
# ============================================================================
42+
# Key Bindings - Motion Types
43+
# ============================================================================
44+
45+
# 1-Character Search (Traditional)
46+
# Usage: prefix + <key> → type a character → hints appear at all occurrences
47+
# Use case: Quick jumps when the target character is unique or easy to spot
48+
# set -g @easymotion-key 's' # Legacy 1-char search (default: 's', backward compatible)
49+
set -g @easymotion-s 's'
50+
51+
# 2-Character Search (similar to leap.nvim)
52+
# Usage: prefix + <key> → type 2 chars → hints appear only where both match consecutively
53+
# Use case: Reduce screen clutter by narrowing down matches with 2 characters
54+
# Benefits:
55+
# - Fewer hints on screen = easier to read
56+
# - More precise targeting
57+
# - Supports CJK (wide) characters
58+
# - Works with case-sensitivity and smartsign options
59+
set -g @easymotion-s2 'f'
60+
61+
# ============================================================================
62+
# Other Configuration Options
63+
# ============================================================================
64+
4165
# Keys used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;')
4266
set -g @easymotion-hints 'asdfghjkl;'
4367

@@ -58,6 +82,7 @@ set -g @easymotion-perf 'true'
5882
set -g @easymotion-case-sensitive 'true'
5983

6084
# Enable smartsign feature (default: false)
85+
# Works with all search modes (s, s2, etc.)
6186
set -g @easymotion-smartsign 'true'
6287
```
6388

@@ -72,9 +97,12 @@ bind-key -T copy-mode-vi V send-keys -X select-line;
7297
```
7398

7499

75-
### Usage
76-
`prefix` + `s` -> hit a character -> hit hints (jump to position) -> press `ve` and `y` to copy
100+
### Usage Examples
101+
102+
**Copy a word:**
103+
`prefix` + `s` → type character → select hint → press `ve` and `y` to copy
77104

105+
**Paste:**
78106
`prefix` + `]` to paste
79107

80108

easymotion.py

Lines changed: 125 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
'TMUX_EASYMOTION_CASE_SENSITIVE', 'false').lower() == 'true'
1919
SMARTSIGN = os.environ.get(
2020
'TMUX_EASYMOTION_SMARTSIGN', 'false').lower() == 'true'
21+
MOTION_TYPE = os.environ.get('TMUX_EASYMOTION_MOTION_TYPE', 's')
2122

2223
# Smartsign mapping table
2324
SMARTSIGN_TABLE = {
@@ -341,32 +342,36 @@ def get_terminal_size():
341342
return width, height - 1 # Subtract 1 from height
342343

343344

344-
def getch(input_file=None):
345-
"""Get a single character from terminal or file
345+
def getch(input_file=None, num_chars=1):
346+
"""Get character(s) from terminal or file
346347
347348
Args:
348349
input_file: Optional filename to read from. If None, read from stdin.
349-
File will be deleted after reading if specified.
350+
num_chars: Number of characters to read (default: 1)
350351
"""
351352
if input_file is None:
352353
# Read from stdin
353354
fd = sys.stdin.fileno()
354355
old_settings = termios.tcgetattr(fd)
355356
try:
356357
tty.setraw(fd)
357-
ch = sys.stdin.read(1)
358+
ch = sys.stdin.read(num_chars)
358359
finally:
359360
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
360361
else:
361-
# Read from file and delete it
362+
# Read from file
362363
try:
363364
with open(input_file, 'r') as f:
364-
ch = f.read(1)
365+
ch = f.read(num_chars)
365366
except FileNotFoundError:
366-
return '\x03' # Return Ctrl+C if file not found
367+
logging.info("File not found")
368+
exit(1)
367369
except Exception as e:
368370
logging.error(f"Error reading from file: {str(e)}")
369-
return '\x03'
371+
exit(1)
372+
if ch == '\x03':
373+
logging.info("Operation cancelled by user")
374+
exit(1)
370375

371376
return ch
372377

@@ -530,29 +535,96 @@ def draw_all_panes(panes, max_x, padding_cache, terminal_height, screen):
530535
screen.refresh()
531536

532537

538+
def generate_smartsign_patterns(pattern):
539+
"""Generate all smartsign variants for ANY pattern
540+
541+
This is a generic function that works for patterns of any length.
542+
Each character position is independently expanded if it has a smartsign mapping.
543+
This enables smartsign support for all search modes (s, s2, s3, etc.)
544+
545+
Args:
546+
pattern: String of any length
547+
548+
Returns:
549+
List of pattern variants (includes original pattern)
550+
551+
Examples:
552+
"3" -> ["3", "#"]
553+
"3," -> ["3,", "#,", "3<", "#<"]
554+
"ab" -> ["ab"]
555+
"3x5" -> ["3x5", "#x5", "3x%", "#x%"] # Future: 3-char support
556+
"""
557+
if not SMARTSIGN:
558+
return [pattern]
559+
560+
import itertools
561+
562+
# For each character position, collect possible characters
563+
char_options = []
564+
for ch in pattern:
565+
options = [ch]
566+
# Add smartsign variant if exists
567+
if ch in SMARTSIGN_TABLE:
568+
options.append(SMARTSIGN_TABLE[ch])
569+
char_options.append(options)
570+
571+
# Generate all combinations (Cartesian product)
572+
patterns = [''.join(combo) for combo in itertools.product(*char_options)]
573+
return patterns
574+
575+
533576
@perf_timer("Finding matches")
534-
def find_matches(panes, search_ch):
535-
"""Find all matches and return match list"""
577+
def find_matches(panes, search_pattern):
578+
"""Generic pattern matching with smartsign support
579+
580+
This function is pattern-agnostic - it works for any search pattern,
581+
regardless of how that pattern was generated (s, s2, bd-w, etc.)
582+
Smartsign is automatically applied via generate_smartsign_patterns().
583+
584+
Args:
585+
panes: List of PaneInfo objects
586+
search_pattern: String to search for (1 or more characters)
587+
"""
536588
matches = []
589+
pattern_length = len(search_pattern)
537590

538-
# If smartsign is enabled, add corresponding symbol
539-
search_chars = [search_ch]
540-
if SMARTSIGN and search_ch in SMARTSIGN_TABLE:
541-
search_chars.append(SMARTSIGN_TABLE[search_ch])
591+
# GENERIC: Apply smartsign transformation (works for any pattern length)
592+
search_patterns = generate_smartsign_patterns(search_pattern)
542593

543594
for pane in panes:
544595
for line_num, line in enumerate(pane.lines):
545-
# 對每個字符位置檢查所有可能的匹配
596+
# Check each position in the line
546597
for pos in range(len(line)):
547-
for ch in search_chars:
598+
# For multi-char search, make sure we have enough characters
599+
if pos + pattern_length > len(line):
600+
continue
601+
602+
# Get substring at current position
603+
substring = line[pos:pos + pattern_length]
604+
605+
# Skip if substring would split a wide character
606+
if pattern_length > 1:
607+
# Check if we're in the middle of a wide char
608+
if pos > 0 and get_char_width(line[pos - 1]) == 2:
609+
# Check if previous char's visual position overlaps with current pos
610+
visual_before = sum(get_char_width(c) for c in line[:pos - 1])
611+
visual_at_pos = sum(get_char_width(c) for c in line[:pos])
612+
if visual_at_pos - visual_before == 1:
613+
# We're at the second half of a wide char, skip
614+
continue
615+
616+
# Check against all search patterns
617+
for pattern in search_patterns:
618+
matched = False
548619
if CASE_SENSITIVE:
549-
if pos < len(line) and line[pos] == ch:
550-
visual_col = sum(get_char_width(c) for c in line[:pos])
551-
matches.append((pane, line_num, visual_col))
620+
matched = (substring == pattern)
552621
else:
553-
if pos < len(line) and line[pos].lower() == ch.lower():
554-
visual_col = sum(get_char_width(c) for c in line[:pos])
555-
matches.append((pane, line_num, visual_col))
622+
matched = (substring.lower() == pattern.lower())
623+
624+
if matched:
625+
visual_col = sum(get_char_width(c) for c in line[:pos])
626+
matches.append((pane, line_num, visual_col))
627+
break # Found match, no need to check other patterns
556628

557629
return matches
558630

@@ -565,17 +637,21 @@ def update_hints_display(screen, positions, current_key):
565637
if hint.startswith(current_key):
566638
next_x = screen_x + get_char_width(char)
567639
if next_x < pane_right_edge:
568-
logging.debug(f"Restoring next char {next_x} {next_char}")
569-
screen.addstr(screen_y, next_x, next_char)
640+
# Use space if next_char is empty (end of line case)
641+
restore_char = next_char if next_char else ' '
642+
logging.debug(f"Restoring next char {next_x} {restore_char}")
643+
screen.addstr(screen_y, next_x, restore_char)
570644
else:
571645
logging.debug(f"Non-matching hint {screen_x} {screen_y} {char}")
572646
# Restore original character for non-matching hints
573647
screen.addstr(screen_y, screen_x, char)
574648
# Always restore second character
575649
next_x = screen_x + get_char_width(char)
576650
if next_x < pane_right_edge:
577-
logging.debug(f"Restoring next char {next_x} {next_char}")
578-
screen.addstr(screen_y, next_x, next_char)
651+
# Use space if next_char is empty (end of line case)
652+
restore_char = next_char if next_char else ' '
653+
logging.debug(f"Restoring next char {next_x} {restore_char}")
654+
screen.addstr(screen_y, next_x, restore_char)
579655
continue
580656

581657
# For matching hints:
@@ -588,7 +664,9 @@ def update_hints_display(screen, positions, current_key):
588664
screen.addstr(screen_y, screen_x, char)
589665
next_x = screen_x + get_char_width(char)
590666
if next_x < pane_right_edge:
591-
screen.addstr(screen_y, next_x, next_char)
667+
# Use space if next_char is empty (end of line case)
668+
restore_char = next_char if next_char else ' '
669+
screen.addstr(screen_y, next_x, restore_char)
592670

593671
screen.refresh()
594672

@@ -616,11 +694,26 @@ def main(screen: Screen):
616694
setup_logging()
617695
panes, max_x, padding_cache = init_panes()
618696

619-
# Read character from temporary file
620-
search_ch = getch(sys.argv[1])
621-
if search_ch == '\x03':
622-
return
623-
matches = find_matches(panes, search_ch)
697+
# Determine search mode and find matches
698+
if MOTION_TYPE == 's':
699+
# 1 char search
700+
search_pattern = getch(sys.argv[1], 1)
701+
search_pattern = search_pattern.replace('\n', '').replace('\r', '')
702+
if not search_pattern:
703+
return
704+
matches = find_matches(panes, search_pattern)
705+
elif MOTION_TYPE == 's2':
706+
# 2 char search
707+
search_pattern = getch(sys.argv[1], 2)
708+
search_pattern = search_pattern.replace('\n', '').replace('\r', '')
709+
if not search_pattern:
710+
return
711+
matches = find_matches(panes, search_pattern)
712+
else:
713+
logging.error(f"Invalid motion type: {MOTION_TYPE}")
714+
exit(1)
715+
716+
# Check for matches
624717
if len(matches) == 0:
625718
sh(['tmux', 'display-message', 'no match'])
626719
return

0 commit comments

Comments
 (0)