Confort du terminal, reconnexion, et hotes deja connus de ssh

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 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-09-04 11:31:50 +02:00
parent f82288b2e4
commit f537868da3
6 changed files with 431 additions and 32 deletions

View File

@@ -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, - 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+Shift+C` / `Ctrl+Shift+V`, `Maj+Inser`, clic molette, et menu au clic droit.
`Ctrl+C` reste l'interruption, comme dans tout terminal `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 - 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 - 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) - Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee)

View File

@@ -6,11 +6,11 @@ import re
import uuid import uuid
from PySide6.QtCore import Qt, Signal 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 ( from PySide6.QtWidgets import (
QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget, QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget,
QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMenu, QMessageBox, QSplitter, 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 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 = QLabel(initial_status)
self.status_label.setObjectName("statusLabel") self.status_label.setObjectName("statusLabel")
self.bar.addWidget(self.status_label, 1) 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 = QFrame()
bar_frame.setObjectName("tabBar") bar_frame.setObjectName("tabBar")
bar_frame.setLayout(self.bar) bar_frame.setLayout(self.bar)
@@ -103,6 +112,28 @@ class BaseTerminalTab(QWidget):
self.splitter.addWidget(self.terminal) self.splitter.addWidget(self.terminal)
layout.addWidget(self.splitter, 1) 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 self.connection = None # assigne par les sous-classes
def _wire_connection(self) -> None: def _wire_connection(self) -> None:
@@ -113,16 +144,56 @@ class BaseTerminalTab(QWidget):
self.terminal.data_to_send.connect(self.connection.send) self.terminal.data_to_send.connect(self.connection.send)
self.terminal.resized.connect(self.connection.resize) 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: def _on_connected(self) -> None:
self.reconnect_btn.hide()
self.terminal.setFocus() self.terminal.setFocus()
def _on_disconnected(self, reason: str) -> None: def _on_disconnected(self, reason: str) -> None:
self.status_label.setText(f"● Deconnecte ({reason})") self.status_label.setText(f"● Deconnecte ({reason})")
self.reconnect_btn.show()
def _on_error(self, message: str) -> None: def _on_error(self, message: str) -> None:
self.status_label.setText(f"● Erreur : {message}") self.status_label.setText(f"● Erreur : {message}")
self.reconnect_btn.show()
QMessageBox.critical(self, "Erreur", message) 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: def close_connection(self) -> None:
if self.connection: if self.connection:
self.connection.close_connection() self.connection.close_connection()
@@ -145,27 +216,30 @@ class SessionTabWidget(BaseTerminalTab):
self.sftp_toggle.setEnabled(False) self.sftp_toggle.setEnabled(False)
self.bar.addWidget(self.sftp_toggle) 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, host=session.host,
port=session.port, port=session.port,
username=session.username, username=session.username,
auth_type=session.auth_type, auth_type=session.auth_type,
key_path=session.key_path, key_path=session.key_path,
password=secret if session.auth_type == AUTH_PASSWORD else "", password=self._secret if session.auth_type == AUTH_PASSWORD else "",
passphrase=secret if session.auth_type == AUTH_KEY else "", passphrase=self._secret if session.auth_type == AUTH_KEY else "",
# comme MobaXterm : le navigateur de fichiers est la des l'ouverture, # comme MobaXterm : le navigateur de fichiers est la des l'ouverture,
# pas cache derriere une action supplementaire # pas cache derriere une action supplementaire
open_sftp_on_connect=True, open_sftp_on_connect=True,
) )
self._wire_connection() connection.sftp_ready.connect(self._on_sftp_ready)
self.connection.sftp_ready.connect(self._on_sftp_ready) connection.password_prompt.connect(self._on_password_prompt)
self.connection.password_prompt.connect(self._on_password_prompt) connection.host_key_prompt.connect(self._on_host_key_prompt)
self.connection.host_key_prompt.connect(self._on_host_key_prompt) connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt) connection.passphrase_prompt.connect(self._on_passphrase_prompt)
self.connection.passphrase_prompt.connect(self._on_passphrase_prompt) return connection
self.sftp_toggle.toggled.connect(self._toggle_sftp)
self.connection.start()
def _on_connected(self) -> None: def _on_connected(self) -> None:
self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}") 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.bar.addWidget(self.sftp_btn)
self.connection = LocalTerminalConnection(shell=shell) self.start_connection()
self._wire_connection()
self.connection.start() def _create_connection(self):
return LocalTerminalConnection(shell=self.shell)
def _on_connected(self) -> None: def _on_connected(self) -> None:
self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}") self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}")

View File

@@ -148,6 +148,13 @@ class SSHConnection(QThread):
try: try:
self.client = paramiko.SSHClient() self.client = paramiko.SSHClient()
DATA_DIR.mkdir(parents=True, exist_ok=True) 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(): if KNOWN_HOSTS_FILE.exists():
self.client.load_host_keys(str(KNOWN_HOSTS_FILE)) self.client.load_host_keys(str(KNOWN_HOSTS_FILE))
self.client.set_missing_host_key_policy(TrustPrompt(self)) self.client.set_missing_host_key_policy(TrustPrompt(self))

View File

