Source

silk / silk.py

#!/usr/bin/python
"""
silk - terminal emulator to control multiple commands at once

silk open multiple virtual terminals for each command
and send keyboard input for all/some/one terminal.
"""

import gobject
import os

from gi.repository import Gdk
from gi.repository import Gtk
from gi.repository import Vte


class Terminal(Vte.Terminal):
    term_name = 'xterm'
    _size = (80, 25)

    def __init__(self, command, size=None, *args, **kwargs):
        super(Terminal, self).__init__(*args, **kwargs)

        if size is not None:
            self._size = size

        self.resize_terminal()
        self.run_command(command)

        # TODO: right click context menu

    @property
    def width(self):
        # FIXME: how to get xpad?
        xpad = 5
        x, _ = self._size
        return self.get_char_width() * x + xpad

    @property
    def height(self):
        # FIXME: how to get ypad?
        ypad = 5
        _, y = self._size
        return self.get_char_height() * y + ypad

    def resize_terminal(self, size=None):
        if size is not None:
            self._size = size

        self.set_size_request(self.width, self.height)

    @property
    def child_exited(self):
        # if child exited, Terminal.get_pty_object() return None
        # XXX: realy?
        return self.get_pty_object() is None

    def run_command(self, command=None):
        if command is None:
            command = Vte.get_user_shell()

        # cwd
        working_directory = ''

        env = os.environ.copy()
        env['TERM'] = self.term_name
        envv = ['%s=%s' % kv for kv in env.iteritems()]

        if isinstance(command, (tuple, list)):
            argv = command
        else:
            argv = ['/bin/sh', '-c', command]

        self.fork_command_full(Vte.PtyFlags.DEFAULT,
                               working_directory,
                               argv, envv, 0,
                               None, None)


class TerminalFrame(Gtk.VBox):
    def __init__(self, command, terminal_size=None):
        super(TerminalFrame, self).__init__()
        self.set_border_width(1)

        self._command = command

        self._hbox = Gtk.HBox()
        self.pack_start(self._hbox, False, False, 0)

        self._sync_check = Gtk.CheckButton()
        self._sync_check.set_active(True)
        self._hbox.pack_start(self._sync_check, False, False, 0)

        self._eb = Gtk.EventBox()
        self._label = Gtk.Label(command)
        self._hbox.pack_start(self._eb, True, True, 0)
        self._eb.add(self._label)

        self.terminal = Terminal(command, terminal_size)
        self.pack_end(self.terminal, True, True, 0)

        self._eb.connect('button-release-event', self.do_eb_button_release)
        self._sync_check.connect('button-release-event',
                                 lambda *_: self.terminal.grab_focus())
        self._sync_check.connect('focus-in-event',
                                 lambda *_: self.terminal.grab_focus())

        self.terminal.connect('child-exited', self.do_terminal_child_exited)
        self.terminal.connect('key-press-event', self.do_terminal_key_press)

        # to make child_focus work as expected
        self.set_focus_chain([self.terminal])

    @property
    def width(self):
        xpad = 2 # border_width * 2
        return self.terminal.width + self.get_border_width() + xpad

    @property
    def height(self):
        # FIXME: how to get ypad?
        ypad = 19
        return self.terminal.height + self.get_border_width() + ypad

    @property
    def is_synced(self):
        return self._sync_check.get_active()

    def do_eb_button_release(self, *args):
        self._sync_check.set_active(not self._sync_check.get_active())
        self.terminal.grab_focus()

    def do_terminal_child_exited(self, *args):
        self._sync_check.set_active(False)
        msg = '\n[Press Enter to restart command]'
        self.terminal.feed(msg, len(msg))
        self.terminal.connect('commit', self.do_terminal_commit)

    def do_terminal_commit(self, term, text, length):
        if text in ('\n', '\r', '\r\n'):
            self.terminal.disconnect_by_func(self.do_terminal_commit)
            self.terminal.feed('\r\n', 2)

            # to avoid this input being feeded to this command,
            # call run_command after this callback
            gobject.timeout_add(0, self.terminal.run_command, self._command)

            return True

    def do_terminal_key_press(self, term, event):
        keyname = Gdk.keyval_name(event.keyval)

        if event.state == Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.MOD1_MASK:
            # Ctrl + Alt + ...
            if keyname == 's':
                self._sync_check.set_active(not self._sync_check.get_active())
                return True

    def set_sync(self, is_synced):
        self._sync_check.set_active(is_synced)


class Config(object):
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)

    def update(self, *args, **kwargs):
        return self.__dict__.update(*args, **kwargs)

    def get(self, *args, **kwargs):
        return self.__dict__.get(*args, **kwargs)


