diff --git a/app/dialogs/group_settings_dialog.py b/app/dialogs/group_settings_dialog.py index 1155620..02d2629 100644 --- a/app/dialogs/group_settings_dialog.py +++ b/app/dialogs/group_settings_dialog.py @@ -13,7 +13,6 @@ from PySide6.QtWidgets import ( QLineEdit, QPushButton, QSpinBox, - QStackedWidget, QVBoxLayout, QWidget, ) @@ -33,6 +32,7 @@ from app.session_manager import ( BastionConfig, SessionStore, ) +from app.ui.auto_height_stack import AutoHeightStackedWidget class GroupSettingsDialog(QDialog): @@ -149,7 +149,7 @@ class GroupSettingsDialog(QDialog): self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL) v.addWidget(self.bastion_mode_combo) - self.bastion_stack = QStackedWidget() + self.bastion_stack = AutoHeightStackedWidget() v.addWidget(self.bastion_stack) self.bastion_stack.addWidget(QWidget()) diff --git a/app/dialogs/session_dialog.py b/app/dialogs/session_dialog.py index a214940..a78b072 100644 --- a/app/dialogs/session_dialog.py +++ b/app/dialogs/session_dialog.py @@ -16,7 +16,6 @@ from PySide6.QtWidgets import ( QMessageBox, QPushButton, QSpinBox, - QStackedWidget, QTextEdit, QVBoxLayout, QWidget, @@ -41,6 +40,7 @@ from app.session_manager import ( Session, SessionStore, ) +from app.ui.auto_height_stack import AutoHeightStackedWidget class SessionDialog(QDialog): @@ -147,9 +147,28 @@ class SessionDialog(QDialog): self.form = form layout.addLayout(form) - layout.addWidget(self._build_bastion_group()) - layout.addWidget(self._build_keepalive_group()) - layout.addWidget(self._build_reconnect_group()) + + advanced_container = QWidget() + advanced_layout = QVBoxLayout(advanced_container) + advanced_layout.setContentsMargins(0, 0, 0, 0) + advanced_layout.addWidget(self._build_bastion_group()) + advanced_layout.addWidget(self._build_keepalive_group()) + advanced_layout.addWidget(self._build_reconnect_group()) + + if self.quick_mode: + # Repliees par defaut : une connexion rapide sert avant tout a + # aller vite (souvent un serveur de test en DHCP) - bastion, + # keepalive et reconnexion restent accessibles sans allonger le + # dialogue pour le cas courant, ou ils ne servent pas. + self.advanced_toggle = QPushButton("▸ Options avancees (bastion, keepalive, reconnexion)") + self.advanced_toggle.setCheckable(True) + self.advanced_toggle.setFlat(True) + self.advanced_toggle.toggled.connect(self._on_advanced_toggled) + layout.addWidget(self.advanced_toggle) + advanced_container.setVisible(False) + + layout.addWidget(advanced_container) + self.advanced_container = advanced_container self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)") if self.quick_mode: @@ -177,6 +196,14 @@ class SessionDialog(QDialog): self.key_combo.addItem(self.session.key_path, self.session.key_path) self.key_combo.setCurrentIndex(self.key_combo.count() - 1) + def _on_advanced_toggled(self, checked: bool) -> None: + self.advanced_container.setVisible(checked) + self.advanced_toggle.setText( + "▾ Options avancees (bastion, keepalive, reconnexion)" if checked + else "▸ Options avancees (bastion, keepalive, reconnexion)" + ) + self.adjustSize() + def _reload_keys(self) -> None: self.key_combo.clear() for entry in self.key_manager.keys: @@ -243,6 +270,11 @@ class SessionDialog(QDialog): self.keepalive_interval_spin.setValue(settings.keepalive_interval or 30) self.keepalive_count_spin.setValue(settings.keepalive_count_max or 3) + # les reglages avances viennent d'etre modifies : les montrer, sans quoi + # ils resteraient caches derriere le repli "Options avancees" + if self.quick_mode and not self.advanced_toggle.isChecked(): + self.advanced_toggle.setChecked(True) + # ---------- Bastion / Jump Host ---------- def _build_bastion_group(self) -> QGroupBox: """Un seul saut pour l'instant (voir BastionConfig) : Aucun / une session @@ -258,7 +290,7 @@ class SessionDialog(QDialog): self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL) v.addWidget(self.bastion_mode_combo) - self.bastion_stack = QStackedWidget() + self.bastion_stack = AutoHeightStackedWidget() v.addWidget(self.bastion_stack) self.bastion_stack.addWidget(QWidget()) # BASTION_NONE : rien a afficher diff --git a/app/ui/auto_height_stack.py b/app/ui/auto_height_stack.py new file mode 100644 index 0000000..64980aa --- /dev/null +++ b/app/ui/auto_height_stack.py @@ -0,0 +1,31 @@ +"""QStackedWidget qui s'adapte a la hauteur de sa page courante. + +Par defaut, QStackedWidget dimensionne son sizeHint() sur sa PLUS GRANDE page, +meme quand une page plus petite (ou vide) est affichee - pense pour eviter +qu'un dialogue ne saute de taille en changeant de page. Ici on veut au +contraire un dialogue qui suit sa page courante : utilise pour les blocs +Bastion/Jump Host (session_dialog.py, group_settings_dialog.py), ou la page +"Aucun" est vide mais la page "Configuration manuelle" a 5 champs - sans ce +correctif, un grand espace vide reste reserve sous la page vide. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QStackedWidget + + +class AutoHeightStackedWidget(QStackedWidget): + def sizeHint(self): + return self.currentWidget().sizeHint() + + def minimumSizeHint(self): + return self.currentWidget().minimumSizeHint() + + def setCurrentIndex(self, index: int) -> None: + super().setCurrentIndex(index) + # previent le parent (le dialogue) que sizeHint() a change, sans quoi + # il ne redemande pas immediatement la nouvelle taille au changement + # de page. + self.updateGeometry() + window = self.window() + if window is not None: + window.adjustSize() diff --git a/tests/test_session_dialog_compact.py b/tests/test_session_dialog_compact.py new file mode 100644 index 0000000..c7b804a --- /dev/null +++ b/tests/test_session_dialog_compact.py @@ -0,0 +1,125 @@ +"""Compacite du formulaire de session : signale par l'utilisateur ("trop grand, +ne rentre pas dans l'ecran") sur le dialogue de connexion SFTP rapide. + +Deux causes distinctes corrigees ensemble : +1. AutoHeightStackedWidget : le QStackedWidget de la section Bastion reservait + par defaut la hauteur de sa PLUS GRANDE page (Configuration manuelle, 5 + champs) meme quand la page vide ("Aucun") etait affichee - un rectangle + vide d'environ 200px, repere en comparant une capture d'ecran avant/apres. +2. En connexion rapide (quick_mode), Bastion/Keepalive/Reconnexion sont + repliees par defaut derriere un bouton "Options avancees..." : peu + pertinentes pour une connexion ponctuelle (souvent un serveur de test).""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path + +from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget + +from app.dialogs.session_dialog import SessionDialog +from app.groups import GroupSettings, GroupSettingsStore +from app.key_manager import KeyManager +from app.session_manager import AUTH_AUTO, BASTION_MANUAL, BastionConfig, Session, SessionStore +from app.ui.auto_height_stack import AutoHeightStackedWidget + +app = QApplication.instance() or QApplication(sys.argv) + +tmp = Path(tempfile.mkdtemp()) +key_manager = KeyManager(registry_path=tmp / "keys.json") +store = SessionStore(path=tmp / "sessions.json") + + +def make_page(height: int) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + label = QLabel("x") + label.setFixedHeight(height) + layout.addWidget(label) + return page + + +print("=== 1. AutoHeightStackedWidget : sizeHint suit la page courante, pas la plus grande ===") +stack = AutoHeightStackedWidget() +small_page = make_page(10) +tall_page = make_page(300) +stack.addWidget(small_page) +stack.addWidget(tall_page) +stack.setCurrentIndex(0) +height_on_small = stack.sizeHint().height() +stack.setCurrentIndex(1) +height_on_tall = stack.sizeHint().height() +print(f" hauteur sur petite page: {height_on_small}, sur grande page: {height_on_tall}") +assert height_on_small < height_on_tall, "la page courante doit determiner la hauteur, pas la plus grande des deux" +assert height_on_tall >= 300 +print(" OK") + +print() +print("=== 2. Connexion rapide : les options avancees sont repliees par defaut ===") +dlg = SessionDialog( + key_manager, session=Session(), parent=None, connect_label="Se connecter", + title="Connexion SFTP", session_store=store, +) +assert hasattr(dlg, "advanced_toggle"), "le bouton de repli doit exister en mode connexion rapide" +assert not dlg.advanced_toggle.isChecked() +assert not dlg.advanced_container.isVisible() +collapsed_height = dlg.sizeHint().height() +print(" hauteur repliee:", collapsed_height) +dlg.close() +print(" OK") + +print() +print("=== 3. Deplier affiche les reglages avances et agrandit le dialogue ===") +dlg = SessionDialog( + key_manager, session=Session(), parent=None, connect_label="Se connecter", + title="Connexion SFTP", session_store=store, +) +dlg.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche +dlg.adjustSize() +collapsed = dlg.height() +dlg.advanced_toggle.setChecked(True) +app.processEvents() +expanded = dlg.height() +print(f" hauteur repliee: {collapsed}, depliee: {expanded}") +assert dlg.advanced_container.isVisible() +assert "▾" in dlg.advanced_toggle.text() +assert expanded > collapsed +dlg.close() +print(" OK") + +print() +print("=== 4. Nouvelle session complete (pas de connexion rapide) : tout est visible, pas de repli ===") +dlg = SessionDialog(key_manager, session=Session(), parent=None, session_store=store) +dlg.show() +assert not hasattr(dlg, "advanced_toggle"), "pas de bouton de repli hors connexion rapide" +assert dlg.advanced_container.isVisible() +dlg.close() +print(" OK") + +print() +print("=== 5. Heriter du groupe en connexion rapide deplie automatiquement si un bastion est herite ===") +group_store = GroupSettingsStore(path=tmp / "groups.json") +group_store.set(GroupSettings( + path="Prod", username="deploy", auth_type=AUTH_AUTO, + bastion=BastionConfig(mode=BASTION_MANUAL, host="bastion.example.com", username="jump"), +)) +dlg = SessionDialog( + key_manager, session=Session(), parent=None, connect_label="Se connecter", + title="Connexion SFTP", session_store=store, group_store=group_store, +) +dlg.show() +assert not dlg.advanced_toggle.isChecked() +assert not dlg.advanced_container.isVisible() +dlg.group_edit.setText("Prod") +dlg._apply_group_defaults() +print(" bouton avance coche apres heritage:", dlg.advanced_toggle.isChecked()) +assert dlg.advanced_toggle.isChecked(), "l'heritage d'un bastion doit reveler les options avancees" +assert dlg.advanced_container.isVisible() +assert dlg.bastion_host_edit.text() == "bastion.example.com" +dlg.close() +print(" OK") + +print() +print("SESSION DIALOG - COMPACITE : TOUS LES TESTS OK")