From 19213980164bf261de7db7b7e848dcd83abaa090 Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Mon, 6 Jul 2026 08:25:33 +0100 Subject: [PATCH 1/3] Fix crash when dragging a tab out of a window, add Detach Tab action Dragging a tab out of its window could crash the whole process, killing every window at once (issue #1022). The gdb log on that issue shows a use-after-free in gtk_notebook_drag_end (gtknotebook.c:3743): our create-window handler removed the page while GTK was still mid-way through its drag bookkeeping, and when the source window had exactly two tabs, hoover() dissolved the notebook entirely, freeing the drag state GTK touches right after the handler returns. That also explains the intermittency: three or more tabs means no notebook collapse. Defer the detach with GObject.idle_add until the drag has fully ended, and queue a re-allocate after failed tab drags so the restored tab label is not left undrawn under Wayland (GTK issue #3143). GTK on Wayland only emits create-window for drops outside any drop-accepting surface (GTK issue #46), so dragging a tab onto another window can never detach there. Add an explicit, DnD-free way to detach as well: a detach_tab keybinding (Shift+Ctrl+D) and a Detach Tab context menu item, plumbed the same way as move-tab. Tabs containing splits detach with all their panes. The keybinding prefs tests hardcode row indices of the sorted keybinding list; the new binding sorts before edit_* and zoom_in, so those indices shift by one. --- terminatorlib/config.py | 3 ++- terminatorlib/notebook.py | 39 ++++++++++++++++++++++----- terminatorlib/paned.py | 1 + terminatorlib/prefseditor.py | 1 + terminatorlib/terminal.py | 4 +++ terminatorlib/terminal_popup_menu.py | 7 +++++ terminatorlib/window.py | 16 +++++++++++ tests/test_prefseditor_keybindings.py | 18 ++++++------- 8 files changed, 72 insertions(+), 17 deletions(-) diff --git a/terminatorlib/config.py b/terminatorlib/config.py index f77dab55..7d7d1c22 100644 --- a/terminatorlib/config.py +++ b/terminatorlib/config.py @@ -207,7 +207,8 @@ 'previous_profile' : '', 'preferences' : '', 'preferences_keybindings' : 'k', - 'help' : 'F1' + 'help' : 'F1', + 'detach_tab' : 'd' }, 'profiles': { 'default': { diff --git a/terminatorlib/notebook.py b/terminatorlib/notebook.py index 540e6579..76e521aa 100644 --- a/terminatorlib/notebook.py +++ b/terminatorlib/notebook.py @@ -38,6 +38,7 @@ def __init__(self, window): self.connect('switch-page', self.deferred_on_tab_switch) self.connect('scroll-event', self.on_scroll_event) self.connect('create-window', self.create_window_detach) + self.connect_after('drag-failed', self.on_tab_drag_failed) self.configure() self.set_can_focus(False) @@ -79,21 +80,44 @@ def configure(self): self.last_active_term = {} def create_window_detach(self, notebook, widget, x, y): - """Create a window to contain a detached tab""" - dbg('creating window for detached tab: %s' % widget) - maker = Factory() + """A tab has been dropped outside any tab bar. GTK emits this + signal in the middle of its drag-and-drop bookkeeping; removing + the page here (and potentially dissolving this notebook via + hoover) leaves GTK dereferencing freed drag state afterwards, + crashing the whole process (issue #1022). Defer the detach until + the drag has fully ended.""" + dbg('deferring detach of dropped tab: %s' % widget) + GObject.idle_add(self.detach_tab_to_new_window, widget, x, y) + + def detach_tab_to_new_window(self, widget, x=None, y=None): + """Detach the tab containing widget into a newly created window""" + child = self.find_tab_root(widget) + if self.page_num(child) == -1: + err('could not find tab to detach for %s' % widget) + return False + dbg('detaching tab into new window: %s' % child) + maker = Factory() window = maker.make('Window') - window.move(x, y) + if x is not None and y is not None: + window.move(x, y) size = self.window.get_size() window.resize(size.width, size.height) - self.detach_tab(widget) - self.disconnect_child(widget) + self.detach_tab(child) + self.disconnect_child(child) self.hoover() - window.add(widget) + window.add(child) window.show_all() + return False + + def on_tab_drag_failed(self, notebook, context, result): + """After a failed tab drag, force a re-allocation: under Wayland + the restored tab label is sometimes not redrawn until the next + mouse-over (GTK issue #3143)""" + GObject.idle_add(self.queue_resize) + return False def create_layout(self, layout): """Apply layout configuration""" @@ -290,6 +314,7 @@ def newtab(self, debugtab=False, widget=None, cwd=None, metadata=None, profile=N 'group-tab-toggle': top_window.group_tab_toggle, 'ungroup-tab': top_window.ungroup_tab, 'move-tab': top_window.move_tab, + 'detach-tab': top_window.detach_tab_to_new_window, 'tab-new': [top_window.tab_new, widget], 'navigate': top_window.navigate_terminal, 'zoom': top_window.zoom, diff --git a/terminatorlib/paned.py b/terminatorlib/paned.py index 0019cfa2..2d0080bd 100644 --- a/terminatorlib/paned.py +++ b/terminatorlib/paned.py @@ -111,6 +111,7 @@ def add(self, widget, metadata=None): 'group-tab-toggle': top_window.group_tab_toggle, 'ungroup-tab': top_window.ungroup_tab, 'move-tab': top_window.move_tab, + 'detach-tab': top_window.detach_tab_to_new_window, 'maximise': [top_window.zoom, False], 'tab-new': [top_window.tab_new, widget], 'navigate': top_window.navigate_terminal, diff --git a/terminatorlib/prefseditor.py b/terminatorlib/prefseditor.py index 0660a40e..fb942573 100755 --- a/terminatorlib/prefseditor.py +++ b/terminatorlib/prefseditor.py @@ -143,6 +143,7 @@ class PrefsEditor: 'resize_right' : _('Resize the terminal right'), 'move_tab_right' : _('Move the tab right'), 'move_tab_left' : _('Move the tab left'), + 'detach_tab' : _('Detach the tab into a new window'), 'toggle_zoom' : _('Maximize terminal'), 'scaled_zoom' : _('Zoom terminal'), 'next_tab' : _('Switch to the next tab'), diff --git a/terminatorlib/terminal.py b/terminatorlib/terminal.py index da977b83..b20cead7 100644 --- a/terminatorlib/terminal.py +++ b/terminatorlib/terminal.py @@ -74,6 +74,7 @@ class Terminal(Gtk.VBox): 'group-all-toggle': (GObject.SignalFlags.RUN_LAST, None, ()), 'move-tab': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_STRING,)), + 'detach-tab': (GObject.SignalFlags.RUN_LAST, None, ()), 'group-win': (GObject.SignalFlags.RUN_LAST, None, ()), 'group-win-toggle': (GObject.SignalFlags.RUN_LAST, None, ()), 'ungroup-win': (GObject.SignalFlags.RUN_LAST, None, ()), @@ -2088,6 +2089,9 @@ def key_move_tab_right(self): def key_move_tab_left(self): self.emit('move-tab', 'left') + def key_detach_tab(self): + self.emit('detach-tab') + def key_toggle_zoom(self): if self.is_zoomed(): self.unzoom() diff --git a/terminatorlib/terminal_popup_menu.py b/terminatorlib/terminal_popup_menu.py index 29f21528..601544d6 100644 --- a/terminatorlib/terminal_popup_menu.py +++ b/terminatorlib/terminal_popup_menu.py @@ -221,6 +221,13 @@ def show(self, widget, event=None): terminal.emit('tab-new', True, terminal)) menu.append(item) + if isinstance(terminal.get_toplevel().get_child(), Gtk.Notebook): + item = self.menu_item(Gtk.MenuItem, 'detach_tab', + _('Detach Ta_b')) + item.connect('activate', lambda x: + terminal.emit('detach-tab')) + menu.append(item) + menu.append(Gtk.SeparatorMenuItem()) item = self.menu_item(Gtk.ImageMenuItem, 'close_term', _('_Close')) diff --git a/terminatorlib/window.py b/terminatorlib/window.py index b1a6bf0f..f36d7f12 100644 --- a/terminatorlib/window.py +++ b/terminatorlib/window.py @@ -588,6 +588,7 @@ def add(self, widget, metadata=None): 'group-tab-toggle': self.group_tab_toggle, 'ungroup-tab': self.ungroup_tab, 'move-tab': self.move_tab, + 'detach-tab': self.detach_tab_to_new_window, 'tab-new': [self.tab_new, widget], 'navigate': self.navigate_terminal, 'rotate-cw': [self.rotate, True], @@ -1035,6 +1036,21 @@ def move_tab(self, widget, direction): notebook.reorder_child(child, page) + def detach_tab_to_new_window(self, widget): + """Handle a request to detach the tab containing widget into a + new window""" + if self.is_zoomed(): + self.unzoom() + + maker = Factory() + notebook = self.get_child() + + if not maker.isinstance(notebook, 'Notebook'): + dbg('not in a notebook, refusing to detach tab') + return + + notebook.detach_tab_to_new_window(widget) + def navigate_terminal(self, terminal, direction): """Navigate around terminals""" if self.is_zoomed(): diff --git a/tests/test_prefseditor_keybindings.py b/tests/test_prefseditor_keybindings.py index 5f1079cc..7f2ef023 100644 --- a/tests/test_prefseditor_keybindings.py +++ b/tests/test_prefseditor_keybindings.py @@ -68,17 +68,17 @@ def test_non_empty_default_keybinding_accels_are_distinct(): "accel_params,expected", [ # 1) 'edit_tab_title' Ctrl+Alt+A - (("9", 97, CONTROL_ALT_MOD, 38), False,), + (("10", 97, CONTROL_ALT_MOD, 38), False,), # 2) 'edit_terminal_title' Ctrl+Alt+A - (("10", 97, CONTROL_ALT_MOD, 38), True,), + (("11", 97, CONTROL_ALT_MOD, 38), True,), # 3) 'edit_window_title' F11 - (("11", 65480, Gdk.ModifierType(0), 95), True,), + (("12", 65480, Gdk.ModifierType(0), 95), True,), # 4) 'zoom_in' Shift+Ctrl+Z - (("70", 122, CONTROL_SHIFT_MOD, 52), True,), + (("71", 122, CONTROL_SHIFT_MOD, 52), True,), # 5) 'close_terminal' Ctrl+Alt+{ (("3", 123, CONTROL_ALT_SHIFT_MOD, 34), False,), # 6) 'zoom_in' Shift+Ctrl+B - (("70", 98, CONTROL_SHIFT_MOD, 56), False,), + (("71", 98, CONTROL_SHIFT_MOD, 56), False,), ], ) def test_message_dialog_is_shown_on_duplicate_accel_assignment( @@ -120,13 +120,13 @@ def test_message_dialog_is_shown_on_duplicate_accel_assignment( "accel_params", [ # 1) 'edit_tab_title' Ctrl+Alt+A - ("9", 97, CONTROL_ALT_MOD, 38), - # 2) 'edit_terminal_title' Ctrl+Alt+A ("10", 97, CONTROL_ALT_MOD, 38), + # 2) 'edit_terminal_title' Ctrl+Alt+A + ("11", 97, CONTROL_ALT_MOD, 38), # 3) 'edit_window_title' F11 - ("11", 65480, Gdk.ModifierType(0), 95), + ("12", 65480, Gdk.ModifierType(0), 95), # 4) 'zoom_in' Shift+Ctrl+Z - ("70", 122, CONTROL_SHIFT_MOD, 52), + ("71", 122, CONTROL_SHIFT_MOD, 52), ], ) def test_duplicate_accels_not_possible_to_set(accel_params): From c679bdd20fa1a239d66efcd9979bfcc79d111d78 Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Mon, 6 Jul 2026 10:01:33 +0100 Subject: [PATCH 2/3] Trim docstrings to one line to match house style --- terminatorlib/notebook.py | 11 ++--------- terminatorlib/window.py | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/terminatorlib/notebook.py b/terminatorlib/notebook.py index 76e521aa..91d1a192 100644 --- a/terminatorlib/notebook.py +++ b/terminatorlib/notebook.py @@ -80,12 +80,7 @@ def configure(self): self.last_active_term = {} def create_window_detach(self, notebook, widget, x, y): - """A tab has been dropped outside any tab bar. GTK emits this - signal in the middle of its drag-and-drop bookkeeping; removing - the page here (and potentially dissolving this notebook via - hoover) leaves GTK dereferencing freed drag state afterwards, - crashing the whole process (issue #1022). Defer the detach until - the drag has fully ended.""" + """Defer detaching a dropped tab until the drag has ended (#1022)""" dbg('deferring detach of dropped tab: %s' % widget) GObject.idle_add(self.detach_tab_to_new_window, widget, x, y) @@ -113,9 +108,7 @@ def detach_tab_to_new_window(self, widget, x=None, y=None): return False def on_tab_drag_failed(self, notebook, context, result): - """After a failed tab drag, force a re-allocation: under Wayland - the restored tab label is sometimes not redrawn until the next - mouse-over (GTK issue #3143)""" + """Re-allocate after a failed tab drag so the label repaints (GTK#3143)""" GObject.idle_add(self.queue_resize) return False diff --git a/terminatorlib/window.py b/terminatorlib/window.py index f36d7f12..c5d8d0e3 100644 --- a/terminatorlib/window.py +++ b/terminatorlib/window.py @@ -1037,8 +1037,7 @@ def move_tab(self, widget, direction): notebook.reorder_child(child, page) def detach_tab_to_new_window(self, widget): - """Handle a request to detach the tab containing widget into a - new window""" + """Detach the tab containing widget into a new window""" if self.is_zoomed(): self.unzoom() From 8e68b7ef8786083bcefdc81d653e75f743e0213c Mon Sep 17 00:00:00 2001 From: Henri Cook Date: Mon, 6 Jul 2026 10:37:37 +0100 Subject: [PATCH 3/3] Drop ticket references from docstrings --- terminatorlib/notebook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terminatorlib/notebook.py b/terminatorlib/notebook.py index 91d1a192..1e938cc6 100644 --- a/terminatorlib/notebook.py +++ b/terminatorlib/notebook.py @@ -80,7 +80,7 @@ def configure(self): self.last_active_term = {} def create_window_detach(self, notebook, widget, x, y): - """Defer detaching a dropped tab until the drag has ended (#1022)""" + """Defer detaching a dropped tab until the drag has ended""" dbg('deferring detach of dropped tab: %s' % widget) GObject.idle_add(self.detach_tab_to_new_window, widget, x, y) @@ -108,7 +108,7 @@ def detach_tab_to_new_window(self, widget, x=None, y=None): return False def on_tab_drag_failed(self, notebook, context, result): - """Re-allocate after a failed tab drag so the label repaints (GTK#3143)""" + """Re-allocate after a failed tab drag so the label repaints""" GObject.idle_add(self.queue_resize) return False