Skip to content

Commit 94f52dc

Browse files
committed
feat: Replace curses with ANSI escape sequences for terminal control
1 parent 6c120c7 commit 94f52dc

File tree

1 file changed

+143
-114
lines changed

1 file changed

+143
-114
lines changed

easymotion.py

Lines changed: 143 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
#!/usr/bin/env python3
2-
import curses
32
import functools
43
import os
54
import re
5+
import sys
6+
import termios
7+
import tty
68
import unicodedata
79
from itertools import islice
810
from typing import List, Optional
911

12+
# ANSI escape sequences
13+
ESC = '\033'
14+
CLEAR = f'{ESC}[2J'
15+
CLEAR_LINE = f'{ESC}[2K'
16+
HIDE_CURSOR = f'{ESC}[?25l'
17+
SHOW_CURSOR = f'{ESC}[?25h'
18+
RESET = f'{ESC}[0m'
19+
RED_FG = f'{ESC}[31m'
20+
GREEN_FG = f'{ESC}[32m'
21+
DIM = f'{ESC}[2m'
22+
1023
# Configuration from environment
1124
KEYS = os.environ.get('TMUX_EASYMOTION_KEYS', 'asdfghjkl;')
1225
HINT_COLOR_1 = int(os.environ.get('TMUX_EASYMOTION_COLOR1', '1')) # RED
@@ -129,14 +142,41 @@ def cleanup_window():
129142
if current_window != previous_window:
130143
pyshell('tmux kill-window')
131144

145+
def get_terminal_size():
146+
"""Get terminal size from tmux"""
147+
output = pyshell('tmux display-message -p "#{client_width},#{client_height}"')
148+
width, height = map(int, output.strip().split(','))
149+
return width, height - 1 # Subtract 1 from height
150+
151+
def init_terminal():
152+
"""Initialize terminal settings"""
153+
sys.stdout.write(HIDE_CURSOR)
154+
sys.stdout.flush()
155+
156+
def restore_terminal():
157+
"""Restore terminal settings"""
158+
sys.stdout.write(SHOW_CURSOR)
159+
sys.stdout.write(RESET)
160+
sys.stdout.flush()
161+
162+
def getch():
163+
"""Get a single character from terminal"""
164+
fd = sys.stdin.fileno()
165+
old_settings = termios.tcgetattr(fd)
166+
try:
167+
tty.setraw(fd)
168+
ch = sys.stdin.read(1)
169+
finally:
170+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
171+
return ch
172+
132173

133174
def tmux_capture_pane(pane):
134175
if pane.scroll_position > 0:
135176
# When scrolled up, use negative numbers to capture from history
136177
# -scroll_pos is where we are in history
137-
# -(scroll_pos - curses.LINES + 1) captures one screen worth from there
138-
end_pos = -(pane.scroll_position - curses.LINES + 1
139-
) # Calculate separately to avoid string formatting issues
178+
# -(scroll_pos - pane.height + 1) captures one screen worth from there
179+
end_pos = -(pane.scroll_position - pane.height + 1)
140180
cmd = f'tmux capture-pane -p -S -{pane.scroll_position} -E {end_pos} -t {pane.pane_id}'
141181
else:
142182
# If not scrolled, just capture current view (default behavior)
@@ -174,14 +214,7 @@ def generate_hints(keys: str, needed_count: Optional[int] = None) -> List[str]:
174214
GREEN = 2
175215

176216

177-
def init_curses():
178-
"""Initialize curses settings and colors"""
179-
curses.curs_set(False)
180-
curses.start_color()
181-
curses.use_default_colors()
182-
curses.init_pair(RED, curses.COLOR_RED, -1)
183-
curses.init_pair(GREEN, curses.COLOR_GREEN, -1)
184-
217+
# Remove the init_curses function as it's no longer needed
185218

186219
def init_panes():
187220
"""Initialize pane information with cached calculations"""
@@ -203,34 +236,25 @@ def init_panes():
203236
return panes, max_x, padding_cache
204237

205238

