diff --git a/doc/terminator.1 b/doc/terminator.1 index b237030a..5d419197 100644 --- a/doc/terminator.1 +++ b/doc/terminator.1 @@ -1,7 +1,7 @@ '\" t .\" Title: terminator .\" Author: [see the "AUTHOR(S)" section] -.\" Generator: Asciidoctor 2.0.18 +.\" Generator: Asciidoctor 2.0.23 .\" Date: 2023-04-01 .\" Manual: Manual for Terminator .\" Source: Terminator @@ -68,7 +68,7 @@ Start with a fullscreen window. .sp \fB\-b\fP, \fB\-\-borderless\fP .RS 4 -Instruct the window manager not to render borders/decorations on the +Instruct the window manager not to render borders/decoration on the Terminator window (this works well with \-\-maximise). .RE .sp @@ -166,6 +166,20 @@ addition to \-\-debug\-classes, only the intersection of the two lists will be displayed. .RE .sp +\fB\-\-tmux\-attach\fP[=\fISESSION\fP] +.RS 4 +Attach to an existing tmux session in control mode. If SESSION is not +specified, attaches to the most recently used session. Terminator will +mirror the tmux session layout, with splits displayed as panes and +tmux windows as tabs. +.RE +.sp +\fB\-\-tmux\-new\fP=\fISESSION\fP +.RS 4 +Create a new tmux session with the given name and attach to it in +control mode. +.RE +.sp \fB\-\-new\-tab\fP .RS 4 If this is specified and Terminator is already running, DBus will be @@ -178,6 +192,54 @@ If this is specified and Terminator is already running, DBus will be used to toggle Terminator window visibility. Usable under Wayland to replace hide keyboad shortcut. .RE +.SH "TMUX CONTROL MODE" +.sp +Terminator can act as a native GUI frontend for tmux sessions using +tmux\(cqs control mode (\fB\-C\fP flag). There are two ways to use this: +.SS "Command line attach" +.sp +Launch Terminator with \fB\-\-tmux\-attach\fP or \fB\-\-tmux\-new\fP to connect to a +tmux session. The tmux layout (panes, splits, tabs) is mirrored in the +Terminator window. Input, resize, and focus changes are synchronized +with tmux. +.SS "In\-terminal detection" +.sp +Type \fBtmux \-CC\fP or \fBtmux \-CC attach\fP inside any Terminator terminal. +Terminator detects the control mode output, takes over the PTY, and +opens a new window with the tmux layout. The original terminal shows a +control mode screen with a \fBCtrl+C to detach\fP prompt. +.SS "Detaching and exiting" +.sp +There are three ways to end a tmux control mode session: +.sp +\fBExit\fP +.RS 4 +When the last shell in the tmux session exits, the tmux +window closes and the original terminal is restored. +.RE +.sp +\fBClose window\fP +.RS 4 +Closing the tmux window (via the window manager or +\fBCtrl+Shift+Q\fP) sends a detach command to tmux, preserving the +session for later reattach. +.RE +.sp +\fBCtrl+C\fP +.RS 4 +From the control mode screen in the original terminal, +pressing \fBCtrl+C\fP detaches from tmux and closes the tmux window. +.RE +.SS "Features" +.sp +Pane splits, tabs, and resize are synchronized bidirectionally with +tmux. Terminal titles are updated from tmux pane information, and +applications that set custom titles (via OSC escape sequences) are +displayed. The window title shows the tmux user, host, and session +name. +.sp +When in tmux mode, overlay titlebars and overlay scrollbars are +automatically enabled regardless of profile settings. .SH "KEYBINDINGS" .sp The following default keybindings can be used to control Terminator. @@ -482,4 +544,4 @@ Terminator was written by Chris Jones and others. This manual page was written by Chris Jones and others. .SH "SEE ALSO" .sp -\fBterminator_config\fP(5) +\fBterminator_config\fP(5) \ No newline at end of file diff --git a/doc/terminator.adoc b/doc/terminator.adoc index 25ba6c1c..b8028bf7 100644 --- a/doc/terminator.adoc +++ b/doc/terminator.adoc @@ -104,6 +104,16 @@ only be printed from the specified functions. If this is specified in addition to --debug-classes, only the intersection of the two lists will be displayed. +**--tmux-attach**[=__SESSION__]:: +Attach to an existing tmux session in control mode. If SESSION is not +specified, attaches to the most recently used session. Terminator will +mirror the tmux session layout, with splits displayed as panes and +tmux windows as tabs. + +**--tmux-new**=__SESSION__:: +Create a new tmux session with the given name and attach to it in +control mode. + *--new-tab*:: If this is specified and Terminator is already running, DBus will be used to spawn a new tab in the first Terminator window. @@ -113,6 +123,46 @@ If this is specified and Terminator is already running, DBus will be used to toggle Terminator window visibility. Usable under Wayland to replace hide keyboad shortcut. +== TMUX CONTROL MODE + +Terminator can act as a native GUI frontend for tmux sessions using +tmux's control mode (*-C* flag). There are two ways to use this: + +=== Command line attach +Launch Terminator with *--tmux-attach* or *--tmux-new* to connect to a +tmux session. The tmux layout (panes, splits, tabs) is mirrored in the +Terminator window. Input, resize, and focus changes are synchronized +with tmux. + +=== In-terminal detection +Type *tmux -CC* or *tmux -CC attach* inside any Terminator terminal. +Terminator detects the control mode output, takes over the PTY, and +opens a new window with the tmux layout. The original terminal shows a +control mode screen with a *Ctrl+C to detach* prompt. + +=== Detaching and exiting +There are three ways to end a tmux control mode session: + +*Exit*:: When the last shell in the tmux session exits, the tmux +window closes and the original terminal is restored. + +*Close window*:: Closing the tmux window (via the window manager or +*Ctrl+Shift+Q*) sends a detach command to tmux, preserving the +session for later reattach. + +*Ctrl+C*:: From the control mode screen in the original terminal, +pressing *Ctrl+C* detaches from tmux and closes the tmux window. + +=== Features +Pane splits, tabs, and resize are synchronized bidirectionally with +tmux. Terminal titles are updated from tmux pane information, and +applications that set custom titles (via OSC escape sequences) are +displayed. The window title shows the tmux user, host, and session +name. + +When in tmux mode, overlay titlebars and overlay scrollbars are +automatically enabled regardless of profile settings. + == KEYBINDINGS The following default keybindings can be used to control Terminator. Most of these keybindings can be changed in the Preferences. diff --git a/doc/terminator_config.5 b/doc/terminator_config.5 index 4af2ca9b..63a3cfaa 100644 --- a/doc/terminator_config.5 +++ b/doc/terminator_config.5 @@ -1,7 +1,7 @@ '\" t .\" Title: terminator_config .\" Author: [see the "AUTHOR(S)" section] -.\" Generator: Asciidoctor 2.0.16 +.\" Generator: Asciidoctor 2.0.23 .\" Date: 2023-10-10 .\" Manual: Manual for Terminator .\" Source: Terminator @@ -123,6 +123,15 @@ If set to True, the window will be hidden from the taskbar. Default value: \fBFalse\fP .RE .sp +\fBhide_tmux_origin\fP = \fIboolean\fP +.RS 4 +If set to True, the terminal window where \fBtmux \-CC\fP was typed will be +automatically minimized when tmux control mode activates. The window is +restored when the tmux session is detached or exited. +.br +Default value: \fBFalse\fP +.RE +.sp \fBgeometry_hinting\fP = \fIboolean\fP .RS 4 If set to True, the window will resize in step with font sizes. @@ -1165,11 +1174,21 @@ Default value: \fBmiddle\fP \fBscrollbar_position\fP = \fIstring\fP .RS 4 Specify where the terminal scrollbar is put. -Can be any of: \*(Aqleft\*(Aq, \*(Aqright\*(Aq, \*(Aqhidden\*(Aq. +Can be any of: \*(Aqleft\*(Aq, \*(Aqright\*(Aq, \*(Aqdisabled\*(Aq. .br Default value: \fBright\fP .RE .sp +\fBoverlay_scrollbar\fP = \fIboolean\fP +.RS 4 +If set to True, the scrollbar overlays the terminal content and is +transparent when scrolled to the bottom. It fades in when scrolled up +in history. Works with the left/right position setting. Always enabled +in tmux control mode regardless of this setting. +.br +Default value: \fBFalse\fP +.RE +.sp \fBscroll_on_output\fP = \fIboolean\fP .RS 4 If set to True, the terminall will scroll to the bottom when an @@ -1241,6 +1260,15 @@ title of that terminal. Default value: \fBTrue\fP .RE .sp +\fBoverlay_titlebar\fP = \fIboolean\fP +.RS 4 +If set to True, the titlebar floats over the terminal content and is +fully transparent until the mouse hovers over it. This saves vertical +space. Always enabled in tmux control mode regardless of this setting. +.br +Default value: \fBFalse\fP +.RE +.sp \fBtitle_hide_sizetext\fP = \fIboolean\fP .RS 4 If set to True, the size of the terminal will not be written on its diff --git a/doc/terminator_config.adoc b/doc/terminator_config.adoc index d0b89fe8..637170a3 100644 --- a/doc/terminator_config.adoc +++ b/doc/terminator_config.adoc @@ -86,6 +86,12 @@ Default value: *False* If set to True, the window will be hidden from the taskbar. + Default value: *False* +*hide_tmux_origin* = _boolean_:: +If set to True, the terminal window where *tmux -CC* was typed will be +automatically minimized when tmux control mode activates. The window is +restored when the tmux session is detached or exited. + +Default value: *False* + *geometry_hinting* = _boolean_:: If set to True, the window will resize in step with font sizes. + Default value: *False* @@ -784,9 +790,16 @@ Default value: *middle* *scrollbar_position* = _string_:: Specify where the terminal scrollbar is put. -Can be any of: 'left', 'right', 'hidden'. + +Can be any of: 'left', 'right', 'disabled'. + Default value: *right* +*overlay_scrollbar* = _boolean_:: +If set to True, the scrollbar overlays the terminal content and is +transparent when scrolled to the bottom. It fades in when scrolled up +in history. Works with the left/right position setting. Always enabled +in tmux control mode regardless of this setting. + +Default value: *False* + *scroll_on_output* = _boolean_:: If set to True, the terminall will scroll to the bottom when an application writes text to it. + @@ -839,6 +852,12 @@ If set to True, the terminal will have a titlebar showing the current title of that terminal. + Default value: *True* +*overlay_titlebar* = _boolean_:: +If set to True, the titlebar floats over the terminal content and is +fully transparent until the mouse hovers over it. This saves vertical +space. Always enabled in tmux control mode regardless of this setting. + +Default value: *False* + *title_hide_sizetext* = _boolean_:: If set to True, the size of the terminal will not be written on its titlebar. + diff --git a/terminator b/terminator index a98ed3e2..3d9aeae0 100755 --- a/terminator +++ b/terminator @@ -128,14 +128,85 @@ if __name__ == '__main__': TERMINATOR.set_dbus_data(dbus_service) TERMINATOR.reconfigure() - try: - 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() + tmux_attach = getattr(OPTIONS, 'tmux_attach', None) + tmux_new = getattr(OPTIONS, 'tmux_new', None) + tmux_session = tmux_attach if tmux_attach is not None else tmux_new + if tmux_session is not None: + from terminatorlib.tmux.controller import TmuxController + new_session = bool(tmux_new) + # Empty session name means "most recent" (like bare tmux attach) + if tmux_session == '' and not new_session: + import subprocess as _sp + try: + result = _sp.run(['tmux', 'list-sessions', '-F', '#{session_name}'], + capture_output=True, text=True, timeout=2) + if result.returncode == 0 and result.stdout.strip(): + tmux_session = result.stdout.strip().split('\n')[0] + dbg('Auto-selected tmux session: %s' % tmux_session) + else: + err('No tmux sessions available') + tmux_session = None + except Exception as ex: + err('Failed to list tmux sessions: %s' % ex) + tmux_session = None + if tmux_session is None: + TERMINATOR.new_window() + else: + tmux_ctrl = TmuxController() + tmux_ctrl.start(tmux_session, new_session=new_session) + tmux_layout = tmux_ctrl.get_initial_layout() + if tmux_layout: + dbg('Using tmux layout: %s' % tmux_layout) + # Extract original tmux size before create + # may cap it to fit the screen. + _orig_tmux_size = None + for _k, _v in tmux_layout.items(): + if _v.get('type') == 'Window' and \ + 'tmux_size' in _v: + _s = _v['tmux_size'] + _orig_tmux_size = ( + int(_s[0]), int(_s[1])) + break + TERMINATOR.create_layout_from_flat(tmux_layout) + # If tmux_size was capped to fit the screen, + # tell tmux so it re-lays-out within our limits. + if _orig_tmux_size and tmux_ctrl.active: + for w in TERMINATOR.windows: + capped = getattr( + w, '_tmux_capped_size', None) + if (capped and + capped != _orig_tmux_size): + dbg('tmux size capped %dx%d -> ' + '%dx%d, sending refresh-client' + % (_orig_tmux_size[0], + _orig_tmux_size[1], + capped[0], capped[1])) + tmux_ctrl.protocol.send_command( + 'refresh-client -C %d,%d' + % (capped[0], capped[1])) + tmux_ctrl.state.last_client_size \ + = capped + break + else: + err('tmux layout not available, falling back to default') + TERMINATOR.new_window() + else: + try: + 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() + # After layout_done, terminals are registered - capture initial content + try: + if tmux_session and tmux_ctrl.active: + tmux_ctrl.handlers.capture_initial_content() + except NameError: + pass + if OPTIONS.debug and OPTIONS.debug >= 2: import terminatorlib.debugserver as debugserver # pylint: disable-msg=W0611 diff --git a/terminatorlib/config.py b/terminatorlib/config.py index ad11f8a3..bc98912a 100644 --- a/terminatorlib/config.py +++ b/terminatorlib/config.py @@ -97,6 +97,7 @@ 'hide_from_taskbar' : False, 'always_on_top' : False, 'hide_on_lose_focus' : False, + 'hide_tmux_origin' : False, 'sticky' : False, 'use_custom_url_handler': False, 'custom_url_handler' : '', @@ -233,7 +234,9 @@ 'font' : 'Mono 10', 'foreground_color' : '#aaaaaa', 'show_titlebar' : True, + 'overlay_titlebar' : False, 'scrollbar_position' : "right", + 'overlay_scrollbar' : False, 'scroll_on_keystroke' : True, 'scroll_on_output' : False, 'scrollback_lines' : 500, diff --git a/terminatorlib/ipc.py b/terminatorlib/ipc.py index a296c6ef..6940233e 100644 --- a/terminatorlib/ipc.py +++ b/terminatorlib/ipc.py @@ -75,6 +75,15 @@ def prepare_attributes(self): def new_window_cmdline(self, options=dbus.Dictionary()): """Create a new Window""" dbg('dbus method called: new_window with parameters %s'%(options)) + + # Handle tmux session requests + tmux_attach = options.get('tmux_attach', '') + tmux_new = options.get('tmux_new', '') + tmux_session = tmux_attach or tmux_new or None + if tmux_session is not None: + self._new_tmux_window(tmux_session, new_session=bool(tmux_new)) + return + if options['configjson']: dbg(options['configjson']) configjson = ConfigJson() @@ -90,6 +99,32 @@ def new_window_cmdline(self, options=dbus.Dictionary()): self.terminator.create_layout(oldopts.layout) self.terminator.layout_done() + def _new_tmux_window(self, session_name, new_session=False): + """Create a new window attached to a tmux session.""" + from terminatorlib.tmux.controller import TmuxController + import threading + + def start_tmux(): + ctrl = TmuxController() + ctrl.start(session_name, new_session=new_session) + tmux_layout = ctrl.get_initial_layout() + if tmux_layout: + from gi.repository import GLib + GLib.idle_add(self._apply_tmux_layout, ctrl, tmux_layout) + else: + err('tmux layout not available via dbus') + + t = threading.Thread(target=start_tmux, daemon=True) + t.start() + + def _apply_tmux_layout(self, ctrl, tmux_layout): + """Apply tmux layout on GTK thread.""" + self.terminator.create_layout_from_flat(tmux_layout) + self.terminator.layout_done() + if ctrl.active: + ctrl.handlers.capture_initial_content() + return False + @dbus.service.method(BUS_NAME, in_signature='a{ss}') def new_tab_cmdline(self, options=dbus.Dictionary()): """Create a new tab""" diff --git a/terminatorlib/optionparse.py b/terminatorlib/optionparse.py index cd20ff8b..26cd01d9 100644 --- a/terminatorlib/optionparse.py +++ b/terminatorlib/optionparse.py @@ -107,6 +107,11 @@ def parse_options(): help=_('List all profiles')) parser.add_argument('--list-layouts', action='store_true', dest='list_layouts', help=_('List all layouts')) + parser.add_argument('--tmux-attach', dest='tmux_attach', metavar='SESSION', + nargs='?', const='', + help=_('Attach to a tmux session in control mode (default: most recent)')) + parser.add_argument('--tmux-new', dest='tmux_new', metavar='SESSION', + help=_('Create a new tmux session in control mode')) for item in ['--sm-client-id', '--sm-config-prefix', '--screen', '-n', '--no-gconf' ]: diff --git a/terminatorlib/paned.py b/terminatorlib/paned.py index 0019cfa2..6ac9faff 100644 --- a/terminatorlib/paned.py +++ b/terminatorlib/paned.py @@ -257,13 +257,30 @@ def get_children(self): children.append(self.get_child2()) return(children) + def _is_any_handle_dragging(self): + """Check if any ancestor paned has a handle being dragged.""" + w = self.get_parent() + while w is not None: + if getattr(w, '_tmux_handle_pressed', False): + return True + w = w.get_parent() + return False + def get_child_metadata(self, widget): """Return metadata about a child""" metadata = {} metadata['had_focus'] = widget.has_focus() + return metadata def get_handlesize(self): - """Why oh why, gtk3?""" + """Return actual handle gap in pixels. + + Prefers the measured gap from do_size_allocate (accurate) + over the deprecated style property (often wrong in GTK 3.20+). + """ + gap = getattr(self, '_measured_gap', None) + if gap is not None: + return gap try: value = GObject.Value(int) self.style_get_property('handle-size', value) @@ -474,6 +491,16 @@ def rotate_recursive(self, parent, w, h, clockwise, metadata=None): children[1].rotate_recursive(container, w2, h2, clockwise) def new_size(self, widget, allocation): + # Tmux-managed paneds: position is controlled by + # _apply_ratios and the do_size_allocate anchor. + # Don't bake in GTK's potentially rescaled position. + if getattr(self, '_tmux_managed', False): + if getattr(self, '_tmux_handle_pressed', False): + from terminatorlib.util import dbg + dbg('new_size: skip (tmux_managed, ' + 'handle_pressed, pos=%d)' % + self.get_position()) + return if self.get_toplevel().set_pos_by_ratio: self.set_position_by_ratio() else: @@ -511,6 +538,8 @@ def set_position(self, pos): class HPaned(Paned, Gtk.HPaned): """Merge Gtk.HPaned into our base Paned Container""" + _char_snap = 0 # set from terminal.py set_font + def __init__(self): """Class initialiser""" Paned.__init__(self) @@ -519,16 +548,87 @@ def __init__(self): self.register_signals(HPaned) self.cnxids.new(self, 'button-press-event', self.on_button_press) self.cnxids.new(self, 'button-release-event', self.on_button_release) + self._snapping = False + self.connect('notify::position', self._snap_position) + + _measured_gap = None # actual pixel gap, measured after first layout + + def _snap_position(self, widget, pspec): + """Snap handle so child2 starts at a character cell boundary.""" + snap = HPaned._char_snap + if self._snapping or snap <= 0: + return + # Skip during tmux layout application — _apply_ratios + # already aligns to cell boundaries and the allocation + # cascade hasn't settled yet. + state = getattr(self, '_tmux_state', None) + if state and state.applying_layout: + return + pos = self.get_position() + gap = self._measured_gap if self._measured_gap else self.get_handlesize() + import math + n = math.ceil((pos + gap) / snap) + snapped = max(n * snap - gap, 0) + if snapped != pos: + self._snapping = True + self.set_pos(snapped) + self._snapping = False + if self._measured_gap is None: + from gi.repository import GLib + def _measure(): + c1 = self.get_child1() + c2 = self.get_child2() + if c1 and c2: + a1 = c1.get_allocation() + a2 = c2.get_allocation() + actual = a2.x - (a1.x + a1.width) + if actual > 0: + self._measured_gap = actual + self._snap_position(self, None) + return False + GLib.idle_add(_measure) + + def do_size_allocate(self, allocation): + target = getattr(self, '_tmux_synced_pos', None) \ + if getattr(self, '_tmux_managed', False) else None + before = self.get_position() + Gtk.HPaned.do_size_allocate(self, allocation) + after = self.get_position() + if getattr(self, '_tmux_handle_pressed', False): + pass # this handle is being dragged — let GTK handle + elif target is not None and after != target: + if self._is_any_handle_dragging(): + pass # ancestor drag — let GTK cascade proceed + else: + Gtk.HPaned.set_position(self, target) + self.set_property('position-set', True) + # Measure actual handle gap (once) for get_handlesize() + if self._measured_gap is None: + c1 = self.get_child1() + c2 = self.get_child2() + if c1 and c2: + a1 = c1.get_allocation() + a2 = c2.get_allocation() + actual = a2.x - (a1.x + a1.width) + if actual > 0: + self._measured_gap = actual def get_length(self): return(self.get_allocated_width()) def set_pos(self, pos): + if getattr(self, '_tmux_handle_pressed', False): + import traceback + from terminatorlib.util import dbg + dbg('HPaned.set_pos(%d) during drag:\n%s' % ( + pos, ''.join(traceback.format_stack(limit=6)))) Gtk.HPaned.set_position(self, pos) self.set_property('position-set', True) class VPaned(Paned, Gtk.VPaned): """Merge Gtk.VPaned into our base Paned Container""" + _char_snap = 0 # set from terminal.py set_font + def __init__(self): """Class initialiser""" Paned.__init__(self) @@ -537,11 +637,81 @@ def __init__(self): self.register_signals(VPaned) self.cnxids.new(self, 'button-press-event', self.on_button_press) self.cnxids.new(self, 'button-release-event', self.on_button_release) + self._snapping = False + self.connect('notify::position', self._snap_position) + + _measured_gap = None # actual pixel gap, measured after first layout + + def _snap_position(self, widget, pspec): + """Snap handle so child2 starts at a character cell boundary.""" + snap = VPaned._char_snap + if self._snapping or snap <= 0: + return + # Skip during tmux layout application — _apply_ratios + # already aligns to cell boundaries and the allocation + # cascade hasn't settled yet. + state = getattr(self, '_tmux_state', None) + if state and state.applying_layout: + return + pos = self.get_position() + gap = self._measured_gap if self._measured_gap else self.get_handlesize() + import math + n = math.ceil((pos + gap) / snap) + snapped = max(n * snap - gap, 0) + if snapped != pos: + self._snapping = True + self.set_pos(snapped) + self._snapping = False + # Measure actual gap after layout settles (once) + if self._measured_gap is None: + from gi.repository import GLib + def _measure(): + c1 = self.get_child1() + c2 = self.get_child2() + if c1 and c2: + a1 = c1.get_allocation() + a2 = c2.get_allocation() + actual = a2.y - (a1.y + a1.height) + if actual > 0: + self._measured_gap = actual + self._snap_position(self, None) + return False + GLib.idle_add(_measure) + + def do_size_allocate(self, allocation): + target = getattr(self, '_tmux_synced_pos', None) \ + if getattr(self, '_tmux_managed', False) else None + before = self.get_position() + Gtk.VPaned.do_size_allocate(self, allocation) + after = self.get_position() + if getattr(self, '_tmux_handle_pressed', False): + pass # this handle is being dragged — let GTK handle + elif target is not None and after != target: + if self._is_any_handle_dragging(): + pass # ancestor drag — let GTK cascade proceed + else: + Gtk.VPaned.set_position(self, target) + self.set_property('position-set', True) + # Measure actual handle gap (once) for get_handlesize() + if self._measured_gap is None: + c1 = self.get_child1() + c2 = self.get_child2() + if c1 and c2: + a1 = c1.get_allocation() + a2 = c2.get_allocation() + actual = a2.y - (a1.y + a1.height) + if actual > 0: + self._measured_gap = actual def get_length(self): return(self.get_allocated_height()) def set_pos(self, pos): + if getattr(self, '_tmux_handle_pressed', False): + import traceback + from terminatorlib.util import dbg + dbg('VPaned.set_pos(%d) during drag:\n%s' % ( + pos, ''.join(traceback.format_stack(limit=6)))) Gtk.VPaned.set_position(self, pos) self.set_property('position-set', True) diff --git a/terminatorlib/preferences.glade b/terminatorlib/preferences.glade index f77a389d..20d404d6 100644 --- a/terminatorlib/preferences.glade +++ b/terminatorlib/preferences.glade @@ -647,6 +647,23 @@ 2 + + + Auto-minimize tmux control mode windows + False + True + True + False + 0.5 + True + + + + 0 + 9 + 2 + + True @@ -3334,6 +3351,23 @@ 4 + + + Overlay scrollbar (show when scrolled) + False + True + True + False + 0 + True + + + + 0 + 5 + 2 + + 4 @@ -3737,6 +3771,23 @@ 2 + + + Overlay titlebar (hover to show) + False + True + True + False + 0.5 + True + + + + False + False + 3 + + False diff --git a/terminatorlib/prefseditor.py b/terminatorlib/prefseditor.py index 5c39335c..df24e1f2 100755 --- a/terminatorlib/prefseditor.py +++ b/terminatorlib/prefseditor.py @@ -374,6 +374,9 @@ def set_values(self): #Hide on lose focus widget = guiget('hideonlosefocuscheck') widget.set_active(self.config['hide_on_lose_focus']) + #Auto-hide tmux control mode windows + widget = guiget('hidetmuxorigincheck') + widget.set_active(self.config['hide_tmux_origin']) #Show on all workspaces widget = guiget('stickycheck') widget.set_active(self.config['sticky']) @@ -581,6 +584,9 @@ def set_profile_values(self, profile): # Show titlebar widget = guiget('show_titlebar') widget.set_active(self.config['show_titlebar']) + # Overlay titlebar + widget = guiget('overlay_titlebar') + widget.set_active(self.config['overlay_titlebar']) # Copy on selection widget = guiget('copy_on_selection') widget.set_active(self.config['copy_on_selection']) @@ -786,6 +792,9 @@ def on_palette_click(event, data, widget=widget): widget.set_active(2) else: widget.set_active(1) + # Overlay scrollbar + widget = guiget('overlay_scrollbar_check') + widget.set_active(self.config['overlay_scrollbar']) # Scrollback lines widget = guiget('scrollback_lines_spinbutton') widget.set_value(self.config['scrollback_lines']) @@ -911,6 +920,11 @@ def on_hideonlosefocuscheck_toggled(self, widget): self.config['hide_on_lose_focus'] = widget.get_active() self.config.save() + def on_hidetmuxorigincheck_toggled(self, widget): + """Auto-hide tmux control mode windows setting changed""" + self.config['hide_tmux_origin'] = widget.get_active() + self.config.save() + def on_stickycheck_toggled(self, widget): """Sticky setting changed""" self.config['sticky'] = widget.get_active() @@ -946,6 +960,11 @@ def on_show_titlebar_toggled(self, widget): self.config['show_titlebar'] = widget.get_active() self.config.save() + def on_overlay_titlebar_toggled(self, widget): + """Overlay titlebar setting changed""" + self.config['overlay_titlebar'] = widget.get_active() + self.config.save() + def on_copy_on_selection_toggled(self, widget): """Copy on selection setting changed""" self.config['copy_on_selection'] = widget.get_active() @@ -1075,6 +1094,11 @@ def on_scrollbar_position_combobox_changed(self, widget): self.config['scrollbar_position'] = value self.config.save() + def on_overlay_scrollbar_check_toggled(self, widget): + """Overlay scrollbar setting changed""" + self.config['overlay_scrollbar'] = widget.get_active() + self.config.save() + def on_background_image_file_set(self,widget): self.config['background_image'] = widget.get_filename() self.config.save() diff --git a/terminatorlib/terminal.py b/terminatorlib/terminal.py index 399a6e47..6d638196 100644 --- a/terminatorlib/terminal.py +++ b/terminatorlib/terminal.py @@ -108,6 +108,9 @@ class Terminal(Gtk.VBox): directory = None is_held_open = False + tmux_pane_id = None + _tmux_controller = None + _tmux_closing = False fgcolor_active = None fgcolor_inactive = None @@ -121,6 +124,30 @@ class Terminal(Gtk.VBox): cnxids = None targets_for_new_group = None + def emit(self, *args): + """Override emit to intercept split/tab signals in tmux mode.""" + if self.tmux_pane_id is not None and len(args) >= 1: + sig = args[0] + if sig == 'split-horiz': + self.tmux_split(horizontal=True) + return + elif sig == 'split-vert': + self.tmux_split(horizontal=False) + return + elif sig == 'split-auto': + self.tmux_split(horizontal=True) + return + elif sig == 'tab-new': + self.tmux_new_window() + return + GObject.GObject.emit(self, *args) + + def tmux_new_window(self): + """Request a new tmux window. The %window-add handler will create the tab.""" + ctrl = self._tmux_controller + if ctrl: + ctrl.protocol.send_command('new-window') + def __init__(self): """Class initialiser""" GObject.GObject.__init__(self) @@ -270,7 +297,18 @@ def close(self): dbg('close: called') self.cnxids.remove_widget(self.vte) self.emit('close-term') - if self.pid is not None: + if self.tmux_pane_id is not None: + ctrl = self._tmux_controller + if ctrl: + pane_id = self.tmux_pane_id + ctrl.unregister_terminal(self) + # If this close was initiated from Terminator (not from tmux), + # kill the tmux pane too + if not self._tmux_closing and ctrl.active: + ctrl.protocol.send_command('kill-pane -t {}'.format(pane_id)) + # Release saved PTY reference so the original fd can be closed + self._saved_pty = None + elif self.pid is not None: try: dbg('close: killing %d' % self.pid) os.kill(self.pid, signal.SIGHUP) @@ -285,17 +323,62 @@ def close(self): del(self.vte) def create_terminalbox(self): - """Create a GtkHBox containing the terminal and a scrollbar""" - - terminalbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) - self.scrollbar = Gtk.Scrollbar.new(Gtk.Orientation.VERTICAL, adjustment=self.vte.get_vadjustment()) + """Create a container with the terminal and an overlay scrollbar. + + Uses Gtk.Overlay so the scrollbar floats on top of the VTE's + right edge instead of taking its own horizontal space. This + means the VTE gets the full terminal width — no pixels lost + to the scrollbar, which keeps character column counts accurate + across split panes. + """ + adj = self.vte.get_vadjustment() + self.scrollbar = Gtk.Scrollbar.new(Gtk.Orientation.VERTICAL, + adjustment=adj) self.scrollbar.set_no_show_all(True) - - terminalbox.pack_start(self.vte, True, True, 0) - terminalbox.pack_start(self.scrollbar, False, True, 0) - terminalbox.show_all() - - return(terminalbox) + self.scrollbar.set_opacity(0.0) + + # Align scrollbar to right edge of the overlay + self.scrollbar.set_halign(Gtk.Align.END) + self.scrollbar.set_valign(Gtk.Align.FILL) + + # Show scrollbar only when scrolled back in history + def _on_adj_changed(*args): + at_bottom = adj.get_value() >= adj.get_upper() - adj.get_page_size() - 1 + self.scrollbar.set_opacity(0.0 if at_bottom else 0.5) + adj.connect('value-changed', _on_adj_changed) + adj.connect('changed', _on_adj_changed) + + self._terminalbox_overlay = Gtk.Overlay() + self._terminalbox_overlay.add(self.vte) + self._terminalbox_overlay.add_overlay(self.scrollbar) + self._terminalbox_overlay.show_all() + + return self._terminalbox_overlay + + def _make_titlebar_overlay(self): + """Move the titlebar into the terminal overlay. + + Makes it float over the VTE content, fully transparent until + the mouse hovers over the titlebar area. Takes zero vertical + space in the layout. + """ + if not self.titlebar: + return + if self.titlebar.get_parent() == self._terminalbox_overlay: + return + # Reparent: remove from VBox, add to the overlay + self.remove(self.titlebar) + self.titlebar.set_opacity(0.0) + self.titlebar.set_halign(Gtk.Align.FILL) + if self.config['title_at_bottom']: + self.titlebar.set_valign(Gtk.Align.END) + else: + self.titlebar.set_valign(Gtk.Align.START) + self._terminalbox_overlay.add_overlay(self.titlebar) + self.titlebar.connect('enter-notify-event', + lambda w, e: w.set_opacity(0.85)) + self.titlebar.connect('leave-notify-event', + lambda w, e: w.set_opacity(0.0)) def load_plugins(self, force = False): registry = plugin.PluginRegistry() @@ -478,6 +561,7 @@ def connect_signals(self): self.cnxids.new(self.vte, 'focus-in-event', self.on_vte_focus_in) self.cnxids.new(self.vte, 'focus-out-event', self.on_vte_focus_out) self.cnxids.new(self.vte, 'size-allocate', self.deferred_on_vte_size_allocate) + self._first_allocate_id = self.vte.connect('size-allocate', self._on_first_allocate) self.vte.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK) self.cnxids.new(self.vte, 'enter_notify_event', @@ -485,6 +569,146 @@ def connect_signals(self): self.cnxids.new(self.vte, 'realize', self.reconfigure) + # Watch for tmux control mode output + self._tmux_detect_id = self.vte.connect('contents-changed', + self.on_vte_contents_changed_tmux_detect) + + def on_vte_contents_changed_tmux_detect(self, widget): + """Detect tmux -CC control mode output in terminal text.""" + if self.tmux_pane_id is not None: + return + # Get last few rows of terminal text to look for control mode markers + col, row = self.vte.get_cursor_position() + if row < 1: + return + # After a PTY restore, only scan text below the watermark to + # avoid re-triggering on stale markers from the previous session + watermark = getattr(self, '_tmux_detect_watermark', 0) + if row <= watermark: + return + start_row = max(watermark, row - 10) + ncols = self.vte.get_column_count() + text = None + try: + text = self.vte.get_text_range_format( + Vte.Format.TEXT, start_row, 0, row, ncols) + if isinstance(text, tuple): + text = text[0] + except Exception: + pass + if not text or not text.strip(): + return + if '%begin' in text and ('%session-changed' in text + or '%end' in text): + dbg('Detected tmux control mode output, taking over PTY') + # Disconnect this handler so we don't fire again + if self._tmux_detect_id: + self.vte.disconnect(self._tmux_detect_id) + self._tmux_detect_id = None + GLib.idle_add(self._takeover_tmux_pty) + + def _takeover_tmux_pty(self): + """Take over the PTY from VTE for tmux control mode. + + Swaps VTE to a dummy PTY (set_pty(None) segfaults), then uses + the original PTY fd to communicate with the tmux -C process. + """ + import os + from terminatorlib.tmux.controller import TmuxController + + ctrl = TmuxController() + + pty = self.vte.get_pty() + if not pty: + dbg('_takeover_tmux_pty: no PTY available') + return False + + # Dup the fd so we own it independently + orig_fd = pty.get_fd() + our_fd = os.dup(orig_fd) + dbg('_takeover_tmux_pty: duped fd %d -> %d' % (orig_fd, our_fd)) + + # Swap VTE to a dummy PTY so it stops reading the original fd. + # (set_pty(None) segfaults when a child is active) + dummy_pty = Vte.Pty.new_sync(Vte.PtyFlags.DEFAULT) + self._saved_pty = pty # prevent GC from closing orig fd + self.vte.set_pty(dummy_pty) + self.vte.feed(b'\r\n\r\n tmux control mode active\r\n' + b' Press Ctrl+C to detach\r\n') + dbg('_takeover_tmux_pty: swapped VTE to dummy PTY') + + # Remember which terminal started the takeover so we can restore it + ctrl._origin_terminal = self + + # Allow Ctrl+C to detach from this screen + self._tmux_origin_ctrl = ctrl + self._tmux_origin_keypress_id = self.vte.connect( + 'key-press-event', self._on_tmux_origin_keypress) + + # Auto-minimize the origin window if configured + if self.config['hide_tmux_origin']: + window = self.get_toplevel() + if window: + self._tmux_origin_window = window + window.iconify() + + # Start the controller in a thread (it blocks waiting for layout) + import threading + def start_tmux(): + ctrl.start_from_pty(our_fd) + tmux_layout = ctrl.get_initial_layout() + if tmux_layout: + GLib.idle_add(self._create_tmux_window, ctrl, tmux_layout) + else: + dbg('_takeover_tmux_pty: no layout from tmux') + t = threading.Thread(target=start_tmux, daemon=True) + t.start() + return False + + def _on_tmux_origin_keypress(self, widget, event): + """Handle keypress on the tmux origin screen. Ctrl+C detaches.""" + ctrl = getattr(self, '_tmux_origin_ctrl', None) + if not ctrl or not ctrl.active: + return False + if (event.get_state() & Gdk.ModifierType.CONTROL_MASK and + event.keyval in (Gdk.KEY_c, Gdk.KEY_C)): + dbg('tmux origin: Ctrl+C, sending detach') + # Just send detach — the normal %exit handler will close + # terminals, stop the controller, and restore this PTY. + # This keeps the bridge reader alive to consume the response. + ctrl.protocol.send_command('detach') + return True + return True # swallow all other keys + + def _cleanup_tmux_origin(self): + """Disconnect the origin screen key handler and restore window.""" + handler_id = getattr(self, '_tmux_origin_keypress_id', None) + if handler_id: + self.vte.disconnect(handler_id) + self._tmux_origin_keypress_id = None + self._tmux_origin_ctrl = None + # Restore the origin window if it was auto-minimized + window = getattr(self, '_tmux_origin_window', None) + if window: + window.deiconify() + window.present() + self._tmux_origin_window = None + + def _create_tmux_window(self, ctrl, tmux_layout): + """Create a new Terminator window with tmux layout.""" + from terminatorlib.terminator import Terminator + term = Terminator() + dbg('_create_tmux_window: creating window with tmux layout') + try: + term.create_layout_from_flat(tmux_layout) + term.layout_done() + ctrl.handlers.capture_initial_content() + except Exception as e: + dbg('_create_tmux_window: error: %s' % e) + import traceback + traceback.print_exc() + return False + def create_popup_group_menu(self, widget, event = None): """Pop up a menu for the group widget""" if event: @@ -781,13 +1005,24 @@ def reconfigure(self, _widget=None): if bg_factor > 1.0: bg_factor = 1.0 self.bgcolor_inactive = self.bgcolor.copy() - dbg(("bgcolor_inactive set to: RGB(%s,%s,%s)", getattr(self.bgcolor_inactive, "red"), - getattr(self.bgcolor_inactive, "green"), - getattr(self.bgcolor_inactive, "blue"))) - for bit in ['red', 'green', 'blue']: - setattr(self.bgcolor_inactive, bit, - getattr(self.bgcolor_inactive, bit) * bg_factor) + # Determine if background is dark or light based on + # average brightness (perceptual luminance) + brightness = (self.bgcolor.red * 0.299 + + self.bgcolor.green * 0.587 + + self.bgcolor.blue * 0.114) + if brightness < 0.5: + # Dark background: blend toward white to lighten inactive + for bit in ['red', 'green', 'blue']: + val = getattr(self.bgcolor_inactive, bit) + setattr(self.bgcolor_inactive, bit, + val + (1.0 - val) * (1.0 - bg_factor)) + else: + # Light background: multiply to darken inactive (original behavior) + for bit in ['red', 'green', 'blue']: + setattr(self.bgcolor_inactive, bit, + getattr(self.bgcolor_inactive, bit) * bg_factor) + dbg(("bgcolor_inactive set to: RGB(%s,%s,%s)", getattr(self.bgcolor_inactive, "red"), getattr(self.bgcolor_inactive, "green"), getattr(self.bgcolor_inactive, "blue"))) @@ -874,14 +1109,27 @@ def reconfigure(self, _widget=None): self.scrollbar.hide() else: self.scrollbar.show() + # In non-overlay mode, keep scrollbar fully visible + if not (self.config['overlay_scrollbar'] + or self.tmux_pane_id is not None): + self.scrollbar.set_opacity(1.0) if self.config['scrollbar_position'] == 'left': - self.terminalbox.reorder_child(self.scrollbar, 0) - elif self.config['scrollbar_position'] == 'right': - self.terminalbox.reorder_child(self.vte, 0) + self.scrollbar.set_halign(Gtk.Align.START) + else: + self.scrollbar.set_halign(Gtk.Align.END) self.titlebar.update() self.vte.queue_draw() + def _on_first_allocate(self, widget, allocation): + """One-shot handler: apply overlay titlebar after widget is ready.""" + if self._first_allocate_id: + self.vte.disconnect(self._first_allocate_id) + self._first_allocate_id = None + if ((self.config['overlay_titlebar'] or self.tmux_pane_id is not None) + and self.titlebar.get_parent() != self._terminalbox_overlay): + self._make_titlebar_overlay() + def set_cursor_color(self): """Set the cursor color appropriately""" if self.config['cursor_color_default']: @@ -905,6 +1153,11 @@ def set_cursor_color(self): def get_window_title(self): """Return the window title""" + if self.tmux_pane_id is not None: + # In tmux mode, use the formatted pane title if available + return (getattr(self, '_tmux_title', None) + or getattr(self, '_tmux_window_name', None) + or self.tmux_pane_id) return self.vte.get_window_title() or str(self.command) def on_group_button_press(self, widget, event): @@ -982,6 +1235,10 @@ def on_keypress(self, widget, event): getattr(self, "key_" + mapping)() return True + if self.tmux_pane_id is not None and self._tmux_controller: + self._tmux_controller.send_keypress(self, event) + return True + # FIXME: This is all clearly wrong. We should be doing this better # maybe we can emit the key event and let Terminator() care? groupsend = self.terminator.groupsend @@ -1373,6 +1630,20 @@ def ensure_visible_and_focussed(self): def on_vte_focus(self, _widget): """Update our UI when we get focus""" + if self.tmux_pane_id is not None: + ctrl = self._tmux_controller + if ctrl and ctrl.active: + # Tell tmux which pane is focused so it updates + # the active pane and window name accordingly + ctrl.protocol.send_command( + 'select-pane -t %s' % self.tmux_pane_id) + if ctrl.handlers: + ctrl.handlers._refresh_pane_titles() + ctrl.handlers._refresh_tab_labels() + title = getattr(self, '_tmux_title', None) + if title: + self.titlebar.set_terminal_title(None, title) + return self.emit('title-change', self.get_window_title()) def on_vte_focus_in(self, _widget, _event): @@ -1431,7 +1702,25 @@ def do_deferred_on_vte_size_allocate(self, 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()) - if self.config['geometry_hinting']: + # Keep snap grid current — VTE char metrics finalize after + # realization and the initial set_font() value may differ. + cw = self.vte.get_char_width() + ch = self.vte.get_char_height() + if cw > 0 and ch > 0: + from .paned import HPaned, VPaned + HPaned._char_snap = cw + VPaned._char_snap = ch + if self.tmux_pane_id is not None and self._tmux_controller: + # Tmux mode: set char-grid increment hints so the WM snaps + # to character boundaries during resize. Skip during + # layout application — per-terminal hints set wrong BASE + # values with splits, causing the WM to snap-shrink. + window = self.get_toplevel() + if not self._tmux_controller.state.applying_layout: + window.set_tmux_geometry_hints(self) + self._tmux_controller.notify_resize(self, + self.vte.get_column_count(), self.vte.get_row_count()) + elif self.config['geometry_hinting']: window = self.get_toplevel() window.deferred_set_rough_geometry_hints() else: @@ -1521,6 +1810,10 @@ def held_open(self, widget=None, respawn=False, debugserver=False): self.titlebar.update() def spawn_child(self, widget=None, respawn=False, debugserver=False, init_command=None): + if self.tmux_pane_id is not None: + dbg('spawn_child: tmux mode, skipping local shell spawn') + return + args = [] shell = None command = init_command @@ -1706,15 +1999,29 @@ def open_url(self, url, prepare=False): def paste_clipboard(self, primary=False, mouse=False): """Paste one of the two clipboards""" if not (mouse and self.config['disable_mouse_paste']): - for term in self.terminator.get_target_terms(self): + if self.tmux_pane_id is not None and self._tmux_controller: if primary: - term.vte.paste_primary() + clip = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY) else: - term.vte.paste_clipboard() + clip = self.clipboard + text = clip.wait_for_text() + if text: + self._tmux_controller.send_paste(self, text) + 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""" + if self.tmux_pane_id is not None and self._tmux_controller: + if isinstance(text, bytes): + text = text.decode('utf-8', errors='replace') + self._tmux_controller.send_paste(self, text) + return # Ensure text is bytes for feed_child if isinstance(text, str): text = text.encode() @@ -1752,9 +2059,38 @@ def zoom_orig(self): self.set_font(Pango.FontDescription(font)) self.custom_font_size = None + _paned_css_applied = False + def set_font(self, fontdesc): """Set the font we want in VTE""" self.vte.set_font(fontdesc) + # Keep snap values current — get_char_width()/get_char_height() + # can change after the VTE is realized (e.g. 9 → 10), and + # _snap_position must use the same grid as _apply_ratios. + char_w = self.vte.get_char_width() + char_h = self.vte.get_char_height() + if char_w > 0 and char_h > 0: + from .paned import HPaned, VPaned + HPaned._char_snap = char_w + VPaned._char_snap = char_h + # Set paned separator size to exactly 1 character cell — matching + # tmux's 1-char separators. Applied once via CSS on first font set. + if not Terminal._paned_css_applied: + if char_w > 0 and char_h > 0: + Terminal._paned_css_applied = True + css = ( + 'paned {{' + ' -GtkPaned-handle-size: {h};' + '}}' + 'vte-terminal {{' + ' padding: 0;' + '}}' + ).format(h=char_w) + provider = Gtk.CssProvider() + provider.load_from_data(css.encode('utf-8')) + Gtk.StyleContext.add_provider_for_screen( + self.get_screen(), provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 10) def get_cursor_position(self): """Return the coordinates of our cursor""" @@ -1849,6 +2185,18 @@ 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.tmux_pane_id = tmux['pane_id'] + if 'width' in tmux and 'height' in tmux: + self.vte.set_size(int(tmux['width']), int(tmux['height'])) + # Move titlebar into the overlay so it takes zero vertical + # space. Transparent until mouse hover. + self._make_titlebar_overlay() + from terminatorlib.tmux.controller import _controllers + # Register with the most recently started controller + if _controllers: + _controllers[-1].register_terminal(self.tmux_pane_id, self) def scroll_by_page(self, pages): """Scroll up or down in pages""" @@ -1955,6 +2303,17 @@ def key_split_horiz(self): def key_split_vert(self): self.emit('split-vert', self.get_cwd()) + def tmux_split(self, horizontal=True): + """Request a split via tmux. The %layout-change handler will create the widget.""" + ctrl = self._tmux_controller + if not ctrl: + return + pane_id = self.tmux_pane_id + # tmux -h = side-by-side, Terminator "horiz" = top/bottom, so invert + orientation = '-v' if horizontal else '-h' + ctrl.protocol.send_command( + 'split-window {} -t {}'.format(orientation, pane_id)) + def key_rotate_cw(self): self.emit('rotate-cw') @@ -2071,6 +2430,9 @@ def key_new_window(self): self.terminator.new_window(self.get_cwd(), self.get_profile()) def key_new_tab(self): + if self.tmux_pane_id is not None: + self.tmux_new_window() + return self.get_toplevel().tab_new(self) def key_new_terminator(self): diff --git a/terminatorlib/terminator.py b/terminatorlib/terminator.py index b4511c41..8a00af66 100644 --- a/terminatorlib/terminator.py +++ b/terminatorlib/terminator.py @@ -197,7 +197,8 @@ def find_window_by_uuid(self, uuid): return window return None - def new_window(self, cwd=None, profile=None): + def new_window(self, cwd=None, profile=None, + tmux_size=None): """Create a window with a Terminal in it""" maker = Factory() window = maker.make('Window') @@ -207,10 +208,50 @@ def new_window(self, cwd=None, profile=None): if profile and self.config['always_split_with_profile']: terminal.force_set_profile(None, profile) window.add(terminal) + if tmux_size: + # Realize the widget hierarchy so VTE can report + # real font metrics before showing the window. + window.realize() + cw = terminal.vte.get_char_width() + ch = terminal.vte.get_char_height() + if cw > 0 and ch > 0: + cols, rows = tmux_size + # Cap against workarea — only clamp dimensions + # that exceed the screen, leave others untouched. + display = window.get_display() + if display: + monitor = (display.get_primary_monitor() + or display.get_monitor(0)) + if monitor: + work = monitor.get_workarea() + max_cols = work.width // cw + max_rows = work.height // ch + if cols > max_cols: + dbg('pre-size: capping cols %d -> %d ' + '(workarea %dpx)' % ( + cols, max_cols, work.width)) + cols = max_cols + if rows > max_rows: + dbg('pre-size: capping rows %d -> %d ' + '(workarea %dpx)' % ( + rows, max_rows, work.height)) + rows = max_rows + tmux_size = (cols, rows) + w, h = cols * cw, rows * ch + window.set_default_size(w, h) + dbg('pre-size: tmux=%dx%d char=%dx%d ' + 'default=%dx%d' % ( + cols, rows, cw, ch, w, h)) window.show(True) + if tmux_size: + # Process events so the WM maps the window at + # the requested size before splits are created. + while Gtk.events_pending(): + Gtk.main_iteration_do(False) terminal.spawn_child() terminal.emit('tab-change', 0) + window._tmux_capped_size = tmux_size return(window, terminal) def create_layout(self, layoutname): @@ -308,6 +349,110 @@ def create_layout(self, layoutname): self.layoutname = layoutname + def create_layout_from_flat(self, flat_layout): + """Create layout from a flat dict with 'parent' references. + + Used by tmux integration to build the window layout from tmux state. + Reuses the same flat-to-nested conversion as create_layout(). + """ + layout = copy.deepcopy(flat_layout) + objects = {} + + self.doing_layout = True + self.last_active_window = None + self.prelayout_windows = self.windows[:] + + # Wind the flat objects into a hierarchy (same logic as create_layout) + hierarchy = {} + count = 0 + while len(layout) > 0 and count < 1000: + count = count + 1 + if count == 1000: + err('hit maximum loop boundary. THIS IS VERY LIKELY A BUG') + for obj in list(layout.keys()): + if layout[obj]['type'].lower() == 'window': + hierarchy[obj] = {} + hierarchy[obj]['type'] = 'Window' + hierarchy[obj]['children'] = {} + for objkey in list(layout[obj].keys()): + if layout[obj][objkey] != '' and objkey not in hierarchy[obj]: + hierarchy[obj][objkey] = layout[obj][objkey] + objects[obj] = hierarchy[obj] + del(layout[obj]) + else: + if 'parent' not in layout[obj]: + err('Invalid object: %s' % obj) + del(layout[obj]) + continue + if layout[obj]['parent'] in objects: + childobj = {} + childobj['type'] = layout[obj]['type'] + childobj['children'] = {} + for objkey in list(layout[obj].keys()): + if objkey not in childobj: + childobj[objkey] = layout[obj][objkey] + objects[layout[obj]['parent']]['children'][obj] = childobj + objects[obj] = childobj + del(layout[obj]) + + layout = hierarchy + + for windef in layout: + if layout[windef]['type'] != 'Window': + err('invalid layout format. %s' % layout) + raise(ValueError) + # Pass tmux_size so new_window can realize the VTE, + # get real font metrics, and set_default_size before + # showing — paneds are born at the correct size. + tmux_size = None + if 'tmux_size' in layout[windef]: + size = layout[windef]['tmux_size'] + tmux_size = (int(size[0]), int(size[1])) + window, terminal = self.new_window( + tmux_size=tmux_size) + # new_window may have capped tmux_size to fit the + # screen — use the capped value for post-layout resize + # and to inform tmux of the actual client size. + tmux_size = getattr(window, '_tmux_capped_size', + tmux_size) + window.create_layout(layout[windef]) + if tmux_size: + # Resize with real char metrics + chrome. + cw = terminal.vte.get_char_width() + ch = terminal.vte.get_char_height() + if cw > 0 and ch > 0: + cols, rows = tmux_size + target_w = cols * cw + target_h = rows * ch + chrome_w = 0 + chrome_h = 0 + content = window.get_child() + if content and \ + hasattr(content, 'get_current_page'): + page_num = content.get_current_page() + if page_num >= 0: + page = content.get_nth_page( + page_num) + _, cnt_w = \ + content.get_preferred_width() + _, cnt_h = \ + content.get_preferred_height() + _, pg_w = \ + page.get_preferred_width() + _, pg_h = \ + page.get_preferred_height() + chrome_w = max(0, cnt_w - pg_w) + chrome_h = max(0, cnt_h - pg_h) + dbg('pre-resize: tmux=%dx%d char=%dx%d ' + 'target=%dx%d chrome=%dx%d' % ( + cols, rows, cw, ch, + target_w, target_h, + chrome_w, chrome_h)) + window.resize(target_w + chrome_w, + target_h + chrome_h) + + self.layoutname = 'tmux' + def layout_done(self): """Layout operations have finished, record that fact""" self.doing_layout = False @@ -323,7 +468,7 @@ def layout_done(self): window_last_active_term_mapping[window] = copy.copy(source.last_active_term) for terminal in self.terminals: - if not terminal.pid: + if not terminal.pid and terminal.tmux_pane_id is None: terminal.spawn_child() for window in self.windows: diff --git a/terminatorlib/titlebar.py b/terminatorlib/titlebar.py index ee9dfb2c..6e14af8d 100644 --- a/terminatorlib/titlebar.py +++ b/terminatorlib/titlebar.py @@ -109,7 +109,7 @@ def update(self, other=None): if self.terminal.is_held_open: temp_heldtext_str = _('[INACTIVE: Right-Click for Relaunch option] ') if not self.config['title_hide_sizetext']: - temp_sizetext_str = " %s" % (self.sizetext) + temp_sizetext_str = " (%s)" % (self.sizetext) self.label.set_text("%s%s%s" % (temp_heldtext_str, self.termtext, temp_sizetext_str)) if (not self.config['title_use_system_font']) and self.config['title_font']: diff --git a/terminatorlib/tmux/__init__.py b/terminatorlib/tmux/__init__.py new file mode 100644 index 00000000..1764fc23 --- /dev/null +++ b/terminatorlib/tmux/__init__.py @@ -0,0 +1 @@ +# Terminator tmux control mode support diff --git a/terminatorlib/tmux/controller.py b/terminatorlib/tmux/controller.py new file mode 100644 index 00000000..182c5ee0 --- /dev/null +++ b/terminatorlib/tmux/controller.py @@ -0,0 +1,1044 @@ +"""Tmux controller - maps tmux state to Terminator widgets. + +One instance per tmux session. Manages the tmux protocol connection, +terminal registration, key translation, and resize handling. +""" + +import threading + +from gi.repository import Gdk, GLib + +from terminatorlib.util import dbg +from terminatorlib.tmux.protocol import TmuxProtocol, TmuxProtocolFromPty +from terminatorlib.tmux.layout import parse_tmux_layout, build_terminator_layout +from terminatorlib.tmux.state import TmuxSyncState + + +ESCAPE_CODE = '\033' + + +def esc(seq): + return '{}{}'.format(ESCAPE_CODE, seq) + + +# Map Gdk keysyms to escape sequences for tmux send-keys +# Standard xterm escape sequences for keys that Gdk.keyval_to_unicode +# can't translate (returns 0). Everything else we get from Gdk directly. +XTERM_KEYS = { + Gdk.KEY_Up: b'\x1b[A', + Gdk.KEY_Down: b'\x1b[B', + Gdk.KEY_Right: b'\x1b[C', + Gdk.KEY_Left: b'\x1b[D', + Gdk.KEY_Home: b'\x1b[H', + Gdk.KEY_End: b'\x1b[F', + Gdk.KEY_Insert: b'\x1b[2~', + Gdk.KEY_Page_Up: b'\x1b[5~', + Gdk.KEY_Page_Down: b'\x1b[6~', + Gdk.KEY_ISO_Left_Tab: b'\x1b[Z', + Gdk.KEY_F1: b'\x1bOP', + Gdk.KEY_F2: b'\x1bOQ', + Gdk.KEY_F3: b'\x1bOR', + Gdk.KEY_F4: b'\x1bOS', + Gdk.KEY_F5: b'\x1b[15~', + Gdk.KEY_F6: b'\x1b[17~', + Gdk.KEY_F7: b'\x1b[18~', + Gdk.KEY_F8: b'\x1b[19~', + Gdk.KEY_F9: b'\x1b[20~', + Gdk.KEY_F10: b'\x1b[21~', + Gdk.KEY_F11: b'\x1b[23~', + Gdk.KEY_F12: b'\x1b[24~', +} + +ARROW_KEYS = {Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right} + +MOUSE_WHEEL = { + Gdk.ScrollDirection.UP: 'C-y C-y C-y', + Gdk.ScrollDirection.DOWN: 'C-e C-e C-e', +} + + +_controllers = [] # all active TmuxController instances + + +def get_controller(terminal): + """Look up the TmuxController that owns a terminal.""" + ctrl = getattr(terminal, '_tmux_controller', None) + if ctrl: + return ctrl + # Fallback: search all controllers + for c in _controllers: + if terminal in c.terminal_to_pane: + return c + return None + + +class TmuxController: + """Controller bridging tmux and Terminator. + + One instance per tmux session. Use get_controller(terminal) to + look up the controller for a given terminal widget. + """ + + def __init__(self): + self.active = False + self.state = TmuxSyncState() + self.state.connect('layout-settled', self._on_layout_settled) + self.pane_to_terminal = {} + self.terminal_to_pane = {} + self.pane_alternate = {} + self.window_layouts = {} + self.window_names = {} + self.window_indices = {} + self._resize_timer = None + self._initial_layout_ready = threading.Event() + self.session_name = None + self.protocol = None + self.handlers = None + self.active_window_id = None # @id of tmux's active window + self._pending_output = {} # pane_id -> [data, ...] for pre-registration %output + self._origin_terminal = None # terminal that ran tmux -CC (PTY takeover) + + def start(self, session_name, new_session=False): + """Start the tmux controller. + + Creates subprocess, wires handlers, starts reader, + queries initial state, and waits for the initial layout + to be ready before returning. + """ + self.session_name = session_name + self._initial_layout_ready = threading.Event() + self.protocol = TmuxProtocol(session_name, new_session=new_session) + + # Import handlers here to avoid circular imports + from terminatorlib.tmux.handlers import TmuxHandlers + self.handlers = TmuxHandlers(self) + + self.protocol.start() + self.active = True + _controllers.append(self) + dbg('TmuxController: started for session %s' % session_name) + + # Query initial state and wait for it + self._query_initial_state() + dbg('TmuxController: waiting for initial layout...') + self._initial_layout_ready.wait(timeout=5.0) + if not self._initial_layout_ready.is_set(): + dbg('TmuxController: timeout waiting for initial layout') + else: + dbg('TmuxController: initial layout ready') + + def start_from_pty(self, pty_fd, session_name=None): + """Start the tmux controller using an existing PTY fd. + + Used when the user runs 'tmux -CC' inside a terminal and we + take over the PTY as the communication channel. + """ + self.session_name = session_name or 'unknown' + self._initial_layout_ready = threading.Event() + self.protocol = TmuxProtocolFromPty(pty_fd) + + from terminatorlib.tmux.handlers import TmuxHandlers + self.handlers = TmuxHandlers(self) + + self.protocol.start() + self.active = True + _controllers.append(self) + dbg('TmuxController: started from PTY fd %d' % pty_fd) + + # Query initial state and wait for it + self._query_initial_state() + dbg('TmuxController: waiting for initial layout...') + self._initial_layout_ready.wait(timeout=5.0) + if not self._initial_layout_ready.is_set(): + dbg('TmuxController: timeout waiting for initial layout') + else: + dbg('TmuxController: initial layout ready') + + def stop(self, send_detach=True): + """Detach from tmux and clean up. + + send_detach: send detach command to tmux before stopping. + False when tmux already exited (would go to the shell). + """ + # Restore termios immediately on the bridge fd before it's closed, + # so the shell gets correct terminal state when it resumes + if self.protocol and hasattr(self.protocol, '_bridge'): + self.protocol._bridge.restore_termios() + if self.protocol: + self.protocol.stop(send_detach=send_detach) + self.state.reset() + self.active = False + # Restore the original PTY on the terminal that ran tmux -CC + if self._origin_terminal: + GLib.idle_add(self._restore_origin_terminal) + # Clear controller reference on terminals + for terminal in list(self.terminal_to_pane.keys()): + terminal._tmux_controller = None + self.pane_to_terminal.clear() + self.terminal_to_pane.clear() + self.pane_alternate.clear() + self.window_layouts.clear() + self.window_indices.clear() + if self in _controllers: + _controllers.remove(self) + dbg('TmuxController: stopped') + + def _restore_origin_terminal(self): + """Restore the original PTY on the terminal that started tmux -CC.""" + terminal = self._origin_terminal + self._origin_terminal = None + saved = getattr(terminal, '_saved_pty', None) + if saved and hasattr(terminal, 'vte') and terminal.vte: + dbg('TmuxController: restoring original PTY on origin terminal') + terminal._cleanup_tmux_origin() + terminal.vte.set_pty(saved) + terminal._saved_pty = None + # Delay reconnecting tmux detection so VTE can consume any + # buffered control mode output (like %exit) first + if not getattr(terminal, '_tmux_detect_id', None): + GLib.timeout_add(500, self._reconnect_tmux_detect, terminal) + return False + + def _reconnect_tmux_detect(self, terminal): + """Reconnect tmux detection after VTE has consumed buffered data.""" + if not hasattr(terminal, 'vte') or not terminal.vte: + return False + _col, row = terminal.vte.get_cursor_position() + terminal._tmux_detect_watermark = row + terminal._tmux_detect_id = terminal.vte.connect( + 'contents-changed', + terminal.on_vte_contents_changed_tmux_detect) + return False # don't repeat + return False + + def _query_initial_state(self): + """Query tmux for current windows and panes.""" + # First learn the session name if we don't know it + if self.session_name == 'unknown': + self.protocol.send_command( + 'display-message -p "#{session_name}"', + callback=self._on_session_name, + ) + else: + self._query_windows() + + def _on_session_name(self, result): + """Handle session name query response.""" + if not result.is_error and result.output_lines: + name = result.output_lines[0].decode('utf-8', errors='replace').strip() + if name: + self.session_name = name + dbg('TmuxController: session name is %s' % name) + self._query_windows() + + def _query_windows(self): + """Query tmux for window layouts and names. + Uses colon separator to avoid brace-related PTY echo issues. + """ + self.protocol.send_command( + 'list-windows -F "W:#{window_id}:#{window_index}:#{window_name}:#{window_active}:#{window_layout}"', + callback=self.handlers.on_initial_list_windows, + ) + + def register_terminal(self, pane_id, terminal): + """Register a terminal widget for a tmux pane.""" + self.pane_to_terminal[pane_id] = terminal + self.terminal_to_pane[terminal] = pane_id + terminal._tmux_controller = self + dbg('TmuxController: registered terminal for pane %s' % pane_id) + + # Replay any %output that arrived before registration + pending = self._pending_output.pop(pane_id, None) + if pending: + dbg('TmuxController: replaying %d buffered chunks for pane %s' % (len(pending), pane_id)) + for data in pending: + GLib.idle_add(self._replay_output, terminal, data) + + def _replay_output(self, terminal, data): + """Feed buffered output to terminal VTE. Called on GTK thread.""" + try: + if hasattr(terminal, 'vte') and terminal.vte: + terminal.vte.feed(data) + except Exception as e: + dbg('TmuxController: replay feed error: %s' % e) + return False + + def unregister_terminal(self, terminal): + """Unregister a terminal widget.""" + pane_id = self.terminal_to_pane.pop(terminal, None) + if pane_id: + self.pane_to_terminal.pop(pane_id, None) + self.pane_alternate.pop(pane_id, None) + terminal._tmux_controller = None + dbg('TmuxController: unregistered terminal for pane %s' % pane_id) + + def send_keypress(self, terminal, event): + """Translate a Gdk key event to raw bytes and send via send-keys -H.""" + pane_id = self.terminal_to_pane.get(terminal) + if not pane_id: + return + + keyval = event.keyval + state = event.state + ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + alt = bool(state & Gdk.ModifierType.MOD1_MASK) + shift = bool(state & Gdk.ModifierType.SHIFT_MASK) + + # Skip Ctrl+Shift+Alt combos + if alt and ctrl and shift: + return + + raw = None + + if keyval in XTERM_KEYS: + raw = XTERM_KEYS[keyval] + # Ctrl+arrow: modify to CSI 1;5 X + if ctrl and keyval in ARROW_KEYS: + raw = b'\x1b[1;5' + raw[-1:] + elif shift and keyval in ARROW_KEYS: + raw = b'\x1b[1;2' + raw[-1:] + else: + # Use Gdk to get the unicode codepoint + uc = Gdk.keyval_to_unicode(keyval) + if uc: + if ctrl and not alt: + # Ctrl+letter: produce control character (e.g. Ctrl-U = 0x15) + if 0x40 <= uc <= 0x7e: + raw = bytes([uc & 0x1f]) + elif 0x60 <= uc <= 0x7e: + raw = bytes([uc & 0x1f]) + else: + raw = chr(uc).encode('utf-8') + else: + raw = chr(uc).encode('utf-8') + else: + return + + if raw is None: + return + + # Alt prefix: ESC before the key bytes + if alt and not ctrl: + raw = b'\x1b' + raw + + # Send as hex via send-keys -H + hex_str = ' '.join('%02x' % b for b in raw) + self.protocol.send_command( + 'send-keys -H -t {} {}'.format(pane_id, hex_str)) + + def send_paste(self, terminal, text): + """Send pasted text to tmux as hex-encoded bytes.""" + pane_id = self.terminal_to_pane.get(terminal) + if not pane_id or not text: + return + raw = text.encode('utf-8') + hex_str = ' '.join('%02x' % b for b in raw) + self.protocol.send_command( + 'send-keys -H -t {} {}'.format(pane_id, hex_str)) + + def send_mousewheel(self, terminal, event): + """Handle mouse scroll in tmux mode. + + Only active when alternate screen is on (e.g. in vim/less). + Returns True if handled, False to let Terminator handle it. + """ + pane_id = self.terminal_to_pane.get(terminal) + if not pane_id: + return False + + if not self.pane_alternate.get(pane_id): + return False + + if event.direction == Gdk.ScrollDirection.SMOOTH: + if event.delta_y <= 0.0: + wheel = MOUSE_WHEEL[Gdk.ScrollDirection.UP] + else: + wheel = MOUSE_WHEEL[Gdk.ScrollDirection.DOWN] + elif event.direction in MOUSE_WHEEL: + wheel = MOUSE_WHEEL[event.direction] + else: + return False + + self.protocol.send_command('send-keys -t {} {}'.format(pane_id, wheel)) + return True + + def notify_resize(self, terminal, cols, rows): + """Notify tmux of terminal resize. + + Distinguishes between window resize (sends refresh-client -C) + and split bar drag (sends relative resize-pane commands). + Called immediately on each VTE size-allocate — the char-size + dedup (size != _last_client_size) prevents redundant commands. + """ + pane_id = self.terminal_to_pane.get(terminal, '?') + # Ignore resizes before initial layout has been applied + if not self.state.layout_applied_time: + return + dbg('notify_resize: %s %dx%d applying=%s' % ( + pane_id, cols, rows, self.state.applying_layout)) + # Don't send resize while we're applying a layout from tmux. + # Clear the flag reactively via _finish_applying_layout at + # priority 210 — this runs after ALL pending notify_resize + # callbacks (priority 200) have been suppressed. + if self.state.applying_layout: + dbg('notify_resize: suppressed (applying_layout' + ' resize_pending=%s)' % + self.state.window_resize_pending) + if self.handlers and not self.state.window_resize_pending: + # Window has reached target size (or no resize + # was requested). Reschedule on every suppressed + # call — defers clearing until after the LAST VTE + # settles, including anchor corrections from + # STALE paneds catching up to their real size. + from gi.repository import GLib + src = self.state.layout_clear_source + if src: + GLib.source_remove(src) + tree = self.state.pending_layout_tree + self.state.layout_clear_source = GLib.idle_add( + self._do_finish_applying_layout, tree, + priority=GLib.PRIORITY_DEFAULT_IDLE + 10) + # During handle drag, still report the dragged pane's + # size to tmux even while applying layout. The + # detection requires _tmux_handle_pressed so only the + # actually-dragged handle is picked up. + for p in self.state.tmux_paneds: + if getattr(p, '_tmux_handle_pressed', False): + self._send_split_bar_resize() + break + return + + def do_resize(): + # Always snapshot current VTE sizes first, so _prev_vte_sizes + # stays current even when we suppress sending commands + def _snapshot_vte_sizes(): + for t, pane_id in self.terminal_to_pane.items(): + try: + self.state.prev_vte_sizes[pane_id] = ( + t.vte.get_column_count(), t.vte.get_row_count()) + except Exception: + pass + + # Check for chrome change (tab bar appeared/disappeared) + import time as _time + for t in self.terminal_to_pane: + try: + top = t.get_toplevel() + if self.handlers and self.state.last_chrome is not None: + chrome = self.handlers._get_chrome_size(top) + if chrome != self.state.last_chrome: + delta_w = chrome[0] - self.state.last_chrome[0] + delta_h = chrome[1] - self.state.last_chrome[1] + ws = top.get_size() + new_w = ws[0] + delta_w + new_h = ws[1] + delta_h + dbg('size_trace chrome_changed: ' + '%dx%d -> %dx%d delta=%dx%d ' + 'resize=%dx%d' % ( + self.state.last_chrome[0], + self.state.last_chrome[1], + chrome[0], chrome[1], + delta_w, delta_h, + new_w, new_h)) + top.resize(new_w, new_h) + self.state.last_chrome = chrome + self.state.layout_applied_time = \ + _time.monotonic() + _snapshot_vte_sizes() + return False + break + except Exception: + pass + + # Detect if the overall window changed size (vs just a split drag) + window_resized = False + tripwire_hit = False + for t in self.terminal_to_pane: + try: + top = t.get_toplevel() + px = top.get_size() + if px != self.state.last_window_pixels: + dbg('window pixels changed %s -> %s' % (self.state.last_window_pixels, px)) + window_resized = True + self.state.last_window_pixels = px + # Check if we hit the tripwire boundary + # Tripwire is in allocation space (includes CSD) + alloc = top.get_allocation() + apx = (alloc.width, alloc.height) + if (self.state.tripwire_armed and self.state.tripwire_pixels + and apx): + hit_w = (self.state.tmux_max_cols is not None + and apx[0] >= self.state.tripwire_pixels[0]) + hit_h = (self.state.tmux_max_rows is not None + and apx[1] >= self.state.tripwire_pixels[1]) + if hit_w or hit_h: + tripwire_hit = True + break + except Exception: + pass + + # Log all widget layers to understand chrome + for t in self.terminal_to_pane: + try: + top = t.get_toplevel() + ws = top.get_size() + wa = top.get_allocation() + va = t.vte.get_allocation() + vc = t.vte.get_column_count() + vr = t.vte.get_row_count() + ta = t.get_allocation() + sf = top.get_scale_factor() + content = top.get_child() + ca = content.get_allocation() if content else None + rp = self.handlers._find_root_paned(t) \ + if self.handlers else None + pa = rp.get_allocation() if rp else None + cw = t.vte.get_char_width() + ch = t.vte.get_char_height() + dbg('do_resize layers: ' + 'scale=%d ws=%dx%d alloc=%dx%d ' + 'content=%s paned=%s ' + 'term=%dx%d vte=%dx%d ' + 'chars=%dx%d char_px=%dx%d' % ( + sf, + ws[0], ws[1], wa.width, wa.height, + '%dx%d' % (ca.width, ca.height) + if ca else 'None', + '%dx%d' % (pa.width, pa.height) + if pa else 'None', + ta.width, ta.height, + va.width, va.height, + vc, vr, cw, ch)) + if ca and pa: + dbg('do_resize chrome: ' + 'content-paned=%dx%d ' + 'ws-vte=%dx%d ' + 'paned-vte=%dx%d ' + 'term-vte=%dx%d' % ( + ca.width - pa.width, + ca.height - pa.height, + ws[0] - va.width, + ws[1] - va.height, + pa.width - va.width, + pa.height - va.height, + ta.width - va.width, + ta.height - va.height)) + elif ca: + dbg('do_resize chrome: ' + 'NO PANED ' + 'content-term=%dx%d ' + 'ws-vte=%dx%d ' + 'term-vte=%dx%d' % ( + ca.width - ta.width, + ca.height - ta.height, + ws[0] - va.width, + ws[1] - va.height, + ta.width - va.width, + ta.height - va.height)) + break + except Exception: + pass + dbg('notify_resize: window_resized=%s pane_count=%d' % ( + window_resized, len(self.terminal_to_pane))) + if window_resized or len(self.terminal_to_pane) <= 1: + # Window resize: send refresh-client -C with total size + if not self.state.refresh_client_in_flight: + total_cols, total_rows = self._calculate_client_size() + if total_cols > 0 and total_rows > 0: + size = (total_cols, total_rows) + if tripwire_hit: + self.state.pending_tripwire_hit = True + if size != self.state.last_client_size: + self.state.last_client_size = size + self.state.begin_refresh() + dbg('sending refresh-client -C %d,%d' + % (total_cols, total_rows)) + self.protocol.send_command( + 'refresh-client -C {},{}'.format( + total_cols, total_rows)) + self._refresh_layout_state( + callback=self._on_refresh_complete) + else: + dbg('notify_resize: skipped (refresh in flight)') + elif self.state.pending_new_paneds: + # New paneds being created (add-pane in progress) — + # don't send resize-pane; the layout will settle via + # new-paneds-allocated → _apply_ratios. + dbg('notify_resize: skipped (pending new paneds)') + else: + # Split bar drag: send absolute resize for the most-changed pane + self._send_split_bar_resize() + + _snapshot_vte_sizes() + return False + + do_resize() + + if self.handlers and self.state.initial_capture_pending: + # Log paned ancestry for settling diagnostics + w = terminal.get_parent() + while w is not None: + if hasattr(w, 'get_position') and hasattr(w, 'get_length'): + c2 = w.get_child2() + c2a = c2.get_allocation() if c2 else None + mgap = getattr(w, '_measured_gap', None) + dbg('settle trace %s: paned=%s ' + 'pos=%d len=%d mgap=%s c2=%s' % ( + pane_id, type(w).__name__, + w.get_position(), w.get_length(), + mgap, + '%dx%d' % (c2a.width, c2a.height) + if c2a else 'None')) + w = w.get_parent() + self.handlers._check_pane_stable(pane_id, cols, rows) + + def _do_finish_applying_layout(self, tree): + """Clear applying_layout after all VTE size-allocate callbacks. + + Scheduled at priority DEFAULT_IDLE+10 (210) from notify_resize. + Rescheduled on every suppressed notify_resize, so it only + fires after the LAST VTE settles (including anchor cascades). + """ + self.state.layout_clear_source = None + if not self.state.applying_layout: + # Stale callback from a previous cycle — the flag + # was already cleared. Don't re-apply ratios. + return False + if tree is None: + # Scheduled from a suppressed notify_resize before + # _update_pane_sizes ran (e.g. split_axis event + # draining). _update_pane_sizes will take over. + return False + if self.handlers: + self.handlers._finish_applying_layout(tree) + return False + + def _on_layout_settled(self, state, tree): + """Handle 'layout-settled' signal from state object. + + Clears refresh-client in-flight flag and processes any + deferred tripwire hit. Runs synchronously during the + signal emission from finish_layout(). + """ + self.state.end_refresh() + self._process_tripwire() + # Recheck window size — it may have changed while + # applying_layout suppressed notify_resize. Deferred + # because _finish_applying_layout still has work to do + # after emitting this signal (tree size check, snapshot). + GLib.idle_add(self._recheck_after_layout) + + def _recheck_after_layout(self): + """Send refresh-client if window capacity differs from tree. + + Derives the client size from window pixel dimensions and + chrome overhead — this is always correct and can't inflate + from stale VTE column counts. Replaces the VTE-sum approach + that caused oscillation with server-initiated layouts. + """ + if self.state.applying_layout: + return False + if self.state.refresh_client_in_flight: + return False + win_px = self.state.last_window_pixels + chrome = self.state.last_chrome + if not win_px or not chrome: + return False + # Get char dimensions from any registered terminal. + char_w = char_h = 0 + for t in list(self.terminal_to_pane.keys())[:1]: + try: + char_w = t.vte.get_char_width() + char_h = t.vte.get_char_height() + except Exception: + pass + if char_w <= 0 or char_h <= 0: + return False + paned_w = win_px[0] - chrome[0] + paned_h = win_px[1] - chrome[1] + total_cols = paned_w // char_w + total_rows = paned_h // char_h + if total_cols > 0 and total_rows > 0: + prev = self.state.last_client_size + size = (total_cols, total_rows) + if prev and size != prev: + self.state.last_client_size = size + self.state.begin_refresh() + dbg('recheck: client size differs, ' + 'refresh-client -C %d,%d ' + '(from %dx%d px, chrome %dx%d)' % + (total_cols, total_rows, + win_px[0], win_px[1], + chrome[0], chrome[1])) + self.protocol.send_command( + 'refresh-client -C {},{}'.format( + total_cols, total_rows)) + self._refresh_layout_state( + callback=self._on_refresh_complete) + return False + + def _ensure_configure_handler(self, window): + """Connect configure-event handler once per window.""" + if not self.state.configure_handler_id: + self.state.configure_handler_id = window.connect( + 'configure-event', + self._on_configure_event) + + def _on_configure_event(self, window, event): + """WM responded to a window resize request. + + Clears _window_resize_pending so the suppressed notify_resize + path can schedule _finish_applying_layout. The allocation + cascade from this configure-event will trigger VTE + size-allocate → notify_resize → idle schedule. + """ + ws = window.get_size() + wa = window.get_allocation() + if self.state.window_resize_pending: + self.state.end_window_resize() + # Don't update last_window_pixels here — let + # notify_resize detect the size change and send + # refresh-client to tmux. last_window_pixels is + # updated by _finish_applying_layout and do_resize. + dbg('configure-event: resize complete, ' + 'size=%s alloc=%dx%d applying=%s' % ( + ws, wa.width, wa.height, + self.state.applying_layout)) + else: + dbg('configure-event: unsolicited, ' + 'size=%s alloc=%dx%d applying=%s ' + 'last_px=%s' % ( + ws, wa.width, wa.height, + self.state.applying_layout, + self.state.last_window_pixels)) + return False # propagate + + def _do_arm_tripwire(self): + """Set max to the next character boundary so we can detect + when the user tries to grow past the current max. + Only constrains axes that have a known limit; unconstrained + axes get screen-max so the user can freely resize them.""" + if (self.state.tmux_max_cols is None and self.state.tmux_max_rows is None) \ + or not self.handlers: + # No constraints — clear any stale MAX hint + if self.handlers: + self.handlers._clear_tmux_max_size() + return False + # For constrained axes, probe +1 char beyond the limit. + # For unconstrained axes, use current tree size (no limit). + tree = None + for tree in self.state.layout_trees.values(): + break + if tree is None: + return False + probe_cols = (self.state.tmux_max_cols + 1) if self.state.tmux_max_cols \ + else tree.width + probe_rows = (self.state.tmux_max_rows + 1) if self.state.tmux_max_rows \ + else tree.height + info_next = self.handlers._chars_to_max_pixels( + probe_cols, probe_rows) + if not info_next: + return False + trip_w, trip_h = info_next[0], info_next[1] + # For unconstrained axes, use a very large value so + # the WM doesn't limit that axis and tripwire never fires. + max_w = trip_w if self.state.tmux_max_cols is not None else 32767 + max_h = trip_h if self.state.tmux_max_rows is not None else 32767 + # _set_max_size_pixels adds CSD → allocation-space values + hint_w, hint_h = self.handlers._set_max_size_pixels(max_w, max_h) + dbg('arming tripwire: cols=%s rows=%s ' + 'trip=%dx%d px (hint=%dx%d)' % ( + self.state.tmux_max_cols or 'free', + self.state.tmux_max_rows or 'free', + max_w, max_h, hint_w, hint_h)) + # Store in allocation space to match get_allocation() comparison + self.state.tripwire_pixels = (hint_w, hint_h) + self.state.tripwire_armed = True + return False + + def _arm_tripwire_after_idle(self): + """Arm the tripwire after 2s of idle (initial or rejection).""" + if self.state.tripwire_timer: + GLib.source_remove(self.state.tripwire_timer) + + def _arm(): + self.state.tripwire_timer = None + return self._do_arm_tripwire() + + self.state.tripwire_timer = GLib.timeout_add(2000, _arm) + + def _send_split_bar_resize(self): + """Send resize-pane for child1 of the dragged handle. + + Identifies which paned handle the user dragged by finding the + paned whose position changed but length stayed the same. + Then targets child1 of that paned — this ensures tmux adjusts + the correct border (between child1 and its next sibling) + rather than taking space from a distant pane. + """ + # Log all pane deltas for debugging + for terminal, pane_id in self.terminal_to_pane.items(): + try: + cur = (terminal.vte.get_column_count(), + terminal.vte.get_row_count()) + prev = self.state.prev_vte_sizes.get(pane_id) + if prev and cur != prev: + dbg('split drag delta: %s prev=%dx%d ' + 'cur=%dx%d' % ( + pane_id, prev[0], prev[1], + cur[0], cur[1])) + except Exception: + pass + + # Find the dragged handle: must have _tmux_handle_pressed, + # position changed from synced, and length unchanged. + paneds = self.state.tmux_paneds + dragged = None + for paned in paneds: + if not getattr(paned, '_tmux_handle_pressed', False): + continue + synced = getattr(paned, '_tmux_synced_pos', None) + if synced is None: + continue + cur_pos = paned.get_position() + cur_len = paned.get_length() + prev_len = getattr(paned, '_tmux_prev_len', cur_len) + child1_id = getattr( + paned, '_tmux_child1_pane_id', '?') + dbg('split drag check: child1=%s pos=%d ' + 'synced=%d len=%d prev_len=%d' % ( + child1_id, cur_pos, synced, + cur_len, prev_len)) + if cur_pos != synced and cur_len == prev_len: + dragged = paned + break + + # Keep prev_len current so the next check can detect + # handle drags (position changed, length same). + for p in paneds: + p._tmux_prev_len = p.get_length() + + if dragged is None: + dbg('split drag: no dragged handle found') + return + + child1_id = getattr(dragged, '_tmux_child1_pane_id', + None) + if child1_id is None: + dbg('split drag: no child1 pane_id') + return + + terminal = self.pane_to_terminal.get(child1_id) + if terminal is None: + dbg('split drag: terminal not found for %s' + % child1_id) + return + + try: + cur_cols = terminal.vte.get_column_count() + cur_rows = terminal.vte.get_row_count() + except Exception: + return + + prev = self.state.prev_vte_sizes.get(child1_id) + if prev is None: + return + + prev_cols, prev_rows = prev + dcols = abs(cur_cols - prev_cols) + drows = abs(cur_rows - prev_rows) + + if dcols == 0 and drows == 0: + dbg('split drag: child1 %s unchanged (%dx%d)' + % (child1_id, cur_cols, cur_rows)) + return + + import time as _time + parts = ['resize-pane -t {}'.format(child1_id)] + if dcols > 0: + parts.append('-x {}'.format(cur_cols)) + if drows > 0: + parts.append('-y {}'.format(cur_rows)) + cmd = ' '.join(parts) + dbg('split drag: %s (child1 of dragged handle)' % cmd) + self.protocol.send_command(cmd) + self.state.layout_applied_time = _time.monotonic() + self._refresh_layout_state() + + def _refresh_layout_state(self, callback=None): + """Send list-windows to refresh our layout tree after a resize.""" + self.protocol.send_command( + 'list-windows -F "W:#{window_id}:#{window_index}:#{window_name}:#{window_active}:#{window_layout}"', + callback=callback or self.handlers.on_initial_list_windows, + ) + + def _on_refresh_complete(self, result): + """Reader thread: list-windows response arrived. + + At this point tmux has processed our refresh-client -C. + If a %layout-change was emitted, on_layout_change already + ran (reader thread processes sequentially) and set + state.layout_change_pending before queuing _update_pane_sizes. + """ + self.handlers.on_initial_list_windows(result) + GLib.idle_add(self._on_refresh_round_trip_done) + + def _on_refresh_round_trip_done(self): + """GTK thread: refresh-client round-trip is complete. + + Checks state flags to decide whether to clear _in_flight + now or let _finish_applying_layout do it later. + """ + if (self.state.applying_layout + or self.state.layout_change_pending): + # Layout application is in progress or about to start. + # _finish_applying_layout will clear _in_flight. + dbg('round_trip_done: deferring to layout application') + return False + + # No layout change from our request — clear gate and recheck. + self.state.end_refresh() + self._process_tripwire() + dbg('round_trip_done: cleared in_flight, rechecking') + + # Trigger one notify_resize to detect if window moved + # during the round-trip and send a fresh request. + for t in list(self.terminal_to_pane.keys())[:1]: + try: + self.notify_resize( + t, t.vte.get_column_count(), + t.vte.get_row_count()) + except Exception: + pass + break + return False + + def _process_tripwire(self): + """Process any tripwire hit that was deferred during in-flight.""" + if self.state.pending_tripwire_hit: + self.state.pending_tripwire_hit = False + self.state.tripwire_armed = False + self.state.tripwire_pixels = None + + def _pane_size_for_tmux(self, terminal): + """Get the tmux pane size for a terminal. Returns exact VTE size.""" + return terminal.vte.get_column_count(), terminal.vte.get_row_count() + + def _debug_terminal_sizes(self, terminal, pane_id): + """Log all pixel and character measurements for a terminal.""" + vte = terminal.vte + char_w = vte.get_char_width() + char_h = vte.get_char_height() + vte_alloc = vte.get_allocation() + term_alloc = terminal.get_allocation() + vte_cols = vte.get_column_count() + vte_rows = vte.get_row_count() + + tb_h = 0 + sb_w = 0 + if hasattr(terminal, 'titlebar') and terminal.titlebar and terminal.titlebar.get_visible(): + tb_h = terminal.titlebar.get_allocation().height + if hasattr(terminal, 'scrollbar') and terminal.scrollbar and terminal.scrollbar.get_visible(): + sb_w = terminal.scrollbar.get_allocation().width + + dbg('DEBUG %s: cell=%dx%d vte_px=%dx%d vte_chars=%dx%d ' + 'term_px=%dx%d titlebar_h=%d scrollbar_w=%d' % ( + pane_id, char_w, char_h, + vte_alloc.width, vte_alloc.height, vte_cols, vte_rows, + term_alloc.width, term_alloc.height, tb_h, sb_w)) + + def _calculate_client_size(self): + """Calculate the total tmux client size from VTE grid sizes. + + Sums individual VTE column/row counts using the layout tree + structure, adding 1 character per tmux separator between panes. + This avoids the bounding-box approach which inflates the count + by including scrollbar and handle pixels in the character total. + """ + terminals = list(self.terminal_to_pane.keys()) + if not terminals: + return 0, 0 + + if len(terminals) == 1: + t = terminals[0] + try: + return t.vte.get_column_count(), t.vte.get_row_count() + except Exception: + return 0, 0 + + # Use layout tree to sum VTE sizes + 1 per tmux separator + if self.handlers and self.state.layout_trees: + for tree in self.state.layout_trees.values(): + cols, rows = self._sum_vte_sizes(tree) + if cols > 0 and rows > 0: + dbg('client size: %dx%d (from VTE grid + separators)' % ( + cols, rows)) + return cols, rows + + # Fallback: single terminal sizes + t = terminals[0] + try: + return t.vte.get_column_count(), t.vte.get_row_count() + except Exception: + return 0, 0 + + def _sum_vte_sizes(self, node): + """Compute total character size from actual VTE widgets + tmux separators. + + Walks the layout tree, reads each leaf's VTE column/row count, + and sums them with +1 per separator (matching tmux's layout math). + Detects unallocated widgets (1x1 pixel) and falls back to tmux + node dimensions to avoid stale set_size() column counts. + """ + if node.is_leaf: + terminal = self.pane_to_terminal.get(node.pane_id) + if terminal: + try: + vte_alloc = terminal.vte.get_allocation() + char_w = terminal.vte.get_char_width() + char_h = terminal.vte.get_char_height() + if (char_w > 0 and char_h > 0 + and vte_alloc.width > char_w + and vte_alloc.height > char_h): + return (terminal.vte.get_column_count(), + terminal.vte.get_row_count()) + except Exception: + pass + return node.width, node.height + + child_sizes = [self._sum_vte_sizes(c) for c in node.children] + n_seps = len(child_sizes) - 1 + + if node.orientation == 'h': + total_cols = sum(s[0] for s in child_sizes) + n_seps + max_rows = max((s[1] for s in child_sizes), default=0) + return total_cols, max_rows + else: + max_cols = max((s[0] for s in child_sizes), default=0) + total_rows = sum(s[1] for s in child_sizes) + n_seps + return max_cols, total_rows + + def get_initial_layout(self): + """Build Terminator layout from tmux's current state. + + Called during startup to configure the initial window layout. + Returns the layout dict or None if not yet available. + """ + if not self.window_layouts: + return None + + from terminatorlib.tmux.layout import parse_tmux_layout, build_terminator_layout + nodes = [] + total_cols = 0 + total_rows = 0 + for window_id, layout_string in self.window_layouts.items(): + try: + node = parse_tmux_layout(layout_string) + except ValueError as e: + dbg('TmuxController: skipping bad layout for %s: %s' % (window_id, e)) + continue + nodes.append(node) + total_cols = max(total_cols, node.width) + total_rows = max(total_rows, node.height) + + return build_terminator_layout(nodes, total_cols, total_rows) diff --git a/terminatorlib/tmux/handlers.py b/terminatorlib/tmux/handlers.py new file mode 100644 index 00000000..fe2cee47 --- /dev/null +++ b/terminatorlib/tmux/handlers.py @@ -0,0 +1,2322 @@ +"""Tmux notification handlers - maps tmux events to Terminator widget operations. + +All GTK operations are dispatched via GLib.idle_add() for thread safety. +""" + +from gi.repository import GLib, Gtk + +from terminatorlib.util import dbg +from terminatorlib.tmux.layout import ( + parse_tmux_layout, get_pane_ids, find_pane_parent, find_pane_node, +) + + +ALTERNATE_SCREEN_ENTER = b'\x1b[?1049h' +ALTERNATE_SCREEN_EXIT = b'\x1b[?1049l' + +SHELL_COMMANDS = {'bash', 'zsh', 'fish', 'sh', 'dash', 'csh', 'tcsh', 'ksh'} + + +class TmuxHandlers: + """Handles tmux notifications and maps them to Terminator operations.""" + + def __init__(self, controller): + self.controller = controller + self.state = controller.state + self.protocol = controller.protocol + # Register handlers + self.protocol.add_handler('output', self.on_output) + self.protocol.add_handler('layout-change', self.on_layout_change) + self.protocol.add_handler('window-add', self.on_window_add) + self.protocol.add_handler('window-close', self.on_window_close) + self.protocol.add_handler('unlinked-window-close', self.on_window_close) + self.protocol.add_handler('window-renamed', self.on_window_renamed) + self.protocol.add_handler('exit', self.on_exit) + + _output_count = 0 + _output_log_interval = 50 # log every Nth %output + + def on_output(self, info): + """Handle %output: feed data to the terminal's VTE.""" + pane_id = info['pane_id'] + data = info['data'] + + self.__class__._output_count += 1 + if self.__class__._output_count <= 5 or \ + self.__class__._output_count % \ + self.__class__._output_log_interval == 0: + dbg('%%output #%d: %s %d bytes' % ( + self.__class__._output_count, + pane_id, len(data))) + + terminal = self.controller.pane_to_terminal.get(pane_id) + if not terminal: + # Buffer output for panes not yet registered (race: %output + # arrives before _create_tab_for_window finishes). + buf = self.controller._pending_output + if pane_id not in buf: + buf[pane_id] = [] + buf[pane_id].append(data) + dbg('TmuxHandlers: buffered %%output for unregistered ' + 'pane %s (%d chunks)' % (pane_id, len(buf[pane_id]))) + return + + # Track alternate screen state (vim, less, etc.) + if ALTERNATE_SCREEN_ENTER in data: + self.controller.pane_alternate[pane_id] = True + if ALTERNATE_SCREEN_EXIT in data: + self.controller.pane_alternate[pane_id] = False + + # Feed to VTE on GTK thread + GLib.idle_add(self._feed_terminal, terminal, data) + + def _feed_terminal(self, terminal, data): + """Feed data to terminal VTE widget. Must be called on GTK thread.""" + try: + if hasattr(terminal, 'vte') and terminal.vte: + terminal.vte.feed(data) + except Exception as e: + dbg('TmuxHandlers: feed error: %s' % e) + return False + + def _feed_terminal_logged(self, terminal, data, pane_id): + """Feed initial capture data with logging.""" + try: + if hasattr(terminal, 'vte') and terminal.vte: + vte = terminal.vte + cols_before = vte.get_column_count() + rows_before = vte.get_row_count() + vte.feed(data) + dbg('initial capture FED %s: %d bytes ' + 'vte=%dx%d' % ( + pane_id, len(data), + cols_before, rows_before)) + else: + dbg('initial capture: %s has no vte' % pane_id) + except Exception as e: + dbg('initial capture feed error %s: %s' % ( + pane_id, e)) + return False + + def _get_chrome_size(self, window): + """Compute chrome pixels (tab bar, borders) excluding pane area. + + Chrome = content_allocation - notebook_page_allocation. + Using the notebook page (not a terminal) avoids counting + other panes as chrome when splits are present. + + Falls back to preferred sizes when allocations aren't + available yet (before GTK's first layout pass). + """ + content = window.get_child() + if not content: + return 0, 0 + ca = content.get_allocation() + if hasattr(content, 'get_current_page'): + page_num = content.get_current_page() + if page_num >= 0: + page = content.get_nth_page(page_num) + pa = page.get_allocation() + cw = ca.width - pa.width + ch = ca.height - pa.height + if cw > 0 or ch > 0: + return cw, ch + # Allocations not ready — use preferred sizes + _, cnt_w = content.get_preferred_width() + _, cnt_h = content.get_preferred_height() + _, pg_w = page.get_preferred_width() + _, pg_h = page.get_preferred_height() + return max(0, cnt_w - pg_w), max(0, cnt_h - pg_h) + return 0, 0 + + def _find_tmux_window(self, terminator): + """Find the Terminator window that contains this controller's terminals.""" + for window in terminator.windows: + for terminal in window.get_terminals(): + if terminal in self.controller.terminal_to_pane: + return window + return None + + def _is_active_window(self, tree, window_id=None): + """Check if this layout tree's panes are in the currently visible tab.""" + # Check tmux's active window flag first (reliable before + # terminals are mapped and during rapid resize sequences) + if window_id is None: + awid = self.controller.active_window_id + if awid: + for wid, t in self.state.layout_trees.items(): + if t is tree: + return wid == awid + elif self.controller.active_window_id: + return window_id == self.controller.active_window_id + # Fall back to GTK mapped state + pane_ids = get_pane_ids(tree) + for pid in pane_ids: + terminal = self.controller.pane_to_terminal.get(pid) + if terminal and terminal.get_mapped(): + return True + return False + + _layout_change_count = 0 + + def on_layout_change(self, info): + """Handle %layout-change: sync Terminator splits with tmux layout.""" + import time, traceback + self.__class__._layout_change_count += 1 + window_id = info['window_id'] + layout_string = info['layout_string'] + + try: + new_tree = parse_tmux_layout(layout_string) + except ValueError as e: + dbg('TmuxHandlers: layout parse error: %s' % e) + return + + new_panes = get_pane_ids(new_tree) + old_tree = self.state.layout_trees.get(window_id) + old_panes = get_pane_ids(old_tree) if old_tree else set() + + deleted_panes = old_panes - new_panes + added_panes = new_panes - old_panes + + # Debug: log every layout-change with dimensions and what triggered it + old_dims = '%dx%d' % (old_tree.width, old_tree.height) if old_tree else 'none' + new_dims = '%dx%d' % (new_tree.width, new_tree.height) + client_size = self.state.last_client_size + elapsed = time.monotonic() - self.state.layout_applied_time \ + if self.state.layout_applied_time else float('inf') + qd = self.protocol._command_queue.qsize() \ + if hasattr(self.protocol, '_command_queue') else '?' + dbg('layout-change #%d: %s old=%s new=%s client=%s ' + 'del=%s add=%s elapsed=%.3fs applying=%s ' + 'qd=%s' % ( + self.__class__._layout_change_count, + window_id, old_dims, new_dims, client_size, + deleted_panes or '{}', added_panes or '{}', + elapsed, self.state.applying_layout, qd)) + + self.state.layout_trees[window_id] = new_tree + self.controller.window_layouts[window_id] = layout_string + + # Update authoritative pane sizes immediately so + # _feed_terminal (which may run before _update_pane_sizes) + # buffers %output that's for the new size while VTEs are + # still at the old size. + self._record_tmux_sizes(new_tree) + + # Trace window size at layout-change entry + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + top = t.get_toplevel() + wa = top.get_allocation() + ws = top.get_size() + va = t.vte.get_allocation() + vc = t.vte.get_column_count() + vr = t.vte.get_row_count() + dbg('size_trace layout-change: ' + 'alloc=%dx%d ws=%dx%d vte=%dx%d ' + 'chars=%dx%d tree=%dx%d' % ( + wa.width, wa.height, ws[0], ws[1], + va.width, va.height, vc, vr, + new_tree.width, new_tree.height)) + except Exception: + pass + + if deleted_panes: + GLib.idle_add(self._close_panes, deleted_panes) + elif added_panes: + GLib.idle_add(self._add_panes, added_panes, new_tree) + + # Resize/constraint logic is active-window-only — + # background windows must not resize us or set MAX. + active = self._is_active_window(new_tree, window_id) + if not active: + if not deleted_panes and not added_panes: + dbg('layout-change: skipping resize for background ' + 'window %s' % window_id) + else: + new_size = (new_tree.width, new_tree.height) + # Use state flags instead of elapsed-time heuristic + # to determine if we caused this layout change. + we_caused_it = (self.state.refresh_client_in_flight + or self.state.window_resize_pending) + + if not client_size: + # Initial startup: resize to match tree + GLib.idle_add(self._resize_window_to_tree, + new_tree) + elif new_size != client_size and not we_caused_it: + # Unsolicited change from tmux/another client + dbg('layout-change: unsolicited size change ' + '%dx%d -> %dx%d (elapsed=%.3fs), ' + 'resizing window' % ( + client_size[0], client_size[1], + new_size[0], new_size[1], elapsed)) + self.state.tmux_max_cols = None + self.state.tmux_max_rows = None + GLib.idle_add(self._resize_window_to_tree, + new_tree) + elif new_size != client_size and we_caused_it: + # Response to our refresh-client + rejected = (new_size[0] < client_size[0] or + new_size[1] < client_size[1]) + if rejected: + dbg('layout-change: tmux rejected ' + '%dx%d -> %dx%d ' + '(elapsed=%.3fs), ' + 're-constraining' % ( + client_size[0], client_size[1], + new_size[0], new_size[1], + elapsed)) + else: + dbg('layout-change: echo-back size ' + 'change %dx%d -> %dx%d ' + '(elapsed=%.3fs)' % ( + client_size[0], client_size[1], + new_size[0], new_size[1], + elapsed)) + # else: size matches — no window resize needed, + # ratios applied below via _update_pane_sizes + + # Update max size from tree dimensions (free, no query) + GLib.idle_add(self._update_max_from_tree, + new_tree.width, new_tree.height) + + # Apply tmux's layout positions. Skip for added panes — + # _add_panes handles layout via pending_new_paneds mechanism + # (new widgets need allocation before ratios can apply). + if not added_panes: + self.state.layout_change_pending = True + GLib.idle_add(self._update_pane_sizes, new_tree) + + def _log_layout_sizes(self, node, depth=0): + """Log tmux layout sizes vs actual VTE and Terminal widget sizes.""" + if node.is_leaf: + terminal = self.controller.pane_to_terminal.get(node.pane_id) + if terminal: + try: + vte_cols = terminal.vte.get_column_count() + vte_rows = terminal.vte.get_row_count() + tw_cols, tw_rows = self.controller._pane_size_for_tmux(terminal) + match = 'OK' if (tw_cols == node.width and tw_rows == node.height) else 'MISMATCH' + dbg(' %s%s: tmux=%dx%d widget=%dx%d vte=%dx%d %s' % ( + ' ' * depth, node.pane_id, node.width, node.height, + tw_cols, tw_rows, vte_cols, vte_rows, match)) + except Exception: + pass + else: + dbg(' %s%s split %dx%d:' % (' ' * depth, node.orientation, node.width, node.height)) + for child in node.children: + self._log_layout_sizes(child, depth + 1) + + def _update_pane_sizes(self, tree): + """Update split ratios to match tmux's pane dimensions. + Called on GTK thread.""" + # Defer if the initial widget tree is still being built. + # split_axis drains the GTK event queue (main_iteration_do), + # so an idle_add from on_layout_change can fire mid-build. + # Running _apply_ratios on a half-built tree sets positions + # on paneds whose children are about to be replaced, and the + # subsequent remove() clears position_set — causing GTK to + # auto-compute a wrong position on the next allocation. + from terminatorlib.terminator import Terminator + if Terminator().doing_layout: + dbg('_update_pane_sizes: deferred (doing_layout)') + GLib.timeout_add(100, self._update_pane_sizes, tree) + return False + # During handle drag: stash tree for replay on release + # (safety net), but continue processing. Tmux is the + # authority — _apply_ratios applies tmux's layout for all + # paneds, and do_size_allocate suppresses anchoring during + # drag to prevent fighting. + for p in self.state.tmux_paneds: + if getattr(p, '_tmux_handle_pressed', False): + self.state.deferred_layout_tree = tree + break + self.state.layout_change_pending = False + import time + # Trace window size at pane-size update + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + top = t.get_toplevel() + wa = top.get_allocation() + ws = top.get_size() + va = t.vte.get_allocation() + vc = t.vte.get_column_count() + vr = t.vte.get_row_count() + dbg('size_trace update_pane_sizes: ' + 'alloc=%dx%d ws=%dx%d vte=%dx%d ' + 'chars=%dx%d tree=%dx%d' % ( + wa.width, wa.height, ws[0], ws[1], + va.width, va.height, vc, vr, + tree.width, tree.height)) + except Exception: + pass + self.state.begin_layout() + dbg('_apply_layout_to_tree: set applying_layout=True') + deferred = False + try: + self._record_tmux_sizes(tree) + self.state.ratios_changed = False + if not tree.is_leaf: + self.state.needs_ratio_retry = False + self._apply_ratios(tree) + if self.state.needs_ratio_retry: + if self.state.pending_new_paneds: + dbg('retrying ratios when new paneds ' + 'allocated (%d pending)' % + len(self.state.pending_new_paneds)) + self._new_paneds_handler_id = \ + self.state.connect( + 'new-paneds-allocated', + self._on_new_paneds_ready, + tree) + else: + dbg('retrying ratios in 100ms ' + '(unallocated paneds)') + GLib.timeout_add( + 100, + self._apply_ratios_and_finish, + tree) + deferred = True + finally: + if not deferred: + import time + self.state.layout_applied_time = time.monotonic() + if self.state.ratios_changed: + # Ratios changed → VTE allocations will change → + # notify_resize will fire and clear the flag + # reactively via _finish_applying_layout. + self.state.pending_layout_tree = tree + else: + # No ratios changed (single pane or same layout). + # No VTE allocations will change, so nothing will + # trigger _finish_applying_layout. Defer to idle + # so GTK can process pending allocations (e.g. + # notebook tab bar) before the chrome check runs. + GLib.idle_add(self._finish_applying_layout, tree) + return False + + def _apply_ratios_and_finish(self, tree): + """Deferred callback to apply ratios for unallocated Paneds. + + _applying_layout stays True until all ratios are applied, + providing continuous suppression of resize echo-back. + """ + self.state.needs_ratio_retry = False + self.state.ratios_changed = False + try: + self._apply_ratios(tree) + if self.state.needs_ratio_retry: + dbg('retrying ratios in 100ms (unallocated paneds)') + GLib.timeout_add(100, self._apply_ratios_and_finish, tree) + return False # keep _applying_layout True + except Exception: + pass + import time + self.state.layout_applied_time = time.monotonic() + if self.state.ratios_changed: + self.state.pending_layout_tree = tree + else: + GLib.idle_add(self._finish_applying_layout, tree) + return False + + def _finish_applying_layout(self, tree): + """Clear _applying_layout after deferred VTE size-allocate + handlers have been processed. + + Runs at priority DEFAULT_IDLE+10 (210), which is lower than + the deferred notify_resize at DEFAULT_IDLE (200). This + ensures _applying_layout stays True long enough to suppress + the stale resize echo-back that would otherwise send a + refresh-client with pre-ratio VTE column counts. + """ + import time + + # Recompute positions from the tree using actual post-resize + # paned allocations. The first _apply_ratios (from + # _update_pane_sizes) used pre-resize dimensions; now that + # the WM resize has landed, recomputing gives exact positions. + + # --- diagnostic: log window and VTE state before _apply_ratios --- + for t in list(self.controller.terminal_to_pane.keys()): + try: + pid = self.controller.terminal_to_pane[t] + top = t.get_toplevel() + ws = top.get_size() + vte = t.vte + vte_pref_h = vte.get_preferred_height() + cols = vte.get_column_count() + rows = vte.get_row_count() + va = vte.get_allocation() + char_h = vte.get_char_height() + # Terminal (VBox) preferred height + term_pref_h = t.get_preferred_height() + ta = t.get_allocation() + dbg('_finish PRE: %s ws=%dx%d ' + 'vte_alloc=%dx%d vte_chars=%dx%d ' + 'vte_pref_h=%s term_pref_h=%s ' + 'term_alloc_h=%d char_h=%d' % ( + pid, ws[0], ws[1], + va.width, va.height, cols, rows, + vte_pref_h, term_pref_h, + ta.height, char_h)) + except Exception as e: + dbg('_finish PRE: %s exception: %s' % ( + self.controller.terminal_to_pane.get(t, '?'), e)) + # Also log root paned preferred height + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + rp = self._find_root_paned(t) + if rp: + rp_pref_h = rp.get_preferred_height() + rp_alloc = rp.get_allocation() + dbg('_finish PRE: root_paned ' + 'pref_h=%s alloc=%dx%d' % ( + rp_pref_h, + rp_alloc.width, rp_alloc.height)) + except Exception: + pass + + # Re-apply ratios for ALL trees with current (correct) + # paned allocations. The first _apply_ratios (from + # _update_pane_sizes) may have used stale allocations + # from before the WM responded to our resize. + self.state.ratios_changed = False + for t in self.state.layout_trees.values(): + if not t.is_leaf: + self._apply_ratios(t) + if self.state.ratios_changed: + # Ratios changed → VTE sizes are stale. + # Don't pre-seed stability or clear + # _applying_layout. Wait for VTE to settle. + self.state.pending_layout_tree = tree + return False + + # --- diagnostic: log window and VTE state after _apply_ratios --- + for t in list(self.controller.terminal_to_pane.keys()): + try: + pid = self.controller.terminal_to_pane[t] + top = t.get_toplevel() + ws = top.get_size() + vte = t.vte + vte_pref_h = vte.get_preferred_height() + va = vte.get_allocation() + term_pref_h = t.get_preferred_height() + ta = t.get_allocation() + dbg('_finish POST: %s ws=%dx%d ' + 'vte_alloc=%dx%d ' + 'vte_pref_h=%s term_pref_h=%s ' + 'term_alloc_h=%d' % ( + pid, ws[0], ws[1], + va.width, va.height, + vte_pref_h, term_pref_h, + ta.height)) + except Exception: + pass + # Also log root paned preferred height after + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + rp = self._find_root_paned(t) + if rp: + rp_pref_h = rp.get_preferred_height() + rp_alloc = rp.get_allocation() + dbg('_finish POST: root_paned ' + 'pref_h=%s alloc=%dx%d' % ( + rp_pref_h, + rp_alloc.width, rp_alloc.height)) + except Exception: + pass + + dbg('_finish_applying_layout: clearing applying_layout') + # Snapshot actual window size so notify_resize detects any + # WM bounce (the WM may have given us a different size than + # we requested, and the configure-event handler may have + # stamped last_window_pixels with a stale value). + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + top = t.get_toplevel() + self.state.last_window_pixels = top.get_size() + break + except Exception: + pass + # Emits 'layout-settled' — controller's handler clears + # refresh_client_in_flight and processes tripwire. + self.state.finish_layout(tree) + self.state.layout_applied_time = time.monotonic() + initial = self.state.last_client_size is None + if not initial: + # Only update client size from the ACTIVE window's + # tree — background windows may have stale/different + # sizes and must not flip-flop the client size. + active = self._is_active_window(tree) + if active and tree and tree.width > 0 and tree.height > 0: + # We just applied the active layout from tmux — + # the tree dimensions ARE the authoritative client + # size. Don't use _calculate_client_size() here: + # VTE pixel rounding means the sum of VTE chars + # can exceed the tree, triggering a grow loop + # (tree→VTE→bigger refresh-client→bigger + # tree→repeat). + size = (tree.width, tree.height) + if size != self.state.last_client_size: + dbg('_finish_applying_layout: client size ' + 'changed %dx%d -> %dx%d (from tree)' % ( + self.state.last_client_size[0], + self.state.last_client_size[1], + size[0], size[1])) + self.state.last_client_size = size + self.controller.protocol.send_command( + 'refresh-client -C {},{}'.format( + size[0], size[1])) + self.state.layout_applied_time = \ + time.monotonic() + self._snapshot_vte_sizes() + # Check unsettled panes against updated targets. + # Background panes match immediately; active panes + # that received their allocation also settle here. + if self.state.initial_capture_pending: + self._update_capture_targets() + for t, pid in (self.controller + .terminal_to_pane.items()): + if pid in self.state.capture_settled: + continue + try: + c = t.vte.get_column_count() + r = t.vte.get_row_count() + self._check_pane_stable(pid, c, r) + except Exception: + pass + else: + # Initial startup: VTE hasn't settled into the + # resized window yet — paned position changes from + # _apply_ratios are still pending allocation. + # Skip reconcile; the natural flow (VTE settles → + # notify_resize → do_resize → refresh-client → + # layout-change) will converge without it. + cols = tree.width if tree else 0 + rows = tree.height if tree else 0 + if cols > 0 and rows > 0: + size = (cols, rows) + self.state.last_client_size = size + dbg('_finish_applying_layout: initial ' + 'client size %d,%d (from tree)' % (cols, rows)) + self.controller._refresh_layout_state() + self._snapshot_vte_sizes() + # Populate capture targets and check all panes. + # At initial startup, active-window VTEs are still + # at stale sizes and won't match targets — they + # settle later via notify_resize or subsequent + # _finish rounds. Background panes match now. + if self.state.initial_capture_pending: + self._update_capture_targets() + for t, pid in (self.controller + .terminal_to_pane.items()): + if pid in self.state.capture_settled: + continue + try: + c = t.vte.get_column_count() + r = t.vte.get_row_count() + self._check_pane_stable(pid, c, r) + except Exception: + pass + # Check for chrome change (e.g. tab bar appeared) before + # reconcile. If chrome changed, update geometry hints first + # (so the WM snaps to the correct grid), then resize the + # window. Skip reconcile — the resize will trigger fresh + # VTE allocations that produce correct sizes. + chrome_changed = False + for t in list(self.controller.terminal_to_pane.keys())[:1]: + try: + top = t.get_toplevel() + chrome = self._get_chrome_size(top) + if (self.state.last_chrome is not None + and chrome != self.state.last_chrome): + dbg('_finish_applying_layout: chrome_changed ' + '%dx%d -> %dx%d' % ( + self.state.last_chrome[0], + self.state.last_chrome[1], + chrome[0], chrome[1])) + self.state.last_chrome = chrome + # Update hints before resize so WM uses new BASE + top.set_tmux_geometry_hints(t) + self._resize_window_to_tree(tree) + self.state.layout_applied_time = \ + time.monotonic() + chrome_changed = True + except Exception: + pass + break + + # No reconcile — refresh-client -C tells tmux the correct + # total size; tmux distributes panes. Reconcile's per-pane + # resize-pane commands caused a feedback loop (resize-pane → + # layout-change → reconcile → resize-pane → ...) that made + # pane proportions jump during window resize. + self.state.dump('_finish_applying_layout') + return False # don't repeat + + def _snapshot_vte_sizes(self): + """Snapshot current VTE sizes into _prev_vte_sizes. + + Called after layout application so notify_resize won't + misinterpret ratio-driven VTE changes as split-bar drags. + """ + for t, pid in self.controller.terminal_to_pane.items(): + try: + self.state.prev_vte_sizes[pid] = ( + t.vte.get_column_count(), t.vte.get_row_count()) + except Exception: + pass + + def _schedule_reconcile(self, tree): + """Schedule reconciliation after all pending allocations. + + Uses idle priority below _finish_applying_layout (210) so + VTE size-allocate handlers have already run by the time + reconcile executes — no timer race. + """ + if self.state.reconcile_timer: + GLib.source_remove(self.state.reconcile_timer) + self.state.reconcile_timer = GLib.idle_add( + self._reconcile_pane_sizes, tree, + priority=GLib.PRIORITY_DEFAULT_IDLE + 20) + + def _reconcile_pane_sizes(self, tree): + """Send resize-pane for any pane where VTE is smaller than tmux.""" + import time + self.state.reconcile_timer = None + client_size = self.state.last_client_size + dbg('reconcile: tree=%dx%d client=%s' % ( + tree.width, tree.height, client_size)) + if client_size and (tree.width > client_size[0] + or tree.height > client_size[1]): + dbg('reconcile: skipping — layout %dx%d ' + 'exceeds client %dx%d' % ( + tree.width, tree.height, + client_size[0], client_size[1])) + return False + + mismatches = [] + self._collect_mismatches(tree, mismatches) + if not mismatches: + dbg('reconcile: all panes match tmux') + return False + dbg('reconcile: fixing %d mismatched pane(s):' % len(mismatches)) + for pane_id, vte_cols, vte_rows, tmux_cols, tmux_rows in mismatches: + dbg(' %s: vte=%dx%d tmux=%dx%d' % ( + pane_id, vte_cols, vte_rows, tmux_cols, tmux_rows)) + parts = ['resize-pane -t {}'.format(pane_id)] + # Only shrink axes — never grow tmux panes + if vte_cols < tmux_cols: + parts.append('-x {}'.format(vte_cols)) + if vte_rows < tmux_rows: + parts.append('-y {}'.format(vte_rows)) + if len(parts) == 1: + continue # nothing to shrink + cmd = ' '.join(parts) + dbg('reconcile: %s' % cmd) + self.protocol.send_command(cmd) + # Suppress echo-back from layout-change responses + self.state.layout_applied_time = time.monotonic() + # Snapshot VTE sizes so subsequent notify_resize has correct baseline + for t, pid in self.controller.terminal_to_pane.items(): + try: + self.state.prev_vte_sizes[pid] = ( + t.vte.get_column_count(), t.vte.get_row_count()) + except Exception: + pass + self.controller._refresh_layout_state() + return False + + def _collect_mismatches(self, node, out): + """Collect panes where VTE is smaller than tmux expects. + + Only shrink direction: if VTE has fewer cols/rows than + tmux's tree, report it so tmux can reallocate. Never + report VTE > tmux — that's pixel rounding slack and + growing tmux panes causes a feedback loop. + """ + if node.is_leaf: + terminal = self.controller.pane_to_terminal.get(node.pane_id) + if terminal: + try: + vte_cols = terminal.vte.get_column_count() + vte_rows = terminal.vte.get_row_count() + if (vte_cols < node.width + or vte_rows < node.height): + out.append((node.pane_id, vte_cols, + vte_rows, node.width, + node.height)) + except Exception: + pass + else: + for child in node.children: + self._collect_mismatches(child, out) + + def _record_tmux_sizes(self, node): + """Record tmux's reported pane sizes to prevent resize feedback loops. + + Only updates _last_pane_sizes (tmux's view). Does NOT touch + _prev_vte_sizes — those track actual VTE widget sizes and are + only updated from real VTE measurements in do_resize. + """ + if node.is_leaf: + self.state.last_pane_sizes[node.pane_id] = (node.width, node.height) + else: + for child in node.children: + self._record_tmux_sizes(child) + + def _apply_ratios(self, node, expected_allocs=None): + """Recursively set split ratios on Paned containers to match tmux layout. + + expected_allocs: dict mapping orientation ('h'/'v') → expected + pixel length, computed from the parent's already-set position. + Eliminates stale GTK get_length() reads for inner paneds whose + parent hasn't propagated its allocation yet. + + Computes ratios in pixels (not characters) to account for the + scrollbar width and titlebar height inside each Terminal widget. + Without this correction, each pane loses ~1 column/row because + the character-based ratio doesn't reserve space for scrollbars. + + For N-ary splits (3+ children), the GTK widget tree uses nested + binary paneds: [A, B, C] becomes Paned1(A, Paned2(B, C)). + This method sets ratios on all intermediate paneds, not just the + outermost one. + """ + if node.is_leaf or len(node.children) < 2: + return + + # Set ratios for each binary split in the chain. + # For children [c0, c1, c2, ...], we have paneds: + # Paned_0: c0 vs (c1 + c2 + ...) + # Paned_1: c1 vs (c2 + ...) (intermediate paned) + # etc. + orient = node.orientation # 'h' or 'v' + remaining = node.children + expected_child2_len = None + child_allocs = {} # child index → allocation in orient + + while len(remaining) >= 2: + first_leaf_l = self._first_leaf(remaining[0]) + first_leaf_r = self._first_leaf(remaining[1]) + term_l = self.controller.pane_to_terminal.get(first_leaf_l.pane_id) + term_r = self.controller.pane_to_terminal.get(first_leaf_r.pane_id) + + if not (term_l and term_r): + break + + paned = self._find_common_paned(term_l, term_r) + if not (paned and hasattr(paned, 'ratio')): + break + + # Get metrics from an allocated terminal + char_w, char_h, sb_w, tb_h, vpad_x, vpad_y = \ + self._get_terminal_metrics(term_l) + if char_w <= 0 or char_h <= 0: + char_w, char_h, sb_w, tb_h, vpad_x, vpad_y = \ + self._get_terminal_metrics(term_r) + if char_w <= 0 or char_h <= 0: + break + + # Mark as tmux-managed and stash state reference + # so _snap_position can check applying_layout. + paned._tmux_managed = True + paned._tmux_state = self.state + + handle_size = paned.get_handlesize() + if expected_child2_len is not None: + paned_len = expected_child2_len + elif expected_allocs and orient in expected_allocs: + paned_len = expected_allocs[orient] + else: + paned_len = paned.get_length() + + if paned_len <= handle_size: + dbg('ratio SKIPPED: paned not allocated ' + '(len=%d <= handle=%d)' % (paned_len, handle_size)) + self.state.needs_ratio_retry = True + break + + left_px = self._subtree_px( + remaining[0], orient, + char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + right_px = sum( + self._subtree_px(c, orient, + char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + for c in remaining[1:]) + # Add separators between right-side children + if len(remaining) > 2: + sep = char_w if orient == 'h' else char_h + right_px += (len(remaining) - 2) * sep + + # Log tree node dimensions vs paned allocation + left_node = remaining[0] + right_nodes = remaining[1:] + left_dim = ('%dx%d' % (left_node.width, + left_node.height) + if left_node.is_leaf + else '%s(%dx%d)' % ( + left_node.orientation, + left_node.width, + left_node.height)) + right_dim = ','.join( + ('%dx%d' % (c.width, c.height) + if c.is_leaf + else '%s(%dx%d)' % ( + c.orientation, c.width, c.height)) + for c in right_nodes) + total_needed = left_px + right_px + handle_size + stale = total_needed > paned_len + handle_size + dbg('ratio tree: left=[%s] right=[%s] ' + 'need=%dpx paned=%dpx %s' % ( + left_dim, right_dim, + total_needed, paned_len, + 'STALE' if stale else 'ok')) + + # If the paned is newly created and STALE, its GTK + # allocation hasn't propagated yet — skip it and + # retry once the correct allocation arrives via + # the 'new-paneds-allocated' signal. + if stale and paned in self.state.pending_new_paneds: + dbg('ratio SKIPPED: new paned has stale ' + 'allocation (need=%d > paned=%d + ' + 'handle=%d)' % ( + total_needed, paned_len, handle_size)) + self.state.needs_ratio_retry = True + break + + # Cap paned_len at the pixel budget _subtree_px + # computed (children + one char-sized separator). + # Without this, excess padding from an ancestor + # (char_sep - handle_size per VPaned level) leaks + # through cross-direction paneds into this one, + # and this formula adds ANOTHER increment — + # doubling dead space per nesting level. + # + # With the cap, target_pos = left_px + (char_sep - + # handle_size), giving child1 exactly its content + # pixels plus one separator's worth of padding + # above the handle. Any ancestor excess migrates + # to the deepest child2 as sub-row dead space. + char_sep = char_h if orient == 'v' else char_w + padded_need = left_px + right_px + char_sep + effective_len = min(paned_len, padded_need) + target_pos = max(0, + effective_len - right_px - handle_size) + + # Snap so child2 starts at a character cell boundary. + # Without this, floor(pixels / char_w) can be off by 1 + # from what tmux expects, causing %output to be fed at + # the wrong column count. + aligned = round( + (target_pos + handle_size) / char_sep) * char_sep + target_pos = max(0, aligned - handle_size) + + # Detect if user is actively dragging THIS handle: + # mouse button held + position differs from last sync + # + length unchanged (not a parent reallocation). + synced = getattr(paned, '_tmux_synced_pos', None) + prev_len = getattr(paned, '_tmux_prev_len', None) + cur_pos = paned.get_position() + user_dragging = (getattr(paned, + '_tmux_handle_pressed', False) + and synced is not None + and prev_len is not None + and cur_pos != synced + and paned_len == prev_len) + + skip_tag = ' SKIP(user dragging)' if user_dragging \ + else '' + + dbg('ratio %s-split: left=%dpx right=%dpx ' + 'pos=%d old_pos=%d paned=%d eff=%d ' + 'char=%dx%d sb=%d tb=%d handle=%d ' + 'vte_pad=%dx%d%s' % ( + orient, left_px, right_px, target_pos, + cur_pos, paned_len, effective_len, + char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y, + skip_tag)) + + if user_dragging: + # Don't fight GTK's native drag for THIS handle. + # DON'T update synced_pos — keep it at the last + # confirmed position so _send_split_bar_resize + # can detect continued movement (pos != synced). + pass + elif abs(cur_pos - target_pos) > 0: + paned.set_pos(target_pos) + paned.ratio = paned.ratio_by_position( + paned_len, handle_size, target_pos) + self.state.ratios_changed = True + paned._tmux_synced_pos = paned.get_position() + else: + paned._tmux_synced_pos = cur_pos + + paned._tmux_prev_len = paned_len + effective_pos = paned.get_position() + expected_child2_len = (paned_len - effective_pos + - handle_size) + mgap = getattr(paned, '_measured_gap', None) + c2 = paned.get_child2() + c2w = c2.get_allocation().width if c2 else -1 + c2h = c2.get_allocation().height if c2 else -1 + dbg('ratio child2: pos=%d paned=%d handle=%d ' + 'mgap=%s expect_c2=%d actual_c2=%s' % ( + effective_pos, paned_len, handle_size, + mgap, expected_child2_len, + '%dx%d' % (c2w, c2h) if c2 else 'None')) + child_idx = len(node.children) - len(remaining) + child_allocs[child_idx] = effective_pos + if not getattr(paned, '_tmux_anchor_connected', False): + paned.connect('button-press-event', + self._on_paned_button_press) + paned.connect('button-release-event', + self._on_paned_button_release) + paned._tmux_anchor_connected = True + + # Store child1's pane_id — use the first leaf so + # resize-pane targets a pane spanning child1's full + # extent (height for VPaned, width for HPaned). + # The last leaf can be deeply nested in a cross- + # orientation sub-split, causing tmux to move an + # internal boundary instead of the main split. + first_leaf_l = self._first_leaf(remaining[0]) + paned._tmux_child1_pane_id = first_leaf_l.pane_id + self.state.tmux_paneds.add(paned) + + # Move to the next intermediate paned + remaining = remaining[1:] + + # Record last child's allocation from the final + # expected_child2_len (set by the last while iteration). + if expected_child2_len is not None and len(node.children) >= 2: + child_allocs[len(node.children) - 1] = expected_child2_len + + # Recurse into children, passing expected allocations so + # inner paneds use computed sizes instead of stale GTK values. + for idx, child in enumerate(node.children): + if not child.is_leaf: + new_allocs = dict(expected_allocs or {}) + if idx in child_allocs: + new_allocs[orient] = child_allocs[idx] + self._apply_ratios(child, new_allocs) + + def _subtree_px(self, node, orientation, char_w, char_h, sb_w, tb_h, + handle_size, vte_pad_x=0, vte_pad_y=0): + """Compute target pixel extent of a layout subtree along orientation. + + For 'h' orientation: returns width in pixels (chars*char_w + vte_pad + scrollbar + handles). + For 'v' orientation: returns height in pixels (chars*char_h + vte_pad + titlebar + handles). + + vte_pad_x/y accounts for VTE's internal CSS padding (typically 1px + each side). Without this, VTE gets exactly cols*char_w pixels but + subtracts its padding first, leaving room for only cols-1 characters. + """ + if node.is_leaf: + if orientation == 'h': + return node.width * char_w + vte_pad_x + sb_w + else: + return node.height * char_h + vte_pad_y + tb_h + + if node.orientation == orientation: + # Same direction: sum children + separators. + # For v-splits, use char_h as separator size (not handle_size) + # so the total matches tmux's 1-char-tall separators. The + # extra pixels (char_h - handle_size) become padding in + # _apply_ratios to visually merge with the handle. + child_px = [self._subtree_px(c, orientation, + char_w, char_h, sb_w, tb_h, + handle_size, vte_pad_x, vte_pad_y) + for c in node.children] + sep = char_w if orientation == 'h' else char_h + return sum(child_px) + (len(child_px) - 1) * sep + else: + # Cross direction: take max — a v-split child needs more + # pixels than a leaf for the same character count (extra + # titlebars, VTE padding, handles inside the subtree). + return max(self._subtree_px(c, orientation, + char_w, char_h, sb_w, tb_h, + handle_size, vte_pad_x, vte_pad_y) + for c in node.children) + + def _get_terminal_metrics(self, terminal): + """Get char/scrollbar/titlebar/VTE-padding pixel sizes from a terminal. + + Returns (char_w, char_h, sb_w, tb_h, vte_pad_x, vte_pad_y). + char_w and char_h are fractional (Pango-based) for accuracy — + get_char_width()/get_char_height() truncate to int, and the + error accumulates over many characters. + Returns all zeros if the terminal is not yet allocated. + """ + try: + int_cw = terminal.vte.get_char_width() + int_ch = terminal.vte.get_char_height() + alloc = terminal.vte.get_allocation() + if int_cw <= 0 or int_ch <= 0 \ + or alloc.width <= int_cw \ + or alloc.height <= int_ch: + return 0, 0, 0, 0, 0, 0 + char_w = int_cw + char_h = int_ch + # Scrollbar is overlaid (Gtk.Overlay) — it doesn't consume + # layout space, so sb_w is always 0 for pixel calculations. + sb_w = 0 + # Titlebar: only counts if packed in the Terminal VBox + # (consuming layout space). When overlaid (tmux mode), the + # titlebar's parent is the Overlay, not the Terminal VBox. + tb_h = 0 + if (hasattr(terminal, 'titlebar') and terminal.titlebar + and terminal.titlebar.get_visible() + and terminal.titlebar.get_parent() == terminal): + tb_h = terminal.titlebar.get_allocation().height + vte_pad_x = 0 + vte_pad_y = 0 + return char_w, char_h, sb_w, tb_h, vte_pad_x, vte_pad_y + except Exception: + return 0, 0, 0, 0, 0, 0 + + def _on_paned_button_press(self, paned, event): + """Track when user starts dragging a paned handle. + + GTK propagates button-press from child paneds up to parents. + Only set the flag if the click actually landed on THIS + paned's handle — not on a descendant widget. + """ + if event.button == 1: + target = Gtk.get_event_widget(event) + c1 = paned.get_child1() + c2 = paned.get_child2() + parent = target.get_parent() if target else None + child1_id = getattr(paned, + '_tmux_child1_pane_id', '?') + dbg('paned button-press RAW: child1=%s ' + 'target=%s parent=%s paned=%s ' + 'is_paned=%s is_c1=%s is_c2=%s' % ( + child1_id, + type(target).__name__ if target else None, + type(parent).__name__ if parent else None, + type(paned).__name__, + target is paned, + target is c1, + target is c2)) + # Accept if the click is on the paned or its handle — + # reject clicks that bubbled up from child widgets. + on_handle = (target is paned + or (target is not None + and parent is paned + and target is not c1 + and target is not c2)) + if on_handle: + dbg('paned button-press: child1=%s' % child1_id) + paned._tmux_handle_pressed = True + return False # let GTK handle the drag + + def _on_paned_button_release(self, paned, event): + """Track when user stops dragging a paned handle. + + Sends a final resize-pane so tmux knows the exact end + position, then replays any stashed layout so tmux's + authoritative layout reconciles all pane positions. + """ + if event.button == 1: + child1_id = getattr(paned, + '_tmux_child1_pane_id', '?') + dbg('paned button-release: child1=%s' % child1_id) + # Send final resize-pane BEFORE clearing drag state + # so _send_split_bar_resize can still detect this + # handle as the dragged one (cur_pos != synced_pos). + self.controller._send_split_bar_resize() + paned._tmux_handle_pressed = False + paned._tmux_synced_pos = paned.get_position() + # Discard stale deferred layout — the final + # resize-pane triggers a fresh layout-change from + # tmux that reconciles all positions correctly. + self.state.deferred_layout_tree = None + return False + + def _on_new_paned_allocated(self, paned, allocation): + """One-shot size-allocate handler for newly created paneds. + + Waits for a second allocation — the first is the default + half-size from GTK, the second is the correct propagated + size from the parent container. + """ + paned_len = paned.get_length() + if paned_len <= paned.get_handlesize(): + return # completely unallocated, keep waiting + + prev = getattr(paned, '_tmux_prev_alloc_len', None) + paned._tmux_prev_alloc_len = paned_len + if prev is None: + return # first alloc (default half-size), wait + + # Second+ allocation — disconnect and mark allocated + handler_id = getattr(paned, + '_tmux_alloc_handler_id', None) + if handler_id: + paned.disconnect(handler_id) + paned._tmux_alloc_handler_id = None + self.state.mark_paned_allocated(paned) + + def _on_new_paneds_ready(self, state, tree): + """Signal: all new paneds allocated — apply ratios.""" + dbg('new-paneds-allocated: applying ratios') + hid = getattr(self, '_new_paneds_handler_id', None) + if hid: + self.state.disconnect(hid) + self._new_paneds_handler_id = None + self._apply_ratios_and_finish(tree) + + def _get_handle_size(self, terminal): + """Get Paned handle size by walking up from a terminal.""" + w = terminal.get_parent() + while w is not None: + if hasattr(w, 'get_handlesize'): + return w.get_handlesize() + w = w.get_parent() + return 0 + + def _find_root_paned(self, terminal): + """Find the highest Paned ancestor (the content container).""" + root_paned = None + w = terminal.get_parent() + while w is not None: + if hasattr(w, 'get_handlesize'): + root_paned = w + w = w.get_parent() + return root_paned + + def _find_common_paned(self, term_a, term_b): + """Find the Paned widget that is the direct common parent of two terminals.""" + # Walk up from term_a collecting parents + parents_a = [] + w = term_a.get_parent() + while w is not None: + parents_a.append(w) + w = w.get_parent() + # Walk up from term_b and find first match + w = term_b.get_parent() + while w is not None: + if w in parents_a: + return w + w = w.get_parent() + return None + + def _close_panes(self, pane_ids): + """Close terminals for deleted panes. Called on GTK thread.""" + for pane_id in pane_ids: + terminal = self.controller.pane_to_terminal.get(pane_id) + if terminal: + # Clean up any pending allocation tracking + paned = terminal.get_parent() + if (paned and paned in + self.state.pending_new_paneds): + handler_id = getattr( + paned, '_tmux_alloc_handler_id', None) + if handler_id: + paned.disconnect(handler_id) + paned._tmux_alloc_handler_id = None + self.state.pending_new_paneds.discard(paned) + terminal._tmux_closing = True + terminal.close() + return False + + def _add_panes(self, pane_ids, layout_tree): + """Create terminals for new panes. Called on GTK thread.""" + from terminatorlib.factory import Factory + maker = Factory() + + # Suppress notify_resize during transient allocations + # from split_axis — the sizes are wrong until + # _update_pane_sizes applies the correct ratios. + self.state.begin_layout() + + for pane_id in pane_ids: + pane_node = find_pane_node(pane_id, layout_tree) + parent_container = find_pane_parent(pane_id, layout_tree) + if not pane_node or not parent_container: + continue + + # Find the sibling pane (the one before the new pane in the parent) + idx = None + for i, child in enumerate(parent_container.children): + if child.is_leaf and child.pane_id == pane_id: + idx = i + break + if idx is None: + continue + + # Find the previous sibling's terminal + sibling_idx = idx + 1 if idx + 1 < len(parent_container.children) else idx - 1 + if sibling_idx >= len(parent_container.children): + continue + sibling = parent_container.children[sibling_idx] + if not sibling.is_leaf: + continue + old_terminal = self.controller.pane_to_terminal.get(sibling.pane_id) + if not old_terminal: + continue + + # Create new terminal + new_terminal = maker.make('Terminal') + new_terminal.tmux_pane_id = pane_id + new_terminal._make_titlebar_overlay() + self.controller.register_terminal(pane_id, new_terminal) + + # Don't capture-pane here — the shell prompt and any + # output that arrived before registration are already + # buffered in _pending_output and replayed by + # register_terminal. Capturing would duplicate them. + + # Split the existing terminal + old_parent = old_terminal.get_parent() + vertical = parent_container.orientation == 'v' + widget_first = idx > sibling_idx + old_parent.split_axis(old_terminal, vertical=vertical, + sibling=new_terminal, widgetfirst=widget_first) + + # Track the new paned for allocation gating — + # _apply_ratios will skip STALE paneds in this set + # and retry once their size-allocate fires. + new_paned = new_terminal.get_parent() + if new_paned and isinstance(new_paned, Gtk.Paned): + self.state.pending_new_paneds.add(new_paned) + handler_id = new_paned.connect( + 'size-allocate', + self._on_new_paned_allocated) + new_paned._tmux_alloc_handler_id = handler_id + + # Cancel any stale _layout_clear_source that notify_resize + # scheduled during split_axis (via main_iteration_do). + # Those were scheduled with pending_layout_tree=None and + # would clear applying_layout prematurely. + src = self.state.layout_clear_source + if src: + GLib.source_remove(src) + self.state.layout_clear_source = None + + # Apply ratios after GTK processes the new paned. + # _apply_ratios handles unallocated paneds via + # _needs_ratio_retry (100ms timeout). + GLib.idle_add(self._update_pane_sizes, layout_tree) + + return False + + def _feed_captured(self, terminal, result): + """Feed captured pane content to a terminal.""" + if result.is_error or not result.output_lines: + return + from terminatorlib.tmux.protocol import unescape_tmux_output + raw = b'\r\n'.join(line for line in result.output_lines if line) + data = unescape_tmux_output(raw) + GLib.idle_add(self._feed_terminal, terminal, data) + + def on_window_add(self, info): + """Handle %window-add: query the new window's layout, then create a tab.""" + window_id = info.get('window_id', '') + dbg('TmuxHandlers: window-add: %s' % window_id) + # Query the layout of this specific window + self.protocol.send_command( + 'list-windows -F "W:#{{window_id}}:#{{window_index}}:#{{window_name}}:#{{window_layout}}" -f "#{{==:#{{window_id}},{wid}}}"'.format( + wid=window_id), + callback=lambda result, wid=window_id: self._on_new_window_layout(wid, result), + ) + + def _on_new_window_layout(self, window_id, result): + """Handle layout query for a newly added window.""" + if result.is_error: + dbg('TmuxHandlers: new window layout query error') + return + for line in result.output_lines: + decoded = line.decode('utf-8', errors='replace').strip() + if not decoded.startswith('W:@'): + continue + rest = decoded[2:] + parts = rest.split(':', 3) + if len(parts) < 4: + continue + wid = parts[0] + window_index = parts[1] + window_name = parts[2] + layout_string = parts[3] + self.controller.window_layouts[window_id] = layout_string + self.controller.window_names[window_id] = window_name + self.controller.window_indices[window_id] = window_index + try: + tree = parse_tmux_layout(layout_string) + self.state.layout_trees[window_id] = tree + except ValueError as e: + dbg('TmuxHandlers: parse error for new window %s: %s' % (window_id, e)) + return + GLib.idle_add(self._create_tab_for_window, window_id, tree) + return + + def _create_tab_for_window(self, window_id, tree): + """Create a new Terminator tab for a tmux window. Called on GTK thread. + + Builds the full split tree with correct sizes matching tmux's + actual pane dimensions. + """ + from terminatorlib.factory import Factory + from terminatorlib.terminator import Terminator + + term = Terminator() + maker = Factory() + + # Create the first terminal from the first leaf + first_pane_id = self._first_leaf(tree).pane_id + root_terminal = maker.make('Terminal') + root_terminal.tmux_pane_id = first_pane_id + root_terminal._make_titlebar_overlay() + self.controller.register_terminal(first_pane_id, root_terminal) + + # Add as a new tab + window = self._find_tmux_window(term) + if not window: + dbg('TmuxHandlers: no tmux window to add tab to') + return False + + if not window.is_child_notebook(): + Factory().make('Notebook', window=window) + notebook = window.get_child() + notebook.newtab(widget=root_terminal) + + # Set initial tab label from cached window name, then refresh + # with full formatting (async query to tmux) + name = self.controller.window_names.get(window_id, '') + if name: + tab_root = notebook.find_tab_root(root_terminal) + if tab_root: + label = notebook.get_tab_label(tab_root) + if label: + label.set_label('[%s]' % name) + self._refresh_tab_labels() + + # Now build the rest of the split tree + if not tree.is_leaf: + self._build_split_tree(tree, root_terminal, maker) + + return False + + def _first_leaf(self, node): + """Find the first leaf node in a layout tree.""" + if node.is_leaf: + return node + return self._first_leaf(node.children[0]) + + def _last_leaf(self, node): + """Find the last leaf node in a layout tree.""" + if node.is_leaf: + return node + return self._last_leaf(node.children[-1]) + + def _build_split_tree(self, node, terminal, maker): + """Recursively split terminals to match the tmux layout tree. + + Starting from a single terminal that represents the first leaf, + split it for each additional child in the layout node. + """ + if node.is_leaf: + return + + # The terminal currently represents the first child. + # For each subsequent child, split from the previous terminal. + current_terminal = terminal + for i in range(1, len(node.children)): + child = node.children[i] + first_leaf = self._first_leaf(child) + + new_terminal = maker.make('Terminal') + new_terminal.tmux_pane_id = first_leaf.pane_id + new_terminal._make_titlebar_overlay() + self.controller.register_terminal(first_leaf.pane_id, new_terminal) + + # vertical=True means VPaned (top/bottom split) = tmux 'v' orientation + vertical = node.orientation == 'v' + + # Calculate ratio: size of everything before this child / total + if vertical: + prev_size = sum(node.children[j].height for j in range(i)) + total_size = sum(c.height for c in node.children) + else: + prev_size = sum(node.children[j].width for j in range(i)) + total_size = sum(c.width for c in node.children) + + parent = current_terminal.get_parent() + parent.split_axis(current_terminal, vertical=vertical, + sibling=new_terminal, widgetfirst=True) + + # Set the ratio on the newly created paned container + paned = current_terminal.get_parent() + if hasattr(paned, 'ratio') and total_size > 0: + # For the i-th split, ratio is first_child / (first + second) + first_child = node.children[i - 1] + if vertical: + ratio = first_child.height / (first_child.height + child.height) + else: + ratio = first_child.width / (first_child.width + child.width) + paned.ratio = ratio + paned.set_position_by_ratio() + + # If the child itself has sub-splits, recurse + if not child.is_leaf: + self._build_split_tree(child, new_terminal, maker) + + # The current terminal for the next iteration stays the same + # (we always split from the last terminal added) + current_terminal = new_terminal + + # Also recurse into the first child if it has sub-splits + first_child = node.children[0] + if not first_child.is_leaf: + self._build_split_tree(first_child, terminal, maker) + + def on_window_close(self, info): + """Handle %window-close: close all terminals in that window.""" + window_id = info.get('window_id', '') + dbg('window-close: %s (known trees: %s)' % ( + window_id, list(self.state.layout_trees.keys()))) + tree = self.state.layout_trees.pop(window_id, None) + self.controller.window_layouts.pop(window_id, None) + if tree: + pane_ids = get_pane_ids(tree) + dbg('closing panes: %s' % pane_ids) + GLib.idle_add(self._close_panes, pane_ids) + else: + dbg('TmuxHandlers: no tree found for window %s' % window_id) + + def on_window_renamed(self, info): + """Handle %window-renamed: refresh tab labels from tmux.""" + window_id = info.get('window_id', '') + name = info.get('name', '') + dbg('TmuxHandlers: window-renamed: %s -> %s' % (window_id, name)) + self.controller.window_names[window_id] = name + # Refresh with full formatting (command, path, custom title) + self._refresh_tab_labels() + + def _update_tab_label(self, window_id, tab_label): + """Update the tab label for a tmux window. Called on GTK thread.""" + tree = self.state.layout_trees.get(window_id) + if not tree: + return False + # Store window name and index on all terminals in this window + name = self.controller.window_names.get(window_id, '') + index = self.controller.window_indices.get(window_id, '') + for pane_id in get_pane_ids(tree): + t = self.controller.pane_to_terminal.get(pane_id) + if t: + t._tmux_window_name = name + t._tmux_window_index = index + first_pane_id = self._first_leaf(tree).pane_id + terminal = self.controller.pane_to_terminal.get(first_pane_id) + if not terminal: + return False + widget = terminal.get_parent() + while widget is not None: + if hasattr(widget, 'find_tab_root'): + tab_root = widget.find_tab_root(terminal) + if tab_root: + label = widget.get_tab_label(tab_root) + if label and label.get_label() != tab_label: + label.set_label(tab_label) + break + widget = widget.get_parent() + return False + + def on_exit(self, info): + """Handle %exit: clean up everything.""" + reason = info.get('reason', 'unknown') + dbg('TmuxHandlers: exit: %s' % reason) + # Restore termios immediately on the reader thread, before the + # shell resumes and readline saves the wrong terminal state. + # Close the duped fd to force the bridge reader to stop instantly + # so it can't consume the shell's prompt from the PTY buffer. + # The original fd (in _saved_pty) remains open for VTE. + proto = self.controller.protocol + if hasattr(proto, '_bridge'): + proto._bridge.restore_termios() + import os + try: + os.close(proto._bridge._fd) + except OSError: + pass + proto._bridge._alive = False + GLib.idle_add(self._handle_exit) + + def _handle_exit(self): + """Handle tmux exit on GTK thread.""" + from terminatorlib.terminator import Terminator + term = Terminator() + window = self._find_tmux_window(term) + # Close any remaining terminals (tmux may not send %window-close + # for the last window before %exit) + for terminal in list(self.controller.pane_to_terminal.values()): + terminal._tmux_closing = True + terminal.close() + self.controller.stop(send_detach=False) + # If the window still exists and has no children, destroy it + if window: + window.hoover() + return False + + def on_initial_list_windows(self, result): + """Handle the initial list-windows response. + + Parses window layouts and stores them for initial layout building. + """ + if result.is_error: + dbg('TmuxHandlers: initial list-windows error') + return + + for line in result.output_lines: + decoded = line.decode('utf-8', errors='replace').strip() + if not decoded: + continue + # Format: W:@WINDOW_ID:INDEX:NAME:ACTIVE:LAYOUT + if not decoded.startswith('W:@'): + dbg('TmuxHandlers: skipping invalid line: %s' % decoded) + continue + # Strip the W: prefix, split on colons + rest = decoded[2:] + parts = rest.split(':', 4) + if len(parts) < 5: + continue + window_id = parts[0] + window_index = parts[1] + window_name = parts[2] + window_active = parts[3] + layout_string = parts[4] + if window_active == '1': + self.controller.active_window_id = window_id + self.controller.window_layouts[window_id] = layout_string + self.controller.window_names[window_id] = window_name + self.controller.window_indices[window_id] = window_index + try: + tree = parse_tmux_layout(layout_string) + self.state.layout_trees[window_id] = tree + except ValueError as e: + dbg('TmuxHandlers: parse error for window %s: %s' % (window_id, e)) + + dbg('TmuxHandlers: initial layout parsed, %d windows' % + len(self.controller.window_layouts)) + + # Signal the controller that the initial layout is ready + self.controller._initial_layout_ready.set() + + # Trees just refreshed — re-check capture targets in case + # the initial _finish_applying_layout set targets from stale + # pre-refresh trees (background windows may have different + # sizes before refresh-client adjusts them). + if self.state.initial_capture_pending: + GLib.idle_add(self._refresh_capture_after_trees) + + def _refresh_capture_after_trees(self): + """Re-check capture targets after trees refreshed.""" + if not self.state.initial_capture_pending: + return False + self._update_capture_targets() + for t, pid in self.controller.terminal_to_pane.items(): + if pid in self.state.capture_settled: + continue + try: + c = t.vte.get_column_count() + r = t.vte.get_row_count() + self._check_pane_stable(pid, c, r) + except Exception: + pass + return False + + def capture_initial_content(self): + """Set up initial state after terminals are registered. + + Sends the initial resize command, captures existing pane content + once (for re-attach), and starts the periodic title refresh timer. + After this initial capture, all content comes via %output. + """ + self._send_initial_resize() + # Defer initial capture until after first ratio reconciliation + # so VTEs are at the correct size. The 'all-panes-stable' + # signal fires exactly once when every pane reports a stable + # size — _do_initial_capture runs in response. + self.state.initial_capture_pending = True + self.state.connect('all-panes-stable', + lambda s: self._on_all_panes_stable()) + self._refresh_pane_titles() + self._refresh_tab_labels() + self._title_timer = GLib.timeout_add(3000, self._periodic_title_refresh) + + def _send_initial_captures(self): + """One-shot capture of existing pane content for re-attach. + + Called after the first ratio reconciliation so VTEs are at their + correct size. Never called again — subsequent content arrives + via the %output stream. + """ + all_panes = [] + for window_id, tree in self.state.layout_trees.items(): + pids = get_pane_ids(tree) + all_panes.extend(sorted(pids)) + dbg('sending initial capture-pane commands: %s ' + '(queue depth=%d)' % ( + all_panes, + self.protocol._command_queue.qsize() + if hasattr(self.protocol, '_command_queue') + else '?')) + for pid in all_panes: + self.protocol.send_command( + 'capture-pane -J -p -t {} -e -S - -E -'.format( + pid), + callback=lambda result, _pid=pid: + self._feed_initial_capture(_pid, result), + ) + + def _feed_initial_capture(self, pane_id, result): + """Feed initially captured content to the right terminal.""" + if result.is_error: + dbg('initial capture ERROR for %s: %d lines' % ( + pane_id, len(result.output_lines))) + return + terminal = self.controller.pane_to_terminal.get(pane_id) + if not terminal: + dbg('initial capture: no terminal for %s' % pane_id) + return + if not result.output_lines: + dbg('initial capture: empty result for %s' % pane_id) + return + from terminatorlib.tmux.protocol import unescape_tmux_output + non_empty = [line for line in result.output_lines if line] + raw = b'\r\n'.join(non_empty) + data = unescape_tmux_output(raw) + dbg('initial capture: %s lines=%d non_empty=%d ' + 'raw=%d data=%d bytes' % ( + pane_id, len(result.output_lines), + len(non_empty), len(raw), len(data))) + GLib.idle_add(self._feed_terminal_logged, + terminal, data, pane_id) + + def _check_pane_stable(self, pane_id, cols, rows): + """Settle pane if VTE size matches its tmux tree target. + + Called from notify_resize (non-suppressed path) and from + _finish_applying_layout. Skips during applying_layout since + VTE sizes are transitional and won't match targets yet. + """ + if self.state.applying_layout: + return + # Targets are only valid for the window size they were + # computed from. If the window changed (WM adjustment, + # user resize), block settling — a new layout round will + # set fresh targets from the updated tree. + if (self.state.capture_window_pixels is not None + and self.state.last_window_pixels + != self.state.capture_window_pixels): + return + target = self.state.capture_targets.get(pane_id) + if target is not None and (cols, rows) == target: + self.state.mark_pane_stable(pane_id) + + def _update_capture_targets(self): + """Populate target sizes from tmux layout tree nodes. + + Also records the current window pixel size. Targets are + only valid for this window size — if the window changes + (e.g. WM adjustment), _check_pane_stable blocks settling + until fresh targets are set from the new layout. + + Only ACTIVE window panes are added to expected_panes. + Background tab VTEs don't get GTK allocations after a + window resize, so they'd never settle (stale allocation + size != post-resize tree target). + """ + self.state.capture_targets.clear() + self.state.capture_settled.clear() + # Collect targets from all trees (for reference) + for tree in self.state.layout_trees.values(): + self._collect_capture_targets(tree) + # Only wait for active window panes to settle — background + # tab VTEs have stale allocations from GTK. + active_panes = set() + awid = self.controller.active_window_id + if awid and awid in self.state.layout_trees: + active_panes = get_pane_ids( + self.state.layout_trees[awid]) + self.state.expected_panes = active_panes & set( + self.controller.terminal_to_pane.values()) + self.state.capture_window_pixels = \ + self.state.last_window_pixels + + def _collect_capture_targets(self, node): + """Recursively extract pane_id → (cols, rows) from tree.""" + if node.is_leaf: + self.state.capture_targets[node.pane_id] = ( + node.width, node.height) + else: + for child in node.children: + self._collect_capture_targets(child) + + def _on_all_panes_stable(self): + """Handle 'all-panes-stable' signal — clean up and capture.""" + dbg('initial capture: all %d panes settled' + % len(self.state.expected_panes)) + self.state.clear_capture_state() + self._do_initial_capture() + + def _do_initial_capture(self): + """VTEs converged — send refresh-client and capture all panes.""" + size = self.state.last_client_size + if size: + dbg('do_initial_capture: refresh-client -C %d,%d' + % (size[0], size[1])) + self.controller.protocol.send_command( + 'refresh-client -C {},{}'.format( + size[0], size[1])) + self._send_initial_captures() + self._snapshot_vte_sizes() + + def _chars_to_max_pixels(self, cols, rows): + """Convert character dimensions to max pixel size for geometry hints. + + Accounts for layout structure, chrome, and CSD.""" + terminals = list(self.controller.terminal_to_pane.keys()) + if not terminals: + return None + + term = None + char_w = char_h = sb_w = tb_h = vpad_x = vpad_y = 0 + for t in terminals: + char_w, char_h, sb_w, tb_h, vpad_x, vpad_y = \ + self._get_terminal_metrics(t) + if char_w > 0: + term = t + break + if not term or char_w <= 0: + return None + + handle_size = self._get_handle_size(term) + + # Use the active window's tree for structural info + tree = None + awid = self.controller.active_window_id + if awid and awid in self.state.layout_trees: + tree = self.state.layout_trees[awid] + if tree is None: + for t in self.state.layout_trees.values(): + if self._is_active_window(t): + tree = t + break + if tree is None: + for tree in self.state.layout_trees.values(): + break + if tree is None: + return None + + target_w = self._subtree_px(tree, 'h', char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + target_h = self._subtree_px(tree, 'v', char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + + # Scale from tree's char dimensions to the requested dimensions + if tree.width > 0 and tree.height > 0: + target_w = target_w * cols / tree.width + target_h = target_h * rows / tree.height + + window = term.get_toplevel() + + # Chrome = tab bar, borders — NOT other panes. + chrome_w, chrome_h = self._get_chrome_size(window) + + max_w = int(target_w) + chrome_w + max_h = int(target_h) + chrome_h + + return (max_w, max_h, chrome_w, chrome_h, 0, 0) + + def _set_max_size_pixels(self, max_w, max_h): + """Apply max size geometry hints on the GTK window. + + max_w/max_h are content-based (get_size() coordinate space). + Geometry hints constrain the outer allocation (including CSD), + so we add CSD to reach the intended content max. + Returns (hint_w, hint_h) — the actual values set on the hint + (in allocation space, i.e. content + CSD). + """ + terminals = list(self.controller.terminal_to_pane.keys()) + if not terminals: + return (max_w, max_h) + window = terminals[0].get_toplevel() + # CSD = difference between outer allocation and content size + alloc = window.get_allocation() + ws = window.get_size() + csd_w = max(0, alloc.width - ws[0]) + csd_h = max(0, alloc.height - ws[1]) + hint_w = max_w + csd_w + hint_h = max_h + csd_h + dbg('size_trace set_max_pixels: ' + 'content_max=%dx%d csd=%dx%d ' + 'hint=%dx%d alloc=%dx%d ws=%dx%d' % ( + max_w, max_h, csd_w, csd_h, + hint_w, hint_h, + alloc.width, alloc.height, ws[0], ws[1])) + # Store in allocation space so window.py re-applies correctly + # as geometry hints (which constrain allocation, not content) + window._tmux_max_size = (hint_w, hint_h) + # If CSD not yet known (window just created), skip — + # setting hints without CSD makes the max too tight. + # The tripwire will set correct hints once CSD is available. + if csd_w == 0 and csd_h == 0: + return (max_w, max_h) + from gi.repository import Gdk + geometry = Gdk.Geometry() + geometry.max_width = hint_w + geometry.max_height = hint_h + window.set_geometry_hints(None, geometry, Gdk.WindowHints.MAX_SIZE) + return (hint_w, hint_h) + + def _update_max_from_tree(self, cols, rows): + """Update per-axis max size constraints from layout tree dimensions. + + Each axis is tracked independently: a column limit doesn't + prevent the user from adding rows, and vice versa. + """ + if self.state.last_client_size is None: + dbg('size_trace update_max: skipped (initial startup)') + return False + + sent = self.state.last_client_size + max_c = self.state.tmux_max_cols + max_r = self.state.tmux_max_rows + + # Per-axis: did tmux exceed a known limit (grew) or reject? + grew_c = max_c is not None and cols > max_c + grew_r = max_r is not None and rows > max_r + rej_c = sent and cols < sent[0] + rej_r = sent and rows < sent[1] + + if grew_c or grew_r: + # Exceeded a known limit — clear that axis's constraint + if grew_c: + self.state.tmux_max_cols = None + if grew_r: + self.state.tmux_max_rows = None + dbg('size_trace update_max: grew cols=%s->%d rows=%s->%d' % ( + max_c or 'free', cols, max_r or 'free', rows)) + + if rej_c or rej_r: + # At least one axis was rejected — constrain only that axis. + if rej_c: + self.state.tmux_max_cols = cols + if rej_r: + self.state.tmux_max_rows = rows + dbg('size_trace update_max: rejected ' + 'sent=%dx%d got=%dx%d max_cols=%s max_rows=%s' % ( + sent[0], sent[1], cols, rows, + self.state.tmux_max_cols or 'free', + self.state.tmux_max_rows or 'free')) + # Use constraint values for pixel computation, not tree values. + # The tree may be smaller than the limit (e.g. 118 cols when + # max is 132) — we need MAX at the limit, not current size. + hint_cols = self.state.tmux_max_cols \ + if self.state.tmux_max_cols is not None else cols + hint_rows = self.state.tmux_max_rows \ + if self.state.tmux_max_rows is not None else rows + info = self._chars_to_max_pixels(hint_cols, hint_rows) + if info: + # Use accumulated state, not just this cycle's rejection, + # so existing constraints on the other axis are preserved. + max_w = info[0] if self.state.tmux_max_cols is not None else 32767 + max_h = info[1] if self.state.tmux_max_rows is not None else 32767 + self._set_max_size_pixels(max_w, max_h) + self.controller._arm_tripwire_after_idle() + elif grew_c or grew_r: + # Grew past a limit — arm tripwire instantly + self.controller._do_arm_tripwire() + else: + # Echo-back confirmation, no change + if not self.state.tripwire_armed \ + and not self.state.tripwire_timer: + self.controller._do_arm_tripwire() + return False + + def _clear_tmux_max_size(self): + """Remove the max size constraint.""" + terminals = list(self.controller.terminal_to_pane.keys()) + if not terminals: + return False + + window = terminals[0].get_toplevel() + if getattr(window, '_tmux_max_size', None): + dbg('clearing tmux max size constraint') + window._tmux_max_size = None + window.set_geometry_hints(None, None, 0) + + return False + + def _resize_window_to_tree(self, tree): + """Resize our GTK window to match tmux's layout tree dimensions. + + Called on GTK thread when tmux changes the layout size (e.g. + another client attached with a different window size). + """ + import time + terminals = list(self.controller.terminal_to_pane.keys()) + if not terminals: + return False + + term = None + char_w = char_h = sb_w = tb_h = vpad_x = vpad_y = 0 + for t in terminals: + char_w, char_h, sb_w, tb_h, vpad_x, vpad_y = \ + self._get_terminal_metrics(t) + if char_w > 0: + term = t + break + if not term or char_w <= 0: + return False + + handle_size = self._get_handle_size(term) + + target_w = self._subtree_px(tree, 'h', char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + target_h = self._subtree_px(tree, 'v', char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y) + + window = term.get_toplevel() + # Chrome = tab bar, borders — NOT other panes. + # window.resize() operates in content space — no CSD. + chrome_w, chrome_h = self._get_chrome_size(window) + + win_w = int(target_w) + chrome_w + win_h = int(target_h) + chrome_h + + wa = window.get_allocation() + ws = window.get_size() + dbg('size_trace resize_to_tree: ' + 'alloc=%dx%d ws=%dx%d ' + 'chrome=%dx%d resize=%dx%d tree=%dx%d' % ( + wa.width, wa.height, ws[0], ws[1], + chrome_w, chrome_h, win_w, win_h, + tree.width, tree.height)) + + window.resize(win_w, win_h) + # Update pixel tracking so notify_resize detects this as a + # window resize (not a split drag) when VTE sizes change + self.state.last_window_pixels = (win_w, win_h) + self.state.layout_applied_time = time.monotonic() + # If window isn't already at target size, the WM hasn't + # processed the resize yet. Gate _finish_applying_layout + # on configure-event so we don't clear _applying_layout + # while paneds are still at old size. + if ws != (win_w, win_h): + self.state.begin_window_resize() + self.controller._ensure_configure_handler(window) + return False + + def _send_initial_resize(self): + """Size our window to match tmux's layout, then tell tmux our size. + + Uses actual VTE char metrics + scrollbar/titlebar/handle sizes to + compute the correct pixel dimensions. If the layout won't fit on + screen, clamps to screen size and tells tmux to use the smaller + dimensions. + """ + # Use the active window's tree — other windows may have + # been pre-shrunk by tmux. Prefer the window ID from + # tmux's active flag (available before terminals are + # mapped), then fall back to _is_active_window (checks + # which tab is visible). + tree = None + awid = self.controller.active_window_id + if awid and awid in self.state.layout_trees: + tree = self.state.layout_trees[awid] + if tree is None: + for t in self.state.layout_trees.values(): + if self._is_active_window(t): + tree = t + break + if tree is None: + for tree in self.state.layout_trees.values(): + break + if tree is None: + return + + # Get metrics from an allocated terminal + terminals = list(self.controller.terminal_to_pane.keys()) + char_w = char_h = sb_w = tb_h = vpad_x = vpad_y = 0 + term = None + for t in terminals: + char_w, char_h, sb_w, tb_h, vpad_x, vpad_y = \ + self._get_terminal_metrics(t) + if char_w > 0: + term = t + break + if not term or char_w <= 0: + # Terminals not realized yet — fall back to tmux dimensions + dbg('initial resize to %dx%d (from tmux layout, ' + 'no metrics yet)' % (tree.width, tree.height)) + self.protocol.send_command( + 'refresh-client -C {},{}'.format(tree.width, tree.height)) + return + + handle_size = self._get_handle_size(term) + + # Measure VTE CSS padding + ctx = term.vte.get_style_context() + vte_css_pad = ctx.get_padding(ctx.get_state()) + dbg('initial_resize step1: metrics ' + 'char=%dx%d sb=%d tb=%d handle=%d vpad=%dx%d ' + 'vte_css_pad=l%d,r%d,t%d,b%d' % ( + char_w, char_h, sb_w, tb_h, + handle_size, vpad_x, vpad_y, + vte_css_pad.left, vte_css_pad.right, + vte_css_pad.top, vte_css_pad.bottom)) + + # Compute pixel dimensions for tmux's layout (content area) + target_w = self._subtree_px(tree, 'h', char_w, char_h, + sb_w, tb_h, handle_size, + vpad_x, vpad_y) + target_h = self._subtree_px(tree, 'v', char_w, char_h, + sb_w, tb_h, handle_size, + vpad_x, vpad_y) + + dbg('initial_resize step2: subtree_px ' + 'tmux=%dx%d target=%.1fx%.1f' % ( + tree.width, tree.height, target_w, target_h)) + + # Measure every layer of the window + window = term.get_toplevel() + wa = window.get_allocation() + ws = window.get_size() + content = window.get_child() + ca = content.get_allocation() if content else None + ta = term.get_allocation() + vte_alloc = term.vte.get_allocation() + vc = term.vte.get_column_count() + vr = term.vte.get_row_count() + + dbg('initial_resize step3: layers ' + 'win_alloc=%dx%d win_size=%dx%d ' + 'content=%s term=%dx%d ' + 'vte=%dx%d vte_chars=%dx%d' % ( + wa.width, wa.height, ws[0], ws[1], + '%dx%d' % (ca.width, ca.height) + if ca else 'None', + ta.width, ta.height, + vte_alloc.width, vte_alloc.height, + vc, vr)) + + # Chrome using different reference points + csd_w = wa.width - ws[0] + csd_h = wa.height - ws[1] + chrome_ws_vte = (ws[0] - vte_alloc.width, + ws[1] - vte_alloc.height) + chrome_alloc_vte = (wa.width - vte_alloc.width, + wa.height - vte_alloc.height) + chrome_content_term = ( + (ca.width - ta.width, ca.height - ta.height) + if ca else (0, 0)) + + dbg('initial_resize step4: chrome options ' + 'csd=%dx%d ' + 'ws-vte=%dx%d ' + 'alloc-vte=%dx%d ' + 'content-term=%dx%d' % ( + csd_w, csd_h, + chrome_ws_vte[0], chrome_ws_vte[1], + chrome_alloc_vte[0], chrome_alloc_vte[1], + chrome_content_term[0], chrome_content_term[1])) + + # Chrome = tab bar, borders — NOT other panes. + # window.resize() operates in content space (get_size()), + # NOT allocation space — do NOT add CSD. + chrome_w, chrome_h = self._get_chrome_size(window) + + # Get screen limits + from gi.repository import Gdk + screen = window.get_screen() + monitor = screen.get_monitor_at_window(window.get_window()) \ + if window.get_window() else 0 + mon_geom = screen.get_monitor_workarea(monitor) + max_w = mon_geom.width + max_h = mon_geom.height + + target_win_w = int(target_w) + chrome_w + target_win_h = int(target_h) + chrome_h + + fits = target_win_w <= max_w and target_win_h <= max_h + win_w = min(target_win_w, max_w) + win_h = min(target_win_h, max_h) + + dbg('initial_resize step5: final ' + 'target_win=%dx%d resize=%dx%d ' + 'screen=%dx%d fits=%s' % ( + target_win_w, target_win_h, + win_w, win_h, + max_w, max_h, fits)) + + ws_before = window.get_size() + window.resize(win_w, win_h) + + # Gate _finish_applying_layout on the WM's response + if ws_before != (win_w, win_h): + self.state.begin_window_resize() + self.controller._ensure_configure_handler(window) + + # Set initial chrome baseline for change detection + self.state.last_chrome = self._get_chrome_size(window) + + # Check what happened after resize + wa2 = window.get_allocation() + ws2 = window.get_size() + va2 = term.vte.get_allocation() + vc2 = term.vte.get_column_count() + vr2 = term.vte.get_row_count() + dbg('initial_resize step6: after resize ' + 'win_alloc=%dx%d win_size=%dx%d ' + 'vte=%dx%d vte_chars=%dx%d' % ( + wa2.width, wa2.height, ws2[0], ws2[1], + va2.width, va2.height, vc2, vr2)) + + if fits: + # Tell tmux we match its layout + dbg('initial resize to %dx%d (matches tmux)' % ( + tree.width, tree.height)) + self.protocol.send_command( + 'refresh-client -C {},{}'.format(tree.width, tree.height)) + else: + # Screen too small — compute what we can fit and tell tmux + # to downsize. Back-calculate character dimensions from pixels. + fit_cols = (win_w - vpad_x - sb_w) // char_w + fit_rows = (win_h - vpad_y - tb_h) // char_h + dbg('initial resize to %dx%d (screen limited from %dx%d)' % ( + fit_cols, fit_rows, tree.width, tree.height)) + self.protocol.send_command( + 'refresh-client -C {},{}'.format(fit_cols, fit_rows)) + + def _periodic_title_refresh(self): + """Periodically refresh pane titles from tmux. + + Tmux intercepts title-setting escape sequences (OSC 0/2) and + does not forward them via %output, so VTE never sees them. + Polling is the only way to get updated pane titles. + """ + if not self.controller.active: + return False + self._refresh_pane_titles() + self._refresh_tab_labels() + return True + + def _refresh_tab_labels(self): + """Query tmux for current window names and active pane info.""" + self.protocol.send_command( + 'list-windows -F "#{window_id}\t#{window_index}\t#{window_name}\t#{pane_current_command}\t#{pane_current_path}\t#{pane_title}"', + callback=self._on_window_names, + ) + + def _on_window_names(self, result): + """Handle window name query response and update tab labels.""" + if result.is_error: + return + import os, socket + home = os.environ.get('HOME', '') + hostname = socket.gethostname() + for line in result.output_lines: + decoded = line.decode('utf-8', errors='replace').strip() + parts = decoded.split('\t', 5) + if len(parts) < 6: + continue + window_id, index, name, command, path, pane_title = parts + self.controller.window_names[window_id] = name + self.controller.window_indices[window_id] = index + # Build tab label: command:path or custom title (no size) + if home and path.startswith(home): + path = '~' + path[len(home):] + has_custom = (pane_title and pane_title != hostname + and pane_title != command) + if has_custom: + tab_label = pane_title + else: + tab_label = '%s:%s' % (command, path) if path else command + GLib.idle_add(self._update_tab_label, window_id, tab_label) + # Set the window title to hostname: session-name + if self.controller.session_name: + self.protocol.send_command( + 'display-message -p "#{user}@#{host_short}"', + callback=self._on_tmux_hostname, + ) + + def _on_tmux_hostname(self, result): + """Handle hostname query response.""" + userhost = 'tmux' + if not result.is_error and result.output_lines: + userhost = result.output_lines[0].decode('utf-8', errors='replace').strip() + title = '%s: %s [tmux]' % (userhost, self.controller.session_name) + GLib.idle_add(self._set_window_title, title) + + def _set_window_title(self, title): + """Set the Terminator window title. Called on GTK thread.""" + from terminatorlib.terminator import Terminator + try: + term = Terminator() + window = self._find_tmux_window(term) + if window: + window.title.force_title(title) + except Exception: + pass + return False + + def _refresh_pane_titles(self): + """Query tmux for pane titles and update terminal titlebars.""" + self.protocol.send_command( + 'list-panes -s -F "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}\t#{pane_title}"', + callback=self._on_pane_titles, + ) + + def _on_pane_titles(self, result): + """Handle pane title query response.""" + if result.is_error: + return + import os, socket + home = os.environ.get('HOME', '') + hostname = socket.gethostname() + for line in result.output_lines: + decoded = line.decode('utf-8', errors='replace').strip() + parts = decoded.split('\t', 3) + if len(parts) < 4: + continue + pane_id, command, path, pane_title = parts + terminal = self.controller.pane_to_terminal.get(pane_id) + if not terminal: + continue + # Shorten home prefix to ~ + if home and path.startswith(home): + path = '~' + path[len(home):] + # Detect app-set custom title (not tmux's default hostname) + has_custom = (pane_title and pane_title != hostname + and pane_title != command) + if has_custom: + title = pane_title + else: + title = '%s:%s' % (command, path) if path else command + GLib.idle_add(self._set_terminal_title, terminal, title) + + def _set_terminal_title(self, terminal, title): + """Set a terminal's titlebar text. Must be called on GTK thread. + + Only updates the pane titlebar directly — does NOT emit + title-change, which would override the tab label with the + pane title instead of the tmux window name. + """ + try: + old_title = getattr(terminal, '_tmux_title', None) + if title == old_title: + return False + terminal._tmux_title = title + if hasattr(terminal, 'titlebar'): + terminal.titlebar.set_terminal_title(None, title) + except Exception: + pass + return False diff --git a/terminatorlib/tmux/layout.py b/terminatorlib/tmux/layout.py new file mode 100644 index 00000000..5f03cfdb --- /dev/null +++ b/terminatorlib/tmux/layout.py @@ -0,0 +1,242 @@ +"""Tmux layout parsing and conversion to Terminator layout format. + +Parses tmux layout strings like: + 13e1,124x26,0,0[124x6,0,0,1,124x6,0,7{62x6,0,7,5,61x6,63,7,6},124x12,0,14,3] + +Where: + - 4-hex-digit checksum + - WxH,X,Y dimensions/position + - {...} horizontal split container + - [...] vertical split container + - trailing integer = pane ID +""" + +import re +from dataclasses import dataclass, field +from typing import List, Optional, Set, Union + + +@dataclass +class LayoutNode: + """A node in the tmux layout tree.""" + width: int + height: int + x_off: int + y_off: int + pane_id: Optional[str] = None # e.g. '%1' for leaf nodes + orientation: Optional[str] = None # 'h' or 'v' for internal nodes + children: List['LayoutNode'] = field(default_factory=list) + + @property + def is_leaf(self) -> bool: + return self.pane_id is not None + + def __hash__(self): + if self.pane_id: + return hash(self.pane_id) + return hash(id(self)) + + def __eq__(self, other): + if isinstance(other, LayoutNode) and self.pane_id and other.pane_id: + return self.pane_id == other.pane_id + return self is other + + +def parse_tmux_layout(layout_string: str) -> LayoutNode: + """Parse a tmux layout string into a LayoutNode tree. + + Format: CHECKSUM,WxH,X,Y... where the remainder describes the tree. + """ + # Strip the 4-char hex checksum and comma + s = layout_string + comma = s.index(',') + s = s[comma + 1:] + node, _ = _parse_node(s, 0) + return node + + +def _parse_node(s: str, pos: int) -> tuple: + """Parse a single node starting at pos, return (node, next_pos).""" + # Parse WxH,X,Y + m = re.match(r'(\d+)x(\d+),(\d+),(\d+)', s[pos:]) + if not m: + raise ValueError('Expected WxH,X,Y at position %d: %s' % (pos, s[pos:pos+20])) + width = int(m.group(1)) + height = int(m.group(2)) + x_off = int(m.group(3)) + y_off = int(m.group(4)) + pos += m.end() + + # What follows? + if pos >= len(s): + raise ValueError('Unexpected end of layout string') + + ch = s[pos] + + if ch == '{': + # Horizontal split container + pos += 1 # skip '{' + children = [] + while pos < len(s) and s[pos] != '}': + if s[pos] == ',': + pos += 1 + child, pos = _parse_node(s, pos) + children.append(child) + pos += 1 # skip '}' + return LayoutNode(width, height, x_off, y_off, + orientation='h', children=children), pos + + elif ch == '[': + # Vertical split container + pos += 1 # skip '[' + children = [] + while pos < len(s) and s[pos] != ']': + if s[pos] == ',': + pos += 1 + child, pos = _parse_node(s, pos) + children.append(child) + pos += 1 # skip ']' + return LayoutNode(width, height, x_off, y_off, + orientation='v', children=children), pos + + elif ch == ',': + # Leaf pane: ,PANE_ID + pos += 1 # skip ',' + m2 = re.match(r'(\d+)', s[pos:]) + if not m2: + raise ValueError('Expected pane ID at position %d' % pos) + pane_id = '%%%s' % m2.group(1) + pos += m2.end() + return LayoutNode(width, height, x_off, y_off, pane_id=pane_id), pos + + else: + raise ValueError('Unexpected character %r at position %d' % (ch, pos)) + + +def layout_to_terminator(node: LayoutNode, parent_name: str, + result: Optional[dict] = None, + pane_index: int = 0, + order: int = 0) -> tuple: + """Convert a LayoutNode tree to Terminator's flat layout format. + + Returns (result_dict, pane_index, order). + """ + if result is None: + result = {} + + if node.is_leaf: + term_name = 'terminal%s' % node.pane_id[1:] # strip '%' + result[term_name] = { + 'type': 'Terminal', + 'parent': parent_name, + 'order': order, + 'tmux': { + 'pane_id': node.pane_id, + 'width': node.width, + 'height': node.height, + }, + } + return result, pane_index, order + 1 + + if len(node.children) == 1: + return layout_to_terminator(node.children[0], parent_name, + result, pane_index, order) + + # Container: create nested binary panes + pane_type = 'VPaned' if node.orientation == 'v' else 'HPaned' + pane_name = 'pane%d' % pane_index + result[pane_name] = { + 'type': pane_type, + 'parent': parent_name, + 'order': order, + } + pane_index += 1 + order += 1 + + # First child + result, pane_index, order = layout_to_terminator( + node.children[0], pane_name, result, pane_index, order) + + # Remaining children: if more than 2, nest them + if len(node.children) == 2: + result, pane_index, order = layout_to_terminator( + node.children[1], pane_name, result, pane_index, order) + else: + # Create a synthetic container for remaining children + remaining = LayoutNode( + node.width, node.height, node.x_off, node.y_off, + orientation=node.orientation, + children=node.children[1:], + ) + result, pane_index, order = layout_to_terminator( + remaining, pane_name, result, pane_index, order) + + return result, pane_index, order + + +def build_terminator_layout(layout_nodes: list, total_cols: int = 0, + total_rows: int = 0) -> dict: + """Build a complete Terminator layout dict from tmux layout nodes. + + Args: + layout_nodes: list of LayoutNode trees (one per tmux window) + total_cols: total columns for window sizing + total_rows: total rows for window sizing + """ + result = {} + window_name = 'window0' + parent_name = window_name + result[window_name] = {'type': 'Window', 'parent': ''} + if total_cols and total_rows: + result[window_name]['tmux_size'] = [total_cols, total_rows] + + if len(layout_nodes) > 1: + notebook_name = 'notebook0' + result[notebook_name] = {'type': 'Notebook', 'parent': parent_name} + parent_name = notebook_name + + pane_index = 0 + order = 0 + for node in layout_nodes: + result, pane_index, order = layout_to_terminator( + node, parent_name, result, pane_index, order) + + return result + + +def get_pane_ids(node: LayoutNode) -> Set[str]: + """Extract all pane IDs from a layout tree.""" + panes = set() + stack = [node] + while stack: + n = stack.pop() + if n.is_leaf: + panes.add(n.pane_id) + else: + stack.extend(n.children) + return panes + + +def find_pane_parent(pane_id: str, node: LayoutNode) -> Optional[LayoutNode]: + """Find the parent container of a pane by ID.""" + stack = [node] + while stack: + n = stack.pop() + if not n.is_leaf: + for child in n.children: + if child.is_leaf and child.pane_id == pane_id: + return n + stack.append(child) + return None + + +def find_pane_node(pane_id: str, node: LayoutNode) -> Optional[LayoutNode]: + """Find a leaf node by pane ID.""" + stack = [node] + while stack: + n = stack.pop() + if n.is_leaf and n.pane_id == pane_id: + return n + if not n.is_leaf: + stack.extend(n.children) + return None diff --git a/terminatorlib/tmux/protocol.py b/terminatorlib/tmux/protocol.py new file mode 100644 index 00000000..ea21b52d --- /dev/null +++ b/terminatorlib/tmux/protocol.py @@ -0,0 +1,456 @@ +"""Tmux control mode protocol layer. + +Handles subprocess management, protocol parsing, and command queuing. +No GTK dependencies in this module. +""" + +import os +import queue +import signal +import subprocess +import threading +import traceback +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Callable, Optional + +from terminatorlib.util import dbg + + +def unescape_tmux_output(data: bytes) -> bytes: + """Reverse tmux's octal escaping. + + In control mode, tmux escapes characters < 0x20 and backslash itself + as \\NNN (octal). This function converts them back to raw bytes. + """ + result = bytearray() + i = 0 + while i < len(data): + if data[i:i+1] == b'\\' and i + 3 < len(data): + # Check for octal escape \NNN + octal = data[i+1:i+4] + try: + val = int(octal, 8) + result.append(val) + i += 4 + continue + except (ValueError, IndexError): + pass + result.append(data[i]) + i += 1 + return bytes(result) + + +@dataclass +class CommandResult: + """Result of a tmux command.""" + timestamp: str = '' + command_number: str = '' + output_lines: list = field(default_factory=list) + is_error: bool = False + + +class TmuxSubprocess: + """Wraps subprocess.Popen for tmux control mode.""" + + TMUX_BINARY = 'tmux' + + def __init__(self, arguments: list): + self._process = None + self._arguments = arguments + self._line_queue = queue.Queue() + self._pipe_thread = None + + def start(self): + """Start the tmux subprocess.""" + self._process = subprocess.Popen( + [self.TMUX_BINARY] + self._arguments, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + bufsize=0, + ) + self._pipe_thread = threading.Thread( + target=self._pipe_content, + daemon=True, + ) + self._pipe_thread.start() + + def _pipe_content(self): + """Read lines from tmux stdout into a queue.""" + try: + for line in iter(self._process.stdout.readline, b''): + self._line_queue.put(line) + except Exception: + pass + finally: + self._line_queue.put(None) + + def send_raw(self, line: str): + """Send a raw line to tmux stdin.""" + try: + self._process.stdin.write('{}\n'.format(line).encode()) + self._process.stdin.flush() + except (IOError, BrokenPipeError): + dbg('TmuxSubprocess: failed to send: %s' % line) + + def stop(self): + """Stop the tmux subprocess.""" + if self._process and self._process.poll() is None: + try: + self._process.terminate() + except Exception: + pass + + def is_alive(self) -> bool: + """Check if the subprocess is running.""" + return self._process is not None and self._process.poll() is None + + def __iter__(self): + return self + + def __next__(self): + line = self._line_queue.get(block=True) + if line is not None: + return line + raise StopIteration + + +class NotificationReader(threading.Thread): + """Daemon thread that reads tmux stdout and parses control mode protocol. + + Dispatches: + - %begin/%end/%error blocks -> matched to pending commands + - %output -> output handler + - %layout-change -> layout handler + - %window-add, %window-close, %window-renamed -> window handlers + - %session-changed, %session-renamed -> session handlers + - %exit -> exit handler + """ + + def __init__(self, subprocess_iter, handler_map, command_queue): + super().__init__(daemon=True) + self._subprocess_iter = subprocess_iter + self._handler_map = handler_map + self._command_queue = command_queue + self._running = True + + def run(self): + try: + for raw_line in self._subprocess_iter: + if not self._running: + break + line = raw_line.rstrip(b'\n') + if not line.startswith(b'%'): + continue + + parts = line[1:].split(b' ', 1) + marker = parts[0].decode('ascii', errors='replace') + data = parts[1] if len(parts) > 1 else b'' + + if marker == 'begin': + self._handle_begin(data, line) + elif marker == 'output': + self._handle_output(data) + elif marker == 'layout-change': + self._handle_layout_change(data) + elif marker == 'exit': + self._dispatch('exit', {'reason': data.decode('utf-8', errors='replace')}) + break + elif marker in ('window-add', 'window-close', 'window-renamed', + 'unlinked-window-add', 'unlinked-window-close', + 'unlinked-window-renamed', + 'session-changed', 'session-renamed', + 'sessions-changed'): + self._handle_notification(marker, data) + else: + dbg('NotificationReader: unknown marker: %s' % marker) + except StopIteration: + pass + except Exception as e: + dbg('NotificationReader: error: %s' % e) + traceback.print_exc() + finally: + self._dispatch('exit', {'reason': 'reader stopped'}) + + def stop(self): + self._running = False + + def _handle_begin(self, data, first_line): + """Handle %begin/%end/%error command result blocks.""" + output_lines = [] + is_error = False + for raw_line in self._subprocess_iter: + line = raw_line.rstrip(b'\n') + if line.startswith(b'%end') or line.startswith(b'%error'): + is_error = line.startswith(b'%error') + break + output_lines.append(line) + + result = CommandResult( + output_lines=output_lines, + is_error=is_error, + ) + + # Match to pending command callback + try: + callback = self._command_queue.get_nowait() + except queue.Empty: + callback = None + + if callback is not None: + try: + callback(result) + except Exception as e: + dbg('NotificationReader: callback error: %s' % e) + traceback.print_exc() + + def _handle_output(self, data: bytes): + """Handle %output %PANE_ID DATA.""" + parts = data.split(b' ', 1) + if len(parts) < 2: + return + pane_id = parts[0].decode('ascii', errors='replace') + raw_output = parts[1] + # Unescape tmux octal encoding + output_bytes = unescape_tmux_output(raw_output) + self._dispatch('output', { + 'pane_id': pane_id, + 'data': output_bytes, + }) + + def _handle_layout_change(self, data: bytes): + """Handle %layout-change @WIN_ID LAYOUT.""" + decoded = data.decode('utf-8', errors='replace') + parts = decoded.split(' ', 2) + if len(parts) >= 2: + self._dispatch('layout-change', { + 'window_id': parts[0], + 'layout_string': parts[1], + }) + + def _handle_notification(self, marker: str, data: bytes): + """Handle simple notifications.""" + decoded = data.decode('utf-8', errors='replace') + parts = decoded.split(' ', 1) + info = {'raw': decoded} + if marker in ('window-add', 'window-close', 'unlinked-window-add', + 'unlinked-window-close'): + info['window_id'] = parts[0] if parts else '' + elif marker in ('window-renamed', 'unlinked-window-renamed'): + info['window_id'] = parts[0] if parts else '' + info['name'] = parts[1] if len(parts) > 1 else '' + elif marker in ('session-changed', 'session-renamed'): + info['session_id'] = parts[0] if parts else '' + info['name'] = parts[1] if len(parts) > 1 else '' + self._dispatch(marker, info) + + def _dispatch(self, marker: str, info: dict): + """Call all registered handlers for this marker.""" + handlers = self._handler_map.get(marker, []) + for handler in handlers: + try: + handler(info) + except Exception as e: + dbg('NotificationReader: handler error for %s: %s' % (marker, e)) + traceback.print_exc() + + +class TmuxProtocol: + """Combines TmuxSubprocess + NotificationReader. + + Provides send_command() with optional callback for responses. + """ + + def __init__(self, session_name: str, new_session: bool = False): + self.session_name = session_name + self._handler_map = defaultdict(list) + self._command_queue = queue.Queue() + self._reader = None + + if new_session: + args = ['-2', '-C', 'new-session', '-s', session_name] + else: + args = ['-2', '-C', 'attach-session', '-t', session_name] + self._subprocess = TmuxSubprocess(args) + + def start(self): + """Start the tmux subprocess and reader thread.""" + self._subprocess.start() + # tmux sends an initial %begin/%end on connect; queue a dummy callback + self._command_queue.put(None) + self._reader = NotificationReader( + self._subprocess, self._handler_map, self._command_queue + ) + self._reader.start() + + def stop(self, send_detach=True): + """Stop the protocol. Sends detach first unless send_detach=False.""" + if send_detach: + try: + self._subprocess.send_raw('detach') + except Exception: + pass + if self._reader: + self._reader.stop() + self._subprocess.stop() + + def send_command(self, cmd: str, callback: Optional[Callable] = None): + """Send a command to tmux. Optional callback receives CommandResult.""" + self._command_queue.put(callback) + self._subprocess.send_raw(cmd) + + def add_handler(self, marker: str, handler: Callable): + """Register a handler for a notification type.""" + self._handler_map[marker].append(handler) + + def is_alive(self) -> bool: + return self._subprocess.is_alive() + + +class PtyTmuxBridge: + """Wraps an existing PTY fd where tmux -CC is already running. + + Used when the user runs 'tmux -CC' inside a Terminator terminal. + We take over the PTY fd as the communication channel to tmux. + + PTY raw mode produces lines with \\x00 prefixes and \\r\\n endings. + This bridge cleans those up before queuing lines. + """ + + def __init__(self, fd: int): + self._fd = fd + self._line_queue = queue.Queue() + self._pipe_thread = None + self._alive = True + self._saved_termios = None + # Disable echo on the PTY so our commands aren't reflected back + try: + import termios + self._saved_termios = termios.tcgetattr(fd) + attrs = list(self._saved_termios) + attrs[3] = attrs[3] & ~termios.ECHO # lflags: disable ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + except Exception as e: + dbg('PtyTmuxBridge: could not disable echo: %s' % e) + + def start(self): + """Start reading from the PTY fd.""" + self._pipe_thread = threading.Thread( + target=self._pipe_content, + daemon=True, + ) + self._pipe_thread.start() + + def _pipe_content(self): + """Read from PTY fd, split into lines, clean up PTY artifacts.""" + import select + buf = b'' + try: + while self._alive: + r, _, _ = select.select([self._fd], [], [], 1.0) + if not r: + continue + try: + data = os.read(self._fd, 65536) + except OSError: + break + if not data: + break + buf += data + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + # Strip PTY artifacts: + # - NUL bytes anywhere (PTY inserts these randomly, + # corrupting octal sequences like \033 when a NUL + # lands between digits) + # - trailing \r from ONLCR output processing + line = line.replace(b'\x00', b'').rstrip(b'\r') + self._line_queue.put(line + b'\n') + except Exception: + pass + finally: + self._line_queue.put(None) + + def send_raw(self, line: str): + """Send a raw line to tmux via the PTY fd.""" + try: + os.write(self._fd, '{}\n'.format(line).encode()) + except (IOError, OSError): + dbg('PtyTmuxBridge: failed to send: %s' % line) + + def restore_termios(self): + """Restore saved terminal attributes on the fd.""" + if self._saved_termios: + try: + import termios + termios.tcsetattr(self._fd, termios.TCSANOW, self._saved_termios) + except Exception: + pass + self._saved_termios = None + + def stop(self): + """Stop reading and close the fd.""" + self._alive = False + try: + os.close(self._fd) + except OSError: + pass + + def is_alive(self) -> bool: + return self._alive + + def __iter__(self): + return self + + def __next__(self): + line = self._line_queue.get(block=True) + if line is not None: + return line + raise StopIteration + + +class TmuxProtocolFromPty: + """Like TmuxProtocol but uses an existing PTY fd. + + Used when tmux -CC was started by the user in a terminal. + """ + + def __init__(self, pty_fd: int): + self._handler_map = defaultdict(list) + self._command_queue = queue.Queue() + self._reader = None + self._bridge = PtyTmuxBridge(pty_fd) + self.session_name = None + + def start(self): + """Start reading from the PTY.""" + self._bridge.start() + # Don't queue a dummy callback — the initial %begin/%end + # already passed through VTE before we took over + self._reader = NotificationReader( + self._bridge, self._handler_map, self._command_queue + ) + self._reader.start() + + def stop(self, send_detach=True): + """Stop the protocol. Sends detach first unless send_detach=False.""" + if send_detach: + try: + self._bridge.send_raw('detach') + except Exception: + pass + if self._reader: + self._reader.stop() + self._bridge.stop() + + def send_command(self, cmd: str, callback: Optional[Callable] = None): + """Send a command to tmux. Optional callback receives CommandResult.""" + self._command_queue.put(callback) + self._bridge.send_raw(cmd) + + def add_handler(self, marker: str, handler: Callable): + """Register a handler for a notification type.""" + self._handler_map[marker].append(handler) + + def is_alive(self) -> bool: + return self._bridge.is_alive() diff --git a/terminatorlib/tmux/state.py b/terminatorlib/tmux/state.py new file mode 100644 index 00000000..ae990a5f --- /dev/null +++ b/terminatorlib/tmux/state.py @@ -0,0 +1,230 @@ +"""Centralized synchronization state for tmux integration. + +All state transitions that cross module boundaries (controller <-> handlers) +emit GObject signals. State that is internal to a single module or purely +data-tracking (no ordering implications) is stored as simple attributes. + +All signal emissions MUST happen on the GTK main thread. +""" + +from gi.repository import GObject + +from terminatorlib.util import dbg + + +class TmuxSyncState(GObject.GObject): + """Single source of truth for tmux sync flags and data.""" + + __gsignals__ = { + # Layout application lifecycle + 'layout-applying': (GObject.SignalFlags.RUN_LAST, None, ()), + 'layout-settled': (GObject.SignalFlags.RUN_LAST, None, + (GObject.TYPE_PYOBJECT,)), + + # Refresh-client round-trip gating + 'refresh-started': (GObject.SignalFlags.RUN_LAST, None, ()), + 'refresh-complete': (GObject.SignalFlags.RUN_LAST, None, ()), + + # Window resize tracking + 'window-resize-requested': (GObject.SignalFlags.RUN_LAST, None, ()), + 'window-resize-complete': (GObject.SignalFlags.RUN_LAST, None, ()), + + # Initial capture convergence + 'all-panes-stable': (GObject.SignalFlags.RUN_LAST, None, ()), + + # New paned allocation gating + 'new-paneds-allocated': (GObject.SignalFlags.RUN_LAST, None, ()), + } + + def __init__(self): + GObject.GObject.__init__(self) + + # -- Signal-backed flags (use setters to emit signals) -- + + self.applying_layout = False + self.refresh_client_in_flight = False + self.window_resize_pending = False + self.layout_change_pending = False + self.initial_capture_pending = False + + # -- Layout timing / scheduling -- + + self.layout_applied_time = 0 # time.monotonic() stamp + self.layout_clear_source = None # GLib source ID + self.layout_clear_scheduled = False + + # -- Size tracking -- + + self.last_client_size = None # (cols, rows) last sent to tmux + self.last_window_pixels = None # (w, h) from window.get_size() + self.last_chrome = None # (w, h) chrome overhead + self.prev_vte_sizes = {} # pane_id -> (cols, rows) + self.last_pane_sizes = {} # pane_id -> (cols, rows) + self.configure_handler_id = None # GTK configure-event handler + + # -- Tripwire mechanism -- + + self.tripwire_armed = False + self.tripwire_pixels = None # (w, h) allocation boundary + self.pending_tripwire_hit = False + self.tripwire_timer = None # GLib timeout ID + self.tmux_max_cols = None # per-axis limit from tmux + self.tmux_max_rows = None + + # -- Layout trees and paned tracking -- + + self.layout_trees = {} # window_id -> LayoutNode + self.tmux_paneds = set() # all tmux-managed Paned widgets + self.pending_new_paneds = set() # new paneds awaiting GTK allocation + + # -- Ratio retry state -- + + self.needs_ratio_retry = False + self.ratios_changed = False + self.pending_layout_tree = None # LayoutNode + self.deferred_layout_tree = None # stashed during handle drag + + # -- Initial capture stability -- + + self.capture_targets = {} # pane_id -> (cols, rows) from tmux tree + self.capture_settled = set() # pane_ids whose VTE matches target + self.expected_panes = set() # all pane_ids to wait for + self.capture_window_pixels = None # win pixels when targets were set + + # -- Other -- + + self.reconcile_timer = None # GLib source ID + + # ── Signal-emitting setters ─────────────────────────────── + + def begin_layout(self): + """Mark layout application as in-progress.""" + if not self.applying_layout: + self.applying_layout = True + self.emit('layout-applying') + + def finish_layout(self, tree): + """Mark layout application as complete.""" + if self.applying_layout: + self.applying_layout = False + self.pending_layout_tree = None + self.emit('layout-settled', tree) + + def begin_refresh(self): + """Mark a refresh-client command as in-flight.""" + if not self.refresh_client_in_flight: + self.refresh_client_in_flight = True + self.emit('refresh-started') + + def end_refresh(self): + """Mark the refresh-client round-trip as complete.""" + if self.refresh_client_in_flight: + self.refresh_client_in_flight = False + self.emit('refresh-complete') + + def begin_window_resize(self): + """Mark that we've called window.resize() and await WM response.""" + if not self.window_resize_pending: + self.window_resize_pending = True + self.emit('window-resize-requested') + + def end_window_resize(self): + """Mark that WM has responded to our resize request.""" + if self.window_resize_pending: + self.window_resize_pending = False + self.emit('window-resize-complete') + + def mark_pane_stable(self, pane_id): + """Record that a pane's VTE size matches its tmux tree target. + + Emits 'all-panes-stable' when every expected pane has settled. + """ + self.capture_settled.add(pane_id) + if (self.expected_panes and + self.capture_settled >= self.expected_panes): + self.initial_capture_pending = False + self.emit('all-panes-stable') + + def mark_paned_allocated(self, paned): + """Record that a newly-created paned has its correct allocation. + + Emits 'new-paneds-allocated' when all pending paneds are allocated. + """ + self.pending_new_paneds.discard(paned) + if not self.pending_new_paneds: + self.emit('new-paneds-allocated') + + def clear_capture_state(self): + """Reset capture tracking after initial capture fires.""" + self.capture_targets.clear() + self.capture_settled.clear() + self.expected_panes.clear() + self.capture_window_pixels = None + + # ── Lifecycle ───────────────────────────────────────────── + + def reset(self): + """Clear all state (called on stop/cleanup).""" + self.applying_layout = False + self.refresh_client_in_flight = False + self.window_resize_pending = False + self.layout_change_pending = False + self.initial_capture_pending = False + self.layout_applied_time = 0 + self.layout_clear_source = None + self.layout_clear_scheduled = False + self.last_client_size = None + self.last_window_pixels = None + self.last_chrome = None + self.prev_vte_sizes.clear() + self.last_pane_sizes.clear() + self.configure_handler_id = None + self.tripwire_armed = False + self.tripwire_pixels = None + self.pending_tripwire_hit = False + self.tripwire_timer = None + self.tmux_max_cols = None + self.tmux_max_rows = None + self.layout_trees.clear() + self.tmux_paneds.clear() + self.pending_new_paneds.clear() + self.needs_ratio_retry = False + self.ratios_changed = False + self.pending_layout_tree = None + self.deferred_layout_tree = None + self.capture_targets.clear() + self.capture_settled.clear() + self.expected_panes.clear() + self.reconcile_timer = None + + # ── Debug ───────────────────────────────────────────────── + + def dump(self, prefix='TmuxState'): + """Log all sync state in a single debug line.""" + dbg('%s: applying=%s layout_time=%.3f ' + 'refresh=%s win_resize=%s ' + 'layout_chg=%s capture=%s ' + 'tripwire=%s/%s max=%s/%s ' + 'client=%s win_px=%s chrome=%s ' + 'trees=%d paneds=%d pending_new=%d ' + 'settled=%d/%d' % ( + prefix, + self.applying_layout, + self.layout_applied_time, + self.refresh_client_in_flight, + self.window_resize_pending, + self.layout_change_pending, + self.initial_capture_pending, + self.tripwire_armed, + self.tripwire_pixels, + self.tmux_max_cols, + self.tmux_max_rows, + self.last_client_size, + self.last_window_pixels, + self.last_chrome, + len(self.layout_trees), + len(self.tmux_paneds), + len(self.pending_new_paneds), + len(self.capture_settled), + len(self.expected_panes), + )) diff --git a/terminatorlib/window.py b/terminatorlib/window.py index 9166d35a..ae898404 100644 --- a/terminatorlib/window.py +++ b/terminatorlib/window.py @@ -279,6 +279,26 @@ def tab_new(self, widget=None, debugtab=False, _param1=None, _param2=None): def on_delete_event(self, window, event, data=None): """Handle a window close request""" + # If this window has tmux terminals, detach instead of closing. + # Individual pane/tab closes still kill their tmux panes, but + # closing the whole window should preserve the tmux session. + terminals = self.get_terminals() + if terminals and any(getattr(t, 'tmux_pane_id', None) is not None + for t in terminals): + dbg('tmux window close: detaching instead of closing') + # Mark all terminals so close() won't kill tmux panes, + # and collect all controllers to stop + controllers = set() + for t in terminals: + if t.tmux_pane_id is not None: + t._tmux_closing = True + ctrl = getattr(t, '_tmux_controller', None) + if ctrl and ctrl.active: + controllers.add(ctrl) + for ctrl in controllers: + ctrl.stop() + return False # allow the window to close + maker = Factory() child = self.get_child() @@ -676,6 +696,53 @@ def do_deferred_set_rough_geometry_hints(self): self.pending_set_rough_geometry_hint = False self.set_rough_geometry_hints() + def resize(self, width, height): + """Override to trace every resize call.""" + import traceback + caller = traceback.extract_stack(limit=3)[0] + wa = self.get_allocation() + ws = self.get_size() + dbg('size_trace RESIZE %dx%d from %s:%d ' + 'alloc=%dx%d ws=%dx%d' % ( + width, height, + caller.filename.split('/')[-1], caller.lineno, + wa.width, wa.height, ws[0], ws[1])) + Gtk.Window.resize(self, width, height) + + def set_geometry_hints(self, widget, geometry, flags): + """Override to trace every geometry hint change.""" + import traceback + caller = traceback.extract_stack(limit=3)[0] + wa = self.get_allocation() + ws = self.get_size() + if geometry and (flags & Gdk.WindowHints.MAX_SIZE) and (flags & Gdk.WindowHints.BASE_SIZE): + dbg('size_trace HINT BASE=%dx%d INC=%dx%d MAX=%dx%d ' + 'from %s:%d alloc=%dx%d ws=%dx%d' % ( + geometry.base_width, geometry.base_height, + geometry.width_inc, geometry.height_inc, + geometry.max_width, geometry.max_height, + caller.filename.split('/')[-1], caller.lineno, + wa.width, wa.height, ws[0], ws[1])) + elif geometry and (flags & Gdk.WindowHints.MAX_SIZE): + dbg('size_trace HINT MAX=%dx%d from %s:%d ' + 'alloc=%dx%d ws=%dx%d' % ( + geometry.max_width, geometry.max_height, + caller.filename.split('/')[-1], caller.lineno, + wa.width, wa.height, ws[0], ws[1])) + elif geometry and (flags & Gdk.WindowHints.BASE_SIZE): + dbg('size_trace HINT BASE=%dx%d INC=%dx%d ' + 'from %s:%d alloc=%dx%d ws=%dx%d' % ( + geometry.base_width, geometry.base_height, + geometry.width_inc, geometry.height_inc, + caller.filename.split('/')[-1], caller.lineno, + wa.width, wa.height, ws[0], ws[1])) + elif geometry is None: + dbg('size_trace HINT CLEAR from %s:%d ' + 'alloc=%dx%d ws=%dx%d' % ( + caller.filename.split('/')[-1], caller.lineno, + wa.width, wa.height, ws[0], ws[1])) + Gtk.Window.set_geometry_hints(self, widget, geometry, flags) + def set_rough_geometry_hints(self): """Walk all the terminals along the top and left edges to fake up how many columns/rows we sort of have""" @@ -685,7 +752,14 @@ def set_rough_geometry_hints(self): self.cached_maker = Factory() maker = self.cached_maker if maker.isinstance(self.get_child(), 'Notebook'): - dbg("We don't currently support geometry hinting with tabs") + max_size = getattr(self, '_tmux_max_size', None) + if max_size: + geometry = Gdk.Geometry() + geometry.max_width = max_size[0] + geometry.max_height = max_size[1] + self.set_geometry_hints(None, geometry, Gdk.WindowHints.MAX_SIZE) + else: + dbg("We don't currently support geometry hinting with tabs") return terminals = self.get_visible_terminals() @@ -728,8 +802,79 @@ def set_rough_geometry_hints(self): geometry.height_inc = font_height self.set_geometry_hints(None, geometry, Gdk.WindowHints.BASE_SIZE | Gdk.WindowHints.RESIZE_INC) + def set_tmux_geometry_hints(self, terminal): + """Set BASE_SIZE + RESIZE_INC hints for tmux char-grid snapping. + + Uses the terminal's font metrics for increment and computes + the base from the difference between window and VTE content. + Also applies MAX_SIZE if a tmux constraint is active. + """ + from gi.repository import Gdk + try: + char_w = terminal.vte.get_char_width() + char_h = terminal.vte.get_char_height() + if char_w <= 0 or char_h <= 0: + return + + # Geometry hints constrain the ALLOCATION (includes CSD), + # not the content size. Base = CSD + chrome so the + # character grid aligns in content space. + content = self.get_child() + if not content: + return + wa = self.get_allocation() + ws = self.get_size() + csd_w = wa.width - ws[0] + csd_h = wa.height - ws[1] + ca = content.get_allocation() + # Chrome = tab bar + borders, NOT other panes. + # Use the notebook page (not the terminal) so splits + # don't inflate the base size. + if hasattr(content, 'get_current_page'): + page_num = content.get_current_page() + if page_num >= 0: + page = content.get_nth_page(page_num) + pa = page.get_allocation() + chrome_w = max(0, ca.width - pa.width) + chrome_h = max(0, ca.height - pa.height) + else: + chrome_w = chrome_h = 0 + else: + # No notebook — content is the paned/terminal + # directly, so there's no chrome (no tab bar, + # no notebook borders). + chrome_w = chrome_h = 0 + base_w = csd_w + chrome_w + base_h = csd_h + chrome_h + + geometry = Gdk.Geometry() + geometry.base_width = max(0, base_w) + geometry.base_height = max(0, base_h) + geometry.width_inc = char_w + geometry.height_inc = char_h + + flags = Gdk.WindowHints.BASE_SIZE | Gdk.WindowHints.RESIZE_INC + + max_size = getattr(self, '_tmux_max_size', None) + if max_size: + geometry.max_width = max_size[0] + geometry.max_height = max_size[1] + flags |= Gdk.WindowHints.MAX_SIZE + + self.set_geometry_hints(None, geometry, flags) + except Exception: + pass + def disable_geometry_hints(self): - self.set_geometry_hints(None, None, 0) + max_size = getattr(self, '_tmux_max_size', None) + if max_size: + from gi.repository import Gdk + geometry = Gdk.Geometry() + geometry.max_width = max_size[0] + geometry.max_height = max_size[1] + self.set_geometry_hints(None, geometry, Gdk.WindowHints.MAX_SIZE) + else: + self.set_geometry_hints(None, None, 0) def tab_change(self, widget, num=None): """Change to a specific tab""" diff --git a/tests/test_tmux_layout.py b/tests/test_tmux_layout.py new file mode 100644 index 00000000..00f3ac10 --- /dev/null +++ b/tests/test_tmux_layout.py @@ -0,0 +1,186 @@ +"""Tests for terminatorlib.tmux.layout""" + +import unittest +from terminatorlib.tmux.layout import ( + parse_tmux_layout, layout_to_terminator, build_terminator_layout, + get_pane_ids, find_pane_parent, find_pane_node, LayoutNode, +) + + +class TestParseTmuxLayout(unittest.TestCase): + """Test tmux layout string parsing.""" + + def test_single_pane(self): + """Single pane layout: checksum,WxH,X,Y,PANE_ID""" + node = parse_tmux_layout('a1b2,80x24,0,0,0') + self.assertTrue(node.is_leaf) + self.assertEqual(node.width, 80) + self.assertEqual(node.height, 24) + self.assertEqual(node.x_off, 0) + self.assertEqual(node.y_off, 0) + self.assertEqual(node.pane_id, '%0') + + def test_vertical_split(self): + """Vertical split: two panes stacked.""" + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13,1]' + node = parse_tmux_layout(layout) + self.assertFalse(node.is_leaf) + self.assertEqual(node.orientation, 'v') + self.assertEqual(len(node.children), 2) + self.assertEqual(node.children[0].pane_id, '%0') + self.assertEqual(node.children[1].pane_id, '%1') + self.assertEqual(node.children[0].height, 12) + self.assertEqual(node.children[1].height, 11) + + def test_horizontal_split(self): + """Horizontal split: two panes side by side.""" + layout = 'a1b2,80x24,0,0{40x24,0,0,0,39x24,41,0,1}' + node = parse_tmux_layout(layout) + self.assertFalse(node.is_leaf) + self.assertEqual(node.orientation, 'h') + self.assertEqual(len(node.children), 2) + self.assertEqual(node.children[0].pane_id, '%0') + self.assertEqual(node.children[1].pane_id, '%1') + + def test_nested_layout(self): + """Nested splits: vertical with horizontal inside.""" + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13{40x11,0,13,1,39x11,41,13,2}]' + node = parse_tmux_layout(layout) + self.assertEqual(node.orientation, 'v') + self.assertEqual(len(node.children), 2) + # First child is a leaf + self.assertTrue(node.children[0].is_leaf) + self.assertEqual(node.children[0].pane_id, '%0') + # Second child is a horizontal split + child = node.children[1] + self.assertEqual(child.orientation, 'h') + self.assertEqual(len(child.children), 2) + self.assertEqual(child.children[0].pane_id, '%1') + self.assertEqual(child.children[1].pane_id, '%2') + + def test_three_way_vertical(self): + """Three-way vertical split.""" + layout = 'sum,80x24,0,0[80x8,0,0,0,80x7,0,9,1,80x7,0,17,2]' + node = parse_tmux_layout(layout) + self.assertEqual(node.orientation, 'v') + self.assertEqual(len(node.children), 3) + self.assertEqual(node.children[0].pane_id, '%0') + self.assertEqual(node.children[1].pane_id, '%1') + self.assertEqual(node.children[2].pane_id, '%2') + + def test_complex_layout(self): + """Complex layout with multiple levels of nesting.""" + layout = '13e1,124x26,0,0[124x6,0,0,1,124x6,0,7{62x6,0,7,5,61x6,63,7,6},124x12,0,14{62x12,0,14,3,61x12,63,14,4}]' + node = parse_tmux_layout(layout) + self.assertEqual(node.orientation, 'v') + self.assertEqual(len(node.children), 3) + # First child: leaf + self.assertTrue(node.children[0].is_leaf) + # Second child: horizontal split + self.assertEqual(node.children[1].orientation, 'h') + self.assertEqual(len(node.children[1].children), 2) + # Third child: horizontal split + self.assertEqual(node.children[2].orientation, 'h') + self.assertEqual(len(node.children[2].children), 2) + + +class TestGetPaneIds(unittest.TestCase): + def test_single_pane(self): + node = parse_tmux_layout('a1b2,80x24,0,0,0') + self.assertEqual(get_pane_ids(node), {'%0'}) + + def test_multiple_panes(self): + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13{40x11,0,13,1,39x11,41,13,2}]' + node = parse_tmux_layout(layout) + self.assertEqual(get_pane_ids(node), {'%0', '%1', '%2'}) + + +class TestFindPaneParent(unittest.TestCase): + def test_finds_parent(self): + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13,1]' + node = parse_tmux_layout(layout) + parent = find_pane_parent('%1', node) + self.assertIsNotNone(parent) + self.assertEqual(parent.orientation, 'v') + + def test_root_pane_returns_none(self): + node = parse_tmux_layout('a1b2,80x24,0,0,0') + parent = find_pane_parent('%0', node) + self.assertIsNone(parent) + + def test_nested_pane(self): + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13{40x11,0,13,1,39x11,41,13,2}]' + node = parse_tmux_layout(layout) + parent = find_pane_parent('%2', node) + self.assertIsNotNone(parent) + self.assertEqual(parent.orientation, 'h') + + +class TestFindPaneNode(unittest.TestCase): + def test_finds_node(self): + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13,1]' + node = parse_tmux_layout(layout) + pane = find_pane_node('%1', node) + self.assertIsNotNone(pane) + self.assertEqual(pane.pane_id, '%1') + self.assertEqual(pane.height, 11) + + def test_not_found(self): + node = parse_tmux_layout('a1b2,80x24,0,0,0') + pane = find_pane_node('%99', node) + self.assertIsNone(pane) + + +class TestLayoutToTerminator(unittest.TestCase): + def test_single_pane(self): + node = parse_tmux_layout('a1b2,80x24,0,0,0') + result, _, _ = layout_to_terminator(node, 'window0') + self.assertIn('terminal0', result) + self.assertEqual(result['terminal0']['type'], 'Terminal') + self.assertEqual(result['terminal0']['parent'], 'window0') + self.assertEqual(result['terminal0']['tmux']['pane_id'], '%0') + + def test_vertical_split(self): + layout = 'a1b2,80x24,0,0[80x12,0,0,0,80x11,0,13,1]' + node = parse_tmux_layout(layout) + result, _, _ = layout_to_terminator(node, 'window0') + # Should have a VPaned and two terminals + pane_found = False + for name, item in result.items(): + if item['type'] == 'VPaned': + pane_found = True + self.assertEqual(item['parent'], 'window0') + self.assertTrue(pane_found) + self.assertIn('terminal0', result) + self.assertIn('terminal1', result) + + def test_horizontal_split(self): + layout = 'a1b2,80x24,0,0{40x24,0,0,0,39x24,41,0,1}' + node = parse_tmux_layout(layout) + result, _, _ = layout_to_terminator(node, 'window0') + pane_found = False + for name, item in result.items(): + if item['type'] == 'HPaned': + pane_found = True + self.assertTrue(pane_found) + + +class TestBuildTerminatorLayout(unittest.TestCase): + def test_single_window(self): + node = parse_tmux_layout('a1b2,80x24,0,0,0') + layout = build_terminator_layout([node], 80, 24) + self.assertIn('window0', layout) + self.assertEqual(layout['window0']['type'], 'Window') + self.assertIn('terminal0', layout) + + def test_multiple_windows(self): + node1 = parse_tmux_layout('a1b2,80x24,0,0,0') + node2 = parse_tmux_layout('c3d4,80x24,0,0,1') + layout = build_terminator_layout([node1, node2], 80, 24) + self.assertIn('window0', layout) + self.assertIn('notebook0', layout) + self.assertEqual(layout['notebook0']['type'], 'Notebook') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tmux_protocol.py b/tests/test_tmux_protocol.py new file mode 100644 index 00000000..e56eecee --- /dev/null +++ b/tests/test_tmux_protocol.py @@ -0,0 +1,86 @@ +"""Tests for terminatorlib.tmux.protocol""" + +import unittest +from terminatorlib.tmux.protocol import unescape_tmux_output, CommandResult + + +class TestUnescapeTmuxOutput(unittest.TestCase): + """Test tmux octal escape reversal.""" + + def test_no_escapes(self): + """Plain ASCII passes through unchanged.""" + data = b'hello world' + self.assertEqual(unescape_tmux_output(data), b'hello world') + + def test_newline_escape(self): + """\\012 (octal for newline) is unescaped.""" + data = b'line1\\012line2' + self.assertEqual(unescape_tmux_output(data), b'line1\nline2') + + def test_backslash_escape(self): + """\\134 (octal for backslash) is unescaped.""" + data = b'path\\134file' + self.assertEqual(unescape_tmux_output(data), b'path\\file') + + def test_tab_escape(self): + """\\011 (octal for tab) is unescaped.""" + data = b'col1\\011col2' + self.assertEqual(unescape_tmux_output(data), b'col1\tcol2') + + def test_carriage_return_escape(self): + """\\015 (octal for CR) is unescaped.""" + data = b'line\\015' + self.assertEqual(unescape_tmux_output(data), b'line\r') + + def test_escape_char(self): + """\\033 (octal for ESC) is unescaped.""" + data = b'\\033[1m' + self.assertEqual(unescape_tmux_output(data), b'\x1b[1m') + + def test_mixed_content(self): + """Mix of escaped and plain content.""" + data = b'\\033[32mhello\\033[0m\\012' + expected = b'\x1b[32mhello\x1b[0m\n' + self.assertEqual(unescape_tmux_output(data), expected) + + def test_empty(self): + self.assertEqual(unescape_tmux_output(b''), b'') + + def test_trailing_backslash(self): + """Backslash at end without enough digits passes through.""" + data = b'test\\' + self.assertEqual(unescape_tmux_output(data), b'test\\') + + def test_non_octal_after_backslash(self): + """Non-octal digits after backslash pass through.""" + data = b'test\\xyz' + self.assertEqual(unescape_tmux_output(data), b'test\\xyz') + + def test_null_byte(self): + """\\000 is unescaped to null byte.""" + data = b'a\\000b' + self.assertEqual(unescape_tmux_output(data), b'a\x00b') + + +class TestCommandResult(unittest.TestCase): + def test_defaults(self): + result = CommandResult() + self.assertEqual(result.timestamp, '') + self.assertEqual(result.command_number, '') + self.assertEqual(result.output_lines, []) + self.assertFalse(result.is_error) + + def test_with_values(self): + result = CommandResult( + timestamp='12345', + command_number='1', + output_lines=[b'line1', b'line2'], + is_error=True, + ) + self.assertEqual(result.timestamp, '12345') + self.assertTrue(result.is_error) + self.assertEqual(len(result.output_lines), 2) + + +if __name__ == '__main__': + unittest.main()