From ef059647fa80a023b859a9919ee58b2e0d9a2487 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 08:54:31 +0200 Subject: [PATCH] Reduire la taille du formulaire de session (connexion rapide SFTP/SSH) Signale par l'utilisateur : le dialogue de "SFTP sur cette connexion" ne tenait pas sur l'ecran. Deux causes distinctes, verifiees par capture d'ecran avant de conclure (rendu hors-ecran + grab()) : 1. Bug reel, dans les deux dialogues qui ont une section Bastion (SessionDialog et GroupSettingsDialog) : leur QStackedWidget reservait par defaut la hauteur de sa PLUS GRANDE page (Configuration manuelle, 5 champs) meme quand la page vide "Aucun" etait affichee - environ 200px de vide pur, visible sur la premiere capture. Nouveau app/ui/auto_height_stack.py (AutoHeightStackedWidget) : le sizeHint suit la page COURANTE, pas la plus grande de toutes: -186px des l'ouverture, a bastion desactive. 2. Choix d'ergonomie : une connexion rapide sert a aller vite (souvent un serveur de test en DHCP, cf. le README) - Bastion/Keepalive/Reconnexion y sont maintenant repliees par defaut derriere un bouton "Options avancees...", toujours accessibles mais qui n'allongent plus le dialogue pour le cas courant. Se deplient automatiquement si "Heriter du groupe" ramene un bastion herite, pour ne pas cacher un changement silencieux. Le dialogue complet ("Nouvelle session" depuis le panneau lateral) est inchange : tout reste visible d'emblee, c'est une operation reflechie, pas une connexion a la volee. Resultat mesure : dialogue de connexion rapide 1026px -> 492px replie (-52%), 876px si depliees (au lieu de 1026, meme contenu, sans le vide). Le dialogue "Nouvelle session" complet passe de meme a 816px grace au seul correctif (1). tests/test_session_dialog_compact.py (5 verifications) : sizeHint de AutoHeightStackedWidget sur la page courante, repli par defaut en connexion rapide, redimensionnement au depli, absence de repli hors connexion rapide, depli automatique sur heritage de groupe. Suite complete revalidee (35/35), ruff propre. Co-Authored-By: Claude Sonnet 5 --- app/dialogs/group_settings_dialog.py | 4 +- app/dialogs/session_dialog.py | 42 +++++++-- app/ui/auto_height_stack.py | 31 +++++++ tests/test_session_dialog_compact.py | 125 +++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 app/ui/auto_height_stack.py create mode 100644 tests/test_session_dialog_compact.py 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")