development-hub/src/development_hub/terminal_display.py

1029 lines
37 KiB
Python

"""Terminal display widget that renders a pyte screen."""
import logging
import os
from collections import deque
from pathlib import Path
import pyte
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import (
QColor,
QDragEnterEvent,
QDropEvent,
QFont,
QFontMetrics,
QKeyEvent,
QPainter,
QPalette,
QWheelEvent,
)
from PySide6.QtWidgets import QWidget
logger = logging.getLogger(__name__)
# Default scrollback buffer size (number of lines)
DEFAULT_SCROLLBACK_LINES = 10000
# Color mapping from pyte to Qt
PYTE_COLORS = {
"black": QColor("#1e1e1e"),
"red": QColor("#f44747"),
"green": QColor("#6a9955"),
"brown": QColor("#dcdcaa"), # yellow
"blue": QColor("#569cd6"),
"magenta": QColor("#c586c0"),
"cyan": QColor("#4ec9b0"),
"white": QColor("#d4d4d4"),
"default": QColor("#d4d4d4"),
# Bright colors
"brightblack": QColor("#808080"),
"brightred": QColor("#f14c4c"),
"brightgreen": QColor("#73c991"),
"brightbrown": QColor("#e2e210"),
"brightblue": QColor("#3794ff"),
"brightmagenta": QColor("#d670d6"),
"brightcyan": QColor("#29b8db"),
"brightwhite": QColor("#ffffff"),
}
BG_COLORS = {
"black": QColor("#1e1e1e"),
"red": QColor("#5a1d1d"),
"green": QColor("#1d3d1d"),
"brown": QColor("#3d3d1d"),
"blue": QColor("#1d1d5a"),
"magenta": QColor("#3d1d3d"),
"cyan": QColor("#1d3d3d"),
"white": QColor("#3d3d3d"),
"default": QColor("#1e1e1e"),
}
class TerminalDisplay(QWidget):
"""Terminal display widget that renders a pyte screen with scrollback."""
key_pressed = Signal(bytes)
cursor_position_requested = Signal()
def __init__(self, rows: int = 24, cols: int = 80, parent=None,
scrollback_lines: int = DEFAULT_SCROLLBACK_LINES):
super().__init__(parent)
self._rows = rows
self._cols = cols
self._scrollback_max = scrollback_lines
# Create pyte screen with history support and stream
self.screen = pyte.HistoryScreen(cols, rows, history=scrollback_lines)
self.screen.set_mode(pyte.modes.LNM) # Line feed mode
self.stream = pyte.Stream(self.screen)
# Scrollback state
self._scroll_offset = 0 # 0 = at bottom (live), positive = scrolled up
self._scrollback_buffer: deque[list] = deque(maxlen=scrollback_lines)
# Set up display
self._setup_display()
# Cursor state (no blinking for performance)
self._cursor_visible = True
# Enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Enable drag-drop
self.setAcceptDrops(True)
# Dirty flag for efficient updates
self._dirty = True
# Selection state
self._selection_start = None # (row, col)
self._selection_end = None # (row, col)
self._selecting = False
# Search state
self._search_pattern = ""
self._search_matches: list[tuple[int, int, int]] = [] # (row, start_col, end_col)
self._current_match_index = -1
def _setup_display(self):
"""Configure the terminal display."""
# Use monospace font - smaller size
self.font = QFont("Monospace", 10)
self.font.setStyleHint(QFont.StyleHint.Monospace)
self.font.setFixedPitch(True)
self.setFont(self.font)
# Calculate character dimensions
fm = QFontMetrics(self.font)
self.char_width = fm.horizontalAdvance("M")
self.char_height = fm.lineSpacing()
# Set background
palette = self.palette()
palette.setColor(QPalette.ColorRole.Window, QColor("#1e1e1e"))
self.setPalette(palette)
self.setAutoFillBackground(True)
def resize_screen(self, rows: int, cols: int):
"""Resize the pyte screen."""
self._rows = rows
self._cols = cols
self.screen.resize(rows, cols)
# Clamp scroll offset to valid range after resize
self._scroll_offset = min(self._scroll_offset, self.scrollback_lines)
self._dirty = True
self.update()
@property
def scrollback_lines(self) -> int:
"""Get the number of lines in the scrollback buffer."""
return len(self._scrollback_buffer)
@property
def is_scrolled(self) -> bool:
"""Check if the terminal is scrolled back (not at live position)."""
return self._scroll_offset > 0
def scroll_to_bottom(self):
"""Scroll to the bottom (live position)."""
if self._scroll_offset != 0:
self._scroll_offset = 0
self.update()
def scroll_up(self, lines: int = 1):
"""Scroll up by the specified number of lines."""
max_scroll = self.scrollback_lines
self._scroll_offset = min(self._scroll_offset + lines, max_scroll)
self.update()
def scroll_down(self, lines: int = 1):
"""Scroll down by the specified number of lines."""
self._scroll_offset = max(self._scroll_offset - lines, 0)
self.update()
def scroll_page_up(self):
"""Scroll up by one page."""
self.scroll_up(self._rows - 1)
def scroll_page_down(self):
"""Scroll down by one page."""
self.scroll_down(self._rows - 1)
def clear_scrollback(self):
"""Clear the scrollback buffer."""
self._scrollback_buffer.clear()
self._scroll_offset = 0
# Also clear pyte's history
if hasattr(self.screen, 'history'):
self.screen.history.top.clear()
self.screen.history.bottom.clear()
self.update()
# === Search functionality ===
def search(self, pattern: str, case_sensitive: bool = False) -> int:
"""Search for a pattern in terminal content.
Args:
pattern: Text to search for
case_sensitive: Whether search is case-sensitive
Returns:
Number of matches found
"""
self._search_pattern = pattern
self._search_matches.clear()
self._current_match_index = -1
if not pattern:
self.update()
return 0
search_pattern = pattern if case_sensitive else pattern.lower()
scrollback_len = len(self._scrollback_buffer)
# Search scrollback buffer (negative row indices)
for row_idx, line in enumerate(self._scrollback_buffer):
line_text = "".join(char.data if char.data else " " for char in line)
if not case_sensitive:
line_text = line_text.lower()
col = 0
while col < len(line_text):
pos = line_text.find(search_pattern, col)
if pos == -1:
break
# Store as (virtual_row, start_col, end_col)
# Virtual row: negative for scrollback, 0+ for screen
virtual_row = row_idx - scrollback_len
self._search_matches.append((virtual_row, pos, pos + len(pattern)))
col = pos + 1
# Search current screen buffer (non-negative row indices)
for row_idx in range(self.screen.lines):
line_text = ""
for col_idx in range(self.screen.columns):
char = self.screen.buffer[row_idx][col_idx]
line_text += char.data if char.data else " "
if not case_sensitive:
line_text = line_text.lower()
col = 0
while col < len(line_text):
pos = line_text.find(search_pattern, col)
if pos == -1:
break
self._search_matches.append((row_idx, pos, pos + len(pattern)))
col = pos + 1
# Jump to first match if any found
if self._search_matches:
self._current_match_index = 0
self._scroll_to_match(0)
self.update()
return len(self._search_matches)
def next_match(self) -> bool:
"""Move to the next search match.
Returns:
True if moved to a match, False if no matches
"""
if not self._search_matches:
return False
self._current_match_index = (self._current_match_index + 1) % len(self._search_matches)
self._scroll_to_match(self._current_match_index)
self.update()
return True
def prev_match(self) -> bool:
"""Move to the previous search match.
Returns:
True if moved to a match, False if no matches
"""
if not self._search_matches:
return False
self._current_match_index = (self._current_match_index - 1) % len(self._search_matches)
self._scroll_to_match(self._current_match_index)
self.update()
return True
def _scroll_to_match(self, match_index: int):
"""Scroll to make the specified match visible."""
if match_index < 0 or match_index >= len(self._search_matches):
return
match_row, _, _ = self._search_matches[match_index]
scrollback_len = len(self._scrollback_buffer)
# Calculate what scroll offset would show this row in the middle of the screen
if match_row < 0:
# Match is in scrollback
# match_row is negative, e.g., -10 means 10 lines before screen
# We need scroll_offset such that this row appears on screen
# virtual_row = scrollback_len - scroll_offset + display_row
# We want display_row ≈ screen.lines // 2
# So: match_row = scrollback_len - scroll_offset + (screen.lines // 2)
# scroll_offset = scrollback_len - match_row + (screen.lines // 2)
target_offset = scrollback_len + (-match_row) - (self.screen.lines // 2)
self._scroll_offset = max(0, min(target_offset, scrollback_len))
else:
# Match is on current screen
self._scroll_offset = 0
def clear_search(self):
"""Clear the search pattern and highlights."""
self._search_pattern = ""
self._search_matches.clear()
self._current_match_index = -1
self.update()
@property
def search_match_count(self) -> int:
"""Get the number of search matches."""
return len(self._search_matches)
@property
def current_match_number(self) -> int:
"""Get the current match number (1-based), or 0 if no matches."""
if self._current_match_index >= 0:
return self._current_match_index + 1
return 0
@property
def total_lines(self) -> int:
"""Get total number of lines (scrollback + screen)."""
return len(self._scrollback_buffer) + self.screen.lines
def feed(self, data: bytes):
"""Feed data to the terminal emulator.
Handles malformed UTF-8 gracefully by replacing invalid sequences.
Also catches any pyte parsing exceptions to prevent crashes.
Captures scrolled lines into the scrollback buffer.
"""
# Decode with error handling for malformed UTF-8
try:
text = data.decode("utf-8", errors="replace")
except Exception:
# Fallback to latin-1 which can decode any byte sequence
try:
text = data.decode("latin-1", errors="replace")
except Exception as e:
logger.warning(f"Failed to decode terminal data: {e}")
return
# Check for cursor position query (DSR) before feeding to pyte
if '\x1b[6n' in text:
self.cursor_position_requested.emit()
text = text.replace('\x1b[6n', '')
if text:
try:
self.stream.feed(text)
except Exception as e:
# Pyte can throw on malformed escape sequences
# Log but don't crash - the terminal should remain usable
logger.warning(f"Pyte stream error: {e}")
# Try to recover by feeding character-by-character
for char in text:
try:
self.stream.feed(char)
except Exception:
pass # Skip problematic characters
# Capture any lines that scrolled off the top into our buffer
self._capture_history()
# Auto-scroll to bottom on new output (unless user is reading history)
# Only auto-scroll if we're near the bottom (within 3 lines)
if self._scroll_offset > 0 and self._scroll_offset <= 3:
self._scroll_offset = 0
self._dirty = True
self.update()
def _capture_history(self):
"""Capture lines from pyte's history into our scrollback buffer."""
# pyte's HistoryScreen stores scrolled-off lines in history.top
if hasattr(self.screen, 'history') and self.screen.history.top:
# Move lines from pyte's history to our buffer
while self.screen.history.top:
line = self.screen.history.top.popleft()
# Convert dict to list preserving column positions
# pyte uses dict with column indices as keys, so {0: 'a', 5: 'c'}
# needs to become a list with gaps filled
if line:
max_col = max(line.keys()) + 1 if line else 0
# Create list with all columns, filling gaps with empty chars
row_chars = []
for col in range(min(max_col, self._cols)):
if col in line:
row_chars.append(line[col])
else:
# Create empty character for gap
row_chars.append(self.screen.default_char)
self._scrollback_buffer.append(row_chars)
else:
self._scrollback_buffer.append([])
def paintEvent(self, event):
"""Render the terminal screen."""
painter = QPainter(self)
painter.setFont(self.font)
# Fill background
default_bg_color = QColor("#1e1e1e")
painter.fillRect(self.rect(), default_bg_color)
fm = QFontMetrics(self.font)
ascent = fm.ascent()
# Calculate visible rows
visible_rows = min(self.screen.lines, self.height() // self.char_height + 1)
scrollback_len = len(self._scrollback_buffer)
# Draw each row
for display_row in range(visible_rows):
y_base = display_row * self.char_height
y_text = y_base + ascent
# Calculate which actual row to display based on scroll offset
# scroll_offset = 0 means we're at the bottom (showing live screen)
# scroll_offset > 0 means we're looking at history
if self._scroll_offset > 0:
# We're scrolled up into history
# The virtual row index (0 = oldest history, scrollback_len = first screen line)
virtual_row = scrollback_len - self._scroll_offset + display_row
if virtual_row < 0:
# Before history starts, draw empty
continue
elif virtual_row < scrollback_len:
# Drawing from scrollback buffer
self._draw_scrollback_row(painter, display_row, virtual_row,
y_base, y_text, ascent, default_bg_color)
continue
else:
# Drawing from screen buffer
buffer_row = virtual_row - scrollback_len
if buffer_row >= self.screen.lines:
continue
else:
# At bottom - draw directly from screen buffer
buffer_row = display_row
# Draw from screen buffer
col = 0
while col < self.screen.columns:
char = self.screen.buffer[buffer_row][col]
x = col * self.char_width
# Get colors
fg_color = self._get_fg_color(char)
bg_color = self._get_bg_color(char)
# Draw background if not default (compare RGB values)
if bg_color.rgb() != default_bg_color.rgb():
painter.fillRect(x, y_base, self.char_width, self.char_height, bg_color)
# Draw character
if char.data and char.data != " ":
painter.setPen(fg_color)
painter.drawText(x, y_text, char.data)
col += 1
# Draw selection
self._draw_selection(painter)
# Draw search highlights
self._draw_search_highlights(painter)
# Draw cursor (block cursor) - only when not scrolled back
if self._cursor_visible and self.hasFocus() and not self.is_scrolled:
cx = self.screen.cursor.x
cy = self.screen.cursor.y
if 0 <= cx < self.screen.columns and 0 <= cy < self.screen.lines:
cursor_x = cx * self.char_width
cursor_y = cy * self.char_height
painter.fillRect(
cursor_x, cursor_y,
self.char_width, self.char_height,
QColor(200, 200, 200, 128)
)
# Draw scroll indicator when scrolled back
if self.is_scrolled:
self._draw_scroll_indicator(painter)
def _draw_scrollback_row(self, painter, display_row: int, history_row: int,
y_base: int, y_text: int, ascent: int,
default_bg_color: QColor):
"""Draw a row from the scrollback buffer."""
if history_row < 0 or history_row >= len(self._scrollback_buffer):
return
line = self._scrollback_buffer[history_row]
for col, char in enumerate(line):
if col >= self.screen.columns:
break
x = col * self.char_width
# Get colors
fg_color = self._get_fg_color(char)
bg_color = self._get_bg_color(char)
# Draw background if not default
if bg_color.rgb() != default_bg_color.rgb():
painter.fillRect(x, y_base, self.char_width, self.char_height, bg_color)
# Draw character
if char.data and char.data != " ":
painter.setPen(fg_color)
painter.drawText(x, y_text, char.data)
def _draw_scroll_indicator(self, painter):
"""Draw an indicator showing scroll position."""
# Draw a small indicator in the top-right corner
indicator_text = f"{self._scroll_offset}"
painter.setPen(QColor("#888888"))
fm = QFontMetrics(self.font)
text_width = fm.horizontalAdvance(indicator_text)
# Background for readability
padding = 4
rect_x = self.width() - text_width - padding * 2
rect_y = 2
rect_w = text_width + padding * 2
rect_h = self.char_height
painter.fillRect(rect_x, rect_y, rect_w, rect_h, QColor(30, 30, 30, 200))
painter.drawText(rect_x + padding, rect_y + fm.ascent(), indicator_text)
def _color_to_qcolor(self, color, default_fg=True) -> QColor:
"""Convert a pyte color to QColor.
Args:
color: The color value from pyte (string, int, or tuple)
default_fg: If True, return default foreground; else default background
"""
default = QColor("#d4d4d4") if default_fg else QColor("#1e1e1e")
if color == "default" or color is None:
return default
# Handle string color names or hex colors
if isinstance(color, str):
# Check if it's a hex color (6 hex digits) - pyte returns 256/truecolor as hex
if len(color) == 6 and all(c in '0123456789abcdefABCDEF' for c in color):
return QColor(f"#{color}")
# For named colors, use our palettes
if default_fg:
return PYTE_COLORS.get(color, default)
else:
return BG_COLORS.get(color, default)
# Handle 256 color palette
if isinstance(color, int):
if color < 16:
# Standard 16 colors
fg_colors = [
QColor("#1e1e1e"), QColor("#f44747"), QColor("#6a9955"), QColor("#dcdcaa"),
QColor("#569cd6"), QColor("#c586c0"), QColor("#4ec9b0"), QColor("#d4d4d4"),
QColor("#808080"), QColor("#f14c4c"), QColor("#73c991"), QColor("#e2e210"),
QColor("#3794ff"), QColor("#d670d6"), QColor("#29b8db"), QColor("#ffffff"),
]
bg_colors = [
QColor("#1e1e1e"), QColor("#5a1d1d"), QColor("#1d3d1d"), QColor("#3d3d1d"),
QColor("#1d1d5a"), QColor("#3d1d3d"), QColor("#1d3d3d"), QColor("#4a4a4a"),
QColor("#4d4d4d"), QColor("#8b3d3d"), QColor("#3d6b3d"), QColor("#6b6b3d"),
QColor("#3d3d8b"), QColor("#6b3d6b"), QColor("#3d6b6b"), QColor("#6a6a6a"),
]
return fg_colors[color] if default_fg else bg_colors[color]
elif color < 232:
# 216 color cube
c = color - 16
r = (c // 36) * 51
g = ((c // 6) % 6) * 51
b = (c % 6) * 51
return QColor(r, g, b)
else:
# Grayscale (24 shades)
gray = (color - 232) * 10 + 8
return QColor(gray, gray, gray)
# Handle RGB tuple (truecolor)
if isinstance(color, tuple) and len(color) == 3:
return QColor(color[0], color[1], color[2])
return default
def _get_fg_color(self, char) -> QColor:
"""Get foreground color for a character."""
if char.reverse:
# Reverse video: swap fg/bg
if char.bg == "default":
return QColor("#1e1e1e")
return self._color_to_qcolor(char.bg, default_fg=True)
if char.fg == "default":
color = QColor("#d4d4d4")
else:
color = self._color_to_qcolor(char.fg, default_fg=True)
# Brighten if bold
if char.bold and isinstance(char.fg, str) and char.fg != "default":
if not char.fg.startswith("bright"):
bright_name = "bright" + char.fg
if bright_name in PYTE_COLORS:
return PYTE_COLORS[bright_name]
return color
def _get_bg_color(self, char) -> QColor:
"""Get background color for a character."""
if char.reverse:
# Reverse video: swap fg/bg, use foreground as background
if char.fg == "default":
return QColor("#d4d4d4") # Light background for reverse
return self._color_to_qcolor(char.fg, default_fg=False)
if char.bg == "default":
return QColor("#1e1e1e")
return self._color_to_qcolor(char.bg, default_fg=False)
def event(self, event):
"""Override event to intercept Tab before Qt's focus handling."""
if event.type() == event.Type.KeyPress:
key = event.key()
modifiers = event.modifiers()
# Intercept Tab and Shift+Tab before Qt uses them for focus navigation
if key == Qt.Key.Key_Tab:
if modifiers == Qt.KeyboardModifier.ShiftModifier:
# Shift+Tab (backtab)
self.key_pressed.emit(b'\x1b[Z')
else:
# Regular Tab
self.key_pressed.emit(b'\t')
return True
elif key == Qt.Key.Key_Backtab:
# Backtab (some systems report this instead of Shift+Tab)
self.key_pressed.emit(b'\x1b[Z')
return True
return super().event(event)
def wheelEvent(self, event: QWheelEvent):
"""Handle mouse wheel for scrolling through history."""
# Get scroll delta (positive = scroll up, negative = scroll down)
delta = event.angleDelta().y()
if delta > 0:
# Scroll up (into history)
lines = max(1, delta // 40) # ~3 lines per notch
self.scroll_up(lines)
elif delta < 0:
# Scroll down (toward live)
lines = max(1, -delta // 40)
self.scroll_down(lines)
event.accept()
def keyPressEvent(self, event: QKeyEvent):
"""Handle keyboard input and send to PTY."""
key = event.key()
modifiers = event.modifiers()
text = event.text()
# Handle Shift+key combinations for scrollback navigation
if modifiers == Qt.KeyboardModifier.ShiftModifier:
if key == Qt.Key.Key_PageUp:
self.scroll_page_up()
return
elif key == Qt.Key.Key_PageDown:
self.scroll_page_down()
return
elif key == Qt.Key.Key_Home:
# Scroll to top of history
self._scroll_offset = self.scrollback_lines
self.update()
return
elif key == Qt.Key.Key_End:
# Scroll to bottom (live)
self.scroll_to_bottom()
return
elif key == Qt.Key.Key_Up:
self.scroll_up(1)
return
elif key == Qt.Key.Key_Down:
self.scroll_down(1)
return
# Handle Ctrl+Shift combinations
if modifiers == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
if key == Qt.Key.Key_V:
self._paste_clipboard()
return
# Handle special key combinations
if modifiers == Qt.KeyboardModifier.ControlModifier:
if key == Qt.Key.Key_C:
self.key_pressed.emit(b'\x03')
return
elif key == Qt.Key.Key_D:
self.key_pressed.emit(b'\x04')
return
elif key == Qt.Key.Key_Z:
self.key_pressed.emit(b'\x1a')
return
elif key == Qt.Key.Key_L:
self.key_pressed.emit(b'\x0c')
return
elif key == Qt.Key.Key_A:
self.key_pressed.emit(b'\x01')
return
elif key == Qt.Key.Key_E:
self.key_pressed.emit(b'\x05')
return
elif key == Qt.Key.Key_K:
self.key_pressed.emit(b'\x0b')
return
elif key == Qt.Key.Key_U:
self.key_pressed.emit(b'\x15')
return
elif key == Qt.Key.Key_W:
self.key_pressed.emit(b'\x17')
return
# Any typing scrolls to bottom
if text and self.is_scrolled:
self.scroll_to_bottom()
# Arrow keys and special keys
if key == Qt.Key.Key_Up:
self.key_pressed.emit(b'\x1b[A')
return
elif key == Qt.Key.Key_Down:
self.key_pressed.emit(b'\x1b[B')
return
elif key == Qt.Key.Key_Right:
self.key_pressed.emit(b'\x1b[C')
return
elif key == Qt.Key.Key_Left:
self.key_pressed.emit(b'\x1b[D')
return
elif key == Qt.Key.Key_Home:
self.key_pressed.emit(b'\x1b[H')
return
elif key == Qt.Key.Key_End:
self.key_pressed.emit(b'\x1b[F')
return
elif key == Qt.Key.Key_Delete:
self.key_pressed.emit(b'\x1b[3~')
return
elif key == Qt.Key.Key_Backspace:
self.key_pressed.emit(b'\x7f')
return
# Tab is handled in event() to intercept before Qt's focus navigation
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
self.key_pressed.emit(b'\r')
return
elif key == Qt.Key.Key_Escape:
self.key_pressed.emit(b'\x1b')
return
elif key == Qt.Key.Key_PageUp:
self.key_pressed.emit(b'\x1b[5~')
return
elif key == Qt.Key.Key_PageDown:
self.key_pressed.emit(b'\x1b[6~')
return
elif key == Qt.Key.Key_Insert:
self.key_pressed.emit(b'\x1b[2~')
return
# Function keys
if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F12:
fn = key - Qt.Key.Key_F1 + 1
if fn <= 4:
self.key_pressed.emit(f'\x1bO{chr(ord("P") + fn - 1)}'.encode())
else:
codes = {5: 15, 6: 17, 7: 18, 8: 19, 9: 20, 10: 21, 11: 23, 12: 24}
self.key_pressed.emit(f'\x1b[{codes.get(fn, 15)}~'.encode())
return
# Regular text
if text:
self.key_pressed.emit(text.encode('utf-8'))
def _pos_to_cell(self, pos) -> tuple[int, int]:
"""Convert pixel position to (row, col).
Returns coordinates in the virtual buffer space where:
- Negative rows are in scrollback history
- Row 0 to screen.lines-1 are in the current screen buffer
"""
col = max(0, min(pos.x() // self.char_width, self.screen.columns - 1))
display_row = max(0, min(pos.y() // self.char_height, self.screen.lines - 1))
# Adjust for scroll offset to get virtual row
if self._scroll_offset > 0:
scrollback_len = len(self._scrollback_buffer)
virtual_row = scrollback_len - self._scroll_offset + display_row
# Convert to our coordinate system where negative = history
if virtual_row < scrollback_len:
row = virtual_row - scrollback_len # Negative for history
else:
row = virtual_row - scrollback_len # 0+ for screen buffer
else:
row = display_row
return (row, col)
def mousePressEvent(self, event):
"""Handle mouse press for selection."""
if event.button() == Qt.MouseButton.LeftButton:
self._selection_start = self._pos_to_cell(event.pos())
self._selection_end = self._selection_start
self._selecting = True
self.update()
def mouseMoveEvent(self, event):
"""Handle mouse move for selection."""
if self._selecting:
self._selection_end = self._pos_to_cell(event.pos())
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release."""
if event.button() == Qt.MouseButton.LeftButton:
self._selecting = False
def contextMenuEvent(self, event):
"""Show context menu with copy/paste."""
from PySide6.QtWidgets import QApplication, QMenu
menu = QMenu(self)
copy_action = menu.addAction("Copy")
copy_action.triggered.connect(self._copy_selection)
copy_action.setEnabled(self._has_selection())
paste_action = menu.addAction("Paste")
paste_action.triggered.connect(self._paste_clipboard)
menu.addSeparator()
clear_action = menu.addAction("Clear Selection")
clear_action.triggered.connect(self._clear_selection)
menu.exec(event.globalPos())
def _has_selection(self) -> bool:
"""Check if there's an active selection."""
return (self._selection_start is not None and
self._selection_end is not None and
self._selection_start != self._selection_end)
def _get_selection_bounds(self) -> tuple[tuple[int, int], tuple[int, int]]:
"""Get normalized selection bounds (start <= end)."""
if not self._has_selection():
return None, None
start = self._selection_start
end = self._selection_end
# Normalize so start comes before end
if (start[0] > end[0]) or (start[0] == end[0] and start[1] > end[1]):
start, end = end, start
return start, end
def _copy_selection(self):
"""Copy selected text to clipboard."""
from PySide6.QtWidgets import QApplication
start, end = self._get_selection_bounds()
if start is None:
return
lines = []
scrollback_len = len(self._scrollback_buffer)
for row in range(start[0], end[0] + 1):
line_start = start[1] if row == start[0] else 0
line_end = end[1] + 1 if row == end[0] else self.screen.columns
line = ""
if row < 0:
# Row is in scrollback buffer
history_idx = scrollback_len + row # Convert negative to positive index
if 0 <= history_idx < scrollback_len:
history_line = self._scrollback_buffer[history_idx]
for col in range(line_start, min(line_end, len(history_line))):
char = history_line[col]
line += char.data if char.data else " "
else:
# Row is in screen buffer
if row < self.screen.lines:
for col in range(line_start, line_end):
char = self.screen.buffer[row][col]
line += char.data if char.data else " "
lines.append(line.rstrip())
text = "\n".join(lines)
QApplication.clipboard().setText(text)
def _paste_clipboard(self):
"""Paste clipboard content as keyboard input."""
from PySide6.QtWidgets import QApplication
text = QApplication.clipboard().text()
if text:
self.key_pressed.emit(text.encode('utf-8'))
def _clear_selection(self):
"""Clear the current selection."""
self._selection_start = None
self._selection_end = None
self.update()
def _draw_selection(self, painter):
"""Draw selection highlight."""
start, end = self._get_selection_bounds()
if start is None:
return
selection_color = QColor(70, 130, 180, 100) # Steel blue, semi-transparent
scrollback_len = len(self._scrollback_buffer)
for row in range(start[0], end[0] + 1):
line_start = start[1] if row == start[0] else 0
line_end = end[1] + 1 if row == end[0] else self.screen.columns
# Convert virtual row to display row based on scroll position
if self._scroll_offset > 0:
# Virtual row 0 = first screen line when scrollback_len lines of history
# display_row = row - (scrollback_len - scroll_offset)
display_row = row + self._scroll_offset
else:
display_row = row
# Check if this row is visible
if display_row < 0 or display_row >= self.screen.lines:
continue
x = line_start * self.char_width
y = display_row * self.char_height
width = (line_end - line_start) * self.char_width
painter.fillRect(x, y, width, self.char_height, selection_color)
def _draw_search_highlights(self, painter):
"""Draw search match highlights."""
if not self._search_matches:
return
scrollback_len = len(self._scrollback_buffer)
highlight_color = QColor(255, 200, 0, 80) # Yellow, semi-transparent
current_match_color = QColor(255, 140, 0, 150) # Orange for current match
for idx, (match_row, start_col, end_col) in enumerate(self._search_matches):
# Convert virtual row to display row based on scroll position
if self._scroll_offset > 0:
# match_row is virtual: negative for scrollback, 0+ for screen
# display_row = match_row + scrollback_len - (scrollback_len - scroll_offset)
display_row = match_row + self._scroll_offset
else:
display_row = match_row
# Check if this row is visible
if display_row < 0 or display_row >= self.screen.lines:
continue
x = start_col * self.char_width
y = display_row * self.char_height
width = (end_col - start_col) * self.char_width
# Use different color for current match
color = current_match_color if idx == self._current_match_index else highlight_color
painter.fillRect(x, y, width, self.char_height, color)
def dragEnterEvent(self, event: QDragEnterEvent):
"""Accept drag events that contain file URLs."""
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
"""Accept drag move events."""
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
"""Handle file/folder drops."""
urls = event.mimeData().urls()
if not urls:
return
# Get the first dropped item's local path
path = urls[0].toLocalFile()
if not path:
return
# Determine what to do based on the path type
path_obj = Path(path)
if path_obj.is_dir():
# Directory -> cd to it
self._inject_text(f'cd "{path}"\n')
elif path_obj.is_file() and os.access(path, os.X_OK):
# Executable file -> run it
self._inject_text(f'"{path}"\n')
else:
# Regular file -> insert quoted path (no newline, user can add args)
self._inject_text(f'"{path}" ')
event.acceptProposedAction()
def _inject_text(self, text: str):
"""Inject text as if user typed it."""
self.key_pressed.emit(text.encode('utf-8'))