diff --git a/guake/boxes.py b/guake/boxes.py
index 665ded971..35c4e7dfc 100644
--- a/guake/boxes.py
+++ b/guake/boxes.py
@@ -5,6 +5,7 @@
gi.require_version("Vte", "2.91") # vte-0.42
gi.require_version("Gtk", "3.0")
+gi.require_version("Gdk", "3.0")
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import Gio
@@ -43,6 +44,15 @@ def iter_terminals(self):
def replace_child(self, old, new):
raise NotImplementedError
+ def get_child_position(self, child):
+ raise NotImplementedError
+
+ def detach_child(self, child, temporary_parent):
+ raise NotImplementedError
+
+ def attach_detached_child(self, position, child):
+ raise NotImplementedError
+
def get_guake(self):
raise NotImplementedError
@@ -170,10 +180,17 @@ def iter_terminals(self):
yield from self.get_child().iter_terminals()
def replace_child(self, old, new):
+ position = self.get_child_position(old)
self.remove(old)
- self.set_child(new)
+ self.child = None
+ self._set_child_at_position(position, new)
def set_child(self, terminal_holder):
+ self._set_child_at_position(0, terminal_holder)
+
+ def _set_child_at_position(self, position, terminal_holder):
+ if position != 0:
+ raise RuntimeError(f"Error adding child at root position {position}")
if isinstance(terminal_holder, TerminalHolder):
self.child = terminal_holder
self.add(self.child)
@@ -183,6 +200,23 @@ def set_child(self, terminal_holder):
def get_child(self):
return self.child
+ def get_child_position(self, child):
+ if self.child is child:
+ return 0
+ raise RuntimeError("RootTerminalBox: unknown child")
+
+ def detach_child(self, child, temporary_parent):
+ position = self.get_child_position(child)
+ child.reparent(temporary_parent)
+ self.child = None
+ return position
+
+ def attach_detached_child(self, position, child):
+ if position != 0:
+ raise RuntimeError(f"Error adding child at root position {position}")
+ self.child = child
+ child.reparent(self)
+
def get_guake(self):
return self.guake
@@ -587,14 +621,32 @@ def iter_terminals(self):
yield from self.get_child2().iter_terminals()
def replace_child(self, old, new):
- if self.get_child1() is old:
- self.remove(old)
- self.set_child_first(new)
- elif self.get_child2() is old:
- self.remove(old)
- self.set_child_second(new)
+ position = self.get_child_position(old)
+ self.remove(old)
+ self._set_child_at_position(position, new)
+
+ def get_child_position(self, child):
+ if self.get_child1() is child:
+ return 1
+ if self.get_child2() is child:
+ return 2
+ raise RuntimeError("DualTerminalBox: unknown child")
+
+ def detach_child(self, child, temporary_parent):
+ position = self.get_child_position(child)
+ child.reparent(temporary_parent)
+ return position
+
+ def attach_detached_child(self, position, child):
+ child.reparent(self)
+
+ def _set_child_at_position(self, position, child):
+ if position == 1:
+ self.set_child_first(child)
+ elif position == 2:
+ self.set_child_second(child)
else:
- print("I have never seen this widget!")
+ raise RuntimeError(f"DualTerminalBox: unknown child position {position}")
def get_guake(self):
return self.get_parent().get_guake()
@@ -636,6 +688,29 @@ def remove_dead_child(self, child):
print("I have never seen this widget!")
+@save_tabs_when_changed
+def swap_terminal_boxes(first, second):
+ if first is second:
+ return
+
+ temporary_parent = Gtk.Box()
+ first_parent = first.get_parent()
+ second_parent = second.get_parent()
+ first_position = first_parent.detach_child(first, temporary_parent)
+ second_position = second_parent.detach_child(second, temporary_parent)
+
+ if first_parent is second_parent:
+ reattachments = (
+ (first_position, second),
+ (second_position, first),
+ )
+ for position, child in sorted(reattachments, key=lambda item: item[0]):
+ first_parent.attach_detached_child(position, child)
+ else:
+ first_parent.attach_detached_child(first_position, second)
+ second_parent.attach_detached_child(second_position, first)
+
+
class TabLabelEventBox(Gtk.EventBox):
def __init__(self, notebook, text, settings):
super().__init__()
diff --git a/guake/callbacks.py b/guake/callbacks.py
index 661625c9c..92bae02ae 100644
--- a/guake/callbacks.py
+++ b/guake/callbacks.py
@@ -1,6 +1,7 @@
import gi
gi.require_version("Gtk", "3.0")
+gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
from gi.repository import Gtk
from guake.about import AboutDialog
@@ -93,6 +94,26 @@ def on_split_vertical(self, *args):
def on_split_horizontal(self, *args):
self.terminal.get_parent().split_h(50)
+ def on_move_pane_up(self, *args):
+ from guake.split_utils import PaneMover
+
+ PaneMover(self.window).move_up(self.terminal)
+
+ def on_move_pane_down(self, *args):
+ from guake.split_utils import PaneMover
+
+ PaneMover(self.window).move_down(self.terminal)
+
+ def on_move_pane_left(self, *args):
+ from guake.split_utils import PaneMover
+
+ PaneMover(self.window).move_left(self.terminal)
+
+ def on_move_pane_right(self, *args):
+ from guake.split_utils import PaneMover
+
+ PaneMover(self.window).move_right(self.terminal)
+
def on_close_terminal(self, *args):
self.terminal.kill()
diff --git a/guake/data/org.guake.gschema.xml b/guake/data/org.guake.gschema.xml
index d8846e77b..ae5eeaeea 100644
--- a/guake/data/org.guake.gschema.xml
+++ b/guake/data/org.guake.gschema.xml
@@ -633,6 +633,26 @@
Focus terminal on the left.
Focus terminal on the left.
+
+ ''
+ Move terminal pane up.
+ Move terminal pane up.
+
+
+ ''
+ Move terminal pane down.
+ Move terminal pane down.
+
+
+ ''
+ Move terminal pane left.
+ Move terminal pane left.
+
+
+ ''
+ Move terminal pane right.
+ Move terminal pane right.
+
''
Move the terminal split handle up.
diff --git a/guake/keybindings.py b/guake/keybindings.py
index bfd76fa1e..f4797ec31 100644
--- a/guake/keybindings.py
+++ b/guake/keybindings.py
@@ -24,12 +24,14 @@
import gi
gi.require_version("Gtk", "3.0")
+gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
from gi.repository import Gtk
from guake import notifier
from guake.common import pixmapfile
from guake.split_utils import FocusMover
+from guake.split_utils import PaneMover
from guake.split_utils import SplitMover
log = logging.getLogger(__name__)
@@ -157,6 +159,42 @@ def x(*args):
or True
),
),
+ (
+ "move-terminal-pane-up",
+ (
+ lambda *args: PaneMover(self.guake.window).move_up(
+ self.guake.get_notebook().get_current_terminal()
+ )
+ or True
+ ),
+ ),
+ (
+ "move-terminal-pane-down",
+ (
+ lambda *args: PaneMover(self.guake.window).move_down(
+ self.guake.get_notebook().get_current_terminal()
+ )
+ or True
+ ),
+ ),
+ (
+ "move-terminal-pane-left",
+ (
+ lambda *args: PaneMover(self.guake.window).move_left(
+ self.guake.get_notebook().get_current_terminal()
+ )
+ or True
+ ),
+ ),
+ (
+ "move-terminal-pane-right",
+ (
+ lambda *args: PaneMover(self.guake.window).move_right(
+ self.guake.get_notebook().get_current_terminal()
+ )
+ or True
+ ),
+ ),
(
"move-terminal-split-up",
(
diff --git a/guake/menus.py b/guake/menus.py
index 60c5f29ab..ea43c9fd3 100644
--- a/guake/menus.py
+++ b/guake/menus.py
@@ -97,6 +97,20 @@ def mk_terminal_context_menu(terminal, window, settings, callback_object):
mi = Gtk.MenuItem(_("Split |"))
mi.connect("activate", callback_object.on_split_vertical)
menu.add(mi)
+ menu.add(Gtk.SeparatorMenuItem())
+ mi = Gtk.MenuItem(_("Move pane up"))
+ mi.connect("activate", callback_object.on_move_pane_up)
+ menu.add(mi)
+ mi = Gtk.MenuItem(_("Move pane down"))
+ mi.connect("activate", callback_object.on_move_pane_down)
+ menu.add(mi)
+ mi = Gtk.MenuItem(_("Move pane left"))
+ mi.connect("activate", callback_object.on_move_pane_left)
+ menu.add(mi)
+ mi = Gtk.MenuItem(_("Move pane right"))
+ mi.connect("activate", callback_object.on_move_pane_right)
+ menu.add(mi)
+ menu.add(Gtk.SeparatorMenuItem())
mi = Gtk.MenuItem(_("Close terminal"))
mi.connect("activate", callback_object.on_close_terminal)
menu.add(mi)
diff --git a/guake/prefs.py b/guake/prefs.py
index d69237510..396afa2ec 100644
--- a/guake/prefs.py
+++ b/guake/prefs.py
@@ -27,6 +27,7 @@
import gi
gi.require_version("Gtk", "3.0")
+gi.require_version("Gdk", "3.0")
gi.require_version("Vte", "2.91") # vte-0.38
from gi.repository import GLib
from gi.repository import Gdk
@@ -120,6 +121,10 @@
{"key": "focus-terminal-down", "label": _("Focus terminal below")},
{"key": "focus-terminal-left", "label": _("Focus terminal on the left")},
{"key": "focus-terminal-right", "label": _("Focus terminal on the right")},
+ {"key": "move-terminal-pane-up", "label": _("Move terminal pane up")},
+ {"key": "move-terminal-pane-down", "label": _("Move terminal pane down")},
+ {"key": "move-terminal-pane-left", "label": _("Move terminal pane left")},
+ {"key": "move-terminal-pane-right", "label": _("Move terminal pane right")},
{
"key": "move-terminal-split-up",
"label": _("Move the terminal split handle up"),
diff --git a/guake/split_utils.py b/guake/split_utils.py
index 53cefe4f3..0e04d9d98 100644
--- a/guake/split_utils.py
+++ b/guake/split_utils.py
@@ -24,6 +24,7 @@
from gi.repository import Gtk
from guake.boxes import DualTerminalBox
from guake.boxes import RootTerminalBox
+from guake.boxes import swap_terminal_boxes
class FocusMover:
@@ -35,59 +36,74 @@ def __init__(self, window):
self.window = window
def move_right(self, terminal):
- window_width, window_height = self.window.get_size()
+ term = self.find_right(terminal)
+ if term is not None:
+ term.grab_focus()
+
+ def move_left(self, terminal):
+ term = self.find_left(terminal)
+ if term is not None:
+ term.grab_focus()
+
+ def move_up(self, terminal):
+ term = self.find_up(terminal)
+ if term is not None:
+ term.grab_focus()
+
+ def move_down(self, terminal):
+ term = self.find_down(terminal)
+ if term is not None:
+ term.grab_focus()
+
+ def find_right(self, terminal):
+ window_width, _ = self.window.get_size()
tx, ty, tw, th = self.list_allocation(terminal)
if tx + tw == window_width:
- return
+ return None
search_x = tx + tw + FocusMover.THRESHOLD
search_y = ty + (th / 2) - FocusMover.BORDER_THICKNESS
- for term in terminal.get_parent().get_root_box().iter_terminals():
- sx, sy, sw, sh = self.list_allocation(term)
- if sx <= search_x <= sx + sw and sy <= search_y <= sy + sh:
- term.grab_focus()
+ return self.find_at(terminal, search_x, search_y)
- def move_left(self, terminal):
- window_width, window_height = self.window.get_size()
+ def find_left(self, terminal):
tx, ty, tw, th = self.list_allocation(terminal)
if tx == 0:
- return
+ return None
search_x = tx - FocusMover.THRESHOLD
search_y = ty + (th / 2) - FocusMover.BORDER_THICKNESS
- for term in terminal.get_parent().get_root_box().iter_terminals():
- sx, sy, sw, sh = self.list_allocation(term)
- if sx <= search_x <= sx + sw and sy <= search_y <= sy + sh:
- term.grab_focus()
+ return self.find_at(terminal, search_x, search_y)
- def move_up(self, terminal):
- window_width, window_height = self.window.get_size()
+ def find_up(self, terminal):
tx, ty, tw, th = self.list_allocation(terminal)
if ty == 0:
- return
+ return None
search_x = tx + (tw / 2) - FocusMover.BORDER_THICKNESS
search_y = ty - FocusMover.THRESHOLD
- for term in terminal.get_parent().get_root_box().iter_terminals():
- sx, sy, sw, sh = self.list_allocation(term)
- if sx <= search_x <= sx + sw and sy <= search_y <= sy + sh:
- term.grab_focus()
+ return self.find_at(terminal, search_x, search_y)
- def move_down(self, terminal):
- window_width, window_height = self.window.get_size()
+ def find_down(self, terminal):
+ _, window_height = self.window.get_size()
tx, ty, tw, th = self.list_allocation(terminal)
if ty + th == window_height:
- return
+ return None
search_x = tx + (tw / 2) - FocusMover.BORDER_THICKNESS
search_y = ty + th + FocusMover.THRESHOLD
+ return self.find_at(terminal, search_x, search_y)
+
+ def find_at(self, terminal, search_x, search_y):
for term in terminal.get_parent().get_root_box().iter_terminals():
+ if term is terminal:
+ continue
sx, sy, sw, sh = self.list_allocation(term)
if sx <= search_x <= sx + sw and sy <= search_y <= sy + sh:
- term.grab_focus()
+ return term
+ return None
def list_allocation(self, terminal):
terminal_rect = terminal.get_parent().get_allocation()
@@ -95,6 +111,28 @@ def list_allocation(self, terminal):
return x, y, terminal_rect.width, terminal_rect.height
+class PaneMover(FocusMover):
+ def move_right(self, terminal):
+ self.move_to_neighbor(terminal, self.find_right(terminal))
+
+ def move_left(self, terminal):
+ self.move_to_neighbor(terminal, self.find_left(terminal))
+
+ def move_up(self, terminal):
+ self.move_to_neighbor(terminal, self.find_up(terminal))
+
+ def move_down(self, terminal):
+ self.move_to_neighbor(terminal, self.find_down(terminal))
+
+ def move_to_neighbor(self, terminal, neighbor):
+ if neighbor is None:
+ return False
+
+ swap_terminal_boxes(terminal.get_parent(), neighbor.get_parent())
+ terminal.grab_focus()
+ return True
+
+
class SplitMover:
THRESHOLD = 35
diff --git a/guake/tests/test_menus.py b/guake/tests/test_menus.py
new file mode 100644
index 000000000..734861ff9
--- /dev/null
+++ b/guake/tests/test_menus.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+
+from types import SimpleNamespace
+
+from guake.callbacks import TerminalContextMenuCallbacks
+from guake.menus import mk_terminal_context_menu
+
+
+class FakeMenu:
+ def __init__(self):
+ self.items = []
+
+ def add(self, item):
+ self.items.append(item)
+
+ def show_all(self):
+ return None
+
+
+class FakeMenuItem:
+ def __init__(self, label=""):
+ self.label = label
+ self.connections = []
+ self.sensitive = True
+
+ def connect(self, signal, callback):
+ self.connections.append((signal, callback))
+
+ def set_label(self, label):
+ self.label = label
+
+ def set_sensitive(self, sensitive):
+ self.sensitive = sensitive
+
+ def set_submenu(self, submenu):
+ self.submenu = submenu
+
+ def set_use_stock(self, use_stock):
+ self.use_stock = use_stock
+
+
+class FakeSeparatorMenuItem:
+ pass
+
+
+class FakeClipboard:
+ @classmethod
+ def get_default(cls, display):
+ return cls()
+
+ def wait_is_text_available(self):
+ return False
+
+
+class FakeCallbacks:
+ def __getattr__(self, name):
+ def callback(*args):
+ return None
+
+ callback.__name__ = name
+ setattr(self, name, callback)
+ return callback
+
+
+class FakeTerminal:
+ found_link = None
+
+ def get_has_selection(self):
+ return False
+
+
+class FakeCustomCommands:
+ def __init__(self, settings, callback):
+ return None
+
+ def should_load(self):
+ return False
+
+
+def test_terminal_context_menu_contains_pane_move_items(monkeypatch):
+ import guake.menus as menus
+
+ fake_gtk = SimpleNamespace(
+ Menu=FakeMenu,
+ MenuItem=FakeMenuItem,
+ ImageMenuItem=FakeMenuItem,
+ SeparatorMenuItem=FakeSeparatorMenuItem,
+ Clipboard=FakeClipboard,
+ )
+ monkeypatch.setattr(menus, "Gtk", fake_gtk)
+ monkeypatch.setattr(menus, "CustomCommands", FakeCustomCommands)
+
+ callbacks = FakeCallbacks()
+ menu = mk_terminal_context_menu(
+ FakeTerminal(),
+ SimpleNamespace(get_display=lambda: object()),
+ SimpleNamespace(general=SimpleNamespace(get_string=lambda key: None)),
+ callbacks,
+ )
+ items_by_label = {
+ item.label: item
+ for item in menu.items
+ if isinstance(item, FakeMenuItem)
+ }
+
+ assert items_by_label["Move pane up"].connections == [
+ ("activate", callbacks.on_move_pane_up)
+ ]
+ assert items_by_label["Move pane down"].connections == [
+ ("activate", callbacks.on_move_pane_down)
+ ]
+ assert items_by_label["Move pane left"].connections == [
+ ("activate", callbacks.on_move_pane_left)
+ ]
+ assert items_by_label["Move pane right"].connections == [
+ ("activate", callbacks.on_move_pane_right)
+ ]
+
+
+def test_terminal_context_callbacks_move_pane(monkeypatch):
+ from guake import split_utils
+
+ calls = []
+ terminal = object()
+ window = object()
+
+ class FakePaneMover:
+ def __init__(self, mover_window):
+ calls.append(("init", mover_window))
+
+ def move_up(self, mover_terminal):
+ calls.append(("up", mover_terminal))
+
+ def move_down(self, mover_terminal):
+ calls.append(("down", mover_terminal))
+
+ def move_left(self, mover_terminal):
+ calls.append(("left", mover_terminal))
+
+ def move_right(self, mover_terminal):
+ calls.append(("right", mover_terminal))
+
+ monkeypatch.setattr(split_utils, "PaneMover", FakePaneMover)
+
+ callbacks = TerminalContextMenuCallbacks(terminal, window, object(), object())
+ callbacks.on_move_pane_up()
+ callbacks.on_move_pane_down()
+ callbacks.on_move_pane_left()
+ callbacks.on_move_pane_right()
+
+ assert calls == [
+ ("init", window),
+ ("up", terminal),
+ ("init", window),
+ ("down", terminal),
+ ("init", window),
+ ("left", terminal),
+ ("init", window),
+ ("right", terminal),
+ ]
diff --git a/guake/tests/test_split_utils.py b/guake/tests/test_split_utils.py
new file mode 100644
index 000000000..e999d0fe9
--- /dev/null
+++ b/guake/tests/test_split_utils.py
@@ -0,0 +1,284 @@
+# -*- coding: utf-8 -*-
+
+import xml.etree.ElementTree as ET
+
+from types import SimpleNamespace
+
+from guake.prefs import HOTKEYS
+from guake.split_utils import PaneMover
+
+
+PANE_MOVE_KEYS = {
+ "move-terminal-pane-up",
+ "move-terminal-pane-down",
+ "move-terminal-pane-left",
+ "move-terminal-pane-right",
+}
+
+
+class FakeWindow:
+ def __init__(self, width, height):
+ self.width = width
+ self.height = height
+
+ def get_size(self):
+ return self.width, self.height
+
+
+class FakeRoot:
+ def __init__(self):
+ self.terminals = []
+
+ def iter_terminals(self):
+ yield from self.terminals
+
+
+class FakeTerminalBox:
+ def __init__(self, root, x, y, width, height):
+ self.root = root
+ self.x = x
+ self.y = y
+ self.allocation = SimpleNamespace(width=width, height=height)
+ self.parent = None
+
+ def get_parent(self):
+ return self.parent
+
+ def set_parent(self, parent):
+ self.parent = parent
+
+ def get_root_box(self):
+ return self.root
+
+ def get_allocation(self):
+ return self.allocation
+
+ def translate_coordinates(self, window, x, y):
+ return self.x, self.y
+
+ def reparent(self, parent):
+ if self.parent is not None:
+ self.parent.remove_child(self)
+ parent.add_child(self)
+
+ def ref(self):
+ raise RuntimeError("ref should not be used through PyGObject")
+
+
+class FakeTerminal:
+ def __init__(self, box):
+ self.box = box
+ self.focus_count = 0
+
+ def get_parent(self):
+ return self.box
+
+ def grab_focus(self):
+ self.focus_count += 1
+
+
+class FakeHolder:
+ def __init__(self, root, first=None, second=None, position=50):
+ self.root = root
+ self.children = {1: None, 2: None}
+ self.position = position
+ if first is not None:
+ self.children[1] = first
+ first.set_parent(self)
+ if second is not None:
+ self.children[2] = second
+ second.set_parent(self)
+
+ def child_at(self, position):
+ return self.children[position]
+
+ def add_child(self, child):
+ if self.children[1] is None:
+ self.children[1] = child
+ elif self.children[2] is None:
+ self.children[2] = child
+ else:
+ raise RuntimeError("holder is full")
+ child.set_parent(self)
+
+ def remove_child(self, child):
+ position = self.get_child_position(child)
+ self.children[position] = None
+ child.set_parent(None)
+
+ def get_root_box(self):
+ return self.root
+
+ def get_child_position(self, child):
+ for position, candidate in self.children.items():
+ if candidate is child:
+ return position
+ raise RuntimeError("unknown child")
+
+ def detach_child(self, child, temporary_parent):
+ position = self.get_child_position(child)
+ child.reparent(temporary_parent)
+ return position
+
+ def attach_detached_child(self, position, child):
+ child.reparent(self)
+
+
+class FakeTemporaryHolder:
+ def __init__(self):
+ self.children = []
+
+ def add_child(self, child):
+ self.children.append(child)
+ child.set_parent(self)
+
+ def remove_child(self, child):
+ self.children.remove(child)
+ child.set_parent(None)
+
+
+class FakeSettingsGroup:
+ def __init__(self):
+ self.changed_keys = []
+
+ def onChangedValue(self, key, callback):
+ self.changed_keys.append(key)
+
+ def triggerOnChangedValue(self, *args):
+ return None
+
+ def get_string(self, key):
+ return ""
+
+
+class FakeSettings:
+ def __init__(self):
+ self.keybindingsGlobal = FakeSettingsGroup()
+ self.keybindingsLocal = FakeSettingsGroup()
+ self.general = SimpleNamespace(get_int=lambda key: 0)
+
+
+class FakeGuake:
+ def __init__(self):
+ self.settings = FakeSettings()
+ self.window = object()
+
+ def gen_accel_switch_tabN(self, index):
+ return self.noop
+
+ def noop(self, *args):
+ return None
+
+ def __getattr__(self, name):
+ if name.startswith("accel_") or name in (
+ "search_on_web",
+ "open_link_under_terminal_cursor",
+ ):
+ return self.noop
+ raise AttributeError(name)
+
+
+def make_terminal(root, x, y, width, height):
+ box = FakeTerminalBox(root, x, y, width, height)
+ terminal = FakeTerminal(box)
+ root.terminals.append(terminal)
+ return box, terminal
+
+
+def patch_temporary_parent(monkeypatch):
+ import guake.boxes as boxes
+
+ monkeypatch.setattr(boxes.Gtk, "Box", FakeTemporaryHolder)
+
+
+def test_move_pane_swaps_terminal_boxes_in_same_parent(monkeypatch):
+ patch_temporary_parent(monkeypatch)
+ root = FakeRoot()
+ left_box, left_terminal = make_terminal(root, 0, 0, 100, 100)
+ right_box, _ = make_terminal(root, 100, 0, 100, 100)
+ parent = FakeHolder(root, left_box, right_box, position=42)
+
+ PaneMover(FakeWindow(200, 100)).move_right(left_terminal)
+
+ assert parent.child_at(1) is right_box
+ assert parent.child_at(2) is left_box
+ assert parent.position == 42
+ assert left_terminal.focus_count == 1
+
+
+def test_move_pane_swaps_terminal_boxes_in_same_parent_from_second_slot(monkeypatch):
+ patch_temporary_parent(monkeypatch)
+ root = FakeRoot()
+ left_box, _ = make_terminal(root, 0, 0, 100, 100)
+ right_box, right_terminal = make_terminal(root, 100, 0, 100, 100)
+ parent = FakeHolder(root, left_box, right_box, position=42)
+
+ PaneMover(FakeWindow(200, 100)).move_left(right_terminal)
+
+ assert parent.child_at(1) is right_box
+ assert parent.child_at(2) is left_box
+ assert parent.position == 42
+ assert right_terminal.focus_count == 1
+
+
+def test_move_pane_swaps_terminal_boxes_in_different_parents(monkeypatch):
+ patch_temporary_parent(monkeypatch)
+ root = FakeRoot()
+ source_box, source_terminal = make_terminal(root, 0, 0, 100, 100)
+ left_sibling, _ = make_terminal(root, 0, 100, 100, 100)
+ right_sibling, _ = make_terminal(root, 100, 100, 100, 100)
+ target_box, _ = make_terminal(root, 100, 0, 100, 100)
+ source_parent = FakeHolder(root, source_box, left_sibling, position=25)
+ target_parent = FakeHolder(root, right_sibling, target_box, position=75)
+
+ PaneMover(FakeWindow(200, 200)).move_right(source_terminal)
+
+ assert source_parent.child_at(1) is target_box
+ assert target_parent.child_at(2) is source_box
+ assert source_parent.position == 25
+ assert target_parent.position == 75
+ assert source_terminal.focus_count == 1
+
+
+def test_move_pane_noops_at_outer_edge(monkeypatch):
+ patch_temporary_parent(monkeypatch)
+ root = FakeRoot()
+ left_box, left_terminal = make_terminal(root, 0, 0, 100, 100)
+ right_box, _ = make_terminal(root, 100, 0, 100, 100)
+ parent = FakeHolder(root, left_box, right_box)
+
+ PaneMover(FakeWindow(200, 100)).move_left(left_terminal)
+
+ assert parent.child_at(1) is left_box
+ assert parent.child_at(2) is right_box
+ assert left_terminal.focus_count == 0
+
+
+def test_pane_move_hotkeys_are_registered(monkeypatch):
+ import guake.keybindings as keybindings
+
+ monkeypatch.setattr(keybindings.Gdk.Display, "get_default", lambda: None)
+ monkeypatch.setattr(keybindings.Gdk.Keymap, "get_for_display", lambda display: object())
+
+ bindings = keybindings.Keybindings(FakeGuake())
+ actions = dict(bindings.keys)
+
+ for key in PANE_MOVE_KEYS:
+ assert key in actions
+ assert callable(actions[key])
+ assert key in bindings.guake.settings.keybindingsLocal.changed_keys
+
+
+def test_pane_move_hotkeys_are_in_schema_and_preferences():
+ schema = ET.parse("guake/data/org.guake.gschema.xml")
+ local_schema = schema.find(".//schema[@id='guake.keybindings.local']")
+ schema_keys = {key.get("name"): key.findtext("default") for key in local_schema}
+ prefs_keys = {
+ item["key"]
+ for group in HOTKEYS
+ for item in group["keys"]
+ }
+
+ for key in PANE_MOVE_KEYS:
+ assert schema_keys[key] == "''"
+ assert key in prefs_keys
diff --git a/releasenotes/notes/pane-reordering-85e2dab417abca7b.yaml b/releasenotes/notes/pane-reordering-85e2dab417abca7b.yaml
new file mode 100644
index 000000000..dc10e49d8
--- /dev/null
+++ b/releasenotes/notes/pane-reordering-85e2dab417abca7b.yaml
@@ -0,0 +1,3 @@
+features:
+ - |
+ - Add terminal pane reordering with hotkeys and context menu actions.