11#!/usr/bin/env python3
22import curses
3+ import functools
34import os
45import re
56import unicodedata
7+ from itertools import islice
8+ from typing import List , Optional
69
7- KEYS = 'asdfghjkl;'
8- stdscr = None
10+ # Configuration from environment
11+ KEYS = os .environ .get ('TMUX_EASYMOTION_KEYS' , 'asdfghjkl;' )
12+ HINT_COLOR_1 = int (os .environ .get ('TMUX_EASYMOTION_COLOR1' , '1' )) # RED
13+ HINT_COLOR_2 = int (os .environ .get ('TMUX_EASYMOTION_COLOR2' , '2' )) # GREEN
14+ VERTICAL_BORDER = os .environ .get ('TMUX_EASYMOTION_VERTICAL_BORDER' , '│' )
15+ HORIZONTAL_BORDER = os .environ .get ('TMUX_EASYMOTION_HORIZONTAL_BORDER' , '─' )
916
1017
11- def get_char_width (char ):
12- """Get visual width of a single character"""
18+ @functools .lru_cache (maxsize = 1024 )
19+ def get_char_width (char : str ) -> int :
20+ """Get visual width of a single character with caching"""
1321 return 2 if unicodedata .east_asian_width (char ) in 'WF' else 1
1422
1523
16- def get_string_width (s ):
24+ @functools .lru_cache (maxsize = 1024 )
25+ def get_string_width (s : str ) -> int :
1726 """Calculate visual width of string, accounting for double-width characters"""
18- width = 0
19- for c in s :
20- width += get_char_width (c )
21- return width
27+ return sum (map (get_char_width , s ))
28+
29+
30+ def get_visual_col (line : str , pos : int ) -> int :
31+ """More efficient visual column calculation"""
32+ return sum (map (get_char_width , islice (line , 0 , pos )))
2233
2334
2435def get_true_position (line , target_col ):
@@ -32,16 +43,22 @@ def get_true_position(line, target_col):
3243 return true_pos
3344
3445
35- def pyshell (cmd ):
46+ def pyshell (cmd : str ) -> str :
47+ """Execute shell command with error handling"""
3648 debug = os .environ .get ('TMUX_EASYMOTION_DEBUG' ) == 'true'
37- if debug :
38- with open (os .path .expanduser ('~/easymotion.log' ), 'a' ) as log :
39- log .write (f"Command: { cmd } \n " )
40- result = os .popen (cmd ).read ()
41- log .write (f"Result: { result } \n " )
42- log .write ("-" * 40 + "\n " )
43- return result
44- return os .popen (cmd ).read ()
49+ try :
50+ result = os .popen (cmd ).read ()
51+ if debug :
52+ with open (os .path .expanduser ('~/easymotion.log' ), 'a' ) as log :
53+ log .write (f"Command: { cmd } \n " )
54+ log .write (f"Result: { result } \n " )
55+ log .write ("-" * 40 + "\n " )
56+ return result
57+ except Exception as e :
58+ if debug :
59+ with open (os .path .expanduser ('~/easymotion.log' ), 'a' ) as log :
60+ log .write (f"Error executing { cmd } : { str (e )} \n " )
61+ raise
4562
4663
4764def get_visible_panes ():
@@ -63,7 +80,7 @@ def __init__(self, pane_id, start_y, height, start_x, width):
6380 self .height = height
6481 self .start_x = start_x
6582 self .width = width
66- self .content = ''
83+ self .lines = [] # Store split lines instead of content
6784 self .positions = []
6885 self .copy_mode = False
6986 self .scroll_position = 0
@@ -124,17 +141,7 @@ def tmux_capture_pane(pane):
124141 else :
125142 # If not scrolled, just capture current view (default behavior)
126143 cmd = f'tmux capture-pane -p -t { pane .pane_id } '
127- return pyshell (cmd )[:- 1 ]
128-
129-
130- def fill_pane_content_with_space (pane_content , width ):
131- lines = pane_content .splitlines ()
132- result = []
133- for line in lines :
134- visual_width = get_string_width (line )
135- padding = max (0 , width - visual_width )
136- result .append (line + ' ' * padding )
137- return '\n ' .join (result )
144+ return pyshell (cmd )[:- 1 ].splitlines () # Split immediately
138145
139146
140147def tmux_move_cursor (pane , line_num , true_col ):
@@ -150,125 +157,210 @@ def tmux_move_cursor(pane, line_num, true_col):
150157 pyshell (cmd )
151158
152159
153- def generate_hints (keys ):
154- """Generate two-character hints from key set more efficiently"""
155- return [k1 + k2 for k1 in keys for k2 in keys ]
160+ def generate_hints (keys : str , needed_count : Optional [int ] = None ) -> List [str ]:
161+ """Generate only as many hints as needed"""
162+ if needed_count is None :
163+ return [k1 + k2 for k1 in keys for k2 in keys ]
164+ hints = []
165+ for k1 in keys :
166+ for k2 in keys :
167+ hints .append (k1 + k2 )
168+ if len (hints ) >= needed_count :
169+ return hints
170+ return hints
156171
157172
158173RED = 1
159174GREEN = 2
160175
161176
162- def main (stdscr ):
163- panes = []
164- for pane_id in get_visible_panes ():
165- pane = get_pane_info (pane_id )
166- pane .content = tmux_capture_pane (pane )
167- panes .append (pane )
168-
177+ def init_curses ():
178+ """Initialize curses settings and colors"""
169179 curses .curs_set (False )
170180 curses .start_color ()
171181 curses .use_default_colors ()
172182 curses .init_pair (RED , curses .COLOR_RED , - 1 )
173183 curses .init_pair (GREEN , curses .COLOR_GREEN , - 1 )
174184
175- hints = generate_hints (KEYS )
176185
177- # Draw all pane contents
186+ def init_panes ():
187+ """Initialize pane information with cached calculations"""
188+ panes = []
189+ max_x = 0
190+ padding_cache = {} # Cache for padding strings
191+ for pane_id in get_visible_panes ():
192+ pane = get_pane_info (pane_id )
193+ pane .lines = tmux_capture_pane (pane )
194+ max_x = max (max_x , pane .start_x + pane .width )
195+ # Pre-calculate padding strings
196+ for line in pane .lines :
197+ visual_width = get_string_width (line )
198+ if visual_width < pane .width :
199+ padding_len = pane .width - visual_width
200+ if padding_len not in padding_cache :
201+ padding_cache [padding_len ] = ' ' * padding_len
202+ panes .append (pane )
203+ return panes , max_x , padding_cache
204+
205+
206+ def draw_pane_content (stdscr , pane , padding_cache ):
207+ """Draw the content of a single pane"""
208+ for y , line in enumerate (pane .lines [:pane .height ]):
209+ visual_width = get_string_width (line )
210+ if visual_width < pane .width :
211+ line = line + padding_cache [pane .width - visual_width ]
212+ try :
213+ stdscr .addstr (pane .start_y + y , pane .start_x , line [:pane .width ])
214+ except curses .error :
215+ pass
216+
217+
218+ def draw_vertical_borders (stdscr , pane , max_x ):
219+ """Draw vertical borders for a pane"""
220+ if pane .start_x + pane .width < max_x : # Only if not rightmost pane
221+ try :
222+ for y in range (pane .start_y , pane .start_y + pane .height ):
223+ stdscr .addstr (y , pane .start_x + pane .width , VERTICAL_BORDER , curses .A_DIM )
224+ except curses .error :
225+ pass
226+
227+
228+ def draw_horizontal_border (stdscr , pane , y_pos ):
229+ """Draw horizontal border for a pane"""
230+ try :
231+ stdscr .addstr (y_pos , pane .start_x , HORIZONTAL_BORDER * pane .width , curses .A_DIM )
232+ except curses .error :
233+ pass
234+
235+
236+ def group_panes_by_end_y (panes ):
237+ """Group panes by their end y position"""
238+ rows = {}
178239 for pane in panes :
179- fixed_width_content = fill_pane_content_with_space (
180- pane .content , pane .width )
181- for y , line in enumerate (
182- fixed_width_content .splitlines ()[:pane .height ]):
240+ end_y = pane .start_y + pane .height
241+ rows .setdefault (end_y , []).append (pane )
242+ return rows
243+
244+
245+ def draw_all_panes (stdscr , panes , max_x , padding_cache ):
246+ """Draw all panes and their borders"""
247+ # Pre-calculate row groups
248+ rows = group_panes_by_end_y (panes )
249+ for pane in panes :
250+ # Draw content and borders in single pass
251+ draw_pane_content (stdscr , pane , padding_cache )
252+ # Vertical borders
253+ if pane .start_x + pane .width < max_x :
254+ try :
255+ for y in range (pane .start_y , pane .start_y + pane .height ):
256+ stdscr .addstr (y , pane .start_x + pane .width , VERTICAL_BORDER , curses .A_DIM )
257+ except curses .error :
258+ pass
259+ # Horizontal borders
260+ end_y = pane .start_y + pane .height
261+ if end_y in rows :
183262 try :
184- stdscr .addstr (pane .start_y + y , pane .start_x ,
185- line [:pane .width ])
263+ stdscr .addstr (end_y , pane .start_x , HORIZONTAL_BORDER * pane .width , curses .A_DIM )
186264 except curses .error :
187265 pass
188266 stdscr .refresh ()
189267
190- search_ch = stdscr .getkey ()
191268
192- # Find matches in all panes
269+ def find_matches (panes , search_ch , hints ):
270+ """Find all matches for the search character and assign hints"""
193271 hint_index = 0
272+ hint_positions = {} # Add lookup dictionary
194273 for pane in panes :
195- lines = pane .content .splitlines ()
196- for line_num , line in enumerate (lines ):
274+ for line_num , line in enumerate (pane .lines ): # Use lines directly
197275 for match in re .finditer (search_ch , line .lower ()):
198276 if hint_index >= len (hints ):
199277 continue
200- visual_col = sum (
201- get_char_width (c ) for c in line [:match .start ()])
202- pane .positions .append ((line_num , visual_col ,
203- line [match .start ()], hints [hint_index ]))
278+ visual_col = sum (get_char_width (c ) for c in line [:match .start ()])
279+ position = (pane , line_num , visual_col )
280+ hint = hints [hint_index ]
281+ pane .positions .append ((line_num , visual_col , line [match .start ()], hint ))
282+ hint_positions [hint ] = position # Store for quick lookup
204283 hint_index += 1
284+ return hint_positions
205285
206- # Draw hints
286+
287+ def draw_all_hints (stdscr , panes ):
288+ """Draw all hints across all panes"""
207289 for pane in panes :
208290 for line_num , col , char , hint in pane .positions :
209291 y = pane .start_y + line_num
210292 x = pane .start_x + col
211- if (y < pane .start_y + pane .height
212- and x < pane .start_x + pane .width and
293+ if (y < pane .start_y + pane .height and
294+ x < pane .start_x + pane .width and
213295 x + get_char_width (char ) + 1 < pane .start_x + pane .width ):
214296 try :
215297 stdscr .addstr (y , x , hint [0 ], curses .color_pair (RED ))
216298 char_width = get_char_width (char )
217- stdscr .addstr (y , x + char_width , hint [1 ],
218- curses .color_pair (GREEN ))
299+ stdscr .addstr (y , x + char_width , hint [1 ], curses .color_pair (GREEN ))
219300 except curses .error :
220301 pass
302+
303+
304+ def main (stdscr ):
305+ init_curses ()
306+ panes , max_x , padding_cache = init_panes ()
307+ hints = generate_hints (KEYS )
308+
309+ # Draw initial pane contents
310+ draw_all_panes (stdscr , panes , max_x , padding_cache )
311+
312+ # Get search character and find matches
313+ search_ch = stdscr .getkey ()
314+ hint_positions = find_matches (panes , search_ch , hints )
315+
316+ # Draw hints for all matches
317+ draw_all_panes (stdscr , panes , max_x , padding_cache )
318+ draw_all_hints (stdscr , panes )
221319 stdscr .refresh ()
222320
223- # Handle hint selection
321+ # Handle first character selection
224322 ch1 = stdscr .getkey ()
225323 if ch1 not in KEYS :
226324 cleanup_window ()
227325 exit (0 )
228326
229- # Redraw and show second character hints
327+ # Redraw panes and show filtered hints
328+ draw_all_panes (stdscr , panes , max_x , padding_cache )
230329 for pane in panes :
231- fixed_width_content = fill_pane_content_with_space (
232- pane .content , pane .width )
233- for y , line in enumerate (
234- fixed_width_content .splitlines ()[:pane .height ]):
235- try :
236- stdscr .addstr (pane .start_y + y , pane .start_x ,
237- line [:pane .width ])
238- except curses .error :
239- pass
240330 for line_num , col , char , hint in pane .positions :
241331 if not hint .startswith (ch1 ):
242332 continue
243333 y = pane .start_y + line_num
244334 x = pane .start_x + col
245335 char_width = get_char_width (char )
246- if (y < pane .start_y + pane .height and x < pane .start_x + pane .width
247- and x + char_width + 1 < pane .start_x + pane .width ):
336+ if (y < pane .start_y + pane .height and
337+ x < pane .start_x + pane .width and
338+ x + char_width + 1 < pane .start_x + pane .width ):
248339 try :
249- stdscr .addstr (y , x + char_width , hint [1 ],
250- curses .color_pair (GREEN ))
340+ stdscr .addstr (y , x + char_width , hint [1 ], curses .color_pair (GREEN ))
251341 except curses .error :
252342 pass
253343 stdscr .refresh ()
254344
345+ # Handle second character selection
255346 ch2 = stdscr .getkey ()
256347 if ch2 not in KEYS :
257348 cleanup_window ()
258349 exit (0 )
259350
260- # Find target pane and position
351+ # Move cursor to selected position - now using lookup
261352 target_hint = ch1 + ch2
262- for pane in panes :
263- for line_num , col , char , hint in pane .positions :
264- if hint == target_hint :
265- lines = pane .content .splitlines ()
266- true_col = get_true_position (lines [line_num ], col )
267- tmux_move_cursor (pane , line_num , true_col )
268- break
353+ if target_hint in hint_positions :
354+ pane , line_num , col = hint_positions [target_hint ]
355+ true_col = get_true_position (pane .lines [line_num ], col ) # Use lines directly
356+ tmux_move_cursor (pane , line_num , true_col )
269357
270358 cleanup_window ()
271359
272360
273361if __name__ == '__main__' :
274- curses .wrapper (main )
362+ try :
363+ curses .wrapper (main )
364+ except KeyboardInterrupt :
365+ cleanup_window ()
366+ exit (0 )
0 commit comments