diff --git a/babi/prompt.py b/babi/prompt.py index a9ede91..13a128f 100644 --- a/babi/prompt.py +++ b/babi/prompt.py @@ -2,6 +2,8 @@ import curses import enum +import glob +import os from typing import TYPE_CHECKING from babi.horizontal_scrolling import line_x @@ -14,12 +16,19 @@ class Prompt: - def __init__(self, screen: Screen, prompt: str, lst: list[str]) -> None: + def __init__( + self, + screen: Screen, + prompt: str, lst: list[str], + *, + file_glob: bool, + ) -> None: self._screen = screen self._prompt = prompt self._lst = lst self._y = len(lst) - 1 self._x = len(self._s) + self._enable_file_complete = file_glob @property def _s(self) -> str: @@ -95,6 +104,24 @@ def _delete(self) -> None: def _cut_to_end(self) -> None: self._s = self._s[:self._x] + def _tab(self) -> None: + if self._enable_file_complete: + self._complete_file() + + def _complete_file(self) -> None: + # only allow completion at the end of the prompt or before a separator + if self._x != len(self._s) and self._s[self._x] not in ('/', ' '): + return + partial = self._s[:self._x] + completions = glob.glob(f'{partial}*') + if not completions: + return + common = os.path.commonprefix(completions) + if not common or common == partial: + return + self._s = common + self._s[self._x:] # don't eat text behind cursor + self._x = len(common) + def _resize(self) -> None: self._screen.resize() @@ -165,6 +192,7 @@ def _submit(self) -> str: b'KEY_DC': _delete, b'^K': _cut_to_end, # misc + b'^I': _tab, b'KEY_RESIZE': _resize, b'^R': _reverse_search, b'^M': _submit, diff --git a/babi/screen.py b/babi/screen.py index e26c702..fba18ae 100644 --- a/babi/screen.py +++ b/babi/screen.py @@ -393,6 +393,7 @@ def prompt( self, prompt: str, *, + file_glob: bool, allow_empty: bool = False, history: str | None = None, default_prev: bool = False, @@ -407,7 +408,7 @@ def prompt( else: history_data = [default] - ret = Prompt(self, prompt, history_data).run() + ret = Prompt(self, prompt, history_data, file_glob=file_glob).run() if ret is not PromptResult.CANCELLED and history is not None: if ret: # only put non-empty things in history @@ -424,7 +425,7 @@ def prompt( return ret def go_to_line(self) -> None: - response = self.prompt('enter line number') + response = self.prompt('enter line number', file_glob=False) if response is not PromptResult.CANCELLED: try: lineno = int(response) @@ -455,7 +456,9 @@ def uncut(self) -> None: self.file.uncut(self.cut_buffer, self.layout.file) def _get_search_re(self, prompt: str) -> Pattern[str] | PromptResult: - response = self.prompt(prompt, history='search', default_prev=True) + response = self.prompt( + prompt, history='search', default_prev=True, file_glob=False, + ) if response is PromptResult.CANCELLED: return response try: @@ -494,7 +497,10 @@ def replace(self) -> None: search_response = self._get_search_re('search (to replace)') if search_response is not PromptResult.CANCELLED: response = self.prompt( - 'replace with', history='replace', allow_empty=True, + 'replace with', + history='replace', + allow_empty=True, + file_glob=False, ) if response is not PromptResult.CANCELLED: try: @@ -685,7 +691,7 @@ def _command_retheme(self, args: list[str]) -> None: } def command(self) -> EditResult | None: - response = self.prompt('', history='command') + response = self.prompt('', history='command', file_glob=False) if response is PromptResult.CANCELLED: return None @@ -720,7 +726,9 @@ def save(self) -> PromptResult | None: # TODO: strip trailing whitespace? # TODO: save atomically? if self.file.filename is None: - filename = self.prompt('enter filename') + filename = self.prompt( + 'enter filename', default=self.file.filename, file_glob=True, + ) if filename is PromptResult.CANCELLED: return PromptResult.CANCELLED else: @@ -762,7 +770,9 @@ def save(self) -> PromptResult | None: return None def save_filename(self) -> PromptResult | None: - response = self.prompt('enter filename', default=self.file.filename) + response = self.prompt( + 'enter filename', default=self.file.filename, file_glob=True, + ) if response is PromptResult.CANCELLED: return PromptResult.CANCELLED else: @@ -770,7 +780,9 @@ def save_filename(self) -> PromptResult | None: return self.save() def open_file(self) -> EditResult | None: - response = self.prompt('enter filename', history='open') + response = self.prompt( + 'enter filename', history='open', file_glob=True, + ) if response is not PromptResult.CANCELLED: opened = File( response, diff --git a/tests/features/open_test.py b/tests/features/open_test.py index 10e38b6..33207ec 100644 --- a/tests/features/open_test.py +++ b/tests/features/open_test.py @@ -35,6 +35,55 @@ def test_open(run, tmpdir): h.press('^X') h.await_text('hello world') + h.press('^X') + h.await_exit() + + +def test_file_glob(run, tmpdir): + base = 'globtest' + prefix = base + 'ffff.txt' + f = tmpdir.join(prefix + 'f') + f.write('hello world') + g = tmpdir.join(base + 'fggg') + g.write('goodbye world') + nonexistant = str(tmpdir.join('NONEXISTANT')) + + incomplete = f'{tmpdir.join(base)}fff' + + with run(str(g)) as h: + h.await_text('goodbye world') + + h.press('^P') + h.press(nonexistant) + h.press('Tab') + # no completion should be possible + h.await_text(f'«{nonexistant[-7:]}') + h.press('^C') + h.await_text('cancelled') + + h.press('^P') + h.press(incomplete) + h.await_text(incomplete[-7:]) + + # completion inside a word should be blocked + h.press('Left') + h.press('Tab') + h.await_text(incomplete[-7:]) + + # move to end of input again + h.press('Right') + # check successful completion + h.press('Tab') + h.await_text(str(f)[-7:]) + + # second tab press shouldn't change anything + h.press('Tab') + h.await_text(str(f)[-7:]) + + h.press('Enter') + h.await_text('[2/2]') + h.await_text('hello world') + h.press('^X') h.press('^X') h.await_exit() diff --git a/tests/features/search_test.py b/tests/features/search_test.py index a3c9e8e..80acea2 100644 --- a/tests/features/search_test.py +++ b/tests/features/search_test.py @@ -365,3 +365,17 @@ def test_search_history_extra_blank_lines(run, xdg_data_home): pass contents = xdg_data_home.join('babi/history/search').read() assert contents == 'hello\n' + + +def test_search_fileglob_disabled(run, tmpdir): + filename = 'aaaa.txt' + query = str(tmpdir.join(filename[0])) + f = tmpdir.join(filename) + f.write('') + with run() as h, and_exit(h): + h.press('^W') + h.press(query) + h.press('Tab') + h.await_text(query[-7:]) + h.await_text_missing(str(f)[-7:]) + h.press('^C')