"""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, QKeySequence, ) from PySide6.QtWidgets import QWidget, QMenu, QApplication 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 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.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) 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 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() # ---------- 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.update() return 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 # ---------- 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) 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_Insert and (modifiers & Qt.ShiftModifier): self.paste_clipboard() 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 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.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() == Qt.MiddleButton: self.paste_clipboard() # habitude Unix : clic molette = coller 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 # un simple clic (sans deplacement) annule la selection if self._sel_anchor == self._sel_focus: self.clear_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() def contextMenuEvent(self, event) -> None: menu = QMenu(self) copy_action = menu.addAction("Copier") copy_action.setShortcut(QKeySequence("Ctrl+Shift+C")) copy_action.setEnabled(self.has_selection()) paste_action = menu.addAction("Coller") paste_action.setShortcut(QKeySequence("Ctrl+Shift+V")) paste_action.setEnabled(bool(QApplication.clipboard().text())) menu.addSeparator() select_all_action = menu.addAction("Tout selectionner") action = menu.exec(event.globalPos()) if action == copy_action: self.copy_selection() elif action == paste_action: self.paste_clipboard() elif action == select_all_action: self.select_all()