Copier-coller dans le terminal, et suite de tests versionnee
Le terminal n'avait aucun copier-coller : impossible de selectionner du texte a la souris ni de coller une commande. C'est la fonction la plus basique d'un terminal et de loin le manque le plus penible a l'usage. - selection a la souris avec surlignage, suivant le theme actif - double-clic : selectionne le mot sous le curseur - Ctrl+Shift+C / Ctrl+Shift+V, Maj+Inser, clic molette, menu au clic droit - Ctrl+C reste l'interruption : les raccourcis du presse-papiers sont traites avant la conversion Ctrl+lettre, sinon Ctrl+Shift+C serait parti comme un Ctrl+C vers le shell - au collage, les fins de ligne sont normalisees en retour chariot ; un \r\n enverrait une ligne vide supplementaire au shell La suite de tests vivait dans un dossier temporaire et aurait ete perdue : elle rejoint le depot dans tests/ avec un lanceur. Elle couvre le presse-papiers, la detection d'hote, la logique SFTP sur serveur simule et la construction de l'application. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,10 @@ 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
|
||||
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
|
||||
|
||||
@@ -74,6 +76,11 @@ class TerminalWidget(QWidget):
|
||||
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)
|
||||
|
||||
@@ -90,12 +97,81 @@ class TerminalWidget(QWidget):
|
||||
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).
|
||||
@@ -189,6 +265,17 @@ class TerminalWidget(QWidget):
|
||||
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
|
||||
@@ -231,6 +318,20 @@ class TerminalWidget(QWidget):
|
||||
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.
|
||||
@@ -263,4 +364,59 @@ class TerminalWidget(QWidget):
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user