Implement ^R reverse search for history

This commit is contained in:
Anthony Sottile
2019-11-30 15:31:36 -08:00
parent ace629bc17
commit b4f7cabb28
2 changed files with 176 additions and 17 deletions

81
babi.py
View File

@@ -259,29 +259,43 @@ class Status:
*,
history: Optional[str] = None,
) -> str:
self.clear()
if history is not None:
lst = [*self._history[history], '']
lst_pos = len(lst) - 1
else:
lst = ['']
lst_pos = 0
self.clear()
pos = 0
while True:
if not prompt or curses.COLS < 7:
prompt_s = ''
elif len(prompt) > curses.COLS - 6:
prompt_s = f'{prompt[:curses.COLS - 7]}…: '
else:
prompt_s = f'{prompt}: '
def buf() -> str:
return lst[lst_pos]
def set_buf(s: str) -> None:
lst[lst_pos] = s
def _save_history_entry() -> None:
if history is not None:
history_lst = self._history[history]
if not history_lst or history_lst[-1] != lst[lst_pos]:
history_lst.append(lst[lst_pos])
def _render_prompt(*, base: str = prompt) -> None:
if not base or curses.COLS < 7:
prompt_s = ''
elif len(base) > curses.COLS - 6:
prompt_s = f'{base[:curses.COLS - 7]}…: '
else:
prompt_s = f'{base}: '
width = curses.COLS - len(prompt_s)
line = _scrolled_line(lst[lst_pos], pos, width, current=True)
cmd = f'{prompt_s}{line}'
screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE)
line_x = _line_x(pos, width)
screen.stdscr.move(curses.LINES - 1, len(prompt_s) + pos - line_x)
while True:
_render_prompt()
key = _get_char(screen.stdscr)
if key.key == curses.KEY_RESIZE:
@@ -302,22 +316,55 @@ class Status:
pos = len(lst[lst_pos])
elif key.key == curses.KEY_BACKSPACE:
if pos > 0:
lst[lst_pos] = lst[lst_pos][:pos - 1] + lst[lst_pos][pos:]
set_buf(buf()[:pos - 1] + buf()[pos:])
pos -= 1
elif key.key == curses.KEY_DC:
if pos < len(lst[lst_pos]):
lst[lst_pos] = lst[lst_pos][:pos] + lst[lst_pos][pos + 1:]
set_buf(buf()[:pos] + buf()[pos + 1:])
elif isinstance(key.wch, str) and key.wch.isprintable():
c = key.wch
lst[lst_pos] = lst[lst_pos][:pos] + c + lst[lst_pos][pos:]
set_buf(buf()[:pos] + key.wch + buf()[pos:])
pos += 1
elif key.keyname == b'^R':
reverse_s = ''
reverse_idx = lst_pos
while True:
reverse_failed = False
for search_idx in range(reverse_idx, -1, -1):
if reverse_s in lst[search_idx]:
reverse_idx = lst_pos = search_idx
pos = len(buf())
break
else:
reverse_failed = True
if reverse_failed:
base = f'{prompt}(failed reverse-search)`{reverse_s}`'
else:
base = f'{prompt}(reverse-search)`{reverse_s}`'
_render_prompt(base=base)
key = _get_char(screen.stdscr)
if key.key == curses.KEY_RESIZE:
screen.resize()
elif key.key == curses.KEY_BACKSPACE:
reverse_s = reverse_s[:-1]
elif isinstance(key.wch, str) and key.wch.isprintable():
reverse_s += key.wch
elif key.keyname == b'^R':
reverse_idx = max(0, reverse_idx - 1)
elif key.keyname == b'^C':
return ''
elif key.key == ord('\r'):
_save_history_entry()
return lst[lst_pos]
else:
break
elif key.keyname == b'^C':
return ''
elif key.key == ord('\r'):
if history is not None:
history_lst = self._history[history]
if not history_lst or history_lst[-1] != lst[lst_pos]:
history_lst.append(lst[lst_pos])
_save_history_entry()
return lst[lst_pos]

View File

@@ -784,6 +784,118 @@ def test_search_history_is_saved_between_sessions(xdg_data_home):
h.press('Enter')
def test_search_reverse_search_history(xdg_data_home, ten_lines):
xdg_data_home.join('babi/history/search').ensure().write(
'line_5\n'
'line_3\n'
'line_1\n',
)
with run(str(ten_lines)) as h, and_exit(h):
h.press('^W')
h.press('^R')
h.await_text('search(reverse-search)``:')
h.press('line')
h.await_text('search(reverse-search)`line`: line_1')
h.press('a')
h.await_text('search(failed reverse-search)`linea`: line_1')
h.press('BSpace')
h.await_text('search(reverse-search)`line`: line_1')
h.press('^R')
h.await_text('search(reverse-search)`line`: line_3')
h.press('Enter')
h.await_cursor_position(x=0, y=4)
def test_search_reverse_search_history_pos_after(xdg_data_home, ten_lines):
xdg_data_home.join('babi/history/search').ensure().write(
'line_3\n',
)
with run(str(ten_lines), height=20) as h, and_exit(h):
h.press('^W')
h.press('^R')
h.press('line')
h.await_text('search(reverse-search)`line`: line_3')
h.press('Right')
h.await_text('search: line_3')
h.await_cursor_position(y=19, x=14)
h.press('^C')
def test_search_reverse_search_enter_saves_entry(xdg_data_home, ten_lines):
xdg_data_home.join('babi/history/search').ensure().write(
'line_1\n'
'line_3\n',
)
with run(str(ten_lines)) as h, and_exit(h):
h.press('^W')
h.press('^R')
h.press('1')
h.await_text('search(reverse-search)`1`: line_1')
h.press('Enter')
h.press('^W')
h.press('Up')
h.await_text('search: line_1')
h.press('^C')
def test_search_reverse_search_history_cancel():
with run() as h, and_exit(h):
h.press('^W')
h.press('^R')
h.await_text('search(reverse-search)``:')
h.press('^C')
h.await_text('cancelled')
def test_search_reverse_search_resizing():
with run() as h, and_exit(h):
h.press('^W')
h.press('^R')
with h.resize(width=24, height=24):
h.await_text('search(reverse-se…:')
h.press('^C')
def test_search_reverse_search_does_not_wrap_around(xdg_data_home):
xdg_data_home.join('babi/history/search').ensure().write(
'line_1\n'
'line_3\n',
)
with run() as h, and_exit(h):
h.press('^W')
h.press('^R')
# this should not wrap around
for i in range(6):
h.press('^R')
h.await_text('search(reverse-search)``: line_1')
h.press('^C')
def test_search_reverse_search_ctrl_r_on_failed_match(xdg_data_home):
xdg_data_home.join('babi/history/search').ensure().write(
'nomatch\n'
'line_1\n',
)
with run() as h, and_exit(h):
h.press('^W')
h.press('^R')
h.press('line')
h.await_text('search(reverse-search)`line`: line_1')
h.press('^R')
h.await_text('search(failed reverse-search)`line`: line_1')
h.press('^C')
def test_search_reverse_search_keeps_current_text_displayed():
with run() as h, and_exit(h):
h.press('^W')
h.press('ohai')
h.await_text('search: ohai')
h.press('^R')
h.await_text('search(reverse-search)``: ohai')
h.press('^C')
def test_scrolling_arrow_key_movement(ten_lines):
with run(str(ten_lines), height=10) as h, and_exit(h):
h.await_text('line_7')