206-
def draw_pane_content(stdscr, pane, padding_cache):
239+
def draw_pane_content(pane, padding_cache):
207240
"""Draw the content of a single pane"""
208241
for y, line in enumerate(pane.lines[:pane.height]):
209242
visual_width = get_string_width(line)
210243
if visual_width < pane.width:
211244
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
245+
sys.stdout.write(f'{ESC}[{pane.start_y + y};{pane.start_x}H{line[:pane.width]}')
216246

217247

218-
def draw_vertical_borders(stdscr, pane, max_x):
248+
def draw_vertical_borders(pane, max_x):
219249
"""Draw vertical borders for a pane"""
220250
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
251+
for y in range(pane.start_y, pane.start_y + pane.height):
252+
sys.stdout.write(f'{ESC}[{y};{pane.start_x + pane.width}H{DIM}{VERTICAL_BORDER}{RESET}')
226253

227254

228-
def draw_horizontal_border(stdscr, pane, y_pos):
255+
def draw_horizontal_border(pane, y_pos):
229256
"""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
257+
sys.stdout.write(f'{ESC}[{y_pos};{pane.start_x}H{DIM}{HORIZONTAL_BORDER * pane.width}{RESET}')
234258

235259

236260
def group_panes_by_end_y(panes):
@@ -242,28 +266,32 @@ def group_panes_by_end_y(panes):
242266
return rows
243267

244268

245-
def draw_all_panes(stdscr, panes, max_x, padding_cache):
269+
def draw_all_panes(panes, max_x, padding_cache, terminal_height):
246270
"""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
271+
sorted_panes = sorted(panes, key=lambda p: p.start_y + p.height)
272+
273+
for pane in sorted_panes:
274+
# 限制繪製的行數
275+
visible_height = min(pane.height, terminal_height - pane.start_y)
276+
277+
# 繪製內容
278+
for y, line in enumerate(pane.lines[:visible_height]):
279+
visual_width = get_string_width(line)
280+
if visual_width < pane.width:
281+
line = line + padding_cache[pane.width - visual_width]
282+
sys.stdout.write(f'{ESC}[{pane.start_y + y + 1};{pane.start_x + 1}H{line[:pane.width]}')
283+
284+
# 繪製垂直邊框
253285
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:
262-
try:
263-
stdscr.addstr(end_y, pane.start_x, HORIZONTAL_BORDER * pane.width, curses.A_DIM)
264-
except curses.error:
265-
pass
266-
stdscr.refresh()
286+
for y in range(pane.start_y, pane.start_y + visible_height):
287+
sys.stdout.write(f'{ESC}[{y + 1};{pane.start_x + pane.width}H{DIM}{VERTICAL_BORDER}{RESET}')
288+
289+
# 只為非最底部的 pane 繪製水平邊框
290+
end_y = pane.start_y + visible_height
291+
if end_y < terminal_height and pane != sorted_panes[-1]:
292+
sys.stdout.write(f'{ESC}[{end_y + 1};{pane.start_x + 1}H{DIM}{HORIZONTAL_BORDER * pane.width}{RESET}')
293+
294+
sys.stdout.flush()
267295

268296

269297
def find_matches(panes, search_ch, hints):
@@ -284,83 +312,84 @@ def find_matches(panes, search_ch, hints):
284312
return hint_positions
285313

286314

287-
def draw_all_hints(stdscr, panes):
315+
def draw_all_hints(panes, terminal_height):
288316
"""Draw all hints across all panes"""
289317
for pane in panes:
290318
for line_num, col, char, hint in pane.positions:
291319
y = pane.start_y + line_num
292320
x = pane.start_x + col
293-
if (y < pane.start_y + pane.height and
321+
if (y < min(pane.start_y + pane.height, terminal_height) and
294322
x < pane.start_x + pane.width and
295323
x + get_char_width(char) + 1 < pane.start_x + pane.width):
296-
try:
297-
stdscr.addstr(y, x, hint[0], curses.color_pair(RED))
298-
char_width = get_char_width(char)
299-
stdscr.addstr(y, x + char_width, hint[1], curses.color_pair(GREEN))
300-
except curses.error:
301-
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)
319-
stdscr.refresh()
320-
321-
# Handle first character selection
322-
ch1 = stdscr.getkey()
323-
if ch1 not in KEYS:
324-
cleanup_window()
325-
exit(0)
324+
sys.stdout.write(f'{ESC}[{y + 1};{x + 1}H{RED_FG}{hint[0]}{RESET}')
325+
char_width = get_char_width(char)
326+
sys.stdout.write(f'{ESC}[{y + 1};{x + char_width + 1}H{GREEN_FG}{hint[1]}{RESET}')
327+
sys.stdout.flush()
326328

