diff options
author | Sören Tempel <soeren+git@soeren-tempel.net> | 2021-05-31 04:57:44 +0200 |
---|---|---|
committer | Sören Tempel <soeren+git@soeren-tempel.net> | 2021-05-31 04:57:44 +0200 |
commit | 3ad5afdfb265c29ac32766ee2023a77c9a4ad978 (patch) | |
tree | 3e46057041d0f24826a904236a541abf405611cc | |
parent | 170d4f003f2b530a6e892b33caaf71baa258d754 (diff) |
Preliminary tab completion support
-rw-r--r-- | saneterm/completion.py | 93 | ||||
-rw-r--r-- | saneterm/keys.py | 2 | ||||
-rw-r--r-- | saneterm/proc.py | 6 | ||||
-rw-r--r-- | saneterm/terminal.py | 11 | ||||
-rw-r--r-- | saneterm/termview.py | 27 |
5 files changed, 136 insertions, 3 deletions
diff --git a/saneterm/completion.py b/saneterm/completion.py new file mode 100644 index 0000000..888c567 --- /dev/null +++ b/saneterm/completion.py @@ -0,0 +1,93 @@ +import os + +class TabComp(): + "Implements a state machine for tab completions on a Gtk.TextBuffer" + + def __init__(self, buffer, compfn): + self.__buffer = buffer + self.__compfn = compfn + + self._tabcomp_mark = None + self._tabcomp_index = 0 + + def reset(self): + """Invalidates any cached completion results. Should be called + when the user presses any key other than the key configured + for tab completions.""" + + self._tabcomp_mark = None + + def next(self, start): + "Completes the word starting at the given start iterator." + + buffer = self.__buffer + + # Distinguish two cases: + # 1. This is the first time the user pressed tab. + # If so: Determine input between word start and end. + # 2. User is switching through generated matches. + # If so: Determine input between word start and _tabcomp_mark. + if self._tabcomp_mark is None: + # Reset has been called → invalidate completion cache + self._tabcomp_matches = None + self._tabcomp_index = 0 + + end = buffer.get_iter_at_offset(buffer.props.cursor_position) + self._tabcomp_mark = buffer.create_mark(None, end, True) + else: + end = buffer.get_iter_at_mark(self._tabcomp_mark) + + # Extract text, regenerate completion results for + # given text if cache has been invalidated above. + text = buffer.get_text(start, end, True) + if self._tabcomp_matches is None: + self._tabcomp_matches = self.__compfn(text) + self._tabcomp_matches.append("") # original text + c = self._tabcomp_matches[self._tabcomp_index] + + # Insert the matched completion text and delete + # text potentially remaining from older completion. + buffer.insert(end, c) + cursor = buffer.get_iter_at_offset(buffer.props.cursor_position) + buffer.delete(end, cursor) + + # Advance current index in matches and wrap-around. + self._tabcomp_index = (self._tabcomp_index + 1) % len(self._tabcomp_matches) + +class FileName(): + "Provides file name completions relative to a given directory." + + def __init__(self, cwd): + self.__cwd = cwd + + def get_matches(self, input): + if input.find("/") != -1: + base = os.path.dirname(input) + prefix = os.path.basename(input) + + if not os.path.abspath(input): + base = os.path.join(self.__cwd, base) + else: + base = self.__cwd + prefix = input + + return self.__get_matches(base, prefix) + + def __get_matches(self, base, prefix): + if not os.path.isdir(base): + return [] + + matches = [] + with os.scandir(base) as it: + for entry in it: + name = entry.name + if prefix != "" and not name.startswith(prefix): + continue + if entry.is_dir(): + name += "/" + + # Strip prefix from name + name = name[len(prefix):] + matches.append(name) + + return matches diff --git a/saneterm/keys.py b/saneterm/keys.py index ab471a2..4b27d67 100644 --- a/saneterm/keys.py +++ b/saneterm/keys.py @@ -27,6 +27,8 @@ class Bindings(): bind "Up" { "history-entry" (1) }; bind "Down" { "history-entry" (-1) }; + bind "Tab" { "tab-completion" () }; + /* Since <ctrl>c is used for VINTR, unbind <ctrl>v */ unbind "<ctrl>v"; } diff --git a/saneterm/proc.py b/saneterm/proc.py index 7fd5b72..c9d7554 100644 --- a/saneterm/proc.py +++ b/saneterm/proc.py @@ -6,6 +6,12 @@ import os def __proc_dir(pid): return os.path.join("/proc", str(pid)) +def cwd(pid): + cwd = os.path.join(__proc_dir(pid), "cwd") + dest = os.readlink(cwd) + + return os.path.abspath(dest) + def executable(pid): exec = os.path.join(__proc_dir(pid), "exe") dest = os.readlink(exec) diff --git a/saneterm/terminal.py b/saneterm/terminal.py index 96eb49f..d35ba24 100644 --- a/saneterm/terminal.py +++ b/saneterm/terminal.py @@ -7,6 +7,7 @@ import fcntl import struct from . import keys +from . import proc from .search import SearchBar from .history import History from .termview import * @@ -70,7 +71,7 @@ class Terminal(Gtk.Window): self.pty.set_callback(self.handle_pty) self.pty.attach(None) - self.termview = TermView(limit) + self.termview = TermView(self.complete, limit) # Block-wise reading from the PTY requires an incremental decoder. self.decoder = codecs.getincrementaldecoder('UTF-8')() @@ -110,6 +111,14 @@ class Terminal(Gtk.Window): (GObject.TYPE_LONG,)) self.termview.connect("history-entry", self.history) + def complete(self, input): + # XXX: This could be cached as the CWD shouldn't + # change unless input is send to the child process. + cwd = proc.cwd(os.tcgetpgrp(self.pty.master)) + + f = completion.FileName(cwd) + return f.get_matches(input) + def focus(self, window, widget): # If no widget is focused, focus the termview by default. # This occurs, for instance, after closing the SearchBar. diff --git a/saneterm/termview.py b/saneterm/termview.py index da56245..ef96258 100644 --- a/saneterm/termview.py +++ b/saneterm/termview.py @@ -1,6 +1,8 @@ from gi.repository import Gtk from gi.repository import GObject +from . import completion + class LimitTextBuffer(Gtk.TextBuffer): """ Buffer which stores a limit amount of lines. If the limit is -1 @@ -55,7 +57,7 @@ class TermView(Gtk.TextView): to the application via the termios-ctrlkey signal. """ - def __init__(self, limit=-1): + def __init__(self, compfunc, limit=-1): # TODO: set insert-hypens to false in GTK 4 # https://docs.gtk.org/gtk4/property.TextTag.insert-hyphens.html Gtk.TextView.__init__(self) @@ -64,6 +66,8 @@ class TermView(Gtk.TextView): self._textbuffer.connect("end-user-action", self.__end_user_action) self.set_buffer(self._textbuffer) + self._tabcomp = completion.TabComp(self._textbuffer, compfunc) + self.set_monospace(True) self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.EMOJI) @@ -76,6 +80,7 @@ class TermView(Gtk.TextView): "move-input-start": self.__move_input_start, "move-input-end": self.__move_input_end, "clear-view": self.__clear_view, + "tab-completion": self.__tabcomp, } for signal in signals.items(): @@ -138,11 +143,14 @@ class TermView(Gtk.TextView): end = self._textbuffer.get_end_iter() text = buffer.get_text(start, end, True) - if text == "\n": + if len(text) != 0 and text[-1] == "\n": self.flush() else: self._last_mark = buffer.create_mark(None, end, True) + # User entered new text → reset tab completion state machine + self._tabcomp.reset() + def do_delete_from_cursor(self, type, count): # If the type is GTK_DELETE_CHARS, GTK+ deletes the selection. if type == Gtk.DeleteType.CHARS: @@ -191,3 +199,18 @@ class TermView(Gtk.TextView): end.backward_chars(off) buffer.delete(buffer.get_start_iter(), end) + + def __tabcomp(self, textview): + buf = textview.get_buffer() + cur = buf.get_iter_at_offset(buf.props.cursor_position) + + # Gtk.TextCharPredicate to find start of word to be completed. + fn = lambda x, _: str.isspace(x) + + out = buf.get_iter_at_mark(self._last_output_mark) + if cur.backward_find_char(fn, None, out): + cur.forward_char() + else: + cur.assign(out) + + self._tabcomp.next(cur) |