├── .gitignore ├── README.md ├── easymotion.py ├── easymotion.tmux └── test_easymotion.py /.gitignore: -------------------------------------------------------------------------------- 1 | .aider* 2 | .env 3 | __pycache__ 4 | .keystroke 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TMUX Easymotion 2 | 3 | - Tmux prefix is `Ctrl+q`: 4 | - Trigger key is `s` 5 | 6 | ![demo](https://github.com/user-attachments/assets/6f9ef875-47b1-4dee-823d-f1990f2af51e) 7 | 8 | 9 | Q: There are already many plugins with similar functionality, why do we need this one? 10 | 11 | A: **This one can jump between panes** 12 | 13 | ### Installation via [TPM](https://github.com/tmux-plugins/tpm) 14 | 15 | Add plugin to the list of TPM plugins in ~/.tmux.conf: 16 | 17 | ```bash 18 | set -g @plugin 'ddzero2c/tmux-easymotion' 19 | set -g @easymotion-key 's' 20 | ``` 21 | 22 | Press `prefix` + `I` to install 23 | 24 | 25 | ### Options: 26 | 27 | ```bash 28 | # Keys used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;') 29 | set -g @easymotion-hints 'asdfghjkl;' 30 | 31 | # Border characters 32 | set -g @easymotion-vertical-border '│' 33 | set -g @easymotion-horizontal-border '─' 34 | 35 | # Use curses instead of ansi escape sequences (default: false) 36 | set -g @easymotion-use-curses 'true' 37 | 38 | # Debug mode - writes debug info to ~/easymotion.log (default: false) 39 | set -g @easymotion-debug 'true' 40 | 41 | # Performance logging - writes timing info to ~/easymotion.log (default: false) 42 | set -g @easymotion-perf 'true' 43 | 44 | # Case sensitive search (default: false) 45 | set -g @easymotion-case-sensitive 'true' 46 | 47 | # Enable smartsign feature (default: false) 48 | set -g @easymotion-smartsign 'true' 49 | ``` 50 | 51 | 52 | ### Vim-like Configuration 53 | 54 | ```bash 55 | set-window-option -g mode-keys vi 56 | bind-key -T copy-mode-vi C-v send-keys -X begin-selection \; send-keys -X rectangle-toggle; 57 | bind-key -T copy-mode-vi v send-keys -X begin-selection; 58 | bind-key -T copy-mode-vi V send-keys -X select-line; 59 | ``` 60 | 61 | 62 | ### Usage 63 | `prefix` + `s` -> hit a character -> hit hints (jump to position) -> press `ve` and `y` to copy 64 | 65 | `prefix` + `]` to paste 66 | 67 | 68 | ### Run tests 69 | 70 | ```bash 71 | pytest test_easymotion.py -v --cache-clear 72 | ``` 73 | 74 | ### Inspire by 75 | - [tmux-yank](https://github.com/tmux-plugins/tmux-yank) 76 | - [vim-easymotion](https://github.com/easymotion/vim-easymotion) 77 | 78 | ### Known issues 79 | - ~~Render wield when tmux pane contain wide character.~~ 80 | ~~- ex. `'哈哈'`.~~ 81 | - ~~Scrolled up panes are not supported~~ 82 | - ~~Broken when tmux window has split panes~~ 83 | - ~~Jump between panes is not supported~~ 84 | -------------------------------------------------------------------------------- /easymotion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import curses 3 | import functools 4 | import logging 5 | import os 6 | import subprocess 7 | import sys 8 | import termios 9 | import time 10 | import tty 11 | import unicodedata 12 | from abc import ABC, abstractmethod 13 | from typing import List, Optional 14 | 15 | # Configuration from environment 16 | HINTS = os.environ.get('TMUX_EASYMOTION_HINTS', 'asdghklqwertyuiopzxcvbnmfj;') 17 | CASE_SENSITIVE = os.environ.get( 18 | 'TMUX_EASYMOTION_CASE_SENSITIVE', 'false').lower() == 'true' 19 | SMARTSIGN = os.environ.get( 20 | 'TMUX_EASYMOTION_SMARTSIGN', 'false').lower() == 'true' 21 | 22 | # Smartsign mapping table 23 | SMARTSIGN_TABLE = { 24 | ',': '<', 25 | '.': '>', 26 | '/': '?', 27 | '1': '!', 28 | '2': '@', 29 | '3': '#', 30 | '4': '$', 31 | '5': '%', 32 | '6': '^', 33 | '7': '&', 34 | '8': '*', 35 | '9': '(', 36 | '0': ')', 37 | '-': '_', 38 | '=': '+', 39 | ';': ':', 40 | '[': '{', 41 | ']': '}', 42 | '`': '~', 43 | "'": '"', 44 | '\\': '|', 45 | ',': '<', 46 | '.': '>' 47 | } 48 | VERTICAL_BORDER = os.environ.get('TMUX_EASYMOTION_VERTICAL_BORDER', '│') 49 | HORIZONTAL_BORDER = os.environ.get('TMUX_EASYMOTION_HORIZONTAL_BORDER', '─') 50 | USE_CURSES = os.environ.get( 51 | 'TMUX_EASYMOTION_USE_CURSES', 'false').lower() == 'true' 52 | 53 | 54 | class Screen(ABC): 55 | # Common attributes for both implementations 56 | A_NORMAL = 0 57 | A_DIM = 1 58 | A_HINT1 = 2 59 | A_HINT2 = 3 60 | 61 | @abstractmethod 62 | def transform_attr(self, attr): 63 | """Transform generic attributes to implementation-specific attributes""" 64 | pass 65 | 66 | @abstractmethod 67 | def init(self): 68 | """Initialize the screen""" 69 | pass 70 | 71 | @abstractmethod 72 | def cleanup(self): 73 | """Cleanup the screen""" 74 | pass 75 | 76 | @abstractmethod 77 | def addstr(self, y: int, x: int, text: str, attr=0): 78 | """Add string with attributes""" 79 | pass 80 | 81 | @abstractmethod 82 | def refresh(self): 83 | """Refresh the screen""" 84 | pass 85 | 86 | @abstractmethod 87 | def clear(self): 88 | """Clear the screen""" 89 | pass 90 | 91 | 92 | class AnsiSequence(Screen): 93 | # ANSI escape sequences 94 | ESC = '\033' 95 | CLEAR = f'{ESC}[2J' 96 | CLEAR_LINE = f'{ESC}[2K' 97 | HIDE_CURSOR = f'{ESC}[?25l' 98 | SHOW_CURSOR = f'{ESC}[?25h' 99 | RESET = f'{ESC}[0m' 100 | DIM = f'{ESC}[2m' 101 | RED = f'{ESC}[1;31m' 102 | GREEN = f'{ESC}[1;32m' 103 | 104 | def init(self): 105 | sys.stdout.write(self.HIDE_CURSOR) 106 | sys.stdout.flush() 107 | 108 | def cleanup(self): 109 | sys.stdout.write(self.SHOW_CURSOR) 110 | sys.stdout.write(self.RESET) 111 | sys.stdout.flush() 112 | 113 | def transform_attr(self, attr): 114 | if attr == self.A_DIM: 115 | return self.DIM 116 | elif attr == self.A_HINT1: 117 | return self.RED 118 | elif attr == self.A_HINT2: 119 | return self.GREEN 120 | return '' 121 | 122 | def addstr(self, y: int, x: int, text: str, attr=0): 123 | attr_str = self.transform_attr(attr) 124 | if attr_str: 125 | sys.stdout.write( 126 | f'{self.ESC}[{y+1};{x+1}H{attr_str}{text}{self.RESET}') 127 | else: 128 | sys.stdout.write(f'{self.ESC}[{y+1};{x+1}H{text}') 129 | 130 | def refresh(self): 131 | sys.stdout.flush() 132 | 133 | def clear(self): 134 | sys.stdout.write(self.CLEAR) 135 | 136 | 137 | class Curses(Screen): 138 | def __init__(self): 139 | self.stdscr = None 140 | 141 | def init(self): 142 | self.stdscr = curses.initscr() 143 | curses.start_color() 144 | curses.use_default_colors() 145 | curses.init_pair(1, curses.COLOR_RED, -1) 146 | curses.init_pair(2, curses.COLOR_GREEN, -1) 147 | curses.noecho() 148 | curses.cbreak() 149 | self.stdscr.keypad(True) 150 | 151 | def cleanup(self): 152 | if not self.stdscr: 153 | return 154 | curses.nocbreak() 155 | self.stdscr.keypad(False) 156 | curses.echo() 157 | curses.endwin() 158 | 159 | def transform_attr(self, attr): 160 | if attr == self.A_DIM: 161 | return curses.A_DIM 162 | elif attr == self.A_HINT1: 163 | return curses.color_pair(1) | curses.A_BOLD 164 | elif attr == self.A_HINT2: 165 | return curses.color_pair(2) | curses.A_BOLD 166 | return curses.A_NORMAL 167 | 168 | def addstr(self, y: int, x: int, text: str, attr=0): 169 | try: 170 | self.stdscr.addstr(y, x, text, self.transform_attr(attr)) 171 | except curses.error: 172 | pass 173 | 174 | def refresh(self): 175 | self.stdscr.refresh() 176 | 177 | def clear(self): 178 | self.stdscr.clear() 179 | 180 | 181 | def setup_logging(): 182 | """Initialize logging configuration based on environment variables""" 183 | debug_mode = os.environ.get('TMUX_EASYMOTION_DEBUG') == 'true' 184 | perf_mode = os.environ.get('TMUX_EASYMOTION_PERF') == 'true' 185 | 186 | if not (debug_mode or perf_mode): 187 | logging.getLogger().disabled = True 188 | return 189 | 190 | log_file = os.path.expanduser('~/easymotion.log') 191 | logging.basicConfig( 192 | filename=log_file, 193 | level=logging.DEBUG, 194 | format=f'%(asctime)s - %(levelname)s - {"CURSE" if USE_CURSES else "ANSI"} - %(message)s' 195 | ) 196 | 197 | 198 | def perf_timer(func_name=None): 199 | """Performance timing decorator that only logs when TMUX_EASYMOTION_PERF is true""" 200 | def decorator(func): 201 | @functools.wraps(func) 202 | def wrapper(*args, **kwargs): 203 | if os.environ.get('TMUX_EASYMOTION_PERF') != 'true': 204 | return func(*args, **kwargs) 205 | 206 | name = func_name or func.__name__ 207 | start_time = time.perf_counter() 208 | result = func(*args, **kwargs) 209 | end_time = time.perf_counter() 210 | 211 | logging.info(f"{name} took: {end_time - start_time:.3f} seconds") 212 | return result 213 | return wrapper 214 | return decorator 215 | 216 | 217 | @functools.lru_cache(maxsize=1024) 218 | def get_char_width(char: str) -> int: 219 | """Get visual width of a single character with caching""" 220 | return 2 if unicodedata.east_asian_width(char) in 'WF' else 1 221 | 222 | 223 | @functools.lru_cache(maxsize=1024) 224 | def get_string_width(s: str) -> int: 225 | """Calculate visual width of string, accounting for double-width characters""" 226 | return sum(map(get_char_width, s)) 227 | 228 | 229 | def get_true_position(line, target_col): 230 | """Calculate true position accounting for wide characters""" 231 | visual_pos = 0 232 | true_pos = 0 233 | while true_pos < len(line) and visual_pos < target_col: 234 | char_width = get_char_width(line[true_pos]) 235 | visual_pos += char_width 236 | true_pos += 1 237 | return true_pos 238 | 239 | 240 | def sh(cmd: list) -> str: 241 | """Execute shell command with optional logging""" 242 | debug_mode = os.environ.get('TMUX_EASYMOTION_DEBUG') == 'true' 243 | 244 | try: 245 | result = subprocess.run( 246 | cmd, 247 | shell=False, 248 | text=True, 249 | capture_output=True, 250 | check=True 251 | ).stdout 252 | 253 | if debug_mode: 254 | logging.debug(f"Command: {cmd}") 255 | logging.debug(f"Result: {result}") 256 | logging.debug("-" * 40) 257 | 258 | return result 259 | except subprocess.CalledProcessError as e: 260 | if debug_mode: 261 | logging.error(f"Error executing {cmd}: {str(e)}") 262 | raise 263 | 264 | 265 | def get_initial_tmux_info(): 266 | """Get all needed tmux info in one optimized call""" 267 | format_str = '#{pane_id},#{window_zoomed_flag},#{pane_active},' + \ 268 | '#{pane_top},#{pane_height},#{pane_left},#{pane_width},' + \ 269 | '#{pane_in_mode},#{scroll_position},' + \ 270 | '#{cursor_y},#{cursor_x},#{copy_cursor_y},#{copy_cursor_x}' 271 | 272 | cmd = ['tmux', 'list-panes', '-F', format_str] 273 | output = sh(cmd).strip() 274 | 275 | panes_info = [] 276 | for line in output.split('\n'): 277 | if not line: 278 | continue 279 | 280 | fields = line.split(',') 281 | # Use destructuring assignment for better readability and performance 282 | (pane_id, zoomed, active, top, height, 283 | left, width, in_mode, scroll_pos, 284 | cursor_y, cursor_x, copy_cursor_y, copy_cursor_x) = fields 285 | 286 | # Only show all panes in non-zoomed state, or only active pane in zoomed state 287 | if zoomed == "1" and active != "1": 288 | continue 289 | 290 | pane = PaneInfo( 291 | pane_id=pane_id, 292 | active=active == "1", 293 | start_y=int(top), 294 | height=int(height), 295 | start_x=int(left), 296 | width=int(width) 297 | ) 298 | 299 | # Optimize flag setting 300 | pane.copy_mode = (in_mode == "1") 301 | pane.scroll_position = int(scroll_pos or 0) 302 | 303 | # Set cursor position 304 | if in_mode == "1": # If in copy mode 305 | pane.cursor_y = int(copy_cursor_y) 306 | pane.cursor_x = int(copy_cursor_x) 307 | else: # If not in copy mode, cursor is at bottom left 308 | pane.cursor_y = int(cursor_y) 309 | pane.cursor_x = int(cursor_x) 310 | 311 | panes_info.append(pane) 312 | 313 | return panes_info 314 | 315 | 316 | class PaneInfo: 317 | __slots__ = ('pane_id', 'active', 'start_y', 'height', 'start_x', 'width', 318 | 'lines', 'positions', 'copy_mode', 'scroll_position', 319 | 'cursor_y', 'cursor_x') 320 | 321 | def __init__(self, pane_id, active, start_y, height, start_x, width): 322 | self.active = active 323 | self.pane_id = pane_id 324 | self.start_y = start_y 325 | self.height = height 326 | self.start_x = start_x 327 | self.width = width 328 | self.lines = [] 329 | self.positions = [] 330 | self.copy_mode = False 331 | self.scroll_position = 0 332 | self.cursor_y = 0 333 | self.cursor_x = 0 334 | 335 | 336 | def get_terminal_size(): 337 | """Get terminal size from tmux""" 338 | output = sh( 339 | ['tmux', 'display-message', '-p', '#{client_width},#{client_height}']) 340 | width, height = map(int, output.strip().split(',')) 341 | return width, height - 1 # Subtract 1 from height 342 | 343 | 344 | def getch(input_file=None): 345 | """Get a single character from terminal or file 346 | 347 | Args: 348 | input_file: Optional filename to read from. If None, read from stdin. 349 | File will be deleted after reading if specified. 350 | """ 351 | if input_file is None: 352 | # Read from stdin 353 | fd = sys.stdin.fileno() 354 | old_settings = termios.tcgetattr(fd) 355 | try: 356 | tty.setraw(fd) 357 | ch = sys.stdin.read(1) 358 | finally: 359 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 360 | else: 361 | # Read from file and delete it 362 | try: 363 | with open(input_file, 'r') as f: 364 | ch = f.read(1) 365 | except FileNotFoundError: 366 | return '\x03' # Return Ctrl+C if file not found 367 | except Exception as e: 368 | logging.error(f"Error reading from file: {str(e)}") 369 | return '\x03' 370 | 371 | return ch 372 | 373 | 374 | def tmux_capture_pane(pane): 375 | """Optimized pane content capture""" 376 | if not pane.height or not pane.width: 377 | return [] 378 | 379 | cmd = ['tmux', 'capture-pane', '-p', '-t', pane.pane_id] 380 | if pane.scroll_position > 0: 381 | end_pos = -(pane.scroll_position - pane.height + 1) 382 | cmd.extend(['-S', str(-pane.scroll_position), '-E', str(end_pos)]) 383 | 384 | # Directly split and limit lines 385 | return sh(cmd)[:-1].split('\n')[:pane.height] 386 | 387 | 388 | def tmux_move_cursor(pane, line_num, true_col): 389 | # Execute commands sequentially 390 | cmds = [ 391 | ['tmux', 'select-pane', '-t', pane.pane_id] 392 | ] 393 | 394 | if not pane.copy_mode: 395 | cmds.append(['tmux', 'copy-mode', '-t', pane.pane_id]) 396 | 397 | cmds.append(['tmux', 'send-keys', '-X', '-t', pane.pane_id, 'top-line']) 398 | 399 | if line_num > 0: 400 | cmds.append(['tmux', 'send-keys', '-X', '-t', pane.pane_id, 401 | '-N', str(line_num), 'cursor-down']) 402 | 403 | cmds.append(['tmux', 'send-keys', '-X', '-t', 404 | pane.pane_id, 'start-of-line']) 405 | 406 | if true_col > 0: 407 | cmds.append(['tmux', 'send-keys', '-X', '-t', pane.pane_id, 408 | '-N', str(true_col), 'cursor-right']) 409 | 410 | for cmd in cmds: 411 | sh(cmd) 412 | 413 | 414 | def assign_hints_by_distance(matches, cursor_y, cursor_x): 415 | """Sort matches by distance and assign hints""" 416 | # Calculate distances and sort 417 | matches_with_dist = [] 418 | for match in matches: 419 | pane, line_num, col = match 420 | dist = (pane.start_y + line_num - cursor_y)**2 + (pane.start_x + col - cursor_x)**2 421 | matches_with_dist.append((dist, match)) 422 | 423 | matches_with_dist.sort(key=lambda x: x[0]) # Sort by distance 424 | 425 | # Generate hints and create mapping 426 | hints = generate_hints(HINTS, len(matches_with_dist)) 427 | logging.debug(f'{hints}') 428 | return {hint: match for (_, match), hint in zip(matches_with_dist, hints)} 429 | 430 | 431 | def generate_hints(keys: str, needed_count: Optional[int] = None) -> List[str]: 432 | """Generate hints with optimal single/double key distribution""" 433 | if not needed_count: 434 | needed_count = len(keys)**2 435 | 436 | keys_list = list(keys) 437 | key_count = len(keys_list) 438 | max_hints = key_count * key_count # All possible double-char combinations 439 | 440 | if needed_count > max_hints: 441 | needed_count = max_hints 442 | 443 | # When needed hints count is less than or equal to available keys, use single chars 444 | if needed_count <= key_count: 445 | return keys_list[:needed_count] 446 | 447 | # Generate all possible double char combinations 448 | double_char_hints = [] 449 | for prefix in keys_list: 450 | for suffix in keys_list: 451 | double_char_hints.append(prefix + suffix) 452 | 453 | # Dynamically calculate how many single chars to keep 454 | single_chars = 0 455 | for i in range(key_count, 0, -1): 456 | if needed_count <= (key_count - i) * key_count + i: 457 | single_chars = i 458 | break 459 | 460 | hints = [] 461 | # Add single chars at the beginning 462 | single_char_hints = keys_list[:single_chars] 463 | hints.extend(single_char_hints) 464 | 465 | # Filter out double char hints that start with any single char hint 466 | filtered_doubles = [h for h in double_char_hints 467 | if h[0] not in single_char_hints] 468 | 469 | # Take needed doubles 470 | needed_doubles = needed_count - single_chars 471 | hints.extend(filtered_doubles[:needed_doubles]) 472 | 473 | return hints[:needed_count] 474 | 475 | 476 | @perf_timer() 477 | def init_panes(): 478 | """Initialize pane information with optimized info gathering""" 479 | panes = [] 480 | max_x = 0 481 | padding_cache = {} 482 | 483 | # Batch get all pane info 484 | panes_info = get_initial_tmux_info() 485 | 486 | # Initialize empty padding cache - will be populated as needed 487 | padding_cache = {} 488 | 489 | # Optimize pane processing with list comprehension 490 | for pane in panes_info: 491 | # Only capture pane content when really needed 492 | if pane.height > 0 and pane.width > 0: 493 | pane.lines = tmux_capture_pane(pane) 494 | max_x = max(max_x, pane.start_x + pane.width) 495 | panes.append(pane) 496 | 497 | return panes, max_x, padding_cache 498 | 499 | 500 | @perf_timer() 501 | def draw_all_panes(panes, max_x, padding_cache, terminal_height, screen): 502 | """Draw all panes and their borders""" 503 | sorted_panes = sorted(panes, key=lambda p: p.start_y + p.height) 504 | 505 | for pane in sorted_panes: 506 | visible_height = min(pane.height, terminal_height - pane.start_y) 507 | 508 | # Draw content 509 | for y, line in enumerate(pane.lines[:visible_height]): 510 | visual_width = get_string_width(line) 511 | if visual_width < pane.width: 512 | padding_size = pane.width - visual_width 513 | if padding_size not in padding_cache: 514 | padding_cache[padding_size] = ' ' * padding_size 515 | line = line + padding_cache[padding_size] 516 | screen.addstr(pane.start_y + y, pane.start_x, line[:pane.width]) 517 | 518 | # Draw vertical borders 519 | if pane.start_x + pane.width < max_x: 520 | for y in range(pane.start_y, pane.start_y + visible_height): 521 | screen.addstr(y, pane.start_x + pane.width, 522 | VERTICAL_BORDER, screen.A_DIM) 523 | 524 | # Draw horizontal borders 525 | end_y = pane.start_y + visible_height 526 | if end_y < terminal_height and pane != sorted_panes[-1]: 527 | screen.addstr(end_y, pane.start_x, HORIZONTAL_BORDER * 528 | pane.width, screen.A_DIM) 529 | 530 | screen.refresh() 531 | 532 | 533 | @perf_timer("Finding matches") 534 | def find_matches(panes, search_ch): 535 | """Find all matches and return match list""" 536 | matches = [] 537 | 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]) 542 | 543 | for pane in panes: 544 | for line_num, line in enumerate(pane.lines): 545 | # 對每個字符位置檢查所有可能的匹配 546 | for pos in range(len(line)): 547 | for ch in search_chars: 548 | 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)) 552 | 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)) 556 | 557 | return matches 558 | 559 | 560 | @perf_timer("Drawing hints") 561 | def update_hints_display(screen, positions, current_key): 562 | """Update hint display based on current key sequence""" 563 | for screen_y, screen_x, pane_right_edge, char, next_char, hint in positions: 564 | logging.debug(f'{screen_x} {pane_right_edge} {char} {next_char} {hint}') 565 | if hint.startswith(current_key): 566 | next_x = screen_x + get_char_width(char) 567 | 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) 570 | else: 571 | logging.debug(f"Non-matching hint {screen_x} {screen_y} {char}") 572 | # Restore original character for non-matching hints 573 | screen.addstr(screen_y, screen_x, char) 574 | # Always restore second character 575 | next_x = screen_x + get_char_width(char) 576 | 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) 579 | continue 580 | 581 | # For matching hints: 582 | if len(hint) > len(current_key): 583 | # Show remaining hint character 584 | screen.addstr(screen_y, screen_x, 585 | hint[len(current_key)], screen.A_HINT2) 586 | else: 587 | # If hint is fully entered, restore all original characters 588 | screen.addstr(screen_y, screen_x, char) 589 | next_x = screen_x + get_char_width(char) 590 | if next_x < pane_right_edge: 591 | screen.addstr(screen_y, next_x, next_char) 592 | 593 | screen.refresh() 594 | 595 | 596 | def draw_all_hints(positions, terminal_height, screen): 597 | """Draw all hints across all panes""" 598 | for screen_y, screen_x, pane_right_edge, char, next_char, hint in positions: 599 | if screen_y >= terminal_height: 600 | continue 601 | 602 | # Draw first character of hint 603 | screen.addstr(screen_y, screen_x, hint[0], screen.A_HINT1) 604 | 605 | # Draw second character if hint has two chars and space allows 606 | if len(hint) > 1: 607 | next_x = screen_x + get_char_width(char) 608 | if next_x < pane_right_edge: 609 | screen.addstr(screen_y, next_x, hint[1], screen.A_HINT2) 610 | 611 | screen.refresh() 612 | 613 | 614 | @perf_timer("Total execution") 615 | def main(screen: Screen): 616 | setup_logging() 617 | panes, max_x, padding_cache = init_panes() 618 | 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) 624 | if len(matches) == 0: 625 | sh(['tmux', 'display-message', 'no match']) 626 | return 627 | # If only one match, jump directly 628 | if len(matches) == 1: 629 | pane, line_num, col = matches[0] 630 | true_col = get_true_position(pane.lines[line_num], col) 631 | tmux_move_cursor(pane, line_num, true_col) 632 | return 633 | 634 | # Get cursor position from current pane 635 | current_pane = next(p for p in panes if p.active) 636 | cursor_y = current_pane.start_y + current_pane.cursor_y 637 | cursor_x = current_pane.start_x + current_pane.cursor_x 638 | logging.debug(f"Cursor position: {current_pane.pane_id}, {cursor_y}, {cursor_x}") 639 | 640 | # Replace HintTree with direct hint assignment 641 | hint_mapping = assign_hints_by_distance(matches, cursor_y, cursor_x) 642 | 643 | # Create flat positions list with all needed info 644 | positions = [] 645 | for hint, (pane, line_num, visual_col) in hint_mapping.items(): 646 | # make sure index is in valid range 647 | if line_num < len(pane.lines): 648 | line = pane.lines[line_num] 649 | # convert visual column to actual column 650 | true_col = get_true_position(line, visual_col) 651 | if true_col < len(line): 652 | char = line[true_col] 653 | next_char = line[true_col+1] if true_col + 1 < len(line) else '' 654 | positions.append(( 655 | pane.start_y + line_num, # screen_y 656 | pane.start_x + visual_col, # screen_x 657 | pane.start_x + pane.width, # pane_right_edge 658 | char, # original char at hint position 659 | next_char, # original char at second hint position (if exists) 660 | hint 661 | )) 662 | 663 | terminal_width, terminal_height = get_terminal_size() 664 | draw_all_panes(panes, max_x, padding_cache, terminal_height, screen) 665 | draw_all_hints(positions, terminal_height, screen) 666 | sh(['tmux', 'select-window', '-t', '{end}']) 667 | 668 | # Handle user input 669 | key_sequence = "" 670 | while True: 671 | ch = getch() 672 | if ch not in HINTS: 673 | return 674 | 675 | key_sequence += ch 676 | target = hint_mapping.get(key_sequence) 677 | 678 | if target: 679 | pane, line_num, col = target 680 | true_col = get_true_position(pane.lines[line_num], col) 681 | tmux_move_cursor(pane, line_num, true_col) 682 | return # Exit after finding and moving to target 683 | elif len(key_sequence) >= 2: # If no target found after 2 chars 684 | return # Exit program 685 | else: 686 | # Update display to show remaining possible hints 687 | update_hints_display(screen, positions, key_sequence) 688 | 689 | 690 | if __name__ == '__main__': 691 | screen: Screen = Curses() if USE_CURSES else AnsiSequence() 692 | screen.init() 693 | try: 694 | main(screen) 695 | except KeyboardInterrupt: 696 | logging.info("Operation cancelled by user") 697 | except Exception as e: 698 | logging.error(f"Error occurred: {str(e)}", exc_info=True) 699 | finally: 700 | screen.cleanup() 701 | -------------------------------------------------------------------------------- /easymotion.tmux: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | get_tmux_option() { 4 | local option=$1 5 | local default_value=$2 6 | local option_value=$(tmux show-option -gqv "$option") 7 | if [ -z $option_value ]; then 8 | echo $default_value 9 | else 10 | echo $option_value 11 | fi 12 | } 13 | 14 | CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 15 | 16 | # Define all options and their default values 17 | HINTS=$(get_tmux_option "@easymotion-hints" "asdghklqwertyuiopzxcvbnmfj;") 18 | VERTICAL_BORDER=$(get_tmux_option "@easymotion-vertical-border" "│") 19 | HORIZONTAL_BORDER=$(get_tmux_option "@easymotion-horizontal-border" "─") 20 | USE_CURSES=$(get_tmux_option "@easymotion-use-curses" "false") 21 | DEBUG=$(get_tmux_option "@easymotion-debug" "false") 22 | PERF=$(get_tmux_option "@easymotion-perf" "false") 23 | CASE_SENSITIVE=$(get_tmux_option "@easymotion-case-sensitive" "false") 24 | SMARTSIGN=$(get_tmux_option "@easymotion-smartsign" "false") 25 | 26 | tmp_file=$CURRENT_DIR/.keystroke 27 | # Execute Python script with environment variables 28 | tmux bind $(get_tmux_option "@easymotion-key" "s") run-shell "\ 29 | printf '\x03' > $tmp_file && tmux command-prompt -1 -p 'easymotion:' 'run-shell \"printf %s\\\\n \\\"%1\\\" > $tmp_file\"' \; \ 30 | neww -d '\ 31 | TMUX_EASYMOTION_HINTS=$HINTS \ 32 | TMUX_EASYMOTION_VERTICAL_BORDER=$VERTICAL_BORDER \ 33 | TMUX_EASYMOTION_HORIZONTAL_BORDER=$HORIZONTAL_BORDER \ 34 | TMUX_EASYMOTION_USE_CURSES=$USE_CURSES \ 35 | TMUX_EASYMOTION_DEBUG=$DEBUG \ 36 | TMUX_EASYMOTION_PERF=$PERF \ 37 | TMUX_EASYMOTION_CASE_SENSITIVE=$CASE_SENSITIVE \ 38 | TMUX_EASYMOTION_SMARTSIGN=$SMARTSIGN \ 39 | $CURRENT_DIR/easymotion.py $tmp_file'" 40 | -------------------------------------------------------------------------------- /test_easymotion.py: -------------------------------------------------------------------------------- 1 | from easymotion import (generate_hints, get_char_width, get_string_width, 2 | get_true_position) 3 | 4 | 5 | def test_get_char_width(): 6 | assert get_char_width('a') == 1 # ASCII character 7 | assert get_char_width('あ') == 2 # Japanese character (wide) 8 | assert get_char_width('漢') == 2 # Chinese character (wide) 9 | assert get_char_width('한') == 2 # Korean character (wide) 10 | assert get_char_width(' ') == 1 # Space 11 | assert get_char_width('\n') == 1 # Newline 12 | 13 | 14 | def test_get_string_width(): 15 | assert get_string_width('hello') == 5 16 | assert get_string_width('こんにちは') == 10 17 | assert get_string_width('hello こんにちは') == 16 18 | assert get_string_width('') == 0 19 | 20 | 21 | def test_get_true_position(): 22 | assert get_true_position('hello', 3) == 3 23 | assert get_true_position('あいうえお', 4) == 2 24 | assert get_true_position('hello あいうえお', 7) == 7 25 | assert get_true_position('', 5) == 0 26 | 27 | 28 | def test_generate_hints(): 29 | test_keys = 'ab' 30 | hints = generate_hints(test_keys) 31 | expected = ['aa', 'ab', 'ba', 'bb'] 32 | assert hints == expected 33 | 34 | 35 | def test_generate_hints_no_duplicates(): 36 | keys = 'asdf' # 4 characters 37 | 38 | # Test all possible hint counts from 1 to max (16) 39 | for count in range(1, 17): 40 | hints = generate_hints(keys, count) 41 | 42 | # Check no duplicates 43 | assert len(hints) == len( 44 | set(hints)), f"Duplicates found in hints for count {count}" 45 | 46 | # For double character hints, check first character usage 47 | single_chars = [h for h in hints if len(h) == 1] 48 | double_chars = [h for h in hints if len(h) == 2] 49 | if double_chars: 50 | for double_char in double_chars: 51 | assert double_char[0] not in single_chars, f"Double char hint { 52 | double_char} starts with single char hint" 53 | 54 | # Check all characters are from the key set 55 | assert all(c in keys for h in hints for c in h), \ 56 | f"Invalid characters found in hints for count {count}" 57 | 58 | 59 | def test_generate_hints_distribution(): 60 | keys = 'asdf' # 4 characters 61 | 62 | # Case i=4: 4 hints (all single chars) 63 | hints = generate_hints(keys, 4) 64 | assert len(hints) == 4 65 | assert all(len(hint) == 1 for hint in hints) 66 | assert set(hints) == set('asdf') 67 | 68 | # Case i=3: 7 hints (3 single + 4 double) 69 | hints = generate_hints(keys, 7) 70 | assert len(hints) == 7 71 | single_chars = [h for h in hints if len(h) == 1] 72 | double_chars = [h for h in hints if len(h) == 2] 73 | assert len(single_chars) == 3 74 | assert len(double_chars) == 4 75 | # Ensure double char prefixes don't overlap with single chars 76 | single_char_set = set(single_chars) 77 | double_char_firsts = set(h[0] for h in double_chars) 78 | assert not (single_char_set & 79 | double_char_firsts), "Double char prefixes overlap with single chars" 80 | 81 | # Case i=2: 10 hints (2 single + 8 double) 82 | hints = generate_hints(keys, 10) 83 | assert len(hints) == 10 84 | single_chars = [h for h in hints if len(h) == 1] 85 | double_chars = [h for h in hints if len(h) == 2] 86 | assert len(single_chars) == 2 87 | assert len(double_chars) == 8 88 | # Ensure double char prefixes don't overlap with single chars 89 | single_char_set = set(single_chars) 90 | double_char_firsts = set(h[0] for h in double_chars) 91 | assert not (single_char_set & 92 | double_char_firsts), "Double char prefixes overlap with single chars" 93 | 94 | # Case i=1: 13 hints (1 single + 12 double) 95 | hints = generate_hints(keys, 13) 96 | assert len(hints) == 13 97 | single_chars = [h for h in hints if len(h) == 1] 98 | double_chars = [h for h in hints if len(h) == 2] 99 | assert len(single_chars) == 1 100 | assert len(double_chars) == 12 101 | # Ensure double char prefixes don't overlap with single chars 102 | single_char_set = set(single_chars) 103 | double_char_firsts = set(h[0] for h in double_chars) 104 | assert not (single_char_set & 105 | double_char_firsts), "Double char prefixes overlap with single chars" 106 | 107 | # Case i=0: 16 hints (all double chars) 108 | hints = generate_hints(keys, 16) 109 | assert len(hints) == 16 110 | assert all(len(hint) == 2 for hint in hints) 111 | # For all double chars case, just ensure no duplicate combinations 112 | assert len(hints) == len(set(hints)) 113 | --------------------------------------------------------------------------------