1029 lines
37 KiB
Python
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'))
|