"""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'))