Skip to content

Commit 504311f

Browse files
committed
feat: Implement dynamic hint generation
1 parent 8ff9e63 commit 504311f

File tree

2 files changed

+222
-64
lines changed

2 files changed

+222
-64
lines changed

easymotion.py

Lines changed: 144 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -358,17 +358,71 @@ def tmux_move_cursor(pane, line_num, true_col):
358358
sh(cmd)
359359

360360

361+
class HintTree:
362+
def __init__(self):
363+
self.targets = {} # Store single key mappings
364+
self.children = {} # Store double key mapping subtrees
365+
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)
374+
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
382+
383+
361384
def generate_hints(keys: str, needed_count: Optional[int] = None) -> List[str]:
362-
"""Generate only as many hints as needed"""
363-
if needed_count is None:
364-
return [k1 + k2 for k1 in keys for k2 in keys]
385+
"""Generate hints with optimal single/double key distribution"""
386+
if not needed_count:
387+
needed_count = len(keys)**2
388+
389+
keys_list = list(keys)
390+
key_count = len(keys_list)
391+
max_hints = key_count * key_count # All possible double-char combinations
392+
393+
if needed_count > max_hints:
394+
needed_count = max_hints
395+
396+
# When needed hints count is less than or equal to available keys, use single chars
397+
if needed_count <= key_count:
398+
return keys_list[:needed_count]
399+
400+
# Generate all possible double char combinations
401+
double_char_hints = []
402+
for prefix in keys_list: # Including first char as prefix
403+
for suffix in keys_list:
404+
double_char_hints.append(prefix + suffix)
405+
406+
# If we need maximum possible combinations, return all double-char hints
407+
if needed_count == max_hints:
408+
return double_char_hints
409+
410+
# Dynamically calculate how many single chars to keep
411+
single_chars = 0
412+
for i in range(key_count, 0, -1):
413+
if needed_count <= (key_count - i + 1) * key_count:
414+
single_chars = i
415+
break
416+
365417
hints = []
366-
for k1 in keys:
367-
for k2 in keys:
368-
hints.append(k1 + k2)
369-
if len(hints) >= needed_count:
370-
return hints
371-
return hints
418+
# Take needed doubles from the end
419+
needed_doubles = needed_count - single_chars
420+
hints.extend(double_char_hints[-needed_doubles:])
421+
422+
# Add single chars at the beginning
423+
hints[0:0] = keys_list[:single_chars]
424+
425+
return hints[:needed_count]
372426

373427

374428
@perf_timer()
@@ -429,38 +483,71 @@ def draw_all_panes(panes, max_x, padding_cache, terminal_height, screen):
429483

430484

431485
@perf_timer("Finding matches")
432-
def find_matches(panes, search_ch, hints):
433-
"""Find all matches for the search character and assign hints"""
434-
hint_index = 0
435-
hint_positions = {} # Add lookup dictionary
486+
def find_matches(panes, search_ch):
487+
"""Find all matches and return match list"""
488+
matches = []
436489
for pane in panes:
437-
for line_num, line in enumerate(pane.lines): # Use lines directly
438-
for match in re.finditer(search_ch, line.lower()):
439-
if hint_index >= len(hints):
440-
continue
441-
visual_col = sum(get_char_width(c)
442-
for c in line[:match.start()])
443-
position = (pane, line_num, visual_col)
444-
hint = hints[hint_index]
445-
pane.positions.append(
446-
(line_num, visual_col, line[match.start()], hint))
447-
hint_positions[hint] = position # Store for quick lookup
448-
hint_index += 1
449-
return hint_positions
490+
for line_num, line in enumerate(pane.lines):
491+
# Search each position in the line
492+
pos = 0
493+
while pos < len(line):
494+
idx = line.lower().find(search_ch.lower(), pos)
495+
if idx == -1:
496+
break
497+
498+
# Calculate visual column position
499+
visual_col = sum(get_char_width(c) for c in line[:idx])
500+
matches.append((pane, line_num, visual_col))
501+
pos = idx + 1
502+
503+
return matches
450504

451505

