Signale par l'utilisateur : "Expliquer cette sortie" n'avait aucun effet
utilisable, le menu restait grise. Cause reelle : ces actions exigeaient une
selection prealable dans le terminal, alors que l'attente naturelle ("explique
ce que j'ai a l'ecran") est de pouvoir les utiliser directement sur ce qui est
affiche - confirme avec l'utilisateur avant d'implementer (deux options
proposees : rabattre sur l'ecran visible, ou juste clarifier le blocage).
app/terminal_widget.py : nouvelle methode visible_text() (l'ecran actuellement
affiche, sans l'historique de scroll - au contraire de buffer_text() qui sert
a la detection de commande ssh et remonte l'historique proche).
app/ui/tabs.py : Expliquer/Diagnostiquer/Proposer une solution/Poser une
question utilisent desormais _ai_source_text() = la selection si elle existe
(pour cibler precisement), sinon l'ecran visible. Les 3 premieres actions du
menu ne sont plus desactivees des qu'il n'y a pas de selection, mais
seulement si vraiment rien n'est affiche ; "Poser une question" reste
utilisable meme un terminal vide (contexte optionnel, comme le bouton
"Nouvelle question" du panneau).
tests/test_ai_tab_integration.py : reecriture des tests 1 et 2 (etats
d'activation du menu) + 2 nouveaux tests verifiant explicitement le repli
(sans selection, le texte envoye est bien celui de l'ecran) et sa priorite
inverse (une selection existante prime toujours sur l'ecran visible).
tests/test_clipboard.py : couverture directe de visible_text() (ecran complet,
lignes vides de fin retirees). Suite complete revalidee (34/34), ruff propre.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
619 lines
24 KiB
Python
619 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,
|
|
QKeyEvent,
|
|
QPainter,
|
|
QWheelEvent,
|
|
)
|
|
from PySide6.QtWidgets import QApplication, QScrollBar, QWidget
|
|
|
|
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 visible_text(self) -> str:
|
|
"""Ce qui est actuellement affiche a l'ecran (sans l'historique de
|
|
defilement) : repli de l'assistant IA quand rien n'est selectionne -
|
|
"expliquer cette sortie" doit alors porter sur ce que l'utilisateur a
|
|
reellement sous les yeux, pas sur tout le scrollback."""
|
|
lines = [line.rstrip() for line in self.screen.display]
|
|
while lines and not lines[-1]:
|
|
lines.pop()
|
|
return "\n".join(lines)
|
|
|
|
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()
|