@@ -13,7 +13,7 @@ from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent, QKeySequence, 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 from app import theme as app_theme
@@ -52,20 +52,22 @@ class ReplyingScreen(pyte.HistoryScreen):
class TerminalWidget(QWidget): class TerminalWidget(QWidget):
data_to_send = Signal(bytes) data_to_send = Signal(bytes)
resized = Signal(int, int) # cols, rows resized = Signal(int, int) # cols, rows
search_requested = Signal() # Ctrl+F : l'onglet ouvre sa barre de recherche
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setFocusPolicy(Qt.StrongFocus) self.setFocusPolicy(Qt.StrongFocus)
self.setAttribute(Qt.WA_OpaquePaintEvent, True) self.setAttribute(Qt.WA_OpaquePaintEvent, True)
self.font_ = QFont("Cascadia Mono", 10) self.font_size = app_theme.load_font_size()
if not self.font_.exactMatch(): self._apply_font(self.font_size)
self.font_ = QFont("Consolas", 10)
self.font_.setStyleHint(QFont.Monospace) # barre de defilement de l'historique, placee a la main (le widget peint
self.setFont(self.font_) # directement sa grille, il n'utilise pas de zone de defilement Qt)
metrics = QFontMetrics(self.font_) self.scrollbar = QScrollBar(Qt.Vertical, self)
self.char_w = metrics.horizontalAdvance("M") self.scrollbar.setCursor(Qt.ArrowCursor)
self.char_h = metrics.height() self.scrollbar.valueChanged.connect(self._on_scrollbar_moved)
self._syncing_scrollbar = False
self.cols = 80 self.cols = 80
self.rows = 24 self.rows = 24
@@ -84,6 +86,44 @@ class TerminalWidget(QWidget):
self._load_palette() self._load_palette()
self.setMinimumSize(200, 100) 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: def _load_palette(self) -> None:
"""Construit les couleurs du terminal a partir du theme courant. """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._bold_fg = QColor(palette["ansi"]["brightwhite"])
self.selection_color = QColor(*palette.get("selection", (79, 140, 255, 90))) 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: def refresh_theme(self) -> None:
"""Recharge les couleurs apres un changement de theme, et redessine.""" """Recharge les couleurs apres un changement de theme, et redessine."""
self._load_palette() self._load_palette()
@@ -158,6 +213,60 @@ class TerminalWidget(QWidget):
self._sel_focus = (self.cols - 1, self.rows - 1) self._sel_focus = (self.cols - 1, self.rows - 1)
self.update() 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 ---------- # ---------- presse-papiers ----------
def copy_selection(self) -> None: def copy_selection(self) -> None:
text = self.selected_text() text = self.selected_text()
@@ -210,9 +319,12 @@ class TerminalWidget(QWidget):
# on revient du scrollback a la vue live : tout le contenu affiche change, # on revient du scrollback a la vue live : tout le contenu affiche change,
# un repaint partiel base sur les seules lignes "dirty" serait incomplet # un repaint partiel base sur les seules lignes "dirty" serait incomplet
self.screen.dirty.clear() self.screen.dirty.clear()
self._sync_scrollbar()
self.update() self.update()
return return
self._sync_scrollbar()
dirty_rows = self.screen.dirty dirty_rows = self.screen.dirty
cursor_y = self.screen.cursor.y cursor_y = self.screen.cursor.y
if cursor_y != self._prev_cursor_y: if cursor_y != self._prev_cursor_y:
@@ -303,14 +415,52 @@ class TerminalWidget(QWidget):
return candidate return candidate
return default 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 ---------- # ---------- redimensionnement ----------
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
new_cols = max(10, self.width() // self.char_w) sb_width = self.scrollbar.sizeHint().width()
new_rows = max(3, self.height() // self.char_h) self.scrollbar.setGeometry(self.width() - sb_width, 0, sb_width, self.height())
if new_cols != self.cols or new_rows != self.rows: self._recompute_grid()
self.cols, self.rows = new_cols, new_rows self._sync_scrollbar()
self.screen.resize(self.rows, self.cols)
self.resized.emit(self.cols, self.rows)
super().resizeEvent(event) super().resizeEvent(event)
# ---------- entree clavier ---------- # ---------- entree clavier ----------
@@ -332,6 +482,35 @@ class TerminalWidget(QWidget):
self.paste_clipboard() self.paste_clipboard()
return 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 : # Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper # sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble. # @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
@@ -354,12 +533,16 @@ class TerminalWidget(QWidget):
def wheelEvent(self, event: QWheelEvent) -> None: def wheelEvent(self, event: QWheelEvent) -> None:
steps = event.angleDelta().y() // 120 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: if steps > 0:
self.screen.prev_page() self.screen.prev_page()
self.scroll_offset += steps self.scroll_offset += steps
elif steps < 0: elif steps < 0:
self.screen.next_page() self.screen.next_page()
self.scroll_offset = max(0, self.scroll_offset + steps) self.scroll_offset = max(0, self.scroll_offset + steps)
self._sync_scrollbar()
self.update() self.update()
def mousePressEvent(self, event) -> None: def mousePressEvent(self, event) -> None:

View File

@@ -129,6 +129,36 @@ def save_theme(theme: str) -> None:
pass 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: def current_theme() -> str:
return _current_theme return _current_theme

View File

@@ -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")