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:
@@ -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)
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
30
app/theme.py
30
app/theme.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
97
tests/test_terminal_comfort.py
Normal file
97
tests/test_terminal_comfort.py
Normal 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")
|
||||||
Reference in New Issue
Block a user