about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSören Tempel <soeren+git@soeren-tempel.net>2021-05-31 04:57:44 +0200
committerSören Tempel <soeren+git@soeren-tempel.net>2021-05-31 04:57:44 +0200
commit3ad5afdfb265c29ac32766ee2023a77c9a4ad978 (patch)
tree3e46057041d0f24826a904236a541abf405611cc
parent170d4f003f2b530a6e892b33caaf71baa258d754 (diff)
Preliminary tab completion support
-rw-r--r--saneterm/completion.py93
-rw-r--r--saneterm/keys.py2
-rw-r--r--saneterm/proc.py6
-rw-r--r--saneterm/terminal.py11
-rw-r--r--saneterm/termview.py27
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)