Files
pyssh-client/app/ui/split_pane.py
Florian 5917e1ca04 feat: ajouter le split terminal (plusieurs panneaux par onglet)
Layouts : 1 (normal), 2 verticaux (cote a cote), 2 horizontaux (empiles), 4
panneaux (grille 2x2), via des QSplitter imbriques (app/ui/split_pane.py).
Chaque panneau reutilise tel quel un widget d'onglet existant
(SessionTabWidget, LocalTerminalTabWidget) : aucune modification de ces
classes, seule une fine barre (menu ☰ + fermeture) les enveloppe.

- Menu contextuel d'un onglet (SSH ou terminal local, pas SFTP, pas
  d'imbrication de splits) : "Diviser" -> choix du layout. Le contenu actuel
  occupe le premier panneau, les autres recoivent un terminal local par
  defaut.
- Menu ☰ d'un panneau : changer son contenu (autre terminal local), ou
  "Deplacer un onglet ici" pour y faire migrer un onglet de premier niveau
  existant sans fermer sa connexion.
- Fermer un panneau le retire de son QSplitter (qui redistribue l'espace tout
  seul) ; s'il n'en reste qu'un, l'onglet redevient un onglet normal
  (redescente automatique, pas de chrome de split residuel).
- Fermer l'onglet divise (bouton x) ferme toutes les connexions des panneaux.
- BroadcastManager (Multi-Send) atteint desormais aussi les panneaux a
  l'interieur d'un split (MainWindow._iter_tabs les deplie).

Bug intercepte par les tests avant integration : reparenter le widget d'origine
dans son panneau retire automatiquement son onglet du QTabWidget (un widget ne
peut avoir qu'un seul parent), ce qui decale les onglets suivants ; un appel
explicite a removeTab() ensuite visait donc le mauvais onglet. Corrige en
laissant Qt gerer ce retrait implicitement plutot que de le dupliquer.

tests/test_split_terminal.py : SSHConnection.start()/LocalTerminalConnection.start()
neutralisees (teste l'agencement des panneaux et le cycle de vie des onglets,
pas une connexion reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:35:39 +02:00

151 lines
5.7 KiB
Python

"""Affichage de plusieurs terminaux dans un meme onglet (split), via des
QSplitter imbriques.
Un SplitContainer prend la place d'un onglet normal dans le QTabWidget de
ConnectionController ; chaque panneau (PaneSlot) heberge un widget d'onglet
existant (SessionTabWidget, LocalTerminalTabWidget, ...) sans le modifier.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QToolButton, QFrame,
)
LAYOUT_SINGLE = "1"
LAYOUT_2V = "2v" # 2 panneaux cote a cote (ligne de separation verticale)
LAYOUT_2H = "2h" # 2 panneaux empiles (ligne de separation horizontale)
LAYOUT_4 = "4" # grille 2x2
LAYOUT_LABELS = {
LAYOUT_2V: "2 panneaux verticaux (cote a cote)",
LAYOUT_2H: "2 panneaux horizontaux (empiles)",
LAYOUT_4: "4 panneaux",
}
_PANE_COUNTS = {LAYOUT_SINGLE: 1, LAYOUT_2V: 2, LAYOUT_2H: 2, LAYOUT_4: 4}
class PaneSlot(QWidget):
"""Un emplacement dans la grille : une fine barre (menu + fermeture)
au-dessus du contenu, qui reste un widget d'onglet inchange."""
close_requested = Signal(object) # emet elle-meme
menu_requested = Signal(object) # emet elle-meme ; le conteneur construit le menu
def __init__(self, content: QWidget, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
bar = QHBoxLayout()
bar.setContentsMargins(4, 2, 4, 2)
self.menu_btn = QToolButton()
self.menu_btn.setText("☰")
self.menu_btn.setToolTip("Changer le contenu de ce panneau")
self.menu_btn.clicked.connect(lambda: self.menu_requested.emit(self))
bar.addWidget(self.menu_btn)
bar.addStretch()
close_btn = QToolButton()
close_btn.setText("✕")
close_btn.setToolTip("Fermer ce panneau")
close_btn.clicked.connect(lambda: self.close_requested.emit(self))
bar.addWidget(close_btn)
bar_frame = QFrame()
bar_frame.setObjectName("tabBar")
bar_frame.setLayout(bar)
layout.addWidget(bar_frame)
self.content = content
layout.addWidget(content, 1)
def close_connection(self) -> None:
if hasattr(self.content, "close_connection"):
self.content.close_connection()
def set_content(self, new_content: QWidget) -> None:
self.layout().replaceWidget(self.content, new_content)
self.content.setParent(None)
self.content = new_content
new_content.show()
class SplitContainer(QWidget):
"""Grille de terminaux (1/2/4 panneaux). Redescend automatiquement a un
seul panneau quand il n'en reste plus qu'un (ferme les autres)."""
all_panes_closed = Signal(object) # (widget restant) -> le proprietaire deplie l'onglet
pane_menu_requested = Signal(object, object) # (pane, container) -> le proprietaire affiche un menu
def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None):
super().__init__(parent)
self.panes: list[PaneSlot] = []
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
contents = [first_widget] + [make_filler() for _ in range(_PANE_COUNTS[layout_kind] - 1)]
for content in contents:
self._add_pane(content)
self._root_layout.addWidget(self._arrange(layout_kind, self.panes))
def _make_pane(self, content: QWidget) -> PaneSlot:
pane = PaneSlot(content)
pane.close_requested.connect(self._on_pane_close_requested)
pane.menu_requested.connect(lambda p: self.pane_menu_requested.emit(p, self))
return pane
def _add_pane(self, content: QWidget) -> PaneSlot:
pane = self._make_pane(content)
self.panes.append(pane)
return pane
@staticmethod
def _arrange(layout_kind: str, panes: list[PaneSlot]) -> QWidget:
if layout_kind == LAYOUT_SINGLE:
return panes[0]
if layout_kind == LAYOUT_2V:
splitter = QSplitter(Qt.Horizontal)
elif layout_kind == LAYOUT_2H:
splitter = QSplitter(Qt.Vertical)
elif layout_kind == LAYOUT_4:
top = QSplitter(Qt.Horizontal)
top.addWidget(panes[0])
top.addWidget(panes[1])
bottom = QSplitter(Qt.Horizontal)
bottom.addWidget(panes[2])
bottom.addWidget(panes[3])
outer = QSplitter(Qt.Vertical)
outer.addWidget(top)
outer.addWidget(bottom)
return outer
else:
raise ValueError(f"layout de split inconnu : {layout_kind}")
for pane in panes:
splitter.addWidget(pane)
return splitter
def replace_pane_content(self, pane: PaneSlot, new_content: QWidget) -> None:
"""Ferme l'ancien contenu du panneau et le remplace (ex : terminal
local par defaut remplace par un onglet deplace ici)."""
pane.close_connection()
pane.set_content(new_content)
def _on_pane_close_requested(self, pane: PaneSlot) -> None:
pane.close_connection()
self.panes.remove(pane)
# Le retirer de son QSplitter suffit : celui-ci redistribue l'espace
# aux panneaux restants tout seul, sans reconstruire toute la grille.
pane.setParent(None)
pane.deleteLater()
if len(self.panes) <= 1:
remaining = self.panes[0].content if self.panes else None
self.all_panes_closed.emit(remaining)
def close_all(self) -> None:
for pane in self.panes:
pane.close_connection()