Skip to content

Commit 6c120c7

Browse files
committed
feat: draw borders for panes
1 parent 4cf93a5 commit 6c120c7

File tree

3 files changed

+178
-101
lines changed

3 files changed

+178
-101
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# TMUX Easymotion
22

3-
![demo](https://github.com/user-attachments/assets/0e97e9ee-2a62-43ac-990a-f896ff5211b2)
3+
![demo](https://github.com/user-attachments/assets/1f18eede-d93a-4406-958e-484c16360323)
44

55
### Installation
66

easymotion.py

Lines changed: 177 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
#!/usr/bin/env python3
22
import curses
3+
import functools
34
import os
45
import re
56
import 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

2435
def 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

4764
def 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

140147
def 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

158173
RED = 1
159174
GREEN = 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

273361
if __name__ == '__main__':
274-
curses.wrapper(main)
362+
try:
363+
curses.wrapper(main)
364+
except KeyboardInterrupt:
365+
cleanup_window()
366+
exit(0)

0 commit comments

Comments
 (0)