Files
pyssh-client/app/terminal_widget.py
snoopy f641357073 Copier-coller a la souris comme dans un terminal Unix
Selectionner copie aussitot au relachement du bouton, et le clic droit colle
directement. Plus de menu contextuel ni de raccourci a connaitre : c'est le
comportement des terminaux Unix et de PuTTY.

- selection relachee, et double-clic sur un mot, copient dans la foulee
- clic droit et clic molette collent immediatement
- un simple clic sans deplacement se contente d'annuler la selection, sans
  toucher au presse-papiers : sinon il ecraserait ce qu'on s'apprete a coller
- le menu contextuel est supprime, le clic droit etant desormais un collage
- "Tout selectionner" passe sur Ctrl+Maj+A, Ctrl+A restant "debut de ligne"
  pour le shell

Les raccourcis clavier restent disponibles pour qui les prefere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:30:17 +02:00

604 lines
24 KiB
Python

"""Widget terminal : emulation ANSI/VT100 (pyte) rendue avec QPainter.
Optimisation cle : on ne redessine que les lignes reellement modifiees depuis le
dernier rendu (via `screen.dirty`, fourni par pyte), au lieu de repeindre tout
l'ecran a chaque octet recu. Sans ca, chaque frappe clavier (et son echo distant)
declenche un repaint de la totalite de la grille, ce qui devient tres sensible
des que le terminal contient beaucoup de lignes ou que la fenetre est grande.
"""
from __future__ import annotations
import pyte
from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import (
QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent,
)
from PySide6.QtWidgets import QWidget, QApplication, QScrollBar
from app import theme as app_theme
# Sequences envoyees pour les touches speciales
KEY_SEQUENCES = {
Qt.Key_Up: b"\x1b[A", Qt.Key_Down: b"\x1b[B", Qt.Key_Right: b"\x1b[C", Qt.Key_Left: b"\x1b[D",
Qt.Key_Home: b"\x1b[H", Qt.Key_End: b"\x1b[F", Qt.Key_Insert: b"\x1b[2~", Qt.Key_Delete: b"\x1b[3~",
Qt.Key_PageUp: b"\x1b[5~", Qt.Key_PageDown: b"\x1b[6~",
Qt.Key_F1: b"\x1bOP", Qt.Key_F2: b"\x1bOQ", Qt.Key_F3: b"\x1bOR", Qt.Key_F4: b"\x1bOS",
Qt.Key_F5: b"\x1b[15~", Qt.Key_F6: b"\x1b[17~", Qt.Key_F7: b"\x1b[18~", Qt.Key_F8: b"\x1b[19~",
Qt.Key_F9: b"\x1b[20~", Qt.Key_F10: b"\x1b[21~", Qt.Key_F11: b"\x1b[23~", Qt.Key_F12: b"\x1b[24~",
Qt.Key_Backspace: b"\x7f", Qt.Key_Tab: b"\t", Qt.Key_Return: b"\r", Qt.Key_Enter: b"\r",
Qt.Key_Escape: b"\x1b",
}
class ReplyingScreen(pyte.HistoryScreen):
"""Ecran pyte capable de repondre aux requetes du terminal.
Certaines pseudo-consoles (ConPTY notamment) interrogent le terminal au demarrage
("Device Attributes" \x1b[c, "Cursor Position Report" \x1b[6n) et **attendent la
reponse** avant de poursuivre. pyte sait construire ces reponses, mais les envoie
via write_process_input(), qui ne fait rien par defaut : sans ce branchement, la
console reste bloquee en attente.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.reply_callback = None
def write_process_input(self, data: str) -> None:
if self.reply_callback:
self.reply_callback(data.encode("utf-8", errors="ignore"))
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_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
self.screen = ReplyingScreen(self.cols, self.rows, history=5000)
# les reponses aux requetes du terminal repartent par le meme canal que le clavier
self.screen.reply_callback = self.data_to_send.emit
self.stream = pyte.ByteStream(self.screen)
self.scroll_offset = 0
self._prev_cursor_y = 0
# selection a la souris : coordonnees (colonne, ligne) dans la grille
self._sel_anchor: tuple[int, int] | None = None
self._sel_focus: tuple[int, int] | None = None
self._selecting = False
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.
Le cache evite de re-parser les memes couleurs hexa a chaque caractere
redessine (c'etait un point chaud du rendu)."""
palette = app_theme.terminal_palette()
self.default_fg = QColor(palette["fg"])
self.default_bg = QColor(palette["bg"])
self.cursor_color = QColor(*palette["cursor"])
self._color_cache: dict[str, QColor] = {
name: QColor(value) for name, value in palette["ansi"].items()
}
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()
self.update()
# ---------- selection a la souris ----------
def _cell_at(self, pos) -> tuple[int, int]:
col = max(0, min(self.cols - 1, pos.x() // self.char_w))
row = max(0, min(self.rows - 1, pos.y() // self.char_h))
return col, row
def _ordered_selection(self) -> tuple[tuple[int, int], tuple[int, int]] | None:
"""Renvoie (debut, fin) remis dans l'ordre de lecture."""
if self._sel_anchor is None or self._sel_focus is None:
return None
a, b = self._sel_anchor, self._sel_focus
if (a[1], a[0]) <= (b[1], b[0]):
return a, b
return b, a
def _selected_columns(self, row: int) -> tuple[int, int] | None:
"""Plage [debut, fin[ de colonnes selectionnees sur cette ligne."""
ordered = self._ordered_selection()
if not ordered:
return None
(sx, sy), (ex, ey) = ordered
if row < sy or row > ey:
return None
start = sx if row == sy else 0
end = (ex + 1) if row == ey else self.cols
return (start, end) if end > start else None
def has_selection(self) -> bool:
return self._ordered_selection() is not None
def selected_text(self) -> str:
ordered = self._ordered_selection()
if not ordered:
return ""
lines = []
for row in range(ordered[0][1], ordered[1][1] + 1):
span = self._selected_columns(row)
if not span:
continue
line = self.screen.buffer[row]
# rstrip : le tampon est complete par des espaces jusqu'au bord de l'ecran
lines.append("".join(line[col].data for col in range(*span)).rstrip())
return "\n".join(lines)
def clear_selection(self) -> None:
if self._sel_anchor is not None:
self._sel_anchor = self._sel_focus = None
self.update()
def select_all(self) -> None:
self._sel_anchor = (0, 0)
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()
if text:
QApplication.clipboard().setText(text)
def paste_clipboard(self) -> None:
text = QApplication.clipboard().text()
if not text:
return
# un terminal attend un retour chariot ; \r\n enverrait une ligne vide de plus
payload = text.replace("\r\n", "\r").replace("\n", "\r")
self.data_to_send.emit(payload.encode("utf-8", errors="ignore"))
def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main).
Les lignes remplies jusqu'a la derniere colonne sont recollees avec la
suivante : une commande longue (prompt Windows + ssh user@ip) deborde sur
deux lignes, et la couper en plein milieu casserait la detection.
"""
lines = list(self.screen.display)
try:
for entry in self.screen.history.top:
lines.insert(0, "".join(char.data for char in entry.values()))
except Exception:
pass
joined: list[str] = []
current = ""
for line in lines:
stripped = line.rstrip()
current += stripped
if len(stripped) >= self.cols: # ligne pleine : la suite continue en dessous
continue
joined.append(current)
current = ""
if current:
joined.append(current)
return "\n".join(joined)
# ---------- alimentation depuis la connexion SSH ----------
def feed(self, data: bytes) -> None:
was_scrolled = self.scroll_offset != 0
self.stream.feed(data)
self.scroll_offset = 0
if was_scrolled:
# 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:
dirty_rows = dirty_rows | {self._prev_cursor_y, cursor_y}
else:
dirty_rows = dirty_rows | {cursor_y}
self._prev_cursor_y = cursor_y
if dirty_rows:
min_y = max(0, min(dirty_rows))
max_y = min(self.rows - 1, max(dirty_rows))
self.update(QRect(0, min_y * self.char_h, self.cols * self.char_w, (max_y - min_y + 1) * self.char_h))
self.screen.dirty.clear()
# ---------- rendu ----------
def paintEvent(self, event) -> None:
painter = QPainter(self)
rect = event.rect()
painter.fillRect(rect, self.default_bg)
painter.setFont(self.font_)
y0 = max(0, rect.top() // self.char_h)
y1 = min(self.rows - 1, rect.bottom() // self.char_h)
cursor = self.screen.cursor
for y in range(y0, y1 + 1):
line = self.screen.buffer[y]
x = 0
while x < self.cols:
char = line[x]
run_text = char.data
start_x = x
fg, bg = self._resolve_colors(char)
# regrouper les caracteres consecutifs de meme style pour limiter les appels de dessin
x += 1
while x < self.cols:
nxt = line[x]
nfg, nbg = self._resolve_colors(nxt)
if nfg == fg and nbg == bg:
run_text += nxt.data
x += 1
else:
break
px = start_x * self.char_w
py = y * self.char_h
run_w = (x - start_x) * self.char_w
if bg != self.default_bg:
painter.fillRect(px, py, run_w, self.char_h, bg)
painter.setPen(fg)
painter.drawText(px, py + self.char_h - 4, run_text)
# surlignage de la selection, par-dessus le texte
for y in range(y0, y1 + 1):
span = self._selected_columns(y)
if span:
start, end = span
painter.fillRect(
start * self.char_w, y * self.char_h,
(end - start) * self.char_w, self.char_h,
self.selection_color,
)
# curseur (bloc plein), non affiche pendant un scroll dans l'historique
if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
cx, cy = cursor.x, cursor.y
painter.fillRect(cx * self.char_w, cy * self.char_h, self.char_w, self.char_h, self.cursor_color)
painter.end()
def _resolve_colors(self, char) -> tuple[QColor, QColor]:
fg_color = self._color_for(char.fg, self.default_fg)
bg_color = self._color_for(char.bg, self.default_bg)
if char.reverse:
fg_color, bg_color = bg_color, fg_color
if char.bold and fg_color is self.default_fg:
fg_color = self._bold_fg
return fg_color, bg_color
def _color_for(self, value: str, default: QColor) -> QColor:
cached = self._color_cache.get(value)
if cached is not None:
return cached
if isinstance(value, str) and len(value) == 6:
candidate = QColor(f"#{value}")
if candidate.isValid():
self._color_cache[value] = candidate
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:
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 ----------
def keyPressEvent(self, event: QKeyEvent) -> None:
key = event.key()
modifiers = event.modifiers()
# Copier/coller AVANT la gestion de Ctrl+lettre : sinon Ctrl+Shift+C partirait
# comme un Ctrl+C (interruption) au lieu de copier. Ctrl+C seul reste l'interruption,
# comme dans tous les terminaux.
if (modifiers & Qt.ControlModifier) and (modifiers & Qt.ShiftModifier):
if key == Qt.Key_C:
self.copy_selection()
return
if key == Qt.Key_V:
self.paste_clipboard()
return
if key == Qt.Key_A:
# Ctrl+A seul reste "debut de ligne" pour le shell
self.select_all()
return
if key == Qt.Key_Insert and (modifiers & Qt.ShiftModifier):
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.
# Sans cette exclusion, taper "@" (et les lettres voisines pendant le chevauchement
# naturel de la frappe) etait intercepte comme un raccourci Ctrl+lettre parasite,
# envoyant un octet de controle au lieu du vrai caractere.
if (modifiers & Qt.ControlModifier) and not (modifiers & Qt.AltModifier) and Qt.Key_A <= key <= Qt.Key_Z:
self.data_to_send.emit(bytes([key - Qt.Key_A + 1]))
return
if key in KEY_SEQUENCES:
self.data_to_send.emit(KEY_SEQUENCES[key])
return
text = event.text()
if text:
self.data_to_send.emit(text.encode("utf-8", errors="ignore"))
return
super().keyPressEvent(event)
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:
self.setFocus()
if event.button() == Qt.LeftButton:
self._sel_anchor = self._sel_focus = self._cell_at(event.position().toPoint())
self._selecting = True
self.update()
elif event.button() in (Qt.RightButton, Qt.MiddleButton):
# Comportement des terminaux Unix (et de PuTTY) : le clic droit colle
# directement, sans passer par un menu.
self.paste_clipboard()
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None:
if self._selecting:
self._sel_focus = self._cell_at(event.position().toPoint())
self.update()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self._selecting = False
if self._sel_anchor == self._sel_focus:
# simple clic sans deplacement : on annule la selection sans toucher
# au presse-papiers, qui contient peut-etre ce qu'on s'apprete a coller
self.clear_selection()
else:
# Comportement des terminaux Unix : relacher la souris apres une
# selection copie immediatement, sans raccourci ni menu.
self.copy_selection()
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
"""Double-clic : selectionne le mot sous le curseur."""
col, row = self._cell_at(event.position().toPoint())
line = self.screen.buffer[row]
separators = " \t\"'`()[]{}<>|;:,"
if line[col].data in separators:
return
start = col
while start > 0 and line[start - 1].data not in separators:
start -= 1
end = col
while end < self.cols - 1 and line[end + 1].data not in separators:
end += 1
self._sel_anchor, self._sel_focus = (start, row), (end, row)
self.update()
self.copy_selection() # comme la selection a la souris : copie immediate
def contextMenuEvent(self, event) -> None:
# Pas de menu contextuel : dans un terminal Unix le clic droit colle,
# et il est traite dans mousePressEvent.
event.ignore()