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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user