327-
# Redraw panes and show filtered hints
328-
draw_all_panes(stdscr, panes, max_x, padding_cache)
329-
for pane in panes:
330-
for line_num, col, char, hint in pane.positions:
331-
if not hint.startswith(ch1):
332-
continue
333-
y = pane.start_y + line_num
334-
x = pane.start_x + col
335-
char_width = get_char_width(char)
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):
339-
try:
340-
stdscr.addstr(y, x + char_width, hint[1], curses.color_pair(GREEN))
341-
except curses.error:
342-
pass
343-
stdscr.refresh()
344-
345-
# Handle second character selection
346-
ch2 = stdscr.getkey()
347-
if ch2 not in KEYS:
348-
cleanup_window()
349-
exit(0)
350-
351-
# Move cursor to selected position - now using lookup
352-
target_hint = ch1 + ch2
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)
357-
358-
cleanup_window()
359329

330+
def main():
331+
try:
332+
init_terminal()
333+
terminal_width, terminal_height = get_terminal_size()
334+
panes, max_x, padding_cache = init_panes()
335+
hints = generate_hints(KEYS)
336+
337+
def clear_and_draw():
338+
sys.stdout.write(CLEAR)
339+
draw_all_panes(panes, max_x, padding_cache, terminal_height)
340+
sys.stdout.write(f'{ESC}[{terminal_height};1H')
341+
sys.stdout.flush()
342+
343+
clear_and_draw()
344+
345+
# Get search character and find matches
346+
search_ch = getch()
347+
hint_positions = find_matches(panes, search_ch, hints)
348+
349+
# Draw hints for all matches
350+
clear_and_draw()
351+
draw_all_hints(panes, terminal_height)
352+
sys.stdout.flush()
353+
354+
# Handle first character selection
355+
ch1 = getch()
356+
if ch1 not in KEYS:
357+
return
358+
359+
# Redraw panes and show filtered hints
360+
clear_and_draw()
361+
for pane in panes:
362+
for line_num, col, char, hint in pane.positions:
363+
if not hint.startswith(ch1):
364+
continue
365+
y = pane.start_y + line_num
366+
x = pane.start_x + col
367+
char_width = get_char_width(char)
368+
if (y < min(pane.start_y + pane.height, terminal_height) and
369+
x < pane.start_x + pane.width and
370+
x + char_width + 1 < pane.start_x + pane.width):
371+
sys.stdout.write(f'{ESC}[{y + 1};{x + char_width + 1}H{GREEN_FG}{hint[1]}{RESET}')
372+
sys.stdout.flush()
373+
374+
# Handle second character selection
375+
ch2 = getch()
376+
if ch2 not in KEYS:
377+
return
378+
379+
# Move cursor to selected position
380+
target_hint = ch1 + ch2
381+
if target_hint in hint_positions:
382+
pane, line_num, col = hint_positions[target_hint]
383+
true_col = get_true_position(pane.lines[line_num], col)
384+
tmux_move_cursor(pane, line_num, true_col)
385+
386+
finally:
387+
restore_terminal()
360388

361389
if __name__ == '__main__':
362390
try:
363-
curses.wrapper(main)
391+
main()
364392
except KeyboardInterrupt:
393+
restore_terminal()
394+
finally:
365395
cleanup_window()
366-
exit(0)

0 commit comments

Comments
 (0)