452506
@perf_timer("Drawing hints")
507+
def update_hints_display(screen, panes, hint_tree, current_key):
508+
"""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)
521+
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)
526+
527+
screen.refresh()
528+
529+
453530
def draw_all_hints(panes, terminal_height, screen):
454531
"""Draw all hints across all panes"""
455532
for pane in panes:
456533
for line_num, col, char, hint in pane.positions:
457534
y = pane.start_y + line_num
458535
x = pane.start_x + col
459-
if (y < min(pane.start_y + pane.height, terminal_height) and x + get_char_width(char) <= pane.start_x + pane.width):
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):
540+
541+
# Always show first character
460542
screen.addstr(y, x, hint[0], screen.A_HINT1)
461-
char_width = get_char_width(char)
462-
if x + char_width < pane.start_x + pane.width:
463-
screen.addstr(y, x + char_width, hint[1], screen.A_HINT2)
543+
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)
550+
464551
screen.refresh()
465552

466553

@@ -473,39 +560,39 @@ def main(screen: Screen):
473560
draw_all_panes(panes, max_x, padding_cache, terminal_height, screen)
474561
sh(['tmux', 'select-window', '-t', '{end}'])
475562

476-
hints = generate_hints(KEYS)
477563
search_ch = getch()
478-
hint_positions = find_matches(panes, search_ch, hints)
479-
480-
draw_all_hints(panes, terminal_height, screen)
564+
matches = find_matches(panes, search_ch)
565+
hints = generate_hints(KEYS, len(matches))
481566

482-
ch1 = getch()
483-
if ch1 not in KEYS:
484-
return
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))
485573

486-
# Update hints consistently using screen.addstr
487-
for pane in panes:
488-
for line_num, col, char, hint in pane.positions:
489-
y = pane.start_y + line_num
490-
x = pane.start_x + col
491-
char_width = get_char_width(char)
492-
if (y < min(pane.start_y + pane.height, terminal_height) and
493-
x + char_width <= pane.start_x + pane.width):
494-
screen.addstr(y, x, hint[1], screen.A_HINT2)
495-
if x + char_width + 1 < pane.start_x + pane.width:
496-
screen.addstr(y, x+char_width, char)
574+
draw_all_hints(panes, terminal_height, screen)
497575

498-
screen.refresh()
576+
# Handle user input
577+
key_sequence = ""
578+
while True:
579+
ch = getch()
580+
if ch not in KEYS:
581+
return
499582

500-
ch2 = getch()
501-
if ch2 not in KEYS:
502-
return
583+
key_sequence += ch
584+
target = hint_tree.get(key_sequence)
503585

504-
target_hint = ch1 + ch2
505-
if target_hint in hint_positions:
506-
pane, line_num, col = hint_positions[target_hint]
507-
true_col = get_true_position(pane.lines[line_num], col)
508-
tmux_move_cursor(pane, line_num, true_col)
586+
if target:
587+
pane, line_num, col = target
588+
true_col = get_true_position(pane.lines[line_num], col)
589+
tmux_move_cursor(pane, line_num, true_col)
590+
return # Exit after finding and moving to target
591+
elif len(key_sequence) >= 2: # If no target found after 2 chars
592+
return # Exit program
593+
else:
594+
# Update display to show remaining possible hints
595+
update_hints_display(screen, panes, hint_tree, key_sequence)
509596

510597

511598
if __name__ == '__main__':

test_easymotion.py

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,82 @@ def test_generate_hints():
3535
assert hints == expected
3636

3737

38-
def test_generate_hints_with_full_keys():
39-
# Test with actual KEYS constant
40-
from easymotion import KEYS
41-
hints = generate_hints(KEYS)
42-
# Check first few hints
43-
assert len(hints) == len(KEYS) * len(KEYS)
38+
def test_generate_hints_no_duplicates():
39+
keys = 'asdf' # 4 characters
40+
41+
# Test all possible hint counts from 1 to max (16)
42+
for count in range(1, 17):
43+
hints = generate_hints(keys, count)
44+
45+
# Check no duplicates
46+
assert len(hints) == len(
47+
set(hints)), f"Duplicates found in hints for count {count}"
48+
49+
# For double character hints, check first character usage
50+
single_chars = [h for h in hints if len(h) == 1]
51+
double_chars = [h for h in hints if len(h) == 2]
52+
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}"
57+
58+
# Check all characters are from the key set
59+
assert all(c in keys for h in hints for c in h), \
60+
f"Invalid characters found in hints for count {count}"
61+
62+
63+
def test_generate_hints_distribution():
64+
keys = 'asdf' # 4 characters
65+
66+
# Case i=4: 4 hints (all single chars)
67+
hints = generate_hints(keys, 4)
68+
assert len(hints) == 4
69+
assert all(len(hint) == 1 for hint in hints)
70+
assert set(hints) == set('asdf')
71+
72+
# Case i=3: 7 hints (3 single + 4 double)
73+
hints = generate_hints(keys, 7)
74+
assert len(hints) == 7
75+
single_chars = [h for h in hints if len(h) == 1]
76+
double_chars = [h for h in hints if len(h) == 2]
77+
assert len(single_chars) == 3
78+
assert len(double_chars) == 4
79+
# Ensure double char prefixes don't overlap with single chars
80+
single_char_set = set(single_chars)
81+
double_char_firsts = set(h[0] for h in double_chars)
82+
assert not (single_char_set &
83+
double_char_firsts), "Double char prefixes overlap with single chars"
84+
85+
# Case i=2: 10 hints (2 single + 8 double)
86+
hints = generate_hints(keys, 10)
87+
assert len(hints) == 10
88+
single_chars = [h for h in hints if len(h) == 1]
89+
double_chars = [h for h in hints if len(h) == 2]
90+
assert len(single_chars) == 2
91+
assert len(double_chars) == 8
92+
# Ensure double char prefixes don't overlap with single chars
93+
single_char_set = set(single_chars)
94+
double_char_firsts = set(h[0] for h in double_chars)
95+
assert not (single_char_set &
96+
double_char_firsts), "Double char prefixes overlap with single chars"
97+
98+
# Case i=1: 13 hints (1 single + 12 double)
99+
hints = generate_hints(keys, 13)
100+
assert len(hints) == 13
101+
single_chars = [h for h in hints if len(h) == 1]
102+
double_chars = [h for h in hints if len(h) == 2]
103+
assert len(single_chars) == 1
104+
assert len(double_chars) == 12
105+
# Ensure double char prefixes don't overlap with single chars
106+
single_char_set = set(single_chars)
107+
double_char_firsts = set(h[0] for h in double_chars)
108+
assert not (single_char_set &
109+
double_char_firsts), "Double char prefixes overlap with single chars"
110+
111+
# Case i=0: 16 hints (all double chars)
112+
hints = generate_hints(keys, 16)
113+
assert len(hints) == 16
44114
assert all(len(hint) == 2 for hint in hints)
45-
assert all(all(c in KEYS for c in hint) for hint in hints)
115+
# For all double chars case, just ensure no duplicate combinations
116+
assert len(hints) == len(set(hints))

0 commit comments

Comments
 (0)