class MainWindow(Gtk.Window):
    config = Config(
        columns = 2,
        terminal_size = (80, 25),
        window_size = (800, 640),
    )

    def __init__(self, config=None, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)

        if config is not None:
            self.config.update(config)

        self.set_default_size(*self.config.window_size)

        self._scrolled = Gtk.ScrolledWindow()
        hadj = self._scrolled.get_hadjustment()
        vadj = self._scrolled.get_vadjustment()
        self._layout = Gtk.Layout.new(hadj, vadj)
        self._grid = Gtk.Grid()

        self.add(self._scrolled)
        self._scrolled.add(self._layout)
        self._layout.put(self._grid, 0, 0)

        self.connect('destroy', lambda *_: Gtk.main_quit())

        # TODO: highlights dffis of terminals
        # TODO: blur terminals that keyboard input will not send to
        # TODO: provide way to open new terminal

    def open(self, *targets):
        for t in targets:
            commands = self.config.get(t, t)
            if not isinstance(commands, (tuple, list)):
                commands = [commands]

            for c in commands:
                self.open_terminal(c)

    @property
    def terminals(self):
        return self._grid.get_children()

    def open_terminal(self, command=None):
        if command is None:
            command = Vte.get_user_shell()

        t = TerminalFrame(command, self.config.terminal_size)
        self.add_terminal(t)

    def add_terminal(self, term_frame):
        row, col = divmod(len(self.terminals), self.config.columns)
        self._grid.attach(term_frame, col, row, 1, 1)

        self.resize_layout()

        term_frame.terminal.connect_object('commit',
                                           self.do_term_commit, term_frame)
        term_frame.terminal.connect_object('key-press-event',
                                           self.do_term_key_press, term_frame)

        term_frame.show_all()

    def resize_layout(self):
        w = 0
        h = 0

        for row in (self.terminals[n:n+self.config.columns]
                    for n in xrange(0, len(self.terminals), self.config.columns)):
            w = max(w, sum(t.width for t in row))
            h += max(t.height for t in row)

        self._layout.set_size(w, h)
        self._grid.set_size_request(w, h)

    def do_term_commit(self, term_frame, text, length):
        if not term_frame.terminal.child_exited and \
           term_frame.terminal.is_focus() and term_frame.is_synced:
            for t in self.terminals:
                if t is not term_frame and \
                   not t.terminal.child_exited and t.is_synced:
                    t.terminal.feed_child(text, length)

    def do_term_key_press(self, term_frame, event):
        keyname = Gdk.keyval_name(event.keyval)

        if event.state == Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.MOD1_MASK:
            # Ctrl + Alt + ...
            # TODO: scroll self._scrolled to selected terminal
            if keyname in ('h', 'Left'):
                self.child_focus(Gtk.DirectionType.LEFT)
                return True

            elif keyname in ('j', 'Down'):
                self.child_focus(Gtk.DirectionType.DOWN)
                return True

            elif keyname in ('k', 'Up'):
                self.child_focus(Gtk.DirectionType.UP)
                return True

            elif keyname in ('l', 'Right'):
                self.child_focus(Gtk.DirectionType.RIGHT)
                return True

            elif keyname == 'w':
                self.child_focus(Gtk.DirectionType.TAB_FORWARD)
                return True

        # FIXME: wrap 80 chars
        elif event.state == Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.MOD1_MASK|Gdk.ModifierType.SHIFT_MASK:
            # Ctrl + Alt + Shift + ...
            if keyname == 'S':
                self.sync_all(not term_frame.is_synced)
                return True

            elif keyname == 'W':
                self.child_focus(Gtk.DirectionType.TAB_BACKWARD)
                return True

    def sync_all(self, is_synced):
        for t in self.terminals:
            t.set_sync(is_synced)


# for develop/debug
def dump_props(widgets):
    print '------------------'
    print widgets
    for p in dir(widgets.props):
        if p.startswith('_'):
            continue
        try:
            print p, getattr(widgets.props, p)
        except:
            pass
    print '------------------'


if __name__ == '__main__':
    from optparse import OptionParser
    op = OptionParser(usage='USAGE: %prog [options] [command, command, ...]')

    op.add_option('-c', '--config', dest='config_file',
                  default=os.path.expanduser('~/.silkrc'),
                  help='(default: ~/.silkrc)')

    opt, args = op.parse_args()

    user_config = {}
    if os.path.exists(opt.config_file):
        execfile(opt.config_file, user_config)

    w = MainWindow(config=user_config)
    w.show_all()
    w.open(*args)

    Gtk.main()