Skip to content

Commit fbda3b3

Browse files
committed
feat: Implement cursor-aware hint generation with distance-based sorting
1 parent 504311f commit fbda3b3

File tree

2 files changed

+126
-97
lines changed

2 files changed

+126
-97
lines changed

easymotion.py

Lines changed: 121 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ def get_initial_tmux_info():
237237
"""Get all needed tmux info in one optimized call"""
238238
format_str = '#{pane_id},#{window_zoomed_flag},#{pane_active},' + \
239239
'#{pane_top},#{pane_height},#{pane_left},#{pane_width},' + \
240-
'#{pane_in_mode},#{scroll_position}'
240+
'#{pane_in_mode},#{scroll_position},' + \
241+
'#{cursor_y},#{cursor_x},#{copy_cursor_y},#{copy_cursor_x}'
241242

242243
cmd = ['tmux', 'list-panes', '-F', format_str]
243244
output = sh(cmd).strip()
@@ -248,19 +249,18 @@ def get_initial_tmux_info():
248249
continue
249250

250251
fields = line.split(',')
251-
if len(fields) != 9:
252-
continue
253-
254252
# Use destructuring assignment for better readability and performance
255253
(pane_id, zoomed, active, top, height,
256-
left, width, in_mode, scroll_pos) = fields
254+
left, width, in_mode, scroll_pos,
255+
cursor_y, cursor_x, copy_cursor_y, copy_cursor_x) = fields
257256

258257
# Only show all panes in non-zoomed state, or only active pane in zoomed state
259258
if zoomed == "1" and active != "1":
260259
continue
261260

