about summary refs log tree commit diff
path: root/saneterm
diff options
context:
space:
mode:
authorSören Tempel <soeren+git@soeren-tempel.net>2021-05-16 02:03:15 +0200
committerSören Tempel <soeren+git@soeren-tempel.net>2021-05-16 02:03:15 +0200
commitf50cf65b74fdda473382af45df3343e7a5a83821 (patch)
tree9d5e0cb4d9a26c521d1b8ec0f4469cabef48eb41 /saneterm
parent64fd725512de68f166e0299ffb5e52aab9bebcf1 (diff)
Add support for setuptools
Diffstat (limited to 'saneterm')
-rw-r--r--saneterm/__init__.py0
-rw-r--r--saneterm/__main__.py11
-rw-r--r--saneterm/input.py27
-rw-r--r--saneterm/terminal.py144
4 files changed, 182 insertions, 0 deletions
diff --git a/saneterm/__init__.py b/saneterm/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/saneterm/__init__.py
diff --git a/saneterm/__main__.py b/saneterm/__main__.py
new file mode 100644
index 0000000..1cd1362
--- /dev/null
+++ b/saneterm/__main__.py
@@ -0,0 +1,11 @@
+import sys
+from terminal import *
+
+def main():
+    win = Terminal(["dash"])
+    win.connect("destroy", Gtk.main_quit)
+    win.show_all()
+    Gtk.main()
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/saneterm/input.py b/saneterm/input.py
new file mode 100644
index 0000000..fdebb7c
--- /dev/null
+++ b/saneterm/input.py
@@ -0,0 +1,27 @@
+import gi
+gi.require_version("Gtk", "3.0")
+
+from gi.repository import Gtk
+from gi.repository import GObject
+
+class KeyBindings():
+    stylesheet = b"""
+        @binding-set saneterm-key-bindings {
+            bind "<ctrl>u" { "kill-after-output" () };
+            bind "<ctrl>a" { "move-input-start" () };
+            bind "<ctrl>e" { "move-input-end" () };
+        }
+
+        * {
+             -gtk-key-bindings: saneterm-key-bindings;
+        }
+    """
+
+    def __init__(self):
+        self.provider = Gtk.CssProvider()
+        self.provider.load_from_data(self.stylesheet)
+
+    def apply(self, widget):
+        style_ctx = widget.get_style_context()
+        style_ctx.add_provider(self.provider,
+                Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
diff --git a/saneterm/terminal.py b/saneterm/terminal.py
new file mode 100644
index 0000000..9615bc5
--- /dev/null
+++ b/saneterm/terminal.py
@@ -0,0 +1,144 @@
+import sys
+import pty
+import os
+import codecs
+
+import input
+
+import gi
+gi.require_version("Gtk", "3.0")
+
+from gi.repository import Gtk
+from gi.repository import Gdk
+from gi.repository import GLib
+from gi.repository import GObject
+
+WIN_TITLE = "saneterm"
+TERM = "dumb"
+
+# XXX: Can also be looked up using unicodedata.lookup("DEL").
+DEL_CHAR = b'\x7f'
+
+class PtySource(GLib.Source):
+    master = -1
+
+    def __init__(self, cmd):
+        GLib.Source.__init__(self)
+        self.cmd = cmd
+
+    def prepare(self):
+        if self.master != -1:
+            return False, -1
+
+        pid, self.master = pty.fork()
+        if pid == pty.CHILD:
+            # Terminal options enforced by saneterm.
+            # Most importantly, local echo is disabled. Instead we show
+            # characters on input directly in the GTK TextView/TextBuffer.
+            os.system("stty -onlcr -echo")
+
+            os.execvpe(self.cmd[0], self.cmd, {"TERM": TERM})
+
+        self.add_unix_fd(self.master, GLib.IOCondition.IN)
+        return False, -1
+
+    def check(self):
+        return False
+
+    def dispatch(self, callback, args):
+        return callback(self.master)
+
+class Terminal(Gtk.Window):
+    def __init__(self, cmd):
+        Gtk.Window.__init__(self, title=WIN_TITLE)
+
+        self.pty = PtySource(cmd)
+        self.pty.set_callback(self.handle_pty)
+        self.pty.attach(None)
+
+        self.textview = Gtk.TextView()
+        self.textview.set_wrap_mode(Gtk.WrapMode(Gtk.WrapMode.WORD_CHAR))
+
+        self.textbuffer = self.textview.get_buffer()
+        self.textbuffer.connect("end-user-action", self.user_input)
+
+        end = self.textbuffer.get_end_iter()
+        self.last_mark = self.textbuffer.create_mark(None, end, True)
+        self.last_output_mark = None
+
+        # Block-wise reading from the PTY requires an incremental decoder.
+        self.decoder = codecs.getincrementaldecoder('UTF-8')()
+
+        bindings = input.KeyBindings()
+        bindings.apply(self.textview)
+
+        self.bind_custom_signals()
+        self.add(self.textview)
+
+    def bind_custom_signals(self):
+        signals = {
+            "kill-after-output": self.kill_after_output,
+            "move-input-start": self.move_input_start,
+            "move-input-end": self.move_input_end,
+        }
+
+        for signal in signals.items():
+            name, func = signal
+            GObject.signal_new(name, self.textview,
+                    GObject.SIGNAL_ACTION, GObject.TYPE_NONE,
+                    ())
+
+            self.textview.connect(name, func)
+
+    def handle_pty(self, master):
+        # XXX: Should be possible to read more than one byte here.
+        data = os.read(master, 1)
+        if not data:
+            raise AssertionError("expected data but did not receive any")
+
+        end = self.textbuffer.get_end_iter()
+        self.textbuffer.insert(end, self.decoder.decode(data))
+
+        end = self.textbuffer.get_end_iter()
+        self.last_mark = self.textbuffer.create_mark(None, end, True)
+        self.last_output_mark = self.last_mark
+
+        return GLib.SOURCE_CONTINUE
+
+    def user_input(self, buffer):
+        start = buffer.get_iter_at_mark(self.last_mark)
+        end = buffer.get_end_iter()
+
+        text = buffer.get_text(start, end, True)
+        if text == "\n":
+            start = buffer.get_iter_at_mark(self.last_output_mark)
+            line = buffer.get_text(start, end, True)
+
+            os.write(self.pty.master, line.encode("UTF-8"))
+            self.last_output_mark = buffer.create_mark(None, end, True)
+
+        self.last_mark = buffer.create_mark(None, end, True)
+
+    def kill_after_output(self, textview):
+        if self.last_output_mark is None:
+            return
+        buffer = textview.get_buffer()
+
+        start = buffer.get_iter_at_mark(self.last_output_mark)
+        end = buffer.get_end_iter()
+
+        buffer.delete(start, end)
+
+    def move_input_start(self, textview):
+        if self.last_output_mark is None:
+            return
+        buffer = textview.get_buffer()
+
+        start = buffer.get_iter_at_mark(self.last_output_mark)
+        buffer.place_cursor(start)
+
+    def move_input_end(self, textview):
+        buffer = textview.get_buffer()
+
+        end = buffer.get_iter_at_mark(self.last_mark)
+        buffer.place_cursor(end)