diff --git a/.gitignore b/.gitignore index d7a0e2637..a91f7d1f1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ terminatorlib/meliae .intltool* data/terminator.appdata.xml data/terminator.desktop + +venv/ diff --git a/INSTALL.md b/INSTALL.md index b9ee5451e..45acaa712 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,7 +8,7 @@ Packages are known to be available under the name "terminator" under a lot of distributions, see below for a list. I also maintain a PPA for Ubuntu 20.04 and up that has the latest release -If you're running ubuntu 20.04 or later, you can run +If you're running ubuntu 20.04 or later, you can run ``` sudo add-apt-repository ppa:mattrose/terminator @@ -24,14 +24,20 @@ dependencies yourself: **Python 3.5+ recommended:** `python3` or `python37` (in FreeBSD) **Python GTK and VTE bindings:** - - Fedora/CentOS: python3-gobject python3-configobj python3-psutil vte291 + + Fedora/CentOS: python3-gobject python3-configobj python3-psutil vte291 keybinder3 intltool gettext - Debian/Ubuntu: python3-gi python3-gi-cairo python3-psutil python3-configobj - gir1.2-keybinder-3.0 gir1.2-vte-2.91 gettext intltool dbus-x11 - FreeBSD: py37-psutil py37-configobj keybinder-gtk3 py37-gobject3 gettext + Debian/Ubuntu: python3-gi python3-gi-cairo python3-psutil python3-configobj + gir1.2-keybinder-3.0 gir1.2-vte-2.91 gettext intltool dbus-x11 + FreeBSD: py37-psutil py37-configobj keybinder-gtk3 py37-gobject3 gettext intltool libnotify vte3 +**Python PyParsing library (only required for Tmux mode):** + + Debian/Ubuntu: python-pyparsing + + FreeBSD: devel/py-pyparsing + If you don't care about native language support or icons, Terminator should run just fine directly from this directory, just: @@ -43,7 +49,7 @@ And go from there. Manpages are available in the 'doc' directory. > make sure to update either the shebangs, call the scripts with `python3` or > use a wrapper script. > -> Setuptools install will update the scripts with the correct shebang. +> Setuptools install will update the scripts with the correct shebang. To install properly, run: @@ -74,7 +80,7 @@ Where ${PREFIX} is the base install directory; e.g. /usr/local. If you maintain terminator for an OS other than these, please get in touch or issue a PR to this file. -Distribution | Contact | Package Info | Source Code | Bug Tracker | +Distribution | Contact | Package Info | Source Code | Bug Tracker | -------------|---------|-----|-------------|-------------| ArchLinux | [@grazzolini] | [archlinux.org] | [git.archlinux.org] | [bugs.archlinux.org] CentOS EPEL | [@mattrose], [@dmaphy] | | [src.fedoraproject.org/branches] diff --git a/TMUX.md b/TMUX.md new file mode 100644 index 000000000..955e5423d --- /dev/null +++ b/TMUX.md @@ -0,0 +1,13 @@ +# Tmux control mode + +Terminator support the [tmux control mode](http://man7.org/linux/man-pages/man1/tmux.1.html#CONTROL_MODE). + +Remote SSH example, starts tmux on remote host and displays tabs and splits in terminator: +``` +terminator -t --remote example.org +``` + +Local session: +``` +terminator -t +``` diff --git a/setup.py b/setup.py index c5347f463..463fa6007 100755 --- a/setup.py +++ b/setup.py @@ -230,6 +230,7 @@ def _find_css_files (self): packages=[ 'terminatorlib', 'terminatorlib.plugins', + 'terminatorlib.tmux', ], setup_requires=[ 'pytest-runner', diff --git a/terminator b/terminator index fe38748d8..1d360ebf7 100755 --- a/terminator +++ b/terminator @@ -23,6 +23,7 @@ import sys import os import psutil import pwd +import time try: ORIGCWD = os.getcwd() except OSError: @@ -88,8 +89,10 @@ if __name__ == '__main__': # continue. Failure to import dbus, or the global config option "dbus" # being False will cause us to continue without the dbus server and open a # window. + # Disable DBUS if using tmux, so we can have multiple sessions (e.g. local + # and remote, multiple remotes, etc.) try: - if OPTIONS.nodbus: + if OPTIONS.nodbus or OPTIONS.tmux: dbg('dbus disabled by command line') raise ImportError from terminatorlib import ipc @@ -125,12 +128,17 @@ if __name__ == '__main__': TERMINATOR.ibus_running = ibus_running try: + if OPTIONS.tmux: + TERMINATOR.start_tmux(remote=OPTIONS.remote) + while TERMINATOR.initial_layout is None: + time.sleep(0.1) dbg('Creating a terminal with layout: %s' % OPTIONS.layout) TERMINATOR.create_layout(OPTIONS.layout) except (KeyError,ValueError) as ex: err('layout creation failed, creating a window ("%s")' % ex) TERMINATOR.new_window() TERMINATOR.layout_done() + TERMINATOR.initial_layout = None if OPTIONS.debug and OPTIONS.debug >= 2: import terminatorlib.debugserver as debugserver diff --git a/terminatorlib/cwd.py b/terminatorlib/cwd.py index fed977474..ba0cc6310 100644 --- a/terminatorlib/cwd.py +++ b/terminatorlib/cwd.py @@ -14,6 +14,8 @@ def get_pid_cwd(pid = None): """Determine the cwd of the current process""" + if pid is not None: + pid = int(pid) psinfo = psutil.Process(pid).as_dict() dbg('psinfo: %s %s' % (psinfo['cwd'],psinfo['pid'])) # return func diff --git a/terminatorlib/notebook.py b/terminatorlib/notebook.py index 52e70b9e9..5438c6353 100644 --- a/terminatorlib/notebook.py +++ b/terminatorlib/notebook.py @@ -164,7 +164,9 @@ def split_axis(self, widget, vertical=True, cwd=None, sibling=None, widgetfirst= sibling.set_cwd(cwd) if self.config['always_split_with_profile']: sibling.force_set_profile(None, widget.get_profile()) - sibling.spawn_child() + sibling.spawn_child( + orientation='vertical' if vertical else 'horizontal', + active_pane_id=getattr(widget, 'pane_id', None)) if widget.group and self.config['split_to_group']: sibling.set_group(None, widget.group) elif self.config['always_split_with_profile']: diff --git a/terminatorlib/optionparse.py b/terminatorlib/optionparse.py index 822f718ac..f745b3cc6 100644 --- a/terminatorlib/optionparse.py +++ b/terminatorlib/optionparse.py @@ -57,6 +57,8 @@ def parse_options(): dest='borderless', help=_('Disable window borders')) parser.add_option('-H', '--hidden', action='store_true', dest='hidden', help=_('Hide the window at startup')) + parser.add_option('-t', '--tmux', action='store_true', + dest='tmux', help=_('Enable tmux integration')) parser.add_option('-T', '--title', dest='forcedtitle', help=_('Specify a title for the window')) parser.add_option('--geometry', dest='geometry', type='string', @@ -72,6 +74,8 @@ def parse_options(): callback=execute_cb, help=_('Use the rest of the command line as a command to ' 'execute inside the terminal, and its arguments')) + parser.add_option('--remote', dest='remote', + help=_('Specify a remote server for tmux to connect to')) parser.add_option('-g', '--config', dest='config', help=_('Specify a config file')) parser.add_option('-j', '--config-json', dest='configjson', diff --git a/terminatorlib/paned.py b/terminatorlib/paned.py index f13b2cbd1..1850d4c2b 100644 --- a/terminatorlib/paned.py +++ b/terminatorlib/paned.py @@ -52,7 +52,9 @@ def split_axis(self, widget, vertical=True, cwd=None, sibling=None, sibling.set_cwd(cwd) if self.config['always_split_with_profile']: sibling.force_set_profile(None, widget.get_profile()) - sibling.spawn_child() + sibling.spawn_child( + orientation='vertical' if vertical else 'horizontal', + active_pane_id=getattr(widget, 'pane_id', None)) if widget.group and self.config['split_to_group']: sibling.set_group(None, widget.group) elif self.config['always_split_with_profile']: diff --git a/terminatorlib/terminal.py b/terminatorlib/terminal.py index c76cd9a86..558a12b89 100644 --- a/terminatorlib/terminal.py +++ b/terminatorlib/terminal.py @@ -16,11 +16,13 @@ except ImportError: from urllib import unquote as urlunquote -from .util import dbg, err, spawn_new_terminator, make_uuid, manual_lookup, display_manager +from .util import dbg, err, spawn_new_terminator, make_uuid, manual_lookup, display_manager, get_column_row_count, \ + get_amount_of_terminals_in_each_direction from . import util from .config import Config from .cwd import get_pid_cwd from .factory import Factory +from pipes import quote from .terminator import Terminator from .titlebar import Titlebar from .terminal_popup_menu import TerminalPopupMenu @@ -140,6 +142,9 @@ class Terminal(Gtk.VBox): cnxids = None targets_for_new_group = None + control = None + pane_id = None + def __init__(self): """Class initialiser""" GObject.GObject.__init__(self) @@ -226,6 +231,8 @@ def __init__(self): self.reconfigure() self.vte.set_size(80, 24) + self.control = self.terminator.tmux_control + def get_vte(self): """This simply returns the vte widget we are using""" return(self.vte) @@ -267,6 +274,9 @@ def switch_to_previous_profile(self): def get_cwd(self): """Return our cwd""" vte_cwd = self.vte.get_current_directory_uri() + if self.terminator.tmux_control and self.terminator.tmux_control.remote is not None: + return None + if vte_cwd: # OSC7 pwd gives an answer return(GLib.filename_from_uri(vte_cwd)[0]) @@ -280,7 +290,7 @@ def close(self): dbg('close: called') self.cnxids.remove_widget(self.vte) self.emit('close-term') - if self.pid is not None: + if not self.terminator.tmux_control: try: dbg('close: killing %d' % self.pid) os.kill(self.pid, signal.SIGHUP) @@ -411,7 +421,6 @@ def maybe_copy_clipboard(self): def connect_signals(self): """Connect all the gtk signals and drag-n-drop mechanics""" - self.scrollbar.connect('button-press-event', self.on_buttonpress) self.cnxids.new(self.vte, 'key-press-event', self.on_keypress) @@ -921,6 +930,10 @@ def on_keypress(self, widget, event): if groupsend == groupsend_type['all']: self.terminator.all_emit(self, 'key-press-event', event) + if self.terminator.tmux_control: + self.control.send_keypress(event, pane_id=self.pane_id) + return True + return False def on_buttonpress(self, widget, event): @@ -1011,6 +1024,8 @@ def on_mousewheel(self, widget, event): elif event.direction == Gdk.ScrollDirection.DOWN or SMOOTH_SCROLL_DOWN: self.scroll_by_page(1) return True + if self.terminator.tmux_control: + return self.control.send_mousewheel(event, pane_id=self.pane_id) return False def popup_menu(self, widget, event=None): @@ -1063,6 +1078,10 @@ def on_drag_motion(self, widget, drag_context, x, y, _time, _data): # on self return + if self.terminator.tmux_control: + # Moving the terminals around is not supported in tmux mode + return + alloc = widget.get_allocation() if self.config['use_theme_colors']: @@ -1184,6 +1203,10 @@ def on_drag_data_received(self, widget, drag_context, x, y, selection_data, # The widget argument is actually a Vte.Terminal(). Turn that into a # terminatorlib Terminal() + if self.terminator.tmux_control: + # Moving the terminals around is not supported in tmux mode + return + maker = Factory() while True: widget = widget.get_parent() @@ -1315,8 +1338,24 @@ def do_deferred_on_vte_size_allocate(self, widget, allocation): self.on_vte_size_allocate(widget, allocation) def on_vte_size_allocate(self, widget, allocation): - self.titlebar.update_terminal_size(self.vte.get_column_count(), - self.vte.get_row_count()) + column_count = self.vte.get_column_count() + row_count = self.vte.get_row_count() + self.titlebar.update_terminal_size(column_count, row_count) + + if self.terminator.tmux_control and not self.terminator.doing_layout: + # self.terminator.tmux_control.resize_pane(self.pane_id, row_count, column_count) + # FIXME: probably not the best place for this, update tmux client size to match the window geometry + window = self.vte.get_toplevel() + column_count, row_count = map(int, get_column_row_count(window)) + horizontal_terminals, vertical_terminals = get_amount_of_terminals_in_each_direction(window) + if not (column_count == 0 and row_count == 0): + self.terminator.tmux_control.refresh_client(column_count+(horizontal_terminals - 1), row_count + (vertical_terminals - 1)) + self.terminator.tmux_control.resize_pane( + pane_id=self.pane_id, + cols=self.vte.get_column_count(), + rows=self.vte.get_row_count() + ) + if self.config['geometry_hinting']: window = self.get_toplevel() window.deferred_set_rough_geometry_hints() @@ -1410,7 +1449,8 @@ def held_open(self, widget=None, respawn=False, debugserver=False): self.is_held_open = True self.titlebar.update() - def spawn_child(self, widget=None, respawn=False, debugserver=False): + def spawn_child(self, widget=None, respawn=False, debugserver=False, + orientation=None, active_pane_id=None): args = [] shell = None command = None @@ -1485,16 +1525,29 @@ def spawn_child(self, widget=None, respawn=False, debugserver=False): if self.terminator.dbus_path: envv.append('TERMINATOR_DBUS_PATH=%s' % self.terminator.dbus_path) - dbg('Forking shell: "%s" with args: %s' % (shell, args)) - args.insert(0, shell) - result, self.pid = self.vte.spawn_sync(Vte.PtyFlags.DEFAULT, - self.cwd, - args, - envv, - GLib.SpawnFlags.FILE_AND_ARGV_ZERO, - None, - None, - None) + if self.terminator.tmux_control: + dbg('Spawning a new tmux terminal with args: %s' % args) + if self.terminator.initial_layout: + pass + else: + command = ' '.join(args) + self.pane_id = str(util.make_uuid()) + self.control.spawn_tmux_child(command=command, + cwd=self.cwd, + marker=self.pane_id, + orientation=orientation, + pane_id=active_pane_id) + else: + dbg('Forking shell: "%s" with args: %s' % (shell, args)) + args.insert(0, shell) + result, self.pid = self.vte.spawn_sync(Vte.PtyFlags.DEFAULT, + self.cwd, + args, + envv, + GLib.SpawnFlags.FILE_AND_ARGV_ZERO, + None, + None, + None) self.command = shell self.titlebar.update() @@ -1562,12 +1615,18 @@ def open_url(self, url, prepare=False): def paste_clipboard(self, primary=False): """Paste one of the two clipboards""" - for term in self.terminator.get_target_terms(self): - if primary: - term.vte.paste_primary() - else: - term.vte.paste_clipboard() - self.vte.grab_focus() + if self.terminator.tmux_control: + def callback(_, content): + content = quote(content.replace('\n', '\r')) + self.control.send_quoted_content(content, self.pane_id) + self.clipboard.request_text(callback) + else: + for term in self.terminator.get_target_terms(self): + if primary: + term.vte.paste_primary() + else: + term.vte.paste_clipboard() + self.vte.grab_focus() def feed(self, text): """Feed the supplied text to VTE""" @@ -1575,10 +1634,16 @@ def feed(self, text): def zoom_in(self): """Increase the font size""" + if self.terminator.tmux_control: + # Zooming causes all kinds of issues when in tmux mode, so we'll just disable it for now + return self.zoom_font(True) def zoom_out(self): """Decrease the font size""" + if self.terminator.tmux_control: + # Zooming causes all kinds of issues when in tmux mode, so we'll just disable it for now + return self.zoom_font(False) def zoom_font(self, zoom_in): @@ -1597,6 +1662,9 @@ def zoom_font(self, zoom_in): def zoom_orig(self): """Restore original font size""" + if self.terminator.tmux_control: + # Zooming causes all kinds of issues when in tmux mode, so we'll just disable it for now + return if self.config['use_system_font']: font = self.config.get_system_mono_font() else: @@ -1700,6 +1768,11 @@ def create_layout(self, layout): self.directory = layout['directory'] if 'uuid' in layout and layout['uuid'] != '': self.uuid = make_uuid(layout['uuid']) + if 'tmux' in layout and layout['tmux'] != '': + tmux = layout['tmux'] + self.pane_id = tmux['pane_id'] + self.terminator.pane_id_to_terminal[self.pane_id] = self + self.control.initial_output(self.pane_id) def scroll_by_page(self, pages): """Scroll up or down in pages""" @@ -1828,12 +1901,16 @@ def key_move_tab_left(self): self.emit('move-tab', 'left') def key_toggle_zoom(self): + if self.terminator.tmux_control: + self.control.toggle_zoom(self.pane_id) if self.is_zoomed(): self.unzoom() else: self.maximise() def key_scaled_zoom(self): + if self.terminator.tmux_control: + self.control.toggle_zoom(self.pane_id, zoom=True) if self.is_zoomed(): self.unzoom() else: diff --git a/terminatorlib/terminator.py b/terminatorlib/terminator.py index 6cbf4d68d..ba59096bd 100644 --- a/terminatorlib/terminator.py +++ b/terminatorlib/terminator.py @@ -16,13 +16,14 @@ from .util import dbg, err, enumerate_descendants from .factory import Factory from .version import APP_NAME, APP_VERSION +from .tmux import notifications +from .tmux import control try: from gi.repository import GdkX11 except ImportError: dbg("could not import X11 gir module") - def eventkey2gdkevent(eventkey): # FIXME FOR GTK3: is there a simpler way of casting from specific EventKey to generic (union) GdkEvent? gdkevent = Gdk.Event.new(eventkey.type) gdkevent.key.window = eventkey.window @@ -67,6 +68,10 @@ class Terminator(Borg): cur_gtk_theme_name = None gtk_settings = None + tmux_control = None + pane_id_to_terminal = None + initial_layout = None + def __init__(self): """Class initialiser""" @@ -95,6 +100,9 @@ def prepare_attributes(self): self.style_providers = [] if not self.doing_layout: self.doing_layout = False + if self.pane_id_to_terminal is None: + self.pane_id_to_terminal = {} + self.connect_signals() def connect_signals(self): @@ -110,6 +118,16 @@ def set_origcwd(self, cwd): os.chdir(cwd) self.origcwd = cwd + def start_tmux(self, remote=None): + """Store the command line argument intended for tmux and start the process""" + if self.tmux_control is None: + handler = notifications.NotificationsHandler(self) + self.tmux_control = control.TmuxControl( + session_name='terminator', + notifications_handler=handler) + self.tmux_control.remote = remote + self.tmux_control.attach_session() + def set_dbus_data(self, dbus_service): """Store the DBus bus details, if they are available""" if dbus_service: @@ -201,6 +219,15 @@ def find_window_by_uuid(self, uuid): return window return None + def find_terminal_by_pane_id(self, pane_id): + """Search our terminals for one matching the supplied pane_id""" + dbg('searching self.terminals for: %s' % pane_id) + for terminal in self.terminals: + dbg('checking: %s (%s)' % (terminal.pane_id, terminal)) + if terminal.pane_id == pane_id: + return terminal + return None + def new_window(self, cwd=None, profile=None): """Create a window with a Terminal in it""" maker = Factory() @@ -218,19 +245,20 @@ def new_window(self, cwd=None, profile=None): def create_layout(self, layoutname): """Create all the parts necessary to satisfy the specified layout""" - layout = None + layout = copy.deepcopy(self.initial_layout) objects = {} self.doing_layout = True self.last_active_window = None self.prelayout_windows = self.windows[:] - layout = copy.deepcopy(self.config.layout_get_config(layoutname)) if not layout: - # User specified a non-existent layout. default to one Terminal - err('layout %s not defined' % layout) - self.new_window() - return + layout = copy.deepcopy(self.config.layout_get_config(layoutname)) + if not layout: + # User specified a non-existent layout. default to one Terminal + err('layout %s not defined' % layout) + self.new_window() + return # Wind the flat objects into a hierarchy hierarchy = {} @@ -490,7 +518,7 @@ def reconfigure(self): css += """ .terminator-terminal-window separator { min-height: %spx; - min-width: %spx; + min-width: %spx; } """ % (self.config['handle_size'],self.config['handle_size']) style_provider = Gtk.CssProvider() @@ -636,11 +664,11 @@ def describe_layout(self): def zoom_in_all(self): for term in self.terminals: term.zoom_in() - + def zoom_out_all(self): for term in self.terminals: term.zoom_out() - + def zoom_orig_all(self): for term in self.terminals: term.zoom_orig() diff --git a/terminatorlib/tmux/__init__.py b/terminatorlib/tmux/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/terminatorlib/tmux/control.py b/terminatorlib/tmux/control.py new file mode 100644 index 000000000..3b40beaa1 --- /dev/null +++ b/terminatorlib/tmux/control.py @@ -0,0 +1,346 @@ +import threading +import subprocess + +from multiprocessing import Queue + +from pipes import quote +from gi.repository import Gtk, Gdk + +from terminatorlib.tmux import notifications +from terminatorlib.util import dbg + +ESCAPE_CODE = "\033" +TMUX_BINARY = "tmux" + + +def esc(seq): + return "{}{}".format(ESCAPE_CODE, seq) + + +KEY_MAPPINGS = { + Gdk.KEY_BackSpace: "\b", + Gdk.KEY_Tab: "\t", + Gdk.KEY_Insert: esc("[2~"), + Gdk.KEY_Delete: esc("[3~"), + Gdk.KEY_Page_Up: esc("[5~"), + Gdk.KEY_Page_Down: esc("[6~"), + Gdk.KEY_Home: esc("[1~"), + Gdk.KEY_End: esc("[4~"), + Gdk.KEY_Up: esc("[A"), + Gdk.KEY_Down: esc("[B"), + Gdk.KEY_Right: esc("[C"), + Gdk.KEY_Left: esc("[D"), +} +ARROW_KEYS = {Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right} +MOUSE_WHEEL = { + # TODO: make it configurable, e.g. like better-mouse-mode plugin + Gdk.ScrollDirection.UP: "C-y C-y C-y", + Gdk.ScrollDirection.DOWN: "C-e C-e C-e", +} + + +# TODO: implement ssh connection using paramiko +class TmuxControl(object): + def __init__(self, session_name, notifications_handler): + self.session_name = session_name + self.notifications_handler = notifications_handler + self.tmux = None + self.output = None + self.input = None + self.consumer = None + self.width = None + self.height = None + self.remote = None + self.alternate_on = False + self.is_zoomed = False + self.requests = Queue() + + def reset(self): + self.tmux = self.input = self.output = self.width = self.height = None + + def remote_connect(self, command): + if self.tmux: + dbg("Already connected.") + return + popen_command = "ssh " + self.remote + self.tmux = subprocess.Popen( + popen_command, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + shell=True, + bufsize=0, + ) + self.input = self.tmux.stdin + self.output = self.tmux.stdout + + self.run_remote_command(command) + + def run_remote_command(self, popen_command): + popen_command = map(quote, popen_command) + command = " ".join(popen_command) + if not self.input: + dbg("No tmux connection. [command={}]".format(command)) + else: + try: + self.input.write("exec {}\n".format(command).encode()) + except IOError: + dbg("Tmux server has gone away.") + return + + def spawn_tmux_child( + self, command, marker, cwd=None, orientation=None, pane_id=None + ): + if self.input: + if orientation: + self.split_window( + cwd=cwd, + orientation=orientation, + pane_id=pane_id, + command=command, + marker=marker, + ) + else: + self.new_window(cwd=cwd, command=command, marker=marker) + else: + self.new_session(cwd=cwd, command=command, marker=marker) + + def split_window(self, cwd, orientation, pane_id, command=None, marker=""): + orientation = "-h" if orientation == "horizontal" else "-v" + tmux_command = 'split-window {} -t {} -P -F "#D {}"'.format( + orientation, pane_id, marker + ) + if cwd: + tmux_command += ' -c "{}"'.format(cwd) + if command: + tmux_command += ' "{}"'.format(command) + + self._run_command(tmux_command, callback=("pane_id_result",)) + + def new_window(self, cwd=None, command=None, marker=""): + tmux_command = 'new-window -P -F "#D {}"'.format(marker) + if cwd: + tmux_command += ' -c "{}"'.format(cwd) + if command: + tmux_command += ' "{}"'.format(command) + + self._run_command(tmux_command, callback=("pane_id_result",)) + + def attach_session(self): + popen_command = [ + TMUX_BINARY, + "-2", + "-C", + "attach-session", + "-t", + self.session_name, + ] + if self.remote: + self.remote_connect(popen_command) + if not self.tmux: + self.tmux = subprocess.Popen( + popen_command, stdout=subprocess.PIPE, stdin=subprocess.PIPE, bufsize=0 + ) + self.input = self.tmux.stdin + self.output = self.tmux.stdout + self.requests.put(notifications.noop) + self.start_notifications_consumer() + self.initial_layout() + + def new_session(self, cwd=None, command=None, marker=""): + popen_command = [ + TMUX_BINARY, + "-2", + "-C", + "new-session", + "-s", + self.session_name, + "-P", + "-F", + "#D {}".format(marker), + ] + if cwd and not self.remote: + popen_command += ["-c", cwd] + if command: + popen_command.append(command) + if self.remote: + self.remote_connect(popen_command) + if not self.tmux: + self.tmux = subprocess.Popen( + popen_command, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ) + self.input = self.tmux.stdin + self.output = self.tmux.stdout + # starting a new session, delete any old requests we may have + # in the queue (e.g. those added while trying to attach to + # a nonexistant session) + while not self.requests.empty(): + self.requests.get(timeout=1) + + self.requests.put(("pane_id_result",)) + self.start_notifications_consumer() + + def refresh_client(self, width, height): + dbg("{}::{}: {}x{}".format("TmuxControl", "refresh_client", width, height)) + self.width = width + self.height = height + self._run_command("refresh-client -C {},{}".format(width, height)) + + def garbage_collect_panes(self): + self._run_command( + 'list-panes -s -t {} -F "#D {}"'.format(self.session_name, "#{pane_pid}"), + callback=("garbage_collect_panes_result",), + ) + + def initial_layout(self): + self._run_command( + 'list-windows -t {} -F "#{{window_layout}}"'.format(self.session_name), + callback=("initial_layout_result",), + ) + + def initial_output(self, pane_id): + self._run_command( + "capture-pane -J -p -t {} -eC -S - -E -".format(pane_id), + callback=("result_callback", pane_id), + ) + + def toggle_zoom(self, pane_id, zoom=False): + self.is_zoomed = not self.is_zoomed + if not zoom: + self._run_command( + "resize-pane -Z -x {} -y {} -t {}".format( + self.width, self.height, pane_id + ) + ) + + def send_keypress(self, event, pane_id): + keyval = event.keyval + state = event.state + + if keyval in KEY_MAPPINGS: + key = KEY_MAPPINGS[keyval] + if keyval in ARROW_KEYS and state & Gdk.ModifierType.CONTROL_MASK: + key = "{}1;5{}".format(key[:2], key[2:]) + else: + key = event.string + + if state & Gdk.ModifierType.MOD1_MASK: + # Hack to have CTRL+SHIFT+Alt PageUp/PageDown/Home/End + # work without these silly [... escaped characters + if state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK): + return + else: + key = esc(key) + + if key == ";": + key = "\\;" + + self.send_content(key, pane_id) + + # Handle mouse scrolling events if the alternate_screen is visible + # otherwise let Terminator handle all the mouse behavior + def send_mousewheel(self, event, pane_id): + SMOOTH_SCROLL_UP = ( + event.direction == Gdk.ScrollDirection.SMOOTH and event.delta_y <= 0.0 + ) + SMOOTH_SCROLL_DOWN = ( + event.direction == Gdk.ScrollDirection.SMOOTH and event.delta_y > 0.0 + ) + if SMOOTH_SCROLL_UP: + wheel = MOUSE_WHEEL[Gdk.ScrollDirection.UP] + elif SMOOTH_SCROLL_DOWN: + wheel = MOUSE_WHEEL[Gdk.ScrollDirection.DOWN] + else: + wheel = MOUSE_WHEEL[event.direction] + + if self.alternate_on: + self._run_command("send-keys -t {} {}".format(pane_id, wheel)) + return True + return False + + def send_content(self, content, pane_id): + key_name_lookup = "-l" if ESCAPE_CODE in content else "" + quote = "'" if "'" not in content else '"' + self._run_command( + "send-keys -t {} {} -- {}{}{}".format( + pane_id, key_name_lookup, quote, content, quote + ) + ) + + def send_quoted_content(self, content, pane_id): + key_name_lookup = "-l" if ESCAPE_CODE in content else "" + self._run_command( + "send-keys -t {} {} -- {}".format(pane_id, key_name_lookup, content) + ) + + def _run_command(self, command, callback=None): + dbg("running command {}".format(command)) # JACK_TEST + if not self.input: + dbg("No tmux connection. [command={}]".format(command)) + else: + try: + self.input.write("{}\n".format(command).encode()) + except IOError: + dbg("Tmux server has gone away.") + return + callback = callback or notifications.noop + dbg("adding callback: {}".format(callback)) # JACK_TEST + self.requests.put(callback) + + @staticmethod + def kill_server(): + command = [TMUX_BINARY, "kill-session", "-t", "terminator"] + subprocess.call(command) + + def start_notifications_consumer(self): + self.consumer = threading.Thread(target=self.consume_notifications) + self.consumer.daemon = True + self.consumer.start() + + def consume_notifications(self): + handler = self.notifications_handler + while True: + try: + if self.tmux.poll() is not None: + dbg("uhhh") # JACK_TEST + break + except AttributeError as e: + dbg("Tmux control instance was reset.") + return + line = self.output.readline()[:-1] + if not line: + continue + dbg("=>>>>> LINE RECEIVED: {}".format(line)) + line = line[1:].split(b" ", 1) + marker = line[0].decode() + try: + line = line[1] + except IndexError: + line = b"" + # skip MOTD, anything that isn't coming from tmux control mode + try: + notification = notifications.notifications_mappings[marker]() + except KeyError: + dbg("Unknown notification.") + continue + notification.consume(line, self.output) + dbg("consumed notification: {}".format(notification)) # JACK_TEST + try: + handler.handle(notification) + except Exception as e: + dbg("Error while handling notification: {}".format(e)) + dbg("handled notification") + handler.terminate() + + def display_pane_tty(self, pane_id): + tmux_command = 'display -pt "{}" "#D {}"'.format(pane_id, "#{pane_tty}") + + self._run_command(tmux_command, callback=("pane_tty_result",)) + + def resize_pane(self, pane_id, rows, cols): + if self.is_zoomed: + # if the pane is zoomed, there is no need for tmux to + # change the current layout + return + tmux_command = 'resize-pane -t "{}" -x {} -y {}'.format(pane_id, cols, rows) + + self._run_command(tmux_command) diff --git a/terminatorlib/tmux/layout.py b/terminatorlib/tmux/layout.py new file mode 100644 index 000000000..6dcd6c596 --- /dev/null +++ b/terminatorlib/tmux/layout.py @@ -0,0 +1,234 @@ +from pyparsing import * + + +class LayoutParser: + """BNF representation for a Tmux Layout + :: + ; + :: ( | ) ? ; + :: {4} ; + :: + ; + :: ; + :: ; + :: "x" ; + :: "{" | "[" ; + :: "}" | "]" ; + :: + ; + :: + ; + :: "0" | ... | "9" ; + :: | "a" | ... | "f" ; + :: "," ; + """ + + layout_parser = None + + def __init__(self): + decimal = Word(nums) + + comma = Suppress(Literal(",")) + start_token = Literal("{") | Literal("[") + end_token = Suppress(Literal("}") | Literal("]")) + + layout_name = Suppress(Word(hexnums, min=4, max=4)) + size = decimal("width") + Suppress(Literal("x")) + decimal("height") + + preamble = size + comma + decimal("x") + comma + decimal("y") + pane = Group(preamble + comma + decimal("pane_id")) + element = Forward() # will be defined later + container = Group(preamble + start_token + OneOrMore(element) + end_token) + + element << (container | pane) + Optional(comma) + + self.layout_parser = layout_name + comma + OneOrMore(element) + + def parse(self, layout): + parsed = self.layout_parser.parseString(layout) + return parsed.asList() + + +def parse_layout(layout): + """Apply our application logic to the parsed layout. + + Arguments: + layout -- Layout parsed by LayoutParser.parse(), + it is represented as a nested list; each nested + list has the following format: + [0] : width, + [1] : height, + [2] : position on x axis, + [3] : position on y axis, + [4] : '{' if the current element is a horizontal splits container, + '[' if the current element is a vertical splits container, + '%[0-9]+' if the current element is a pane + [5+] : if present, they are nested lists with the same structure + + """ + result = [] + + children = [] + for item in layout[5:]: + children.extend(parse_layout(item)) + + if layout[4] == "{": + result.append(Horizontal(layout[0], layout[1], layout[2], layout[3], children)) + + elif layout[4] == "[": + result.append(Vertical(layout[0], layout[1], layout[2], layout[3], children)) + else: + result.append( + Pane(layout[0], layout[1], layout[2], layout[3], "%{}".format(layout[4])) + ) + + return result + + +def convert_to_terminator_layout(window_layouts): + assert len(window_layouts) > 0 + result = {} + pane_index = 0 + window_name = "window0" + parent_name = window_name + result[window_name] = {"type": "Window", "parent": ""} + if len(window_layouts) > 1: + notebook_name = "notebook0" + result[notebook_name] = {"type": "Notebook", "parent": parent_name} + parent_name = notebook_name + order = 0 + for window_layout in window_layouts: + converter = _get_converter(window_layout) + pane_index, order = converter( + result, parent_name, window_layout, pane_index, order + ) + return result + + +class Container(object): + def __init__(self, width, height, x, y): + self.width = width + self.height = height + self.x = x + self.y = y + + def __str__(self): + return "{}[width={}, height={}, x={}, y={}, {}]".format( + self.__class__.__name__, + self.width, + self.height, + self.x, + self.y, + self._child_str(), + ) + + __repr__ = __str__ + + def _child_str(self): + raise NotImplementedError() + + +class Pane(Container): + def __init__(self, width, height, x, y, pane_id): + super(Pane, self).__init__(width, height, x, y) + self.pane_id = pane_id + + def _child_str(self): + return "pane_id={}".format(self.pane_id) + + +class Vertical(Container): + def __init__(self, width, height, x, y, children): + super(Vertical, self).__init__(width, height, x, y) + self.children = children + + def _child_str(self): + return "children={}".format(self.children) + + +class Horizontal(Container): + def __init__(self, width, height, x, y, children): + super(Horizontal, self).__init__(width, height, x, y) + self.children = children + + def _child_str(self): + return "children={}".format(self.children) + + +def _covert_pane_to_terminal(result, parent_name, pane, pane_index, order): + assert isinstance(pane, Pane) + terminal = _convert(parent_name, "Terminal", pane, order) + order += 1 + terminal["tmux"]["pane_id"] = pane.pane_id + result["terminal{}".format(pane.pane_id[1:])] = terminal + return pane_index, order + + +def _convert_vertical_to_vpane( + result, parent_name, vertical_or_children, pane_index, order +): + return _convert_container_to_terminator_pane( + result, parent_name, vertical_or_children, pane_index, Vertical, order + ) + + +def _convert_horizontal_to_hpane( + result, parent_name, horizontal_or_children, pane_index, order +): + return _convert_container_to_terminator_pane( + result, parent_name, horizontal_or_children, pane_index, Horizontal, order + ) + + +def _convert_container_to_terminator_pane( + result, parent_name, container_or_children, pane_index, pane_type, order +): + terminator_type = "VPaned" if issubclass(pane_type, Vertical) else "HPaned" + if isinstance(container_or_children, pane_type): + container = container_or_children + pane = _convert(parent_name, terminator_type, container_or_children, order) + order += 1 + children = container.children + else: + children = container_or_children + if len(children) == 1: + child = children[0] + child_converter = _get_converter(child) + return child_converter(result, parent_name, child, pane_index, order) + pane = {"type": terminator_type, "parent": parent_name} + pane_name = "pane{}".format(pane_index) + result[pane_name] = pane + parent_name = pane_name + pane_index += 1 + child1 = children[0] + child1_converter = _get_converter(child1) + pane_index, order = child1_converter(result, parent_name, child1, pane_index, order) + pane_index, order = _convert_container_to_terminator_pane( + result, parent_name, children[1:], pane_index, pane_type, order + ) + return pane_index, order + + +converters = { + Pane: _covert_pane_to_terminal, + Vertical: _convert_vertical_to_vpane, + Horizontal: _convert_horizontal_to_hpane, +} + + +def _get_converter(container): + try: + return converters[type(container)] + except KeyError: + raise ValueError("Illegal window layout: {}".format(container)) + + +def _convert(parent_name, type_name, container, order): + assert isinstance(container, Container) + return { + "type": type_name, + "parent": parent_name, + "order": order, + "tmux": { + "width": container.width, + "height": container.height, + "x": container.x, + "y": container.y, + }, + } diff --git a/terminatorlib/tmux/notifications.py b/terminatorlib/tmux/notifications.py new file mode 100644 index 000000000..9c7838c38 --- /dev/null +++ b/terminatorlib/tmux/notifications.py @@ -0,0 +1,349 @@ +from gi.repository import GObject + +from terminatorlib.util import dbg +from terminatorlib.tmux import layout + +import string + +ATTACH_ERROR_STRINGS = [ + b"can't find session terminator", + b"no current session", + b"no sessions", +] +ALTERNATE_SCREEN_ENTER_CODES = [b"\\033[?1049h"] +ALTERNATE_SCREEN_EXIT_CODES = [b"\\033[?1049l"] + +notifications_mappings = {} + + +def notification(cls): + notifications_mappings[cls.marker] = cls + return cls + + +class Notification(object): + marker = "undefined" + attributes = [] + + def consume(self, line, out): + pass + + def __str__(self): + attributes = [ + '{}="{}"'.format(attribute, getattr(self, attribute, "")) + for attribute in self.attributes + ] + return "{}[{}]".format(self.marker, ", ".join(attributes)) + + +@notification +class Result(Notification): + + marker = "begin" + attributes = ["begin_timestamp", "code", "result", "end_timestamp", "error"] + + def consume(self, line, out): + timestamp, code, _ = line.split(b" ") + self.begin_timestamp = timestamp + self.code = code + result = [] + line = out.readline()[:-1] + while not (line.startswith(b"%end") or line.startswith(b"%error")): + result.append(line) + line = out.readline()[:-1] + self.result = result + end, timestamp, code, _ = line.split(b" ") + self.end_timestamp = timestamp + self.error = end == b"%error" + + +@notification +class Exit(Notification): + + marker = "exit" + attributes = ["reason"] + + def consume(self, line, *args): + self.reason = line[0] if line else None + + +@notification +class LayoutChange(Notification): + + marker = "layout-change" + attributes = ["window_id", "window_layout", "window_visible_layout", "window_flags"] + + def consume(self, line, *args): + # attributes not present default to None + line_items = line.decode("unicode-escape").split(" ") + window_id, window_layout, window_visible_layout, window_flags = line_items + [ + None + ] * (len(self.attributes) - len(line_items)) + self.window_id = window_id + self.window_layout = window_layout + self.window_visible_layout = window_visible_layout + self.window_flags = window_flags + + +@notification +class Output(Notification): + + marker = "output" + attributes = ["pane_id", "output"] + + def consume(self, line, *args): + # pane_id = line[0] + # output = ' '.join(line[1:]) + pane_id, output = line.split(b" ", 1) + self.pane_id = pane_id.decode("latin-1") + self.output = output + + +@notification +class SessionChanged(Notification): + + marker = "session-changed" + attributes = ["session_id", "session_name"] + + def consume(self, line, *args): + session_id, session_name = line.decode("unicode-escape").split(" ") + self.session_id = session_id + self.session_name = session_name + + +@notification +class SessionRenamed(Notification): + + marker = "session-renamed" + attributes = ["session_id", "session_name"] + + def consume(self, line, *args): + session_id, session_name = line.decode("unicode-escape").split(" ") + self.session_id = session_id + self.session_name = session_name + + +@notification +class SessionsChanged(Notification): + + marker = "sessions-changed" + attributes = [] + + +@notification +class UnlinkedWindowAdd(Notification): + + marker = "unlinked-window-add" + attributes = ["window_id"] + + def consume(self, line, *args): + (window_id,) = line.decode("unicode-escape").split(" ") + self.window_id = window_id + + +@notification +class WindowAdd(Notification): + + marker = "window-add" + attributes = ["window_id"] + + def consume(self, line, *args): + (window_id,) = line.decode("unicode-escape").split(" ") + self.window_id = window_id + + +@notification +class UnlinkedWindowClose(Notification): + + marker = "unlinked-window-close" + attributes = ["window_id"] + + def consume(self, line, *args): + (window_id,) = line.decode("unicode-escape").split(" ") + self.window_id = window_id + + +@notification +class WindowClose(Notification): + + marker = "window-close" + attributes = ["window_id"] + + def consume(self, line, *args): + (window_id,) = line.decode("unicode-escape").split(" ") + self.window_id = window_id + + +@notification +class UnlinkedWindowRenamed(Notification): + + marker = "unlinked-window-renamed" + attributes = ["window_id", "window_name"] + + def consume(self, line, *args): + window_id, window_name = line.decode("unicode-escape").split(" ") + self.window_id = window_id + self.window_name = window_name + + +@notification +class WindowRenamed(Notification): + + marker = "window-renamed" + attributes = ["window_id", "window_name"] + + def consume(self, line, *args): + window_id, window_name = line.decode("unicode-escape").split(" ") + self.window_id = window_id + self.window_name = window_name + + +class NotificationsHandler(object): + def __init__(self, terminator): + self.terminator = terminator + self.layout_parser = layout.LayoutParser() + + def handle(self, notification): + try: + handler_method = getattr( + self, "handle_{}".format(notification.marker.replace("-", "_")) + ) + except AttributeError as e: + dbg("Handler for notification {} not found".format(notification.marker)) + else: + try: + handler_method(notification) + except Exception as e: + dbg( + "something went wrong while handling {}: {}".format( + notification.marker, e + ) + ) + + def handle_begin(self, notification): + dbg("### {}".format(notification)) + assert isinstance(notification, Result) + dbg("######## getting callback") # JACK_TEST + callback = self.terminator.tmux_control.requests.get() + dbg(callback) # JACK_TEST + if notification.error: + dbg("Request error: {}".format(notification)) + if notification.result[0] in ATTACH_ERROR_STRINGS: + # if we got here it means that attaching to an existing session + # failed, invalidate the layout so the Terminator initialization + # can pick up from where we left off + self.terminator.initial_layout = {} + self.terminator.tmux_control.reset() + return + if isinstance(callback, tuple): + if len(callback) > 1: + self.__getattribute__(callback[0])(notification.result, *callback[1:]) + else: + self.__getattribute__(callback[0])(notification.result) + elif callable(callback): + callback(notification.result) + + def handle_output(self, notification): + assert isinstance(notification, Output) + pane_id = notification.pane_id + output = notification.output + dbg(pane_id) + dbg(self.terminator.pane_id_to_terminal) + terminal = self.terminator.pane_id_to_terminal.get(pane_id) + if not terminal: + return + for code in ALTERNATE_SCREEN_ENTER_CODES: + if code in output: + self.terminator.tmux_control.alternate_on = True + for code in ALTERNATE_SCREEN_EXIT_CODES: + if code in output: + self.terminator.tmux_control.alternate_on = False + # NOTE: using neovim, enabling visual-bell and setting t_vb empty results in incorrect + # escape sequences (C-g) being printed in the neovim window; remove them until we can + # figure out the root cause + # terminal.vte.feed(output.replace("\033g", "").encode('utf-8')) + dbg(output) + terminal.vte.feed(output.decode("unicode-escape").encode("latin-1")) + + def handle_layout_change(self, notification): + assert isinstance(notification, LayoutChange) + GObject.idle_add(self.terminator.tmux_control.garbage_collect_panes) + + def handle_window_close(self, notification): + assert isinstance(notification, WindowClose) + GObject.idle_add(self.terminator.tmux_control.garbage_collect_panes) + + def handle_window_add(self, notification): + assert isinstance(notification, WindowAdd) + GObject.idle_add(self.terminator.tmux_control.garbage_collect_panes) + + def handle_unlinked_window_close(self, notification): + assert isinstance(notification, UnlinkedWindowClose) + GObject.idle_add(self.terminator.tmux_control.garbage_collect_panes) + + def pane_id_result(self, result): + pane_id, marker = result[0].decode("unicode-escape").split(" ") + terminal = self.terminator.find_terminal_by_pane_id(marker) + terminal.pane_id = pane_id + self.terminator.pane_id_to_terminal[pane_id] = terminal + + # NOTE: UNUSED; if we ever end up needing this, create the tty property in + # the Terminal class first + def pane_tty_result(self, result): + dbg(result) + pane_id, pane_tty = result[0].split(" ") + # self.terminator.pane_id_to_terminal[pane_id].tty = pane_tty + + def garbage_collect_panes_result(self, result): + pane_id_to_terminal = self.terminator.pane_id_to_terminal + removed_pane_ids = list(pane_id_to_terminal.keys()) + + for line in result: + pane_id, pane_pid = line.decode("unicode-escape").split(" ") + try: + removed_pane_ids.remove(pane_id) + pane_id_to_terminal[pane_id].pid = pane_pid + except ValueError: + dbg("Pane already reaped, keep going.") + continue + + if removed_pane_ids: + + def callback(): + for pane_id in removed_pane_ids: + terminal = pane_id_to_terminal.pop(pane_id, None) + if terminal: + terminal.close() + return False + + GObject.idle_add(callback) + + def initial_layout_result(self, result): + window_layouts = [] + for line in result: + window_layout = line.strip() + parsed_layout = self.layout_parser.parse(window_layout.decode()) + window_layouts.append(layout.parse_layout(parsed_layout[0])) + terminator_layout = layout.convert_to_terminator_layout(window_layouts) + import pprint + + dbg(pprint.pformat(terminator_layout)) + self.terminator.initial_layout = terminator_layout + + def result_callback(self, result, pane_id): + terminal = self.terminator.pane_id_to_terminal.get(pane_id) + if not terminal: + return + output = b"\r\n".join(line for line in result if line) + terminal.vte.feed(output.decode("unicode-escape").encode("latin-1")) + + def terminate(self): + def callback(): + for window in self.terminator.windows: + window.emit("destroy") + + GObject.idle_add(callback) + + +def noop(*args): + dbg("passed on notification: {}".format(args)) diff --git a/terminatorlib/util.py b/terminatorlib/util.py index 769343a56..c91b46948 100644 --- a/terminatorlib/util.py +++ b/terminatorlib/util.py @@ -35,7 +35,7 @@ sys.exit(1) # set this to true to enable debugging output -DEBUG = False +DEBUG = True # set this to true to additionally list filenames in debugging DEBUGFILES = False # list of classes to show debugging for. empty list means show all classes @@ -320,6 +320,67 @@ def enumerate_descendants(parent): len(terminals), parent)) return(containers, terminals) +def get_column_row_count(window): + column_sum = 0 + row_sum = 0 + + base_x = base_y = None + + # NOTE: on Wayland, we cannot assume that the coordinate system + # for our application starts at 0x0, so we try to guess our + # current baseline at runtime + if display_manager() == 'WAYLAND' and not base_x: + base_x, base_y = get_wayland_baseline(window) + else: + base_x = base_y = 0 + + terminals = window.get_visible_terminals() + for terminal in terminals: + rect = terminal.get_allocation() + if rect.x <= base_x: + cols, rows = terminal.get_size() + row_sum = row_sum + int(rows) + if rect.y <= base_y: + cols, rows = terminal.get_size() + column_sum = column_sum + int(cols) + + return (column_sum, row_sum) + +def get_amount_of_terminals_in_each_direction(window): + base_x = base_y = None + + # NOTE: on Wayland, we cannot assume that the coordinate system + # for our application starts at 0x0, so we try to guess our + # current baseline at runtime + if display_manager() == 'WAYLAND' and not base_x: + base_x, base_y = get_wayland_baseline(window) + else: + base_x = base_y = 0 + + terminals = window.get_visible_terminals() + horizontal_terminals = 0 + vertical_terminals = 0 + for terminal in terminals: + rect = terminal.get_allocation() + + if rect.x <= base_x: + vertical_terminals += 1 + if rect.y <= base_y: + horizontal_terminals += 1 + + return horizontal_terminals, vertical_terminals + +def get_wayland_baseline(window): + terminals = window.get_visible_terminals() + + base_x = base_y = sys.maxint + for terminal in terminals: + rect = terminal.get_allocation() + base_x = min(base_x, rect.x) + base_y = min(base_y, rect.y) + + return (base_x, base_y) + def make_uuid(str_uuid=None): """Generate a UUID for an object""" if str_uuid: diff --git a/terminatorlib/window.py b/terminatorlib/window.py index fb01845aa..08beba15a 100644 --- a/terminatorlib/window.py +++ b/terminatorlib/window.py @@ -473,13 +473,15 @@ def split_axis(self, widget, vertical=True, cwd=None, sibling=None, widgetfirst= container = maker.make('HPaned') self.set_pos_by_ratio = True - if not sibling: sibling = maker.make('Terminal') sibling.set_cwd(cwd) + # TODO (dank): is widget ever not a Terminal? if self.config['always_split_with_profile']: sibling.force_set_profile(None, widget.get_profile()) - sibling.spawn_child() + sibling.spawn_child( + orientation='vertical' if vertical else 'horizontal', + active_pane_id=getattr(widget, 'pane_id', None)) if widget.group and self.config['split_to_group']: sibling.set_group(None, widget.group) elif self.config['always_split_with_profile']: @@ -504,6 +506,9 @@ def split_axis(self, widget, vertical=True, cwd=None, sibling=None, widgetfirst= def zoom(self, widget, font_scale=True): """Zoom a terminal widget""" + if self.terminator.tmux_control: + # Zooming causes all kinds of issues when in tmux mode, so we'll just disable it for now + return children = self.get_children() if widget in children: @@ -630,9 +635,9 @@ def set_rough_geometry_hints(self): return terminals = self.get_visible_terminals() + column_sum = 0 row_sum = 0 - for terminal in terminals: rect = terminal.get_allocation() if rect.x == 0: diff --git a/tests/test_tmux.py b/tests/test_tmux.py new file mode 100644 index 000000000..456e9139c --- /dev/null +++ b/tests/test_tmux.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python2 + +import os +import sys, os.path + +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))) + +import unittest +from terminatorlib.tmux import notifications + + +class NotificationsTests(unittest.TestCase): + def test_layout_changed_parsing(self): + layouts = [ + b"sum,80x24,0,0,0", + b"sum,80x24,0,0[80x12,0,0,0,80x5,0,13,1,80x2,0,19,2,80x2,0,22,3]", + b"sum,80x24,0,0{40x24,0,0,0,19x24,41,0,1,9x24,61,0,2,4x24,71,0,3,4x24,76,0,4}", + b"sum,80x24,0,0[80x12,0,0,0,80x5,0,13,1,80x5,0,19{40x5,0,19,2,19x5,41,19,3,9x5,61,19,4,4x5,71,19,5,4x5,76,19,6}]", + ] + for layout in layouts: + notification = notifications.LayoutChange() + notification.consume(b"fake " + layout) + print(notification.window_layout) + + +def main(): + unittest.main() + + +if __name__ == "__main__": + main()