about summary refs log tree commit diff
path: root/saneterm/terminal.py
blob: c3ae3cf06724d1d22f7bd83ee72730ce5ed584cd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import sys
import pty
import os
import codecs
import termios
import fcntl
import struct

from . import keys
from .history import History
from .termview import *

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 Pango

NAME = "saneterm"
TERM = "dumb"

class PtySource(GLib.Source):
    master = -1

    def __init__(self, cmd):
        GLib.Source.__init__(self)
        self.cmd = cmd
        self.tag = None

    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 termview/TextBuffer.
            os.system("stty -onlcr -echo")

            os.environ["TERM"] = TERM
            os.execvp(self.cmd[0], self.cmd)

        events = GLib.IOCondition.IN|GLib.IOCondition.HUP
        self.tag = self.add_unix_fd(self.master, events)

        return False, -1

    def check(self):
        return False

    def dispatch(self, callback, args):
        return callback(self, self.tag, self.master)

class Terminal(Gtk.Window):
    config = {
        'autoscroll': True,
        'wordwrap': True,
    }

    def __init__(self, cmd):
        Gtk.Window.__init__(self, title=NAME)
        self.set_name(NAME)

        self.hist = History()
        self.reset_history_index()

        self.pty = PtySource(cmd)
        self.pty.set_callback(self.handle_pty)
        self.pty.attach(None)

        self.termview = TermView()

        # Block-wise reading from the PTY requires an incremental decoder.
        self.decoder = codecs.getincrementaldecoder('UTF-8')()

        self.termview.connect("new-user-input", self.user_input)
        self.termview.connect("termios-ctrlkey", self.termios_ctrl)
        self.termview.connect("size-allocate", self.autoscroll)
        self.termview.connect("populate-popup", self.populate_popup)

        self.connect("configure-event", self.update_size)
        self.connect("destroy", self.destroy)

        bindings = keys.Bindings(self.termview)
        for key, idx in keys.CTRL.items():
            bindings.add_bind(key, "termios-ctrlkey", idx)

        self.scroll = Gtk.ScrolledWindow().new(None, None)
        self.scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS)

        self.scroll.add(self.termview)
        self.add(self.scroll)

        self.update_wrapmode()

        GObject.signal_new("history-entry", self.termview,
                GObject.SIGNAL_ACTION, GObject.TYPE_NONE,
                (GObject.TYPE_LONG,))
        self.termview.connect("history-entry", self.history)

    def destroy(self, widget):
        self.hist.close()

    def update_wrapmode(self):
        # XXX: Need to set hscroll mode explicitly and cannot rely on
        # AUTOMATIC, as hypenation may introduce a horizontal scrollbar
        # otherwise. With Gtk+4.0 we can disable hypenation explicitly.
        # See: https://gitlab.gnome.org/GNOME/gtk/-/issues/2384
        if self.config['wordwrap']:
            wmode = Gtk.WrapMode.WORD_CHAR
            hscroll = Gtk.PolicyType.NEVER
        else:
            wmode = Gtk.WrapMode.NONE
            hscroll = Gtk.PolicyType.AUTOMATIC

        self.termview.set_wrap_mode(wmode)

        _, vscroll = self.scroll.get_policy()
        self.scroll.set_policy(hscroll, vscroll)

    def update_size(self, widget, rect):
        # PTY must already be initialized
        if self.pty.master == -1:
            return

        # Widget width/height in pixels, is later converted
        # to rows/columns by dividing these values by the
        # font width/height as determined by the PangoLayout.
        width, height = widget.get_size()

        ctx = self.termview.get_pango_context()
        layout = Pango.Layout(ctx)
        layout.set_markup(" ") # assumes monospace
        fw, fh = layout.get_pixel_size()

        rows = int(height / fh)
        cols = int(width / fw)

        # TODO: use tcsetwinsize() instead of the ioctl.
        # See: https://github.com/python/cpython/pull/23686
        ws = struct.pack('HHHH', rows, cols, width, height) # struct winsize
        fcntl.ioctl(self.pty.master, termios.TIOCSWINSZ, ws)

    def handle_pty(self, source, tag, master):
        cond = source.query_unix_fd(tag)
        if cond & GLib.IOCondition.HUP:
            Gtk.main_quit()
            return GLib.SOURCE_REMOVE

        data = os.read(master, 4096)
        if not data:
            raise AssertionError("expected data but did not receive any")

        self.termview.insert_data(self.decoder.decode(data))
        return GLib.SOURCE_CONTINUE

    def reset_history_index(self):
        self.hist_index = -1

    def history(self, termview, idx):
        # Backup index and restore it if no entry with new index exists.
        backup_index = self.hist_index

        self.hist_index += idx
        entry = self.hist.get_entry(self.pty.master, self.hist_index)

        if entry is None:
            self.hist_index = backup_index
        else:
            self.termview.emit("kill-after-output")
            self.termview.emit("insert-at-cursor", entry)

    def autoscroll(self, widget, rect):
        if not self.config['autoscroll']:
            return

        # For some reason it is not possible to use .scroll_to_mark()
        # et cetera on the TextView contained in the ScrolledWindow.
        adj = self.scroll.get_vadjustment()
        adj.set_value(adj.get_upper() - adj.get_page_size())

    def populate_popup(self, textview, popup):
        def toggle_config(mitem, key):
            self.config[key] = not self.config[key]
            if key == 'wordwrap':
                self.update_wrapmode()

        popup.append(Gtk.SeparatorMenuItem())
        for key, enabled in self.config.items():
            mitem = Gtk.CheckMenuItem(key.capitalize())
            mitem.set_active(enabled)

            mitem.connect('toggled', toggle_config, key)
            popup.append(mitem)

        popup.show_all()

    def user_input(self, termview, line):
        self.hist.add_entry(self.pty.master, line)
        self.reset_history_index()

        os.write(self.pty.master, line.encode("UTF-8"))

    def termios_ctrl(self, termview, cidx):
        # termios ctrl keys are ignored if the cursor is not at the
        # buffer position where the next character would appear.
        if not termview.cursor_at_end():
            return
        elif cidx == termios.VEOF:
            termview.flush()

        # TODO: Employ some heuristic to cache tcgetattr result.
        cc = termios.tcgetattr(self.pty.master)[-1]
        os.write(self.pty.master, cc[cidx])

        # XXX: Clear line-based buffer here (i.e. update the
        # marks in TermView) in case the application doesn't
        # write anything to the PTY on receiving the CC.