1818 'TMUX_EASYMOTION_CASE_SENSITIVE' , 'false' ).lower () == 'true'
1919SMARTSIGN = 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
2324SMARTSIGN_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