262261
pane = PaneInfo(
263262
pane_id=pane_id,
263+
active=active == "1",
264264
start_y=int(top),
265265
height=int(height),
266266
start_x=int(left),
@@ -271,16 +271,26 @@ def get_initial_tmux_info():
271271
pane.copy_mode = (in_mode == "1")
272272
pane.scroll_position = int(scroll_pos or 0)
273273

274+
# Set cursor position
275+
if in_mode == "1": # If in copy mode
276+
pane.cursor_y = int(copy_cursor_y)
277+
pane.cursor_x = int(copy_cursor_x)
278+
else: # If not in copy mode, cursor is at bottom left
279+
pane.cursor_y = int(cursor_y)
280+
pane.cursor_x = int(cursor_x)
281+
274282
panes_info.append(pane)
275283

276284
return panes_info
277285

278286

279287
class PaneInfo:
280-
__slots__ = ('pane_id', 'start_y', 'height', 'start_x', 'width',
281-
'lines', 'positions', 'copy_mode', 'scroll_position')
288+
__slots__ = ('pane_id', 'active', 'start_y', 'height', 'start_x', 'width',
289+
'lines', 'positions', 'copy_mode', 'scroll_position',
290+
'cursor_y', 'cursor_x')
282291

283-
def __init__(self, pane_id, start_y, height, start_x, width):
292+
def __init__(self, pane_id, active, start_y, height, start_x, width):
293+
self.active = active
284294
self.pane_id = pane_id
285295
self.start_y = start_y
286296
self.height = height
@@ -290,12 +300,8 @@ def __init__(self, pane_id, start_y, height, start_x, width):
290300
self.positions = []
291301
self.copy_mode = False
292302
self.scroll_position = 0
293-
294-
295-
def tmux_pane_id():
296-
# Get the ID of the pane that launched this script
297-
source_pane = os.environ.get('TMUX_PANE')
298-
return source_pane or '%0'
303+
self.cursor_y = 0
304+
self.cursor_x = 0
299305

300306

301307
def get_terminal_size():
@@ -358,27 +364,21 @@ def tmux_move_cursor(pane, line_num, true_col):
358364
sh(cmd)
359365

360366

361-
class HintTree:
362-
def __init__(self):
363-
self.targets = {} # Store single key mappings
364-
self.children = {} # Store double key mapping subtrees
367+
def assign_hints_by_distance(matches, cursor_y, cursor_x):
368+
"""Sort matches by distance and assign hints"""
369+
# Calculate distances and sort
370+
matches_with_dist = []
371+
for match in matches:
372+
pane, line_num, col = match
373+
dist = (line_num - cursor_y)**2 + (col - cursor_x)**2
374+
matches_with_dist.append((dist, match))
365375

366-
def add(self, hint, target):
367-
if len(hint) == 1:
368-
self.targets[hint] = target
369-
else:
370-
first, rest = hint[0], hint[1]
371-
if first not in self.children:
372-
self.children[first] = HintTree()
373-
self.children[first].add(rest, target)
376+
matches_with_dist.sort(key=lambda x: x[0]) # Sort by distance
374377

375-
def get(self, key_sequence):
376-
if len(key_sequence) == 1:
377-
return self.targets.get(key_sequence)
378-
first, rest = key_sequence[0], key_sequence[1]
379-
if first in self.children:
380-
return self.children[first].get(rest)
381-
return None
378+
# Generate hints and create mapping
379+
hints = generate_hints(KEYS, len(matches_with_dist))
380+
logging.debug(f'{hints}')
381+
return {hint: match for (_, match), hint in zip(matches_with_dist, hints)}
382382

383383

384384
def generate_hints(keys: str, needed_count: Optional[int] = None) -> List[str]:
@@ -399,28 +399,29 @@ def generate_hints(keys: str, needed_count: Optional[int] = None) -> List[str]:
399399

400400
# Generate all possible double char combinations
401401
double_char_hints = []
402-
for prefix in keys_list: # Including first char as prefix
402+
for prefix in keys_list:
403403
for suffix in keys_list:
404404
double_char_hints.append(prefix + suffix)
405405

406-
# If we need maximum possible combinations, return all double-char hints
407-
if needed_count == max_hints:
408-
return double_char_hints
409-
410406
# Dynamically calculate how many single chars to keep
411407
single_chars = 0
412408
for i in range(key_count, 0, -1):
413-
if needed_count <= (key_count - i + 1) * key_count:
409+
if needed_count <= (key_count - i) * key_count + i:
414410
single_chars = i
415411
break
416412

417413
hints = []
418-
# Take needed doubles from the end
419-
needed_doubles = needed_count - single_chars
420-
hints.extend(double_char_hints[-needed_doubles:])
421-
422414
# Add single chars at the beginning
423-
hints[0:0] = keys_list[:single_chars]
415+
single_char_hints = keys_list[:single_chars]
416+
hints.extend(single_char_hints)
417+
418+
# Filter out double char hints that start with any single char hint
419+
filtered_doubles = [h for h in double_char_hints
420+
if h[0] not in single_char_hints]
421+
422+
# Take needed doubles
423+
needed_doubles = needed_count - single_chars
424+
hints.extend(filtered_doubles[:needed_doubles])
424425

425426
return hints[:needed_count]
426427

@@ -504,49 +505,55 @@ def find_matches(panes, search_ch):
504505

505506

506507
@perf_timer("Drawing hints")
507-
def update_hints_display(screen, panes, hint_tree, current_key):
508+
def update_hints_display(screen, positions, current_key):
508509
"""Update hint display based on current key sequence"""
509-
terminal_width, terminal_height = get_terminal_size()
510-
511-
for pane in panes:
512-
for line_num, col, char, hint in pane.positions:
513-
y = pane.start_y + line_num
514-
x = pane.start_x + col
515-
516-
# First restore the second character position to original character
517-
if len(hint) > 1:
518-
char_width = get_char_width(char)
519-
if x + char_width < pane.start_x + pane.width:
520-
screen.addstr(y, x + char_width, char)
510+
for screen_y, screen_x, pane_right_edge, char, next_char, hint in positions:
511+
logging.debug(f'{screen_x} {pane_right_edge} {char} {next_char} {hint}')
512+
if hint.startswith(current_key):
513+
next_x = screen_x + get_char_width(char)
514+
if next_x < pane_right_edge:
515+
logging.debug(f"Restoring next char {next_x} {next_char}")
516+
screen.addstr(screen_y, next_x, next_char)
517+
else:
518+
logging.debug(f"Non-matching hint {screen_x} {screen_y} {char}")
519+
# Restore original character for non-matching hints
520+
screen.addstr(screen_y, screen_x, char)
521+
# Always restore second character
522+
next_x = screen_x + get_char_width(char)
523+
if next_x < pane_right_edge:
524+
logging.debug(f"Restoring next char {next_x} {next_char}")
525+
screen.addstr(screen_y, next_x, next_char)
526+
continue
521527

522-
# Then show new hints based on current input
523-
if hint.startswith(current_key):
524-
if len(hint) > len(current_key):
525-
screen.addstr(y, x, hint[len(current_key)], screen.A_HINT2)
528+
# For matching hints:
529+
if len(hint) > len(current_key):
530+
# Show remaining hint character
531+
screen.addstr(screen_y, screen_x,
532+
hint[len(current_key)], screen.A_HINT2)
533+
else:
534+
# If hint is fully entered, restore all original characters
535+
screen.addstr(screen_y, screen_x, char)
536+
next_x = screen_x + get_char_width(char)
537+
if next_x < pane_right_edge:
538+
screen.addstr(screen_y, next_x, next_char)
526539

527540
screen.refresh()
528541

529542

530-
def draw_all_hints(panes, terminal_height, screen):
543+
def draw_all_hints(positions, terminal_height, screen):
531544
"""Draw all hints across all panes"""
532-
for pane in panes:
533-
for line_num, col, char, hint in pane.positions:
534-
y = pane.start_y + line_num
535-
x = pane.start_x + col
536-
537-
# Ensure position is within visible range
538-
if (y < min(pane.start_y + pane.height, terminal_height) and
539-
x + get_char_width(char) <= pane.start_x + pane.width):
545+
for screen_y, screen_x, pane_right_edge, char, next_char, hint in positions:
546+
if screen_y >= terminal_height:
547+
continue
540548

541-
# Always show first character
542-
screen.addstr(y, x, hint[0], screen.A_HINT1)
549+
# Draw first character of hint
550+
screen.addstr(screen_y, screen_x, hint[0], screen.A_HINT1)
543551

544-
# Only show second character for two-character hints
545-
if len(hint) > 1:
546-
char_width = get_char_width(char)
547-
if x + char_width < pane.start_x + pane.width:
548-
screen.addstr(y, x + char_width,
549-
hint[1], screen.A_HINT2)
552+
# Draw second character if hint has two chars and space allows
553+
if len(hint) > 1:
554+
next_x = screen_x + get_char_width(char)
555+
if next_x < pane_right_edge:
556+
screen.addstr(screen_y, next_x, hint[1], screen.A_HINT2)
550557

551558
screen.refresh()
552559

@@ -562,16 +569,42 @@ def main(screen: Screen):
562569

563570
search_ch = getch()
564571
matches = find_matches(panes, search_ch)
565-
hints = generate_hints(KEYS, len(matches))
566572

567-
# Build hint tree
568-
hint_tree = HintTree()
569-
for match, hint in zip(matches, hints):
570-
hint_tree.add(hint, match)
571-
pane, line_num, col = match
572-
pane.positions.append((line_num, col, pane.lines[line_num][col], hint))
573+
# If only one match, jump directly
574+
if len(matches) == 1:
575+
pane, line_num, col = matches[0]
576+
true_col = get_true_position(pane.lines[line_num], col)
577+
tmux_move_cursor(pane, line_num, true_col)
578+
return
579+
580+
# Get cursor position from current pane
581+
current_pane = next(p for p in panes if p.active)
582+
cursor_y = current_pane.cursor_y
583+
cursor_x = current_pane.cursor_x
584+
logging.debug(f"Cursor position: {current_pane.pane_id}, {
585+
cursor_y}, {cursor_x}")
586+
587+
# Replace HintTree with direct hint assignment
588+
hint_mapping = assign_hints_by_distance(matches, cursor_y, cursor_x)
589+
590+
# Create flat positions list with all needed info
591+
positions = [
592+
(pane.start_y + line_num, # screen_y
593+
pane.start_x + col, # screen_x
594+
pane.start_x + pane.width, # pane_right_edge
595+
char, # original char at hint position
596+
# original char at second hint position (if exists)
597+
next_char,
598+
hint)
599+
for hint, (pane, line_num, col) in hint_mapping.items()
600+
for char, next_char in [(
601+
pane.lines[line_num][col],
602+
pane.lines[line_num][col+1] if col +
603+
1 < len(pane.lines[line_num]) else ''
604+
)]
605+
]
573606

574-
draw_all_hints(panes, terminal_height, screen)
607+
draw_all_hints(positions, terminal_height, screen)
575608

576609
# Handle user input
577610
key_sequence = ""
@@ -581,7 +614,7 @@ def main(screen: Screen):
581614
return
582615

583616
key_sequence += ch
584-
target = hint_tree.get(key_sequence)
617+
target = hint_mapping.get(key_sequence)
585618

586619
if target:
587620
pane, line_num, col = target
@@ -592,7 +625,7 @@ def main(screen: Screen):
592625
return # Exit program
593626
else:
594627
# Update display to show remaining possible hints
595-
update_hints_display(screen, panes, hint_tree, key_sequence)
628+
update_hints_display(screen, positions, key_sequence)
596629

597630

598631
if __name__ == '__main__':

test_easymotion.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import time
2-
from unittest.mock import patch
3-
4-
from easymotion import (draw_all_panes, generate_hints, get_char_width,
5-
get_string_width, get_true_position, init_panes)
1+
from easymotion import (generate_hints, get_char_width, get_string_width,
2+
get_true_position)
63

74

85
def test_get_char_width():
@@ -50,10 +47,9 @@ def test_generate_hints_no_duplicates():
5047
single_chars = [h for h in hints if len(h) == 1]
5148
double_chars = [h for h in hints if len(h) == 2]
5249
if double_chars:
53-
first_chars = [h[0] for h in double_chars]
54-
assert set(first_chars) not in single_chars, \
55-
f"Duplicate first characters in double-char hints for count {
56-
count}"
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"
5753

5854
# Check all characters are from the key set
5955
assert all(c in keys for h in hints for c in h), \

0 commit comments

Comments
 (0)