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.