From f537868da3e279216e5a69ade9eecd3a4ca2a6a4 Mon Sep 17 00:00:00 2001 From: snoopy Date: Fri, 4 Sep 2026 11:31:50 +0200 Subject: [PATCH] Confort du terminal, reconnexion, et hotes deja connus de ssh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom de police : Ctrl+molette et Ctrl+ +/-, Ctrl+0 pour revenir a la taille par defaut. La taille choisie est memorisee entre deux lancements. Barre de defilement de l'historique. pyte ne sait defiler que par demi-pages : la barre est donc graduee en pages plutot qu'en lignes, ce qui donne un repere visuel de position et permet de faire glisser. Maj+PagePrec/Suiv au clavier, Ctrl+Fin pour revenir a la vue en direct. Recherche dans l'historique (Ctrl+F) : la barre apparait en bas de l'onglet et la recherche remonte l'historique page par page jusqu'a trouver, en selectionnant la correspondance — donc visible et directement copiable. Reconnexion en un clic : un bouton apparait dans la barre de l'onglet des que la connexion tombe. La fabrication de la connexion a ete extraite dans _create_connection(), ce qui permet de la recreer a l'identique sans ressaisir quoi que ce soit. Utile avec des serveurs de test qui redemarrent souvent. Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont desormais reconnus, ce qui evite de reconfirmer une empreinte que votre ssh connait deja. paramiko ne reecrit jamais ce fichier : les nouvelles cles continuent d'aller dans celui de l'application. Co-Authored-By: Claude Opus 5 --- README.md | 7 ++ app/main_window.py | 109 ++++++++++++++--- app/ssh_connection.py | 7 ++ app/terminal_widget.py | 213 ++++++++++++++++++++++++++++++--- app/theme.py | 30 +++++ tests/test_terminal_comfort.py | 97 +++++++++++++++ 6 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 tests/test_terminal_comfort.py diff --git a/README.md b/README.md index b07072b..2d0d576 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com - Copier-coller dans le terminal : selection a la souris, double-clic pour selectionner un mot, `Ctrl+Shift+C` / `Ctrl+Shift+V`, `Maj+Inser`, clic molette, et menu au clic droit. `Ctrl+C` reste l'interruption, comme dans tout terminal +- Confort d'affichage : zoom `Ctrl+molette` ou `Ctrl+ +/-` (`Ctrl+0` remet a zero, la taille est + memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir + en direct, et recherche dans l'historique avec `Ctrl+F` +- Reconnexion en un clic quand une session tombe (bouton dans la barre de l'onglet) : pratique + avec des serveurs de test qui redemarrent souvent +- Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de + double confirmation d'empreinte. Les nouvelles cles vont dans le fichier propre a l'application - Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif - Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic - Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee) diff --git a/app/main_window.py b/app/main_window.py index 29bdde4..81e3ee4 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -6,11 +6,11 @@ import re import uuid from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction, QActionGroup, QKeySequence +from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut from PySide6.QtWidgets import ( QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMenu, QMessageBox, QSplitter, - QStatusBar, QFrame, QApplication, + QStatusBar, QFrame, QApplication, QLineEdit, ) from app.session_manager import SessionStore, Session, AUTH_KEY, AUTH_PASSWORD @@ -93,6 +93,15 @@ class BaseTerminalTab(QWidget): self.status_label = QLabel(initial_status) self.status_label.setObjectName("statusLabel") self.bar.addWidget(self.status_label, 1) + + # Apparait quand la connexion tombe : les serveurs de test redemarrent souvent, + # inutile de fermer l'onglet et de tout ressaisir. + self.reconnect_btn = QToolButton() + self.reconnect_btn.setObjectName("primary") + self.reconnect_btn.setText("⟳ Reconnecter") + self.reconnect_btn.hide() + self.reconnect_btn.clicked.connect(self.reconnect) + self.bar.addWidget(self.reconnect_btn) bar_frame = QFrame() bar_frame.setObjectName("tabBar") bar_frame.setLayout(self.bar) @@ -103,6 +112,28 @@ class BaseTerminalTab(QWidget): self.splitter.addWidget(self.terminal) layout.addWidget(self.splitter, 1) + # barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F + self.search_bar = QFrame() + self.search_bar.setObjectName("tabBar") + search_layout = QHBoxLayout(self.search_bar) + search_layout.setContentsMargins(10, 4, 10, 4) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Rechercher dans l'historique... (Entree = precedent, Echap = fermer)") + self.search_status = QLabel("") + self.search_status.setObjectName("statusLabel") + close_search = QToolButton() + close_search.setText("✕") + search_layout.addWidget(self.search_input, 1) + search_layout.addWidget(self.search_status) + search_layout.addWidget(close_search) + self.search_bar.hide() + layout.addWidget(self.search_bar) + + self.terminal.search_requested.connect(self.open_search) + self.search_input.returnPressed.connect(self._run_search) + close_search.clicked.connect(self.close_search) + QShortcut(QKeySequence(Qt.Key_Escape), self.search_input, activated=self.close_search) + self.connection = None # assigne par les sous-classes def _wire_connection(self) -> None: @@ -113,16 +144,56 @@ class BaseTerminalTab(QWidget): self.terminal.data_to_send.connect(self.connection.send) self.terminal.resized.connect(self.connection.resize) + def _create_connection(self): + """Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui + permet de la recreer a l'identique lors d'une reconnexion.""" + raise NotImplementedError + + def start_connection(self) -> None: + self.connection = self._create_connection() + self._wire_connection() + self.connection.start() + + def reconnect(self) -> None: + if self.connection: + self.connection.close_connection() + self.connection.wait(1500) + self.terminal.reset_screen() + self.reconnect_btn.hide() + self.status_label.setText("○ Reconnexion...") + self.start_connection() + def _on_connected(self) -> None: + self.reconnect_btn.hide() self.terminal.setFocus() def _on_disconnected(self, reason: str) -> None: self.status_label.setText(f"● Deconnecte ({reason})") + self.reconnect_btn.show() def _on_error(self, message: str) -> None: self.status_label.setText(f"● Erreur : {message}") + self.reconnect_btn.show() QMessageBox.critical(self, "Erreur", message) + # ---------- recherche dans l'historique ---------- + def open_search(self) -> None: + self.search_bar.show() + self.search_input.setFocus() + self.search_input.selectAll() + + def close_search(self) -> None: + self.search_bar.hide() + self.search_status.clear() + self.terminal.setFocus() + + def _run_search(self) -> None: + needle = self.search_input.text() + if not needle: + return + found = self.terminal.find_text(needle) + self.search_status.setText("" if found else "aucun resultat") + def close_connection(self) -> None: if self.connection: self.connection.close_connection() @@ -145,27 +216,30 @@ class SessionTabWidget(BaseTerminalTab): self.sftp_toggle.setEnabled(False) self.bar.addWidget(self.sftp_toggle) - self.connection = SSHConnection( + self._secret = secret + self.sftp_toggle.toggled.connect(self._toggle_sftp) + self.start_connection() + + def _create_connection(self): + session = self.session + connection = SSHConnection( host=session.host, port=session.port, username=session.username, auth_type=session.auth_type, key_path=session.key_path, - password=secret if session.auth_type == AUTH_PASSWORD else "", - passphrase=secret if session.auth_type == AUTH_KEY else "", + password=self._secret if session.auth_type == AUTH_PASSWORD else "", + passphrase=self._secret if session.auth_type == AUTH_KEY else "", # comme MobaXterm : le navigateur de fichiers est la des l'ouverture, # pas cache derriere une action supplementaire open_sftp_on_connect=True, ) - self._wire_connection() - self.connection.sftp_ready.connect(self._on_sftp_ready) - self.connection.password_prompt.connect(self._on_password_prompt) - self.connection.host_key_prompt.connect(self._on_host_key_prompt) - self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) - self.connection.passphrase_prompt.connect(self._on_passphrase_prompt) - self.sftp_toggle.toggled.connect(self._toggle_sftp) - - self.connection.start() + connection.sftp_ready.connect(self._on_sftp_ready) + connection.password_prompt.connect(self._on_password_prompt) + connection.host_key_prompt.connect(self._on_host_key_prompt) + connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) + connection.passphrase_prompt.connect(self._on_passphrase_prompt) + return connection def _on_connected(self) -> None: self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") @@ -313,9 +387,10 @@ class LocalTerminalTabWidget(BaseTerminalTab): ) self.bar.addWidget(self.sftp_btn) - self.connection = LocalTerminalConnection(shell=shell) - self._wire_connection() - self.connection.start() + self.start_connection() + + def _create_connection(self): + return LocalTerminalConnection(shell=self.shell) def _on_connected(self) -> None: self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}") diff --git a/app/ssh_connection.py b/app/ssh_connection.py index 8ced4e5..bfd9844 100644 --- a/app/ssh_connection.py +++ b/app/ssh_connection.py @@ -148,6 +148,13 @@ class SSHConnection(QThread): try: self.client = paramiko.SSHClient() DATA_DIR.mkdir(parents=True, exist_ok=True) + # Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont + # reconnus : inutile de reconfirmer une empreinte que votre ssh connait deja. + # paramiko ne reecrit jamais ce fichier ; les nouvelles cles vont dans le notre. + try: + self.client.load_system_host_keys() + except Exception: + pass if KNOWN_HOSTS_FILE.exists(): self.client.load_host_keys(str(KNOWN_HOSTS_FILE)) self.client.set_missing_host_key_policy(TrustPrompt(self)) diff --git a/app/terminal_widget.py b/app/terminal_widget.py index 6cd0027..5d3375c 100644 --- a/app/terminal_widget.py +++ b/app/terminal_widget.py @@ -13,7 +13,7 @@ from PySide6.QtCore import QRect, Qt, Signal from PySide6.QtGui import ( QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, QKeySequence, ) -from PySide6.QtWidgets import QWidget, QMenu, QApplication +from PySide6.QtWidgets import QWidget, QMenu, QApplication, QScrollBar from app import theme as app_theme @@ -52,20 +52,22 @@ class ReplyingScreen(pyte.HistoryScreen): class TerminalWidget(QWidget): data_to_send = Signal(bytes) resized = Signal(int, int) # cols, rows + search_requested = Signal() # Ctrl+F : l'onglet ouvre sa barre de recherche def __init__(self, parent=None): super().__init__(parent) self.setFocusPolicy(Qt.StrongFocus) self.setAttribute(Qt.WA_OpaquePaintEvent, True) - self.font_ = QFont("Cascadia Mono", 10) - if not self.font_.exactMatch(): - self.font_ = QFont("Consolas", 10) - self.font_.setStyleHint(QFont.Monospace) - self.setFont(self.font_) - metrics = QFontMetrics(self.font_) - self.char_w = metrics.horizontalAdvance("M") - self.char_h = metrics.height() + self.font_size = app_theme.load_font_size() + self._apply_font(self.font_size) + + # barre de defilement de l'historique, placee a la main (le widget peint + # directement sa grille, il n'utilise pas de zone de defilement Qt) + self.scrollbar = QScrollBar(Qt.Vertical, self) + self.scrollbar.setCursor(Qt.ArrowCursor) + self.scrollbar.valueChanged.connect(self._on_scrollbar_moved) + self._syncing_scrollbar = False self.cols = 80 self.rows = 24 @@ -84,6 +86,44 @@ class TerminalWidget(QWidget): self._load_palette() self.setMinimumSize(200, 100) + # ---------- police ---------- + def _apply_font(self, size: int) -> None: + self.font_ = QFont("Cascadia Mono", size) + if not self.font_.exactMatch(): + self.font_ = QFont("Consolas", size) + self.font_.setStyleHint(QFont.Monospace) + self.setFont(self.font_) + metrics = QFontMetrics(self.font_) + self.char_w = max(1, metrics.horizontalAdvance("M")) + self.char_h = max(1, metrics.height()) + + def set_font_size(self, size: int) -> None: + """Change la taille de police et reajuste la grille en consequence.""" + size = max(6, min(32, size)) + if size == self.font_size: + return + self.font_size = size + self._apply_font(size) + app_theme.save_font_size(size) + self._recompute_grid() + self.update() + + def zoom(self, delta: int) -> None: + self.set_font_size(self.font_size + delta) + + def reset_zoom(self) -> None: + self.set_font_size(10) + + def _recompute_grid(self) -> None: + """Recalcule le nombre de colonnes/lignes disponibles et previent la session.""" + width = max(0, self.width() - self.scrollbar.sizeHint().width()) + new_cols = max(10, width // self.char_w) + new_rows = max(3, self.height() // self.char_h) + if new_cols != self.cols or new_rows != self.rows: + self.cols, self.rows = new_cols, new_rows + self.screen.resize(self.rows, self.cols) + self.resized.emit(self.cols, self.rows) + def _load_palette(self) -> None: """Construit les couleurs du terminal a partir du theme courant. @@ -99,6 +139,21 @@ class TerminalWidget(QWidget): self._bold_fg = QColor(palette["ansi"]["brightwhite"]) self.selection_color = QColor(*palette.get("selection", (79, 140, 255, 90))) + def reset_screen(self) -> None: + """Vide l'ecran et l'historique (utilise lors d'une reconnexion).""" + self.screen.reset() + self.screen.resize(self.rows, self.cols) + try: + self.screen.history.top.clear() + self.screen.history.bottom.clear() + except Exception: + pass + self.clear_selection() + self.scroll_offset = 0 + self._prev_cursor_y = 0 + self._sync_scrollbar() + self.update() + def refresh_theme(self) -> None: """Recharge les couleurs apres un changement de theme, et redessine.""" self._load_palette() @@ -158,6 +213,60 @@ class TerminalWidget(QWidget): self._sel_focus = (self.cols - 1, self.rows - 1) self.update() + # ---------- recherche dans l'historique ---------- + def find_text(self, needle: str, backwards: bool = True) -> bool: + """Cherche `needle` a l'ecran, puis en remontant l'historique page par page. + + pyte ne sait defiler que par demi-pages : on avance donc de page en page et on + inspecte l'ecran a chaque etape. La ligne trouvee est selectionnee, ce qui la + rend visible et immediatement copiable. + """ + if not needle: + return False + needle_low = needle.lower() + + def match_on_screen() -> bool: + for row, line in enumerate(self.screen.display): + col = line.lower().find(needle_low) + if col >= 0: + self._sel_anchor = (col, row) + self._sel_focus = (col + len(needle) - 1, row) + self.update() + return True + return False + + if match_on_screen(): + return True + + above, below = self._history_lengths() + steps = 0 + max_steps = max(1, (above if backwards else below) // max(1, self.rows // 2)) + 1 + while steps < max_steps: + if backwards: + self.screen.prev_page() + self.scroll_offset += 1 + else: + self.screen.next_page() + self.scroll_offset = max(0, self.scroll_offset - 1) + steps += 1 + if match_on_screen(): + self._sync_scrollbar() + return True + + self._sync_scrollbar() + self.update() + return False + + def scroll_to_bottom(self) -> None: + """Revient a la vue en direct.""" + _, below = self._history_lengths() + page = max(1, self.rows // 2) + for _ in range((below + page - 1) // page): + self.screen.next_page() + self.scroll_offset = 0 + self._sync_scrollbar() + self.update() + # ---------- presse-papiers ---------- def copy_selection(self) -> None: text = self.selected_text() @@ -210,9 +319,12 @@ class TerminalWidget(QWidget): # on revient du scrollback a la vue live : tout le contenu affiche change, # un repaint partiel base sur les seules lignes "dirty" serait incomplet self.screen.dirty.clear() + self._sync_scrollbar() self.update() return + self._sync_scrollbar() + dirty_rows = self.screen.dirty cursor_y = self.screen.cursor.y if cursor_y != self._prev_cursor_y: @@ -303,14 +415,52 @@ class TerminalWidget(QWidget): return candidate return default + # ---------- barre de defilement ---------- + def _history_lengths(self) -> tuple[int, int]: + """(lignes au-dessus, lignes en dessous) dans l'historique pyte.""" + try: + return len(self.screen.history.top), len(self.screen.history.bottom) + except Exception: + return 0, 0 + + def _sync_scrollbar(self) -> None: + """Reflete la position dans l'historique. pyte ne defile que par demi-pages : + la barre est donc graduee en pages, pas en lignes.""" + above, below = self._history_lengths() + page = max(1, self.rows // 2) + pages_above = (above + page - 1) // page + pages_below = (below + page - 1) // page + total = pages_above + pages_below + + self._syncing_scrollbar = True + self.scrollbar.setRange(0, total) + self.scrollbar.setPageStep(1) + self.scrollbar.setValue(pages_above) # au maximum = vue en direct + self.scrollbar.setVisible(total > 0) + self._syncing_scrollbar = False + + def _on_scrollbar_moved(self, value: int) -> None: + if self._syncing_scrollbar: + return + above, _ = self._history_lengths() + page = max(1, self.rows // 2) + current = (above + page - 1) // page + steps = value - current + for _ in range(abs(steps)): + if steps > 0: + self.screen.next_page() + self.scroll_offset = max(0, self.scroll_offset - 1) + else: + self.screen.prev_page() + self.scroll_offset += 1 + self.update() + # ---------- redimensionnement ---------- def resizeEvent(self, event) -> None: - new_cols = max(10, self.width() // self.char_w) - new_rows = max(3, self.height() // self.char_h) - if new_cols != self.cols or new_rows != self.rows: - self.cols, self.rows = new_cols, new_rows - self.screen.resize(self.rows, self.cols) - self.resized.emit(self.cols, self.rows) + sb_width = self.scrollbar.sizeHint().width() + self.scrollbar.setGeometry(self.width() - sb_width, 0, sb_width, self.height()) + self._recompute_grid() + self._sync_scrollbar() super().resizeEvent(event) # ---------- entree clavier ---------- @@ -332,6 +482,35 @@ class TerminalWidget(QWidget): self.paste_clipboard() return + if modifiers & Qt.ControlModifier: + if key in (Qt.Key_Plus, Qt.Key_Equal): + self.zoom(1) + return + if key == Qt.Key_Minus: + self.zoom(-1) + return + if key == Qt.Key_0: + self.reset_zoom() + return + if key == Qt.Key_F: + self.search_requested.emit() + return + + # defilement de l'historique au clavier + if modifiers & Qt.ShiftModifier and key in (Qt.Key_PageUp, Qt.Key_PageDown): + if key == Qt.Key_PageUp: + self.screen.prev_page() + self.scroll_offset += 1 + else: + self.screen.next_page() + self.scroll_offset = max(0, self.scroll_offset - 1) + self._sync_scrollbar() + self.update() + return + if modifiers & Qt.ControlModifier and key == Qt.Key_End: + self.scroll_to_bottom() + return + # Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce : # sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper # @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble. @@ -354,12 +533,16 @@ class TerminalWidget(QWidget): def wheelEvent(self, event: QWheelEvent) -> None: steps = event.angleDelta().y() // 120 + if event.modifiers() & Qt.ControlModifier: + self.zoom(1 if steps > 0 else -1) # Ctrl+molette = zoom, comme partout + return if steps > 0: self.screen.prev_page() self.scroll_offset += steps elif steps < 0: self.screen.next_page() self.scroll_offset = max(0, self.scroll_offset + steps) + self._sync_scrollbar() self.update() def mousePressEvent(self, event) -> None: diff --git a/app/theme.py b/app/theme.py index ee770b1..4e66e3f 100644 --- a/app/theme.py +++ b/app/theme.py @@ -129,6 +129,36 @@ def save_theme(theme: str) -> None: pass +DEFAULT_FONT_SIZE = 10 + + +def load_font_size() -> int: + try: + data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + size = int(data.get("font_size", DEFAULT_FONT_SIZE)) + return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE + except (OSError, json.JSONDecodeError, TypeError, ValueError): + return DEFAULT_FONT_SIZE + + +def save_font_size(size: int) -> None: + _update_settings("font_size", size) + + +def _update_settings(key: str, value) -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + data = {} + try: + data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + data[key] = value + try: + SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") + except OSError: + pass + + def current_theme() -> str: return _current_theme diff --git a/tests/test_terminal_comfort.py b/tests/test_terminal_comfort.py new file mode 100644 index 0000000..bc4d85f --- /dev/null +++ b/tests/test_terminal_comfort.py @@ -0,0 +1,97 @@ +"""Confort du terminal : zoom de police, barre de defilement, recherche dans l'historique.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QKeyEvent +from PySide6.QtCore import QEvent, Qt + +from app.terminal_widget import TerminalWidget + +app = QApplication([]) +t = TerminalWidget() +t.resize(900, 500) +t.show() + +print("=== 1. Zoom de police ===") +base = t.font_size +base_cols = t.cols +t.zoom(4) +print(f" taille {base} -> {t.font_size} | colonnes {base_cols} -> {t.cols}") +assert t.font_size == base + 4 +assert t.cols < base_cols, "agrandir la police doit reduire le nombre de colonnes" + +t.zoom(-4) +assert t.font_size == base +t.set_font_size(999) +print(" taille bornee au maximum :", t.font_size) +assert t.font_size == 32, t.font_size +t.set_font_size(1) +print(" taille bornee au minimum :", t.font_size) +assert t.font_size == 6, t.font_size +t.reset_zoom() +assert t.font_size == 10 + +print() +print("=== 2. Ctrl+molette zoome, molette seule defile ===") +before = t.font_size + + +class Wheel: + def __init__(self, ctrl): + self._ctrl = ctrl + + def angleDelta(self): + return type("D", (), {"y": lambda s: 120})() + + def modifiers(self): + return Qt.ControlModifier if self._ctrl else Qt.NoModifier + + +t.wheelEvent(Wheel(ctrl=True)) +print(f" Ctrl+molette : {before} -> {t.font_size}") +assert t.font_size == before + 1 +t.reset_zoom() + +print() +print("=== 3. Barre de defilement liee a l'historique ===") +assert not t.scrollbar.isVisible(), "pas d'historique = pas de barre" +for i in range(200): + t.feed(f"ligne d historique {i}\r\n".encode()) +print(" apres 200 lignes -> barre visible :", t.scrollbar.isVisible(), + "| plage :", t.scrollbar.minimum(), "a", t.scrollbar.maximum()) +assert t.scrollbar.isVisible() +assert t.scrollbar.maximum() > 0 +assert t.scrollbar.value() == t.scrollbar.maximum(), "en vue directe, la barre est en bas" + +print() +print("=== 4. Recherche dans l'historique ===") +t.feed(b"une aiguille dans une botte de foin\r\n") +for i in range(120): + t.feed(f"remplissage {i}\r\n".encode()) + +trouve = t.find_text("aiguille") +print(" 'aiguille' trouvee :", trouve, "| texte selectionne :", repr(t.selected_text())) +assert trouve, "la recherche doit remonter l'historique" +assert "aiguille" in t.selected_text() + +absent = t.find_text("chaine-totalement-absente-xyz") +print(" chaine absente -> trouvee :", absent) +assert not absent + +print() +print("=== 5. Retour a la vue en direct ===") +t.scroll_to_bottom() +print(" scroll_offset :", t.scroll_offset, "| barre en bas :", t.scrollbar.value() == t.scrollbar.maximum()) +assert t.scroll_offset == 0 + +print() +print("=== 6. Ctrl+F emet la demande de recherche ===") +demandes = [] +t.search_requested.connect(lambda: demandes.append(True)) +t.keyPressEvent(QKeyEvent(QEvent.KeyPress, Qt.Key_F, Qt.ControlModifier, "")) +print(" signal emis :", bool(demandes)) +assert demandes + +print() +print("CONFORT DU TERMINAL : TOUS LES TESTS OK")