diff --git a/termqt/colors.py b/termqt/colors.py index e873be6..ddf5315 100644 --- a/termqt/colors.py +++ b/termqt/colors.py @@ -1,4 +1,4 @@ -from Qt.QtGui import QColor +from PyQt5.QtGui import QColor # This file stores all xterm-256 colors. # See https://jonasjacek.github.io/colors/ diff --git a/termqt/terminal_buffer.py b/termqt/terminal_buffer.py index 9f5b051..c0b6225 100755 --- a/termqt/terminal_buffer.py +++ b/termqt/terminal_buffer.py @@ -4,8 +4,8 @@ from functools import partial from collections import deque -from Qt.QtGui import QColor -from Qt.QtCore import Qt, QMutex +from PyQt5.QtGui import QColor +from PyQt5.QtCore import Qt, QMutex from .colors import colors8, colors16, colors256 @@ -637,6 +637,10 @@ def __init__(self, self._alt_buffer_display_offset = None self._alt_cursor_position = Position(0, 0) + # selection + self._selection_start = None + self._selection_end = None + # scroll bar self._postpone_scroll_update = False self._scroll_update_pending = False @@ -657,6 +661,98 @@ def __init__(self, self.create_buffer(row_len, col_len) + # Add methods to manage selection state + + def set_selection_start(self, start_position): + self._selection_start = start_position + self._selection_end = start_position + + def set_selection_end(self, end_position): + self._selection_end = end_position + + def set_selection_finish(self, end_position): + self.set_selection_end(end_position) + + start_col, start_row = self._selection_start + end_col, end_row = self._selection_end + if (start_row, start_col) == (end_row, end_col): + self._selection_start = self._selection_end = None + + def reset_selection(self): + self._selection_start = None + self._selection_end = None + + def get_selection(self): + # Ensure the selection is ordered correctly + if self._selection_start and self._selection_end: + start = min(self._selection_start, self._selection_end) + end = max(self._selection_start, self._selection_end) + return start, end + return None, None + + def _get_selected_text(self): + start, end = self.get_selection() + if start and end: + selected_text = "" + for row in range(start.y, end.y + 1): + if 0 <= row < len(self._buffer): + line = self._buffer[row] + line_text = "" + for col in range(len(line)): + if start.y == end.y: + if start.x <= col <= end.x: + line_text += line[col].char if line[col] else ' ' + elif row == start.y and col >= start.x: + line_text += line[col].char if line[col] else ' ' + elif row == end.y and col <= end.x: + line_text += line[col].char if line[col] else ' ' + elif start.y < row < end.y: + line_text += line[col].char if line[col] else ' ' + selected_text += line_text + '\n' + return selected_text.strip('\n') + return "" + + def _get_selected_text_rstrip(self): + start, end = self.get_selection() + if start and end: + selected_text = "" + for row in range(start.y, end.y + 1): + if 0 <= row < len(self._buffer): + line = self._buffer[row] + line_text = "" + for col in range(len(line)): + if start.y == end.y: + if start.x <= col <= end.x: + line_text += line[col].char if line[col] else ' ' + elif row == start.y and col >= start.x: + line_text += line[col].char if line[col] else ' ' + elif row == end.y and col <= end.x: + line_text += line[col].char if line[col] else ' ' + elif start.y < row < end.y: + line_text += line[col].char if line[col] else ' ' + selected_text += line_text.rstrip() + '\n' + return selected_text.strip('\n') + return "" + + def _get_all_text(self): + all_text = "" + for row in self._buffer: + line_text = ''.join(c.char if c else ' ' for c in row) + all_text += line_text + '\n' + return all_text.strip('\n') + + def _get_all_text_rstrip(self): + all_text = [] + for row in self._buffer: + line_text = ''.join(c.char if c else ' ' for c in row).rstrip() + all_text.append(line_text) + + # Remove empty lines at the end of the buffer + while all_text and not all_text[-1]: + all_text.pop() + + return '\n'.join(all_text) + def _register_escape_callbacks(self): ep = self.escape_processor ep.erase_display_cb = self.erase_display diff --git a/termqt/terminal_io_windows.py b/termqt/terminal_io_windows.py index 8d28c7e..e0cc83f 100644 --- a/termqt/terminal_io_windows.py +++ b/termqt/terminal_io_windows.py @@ -82,6 +82,8 @@ def _read_loop(self): self.stdout_callback(bytes(buf, 'utf-8')) else: self.stdout_callback(buf) + except EOFError: + pass finally: self.logger.info("Spawned process has been killed") if self.running: diff --git a/termqt/terminal_widget.py b/termqt/terminal_widget.py index ce23567..0ebb472 100644 --- a/termqt/terminal_widget.py +++ b/termqt/terminal_widget.py @@ -2,14 +2,16 @@ import math from enum import Enum -from Qt.QtWidgets import QWidget, QScrollBar -from Qt.QtGui import (QPainter, QColor, QPalette, QFontDatabase, - QPen, QFont, QFontInfo, QFontMetrics, QPixmap) -from Qt.QtCore import Qt, QTimer, QMutex, Signal +from PyQt5.QtWidgets import QWidget, QScrollBar, QMenu, QAction, QApplication +from PyQt5.QtGui import (QPainter, QColor, QPalette, QFontDatabase, + QPen, QFont, QFontInfo, QFontMetrics, QPixmap) +from PyQt5.QtCore import Qt, QTimer, QMutex, pyqtSignal -from .terminal_buffer import TerminalBuffer, DEFAULT_BG_COLOR, \ +from .terminal_buffer import Position, TerminalBuffer, DEFAULT_BG_COLOR, \ DEFAULT_FG_COLOR, ControlChar, Placeholder +SELECTION_BG_COLOR = Qt.cyan + class CursorState(Enum): ON = 1 @@ -26,20 +28,20 @@ class Terminal(TerminalBuffer, QWidget): # thread, Qt will crash immediately. Just don't do that. # signal for triggering a on-canvas buffer repaint - buffer_repaint_sig = Signal() + buffer_repaint_sig = pyqtSignal() # signal for triggering a on-canvas cursor repaint - cursor_repaint_sig = Signal() + cursor_repaint_sig = pyqtSignal() # signal for triggering a repaint for both the canvas and the widget - total_repaint_sig = Signal() + total_repaint_sig = pyqtSignal() # internal signal for triggering stdout routine for buffering and # painting. Note: Use stdout() method. - _stdout_sig = Signal(bytes) + _stdout_sig = pyqtSignal(bytes) # update scroll bar - update_scroll_sig = Signal() + update_scroll_sig = pyqtSignal() def __init__(self, width, @@ -84,6 +86,8 @@ def __init__(self, self.set_bg(DEFAULT_BG_COLOR) self.set_fg(DEFAULT_FG_COLOR) + self.selection_color = SELECTION_BG_COLOR + self.enable_selection_keys = True self.metrics = None self.set_font(font) self.setAutoFillBackground(True) @@ -115,6 +119,75 @@ def __init__(self, self._stdout_sig.connect(self._stdout) self.resize(width, height) + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + pos = self._map_pixel_to_cell(event.pos()) + self.set_selection_start(pos) + self.set_selection_end(pos) + + self._paint_buffer() + self._restore_cursor_state() + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: + pos = self._map_pixel_to_cell(event.pos()) + self.set_selection_end(pos) + + self._paint_buffer() + self._restore_cursor_state() + + def mouseReleaseEvent(self, event): + if event.button() == Qt.RightButton: + self._show_context_menu(event.pos()) + + if self._selection_start is not None: + pos = self._map_pixel_to_cell(event.pos()) + self.set_selection_finish(pos) + + self._paint_buffer() + self._restore_cursor_state() + + def _show_context_menu(self, position): + menu = QMenu(self) + + copy_action = QAction("Copy", self) + copy_action.triggered.connect(self._copy_selection) + menu.addAction(copy_action) + + copy_all_action = QAction("Copy All", self) + copy_all_action.triggered.connect(self._copy_all) + menu.addAction(copy_all_action) + + paste_action = QAction("Paste", self) + paste_action.triggered.connect(self._paste_from_clipboard) + menu.addAction(paste_action) + + menu.exec_(self.mapToGlobal(position)) + + def _paste_from_clipboard(self): + clipboard = QApplication.clipboard() + text = clipboard.text() + if text: + # Or any other method you use to handle input + self.input(text.encode('utf-8')) + return True + return False + + def _copy_all(self): + all_text = self._get_all_text_rstrip() + clipboard = QApplication.clipboard() + clipboard.setText(all_text) + + def _copy_selection(self): + selected_text = self._get_selected_text_rstrip() + clipboard = QApplication.clipboard() + clipboard.setText(selected_text) + + def _map_pixel_to_cell(self, pos): + col = int((pos.x() - self._padding / 2) / self.char_width) + row = int((pos.y() - self._padding / 2) / self.line_height) + return Position(col, row + self._buffer_display_offset) + def wheelEvent(self, event): # Number of lines to scroll per wheel step lines_per_step = 3 @@ -224,40 +297,100 @@ def _paint_buffer(self): qp.fillRect(self.rect(), DEFAULT_BG_COLOR) + # Calculate selection bounds + if self._selection_start and self._selection_end: + start_col, start_row = self._selection_start + end_col, end_row = self._selection_end + if (start_row, start_col) > (end_row, end_col): + start_row, end_row = end_row, start_row + start_col, end_col = end_col, start_col + else: + start_col = start_row = end_col = end_row = None + for ln in range(self.col_len): real_ln = ln + offset if real_ln < 0 or real_ln >= len(self._buffer): break - row = self._buffer[ln + offset] - + row = self._buffer[real_ln] ht += lh - for cn, c in enumerate(row): - if c: - if c.placeholder == Placeholder.NON: + + if start_row is not None and start_row <= real_ln <= end_row: + if real_ln == start_row or real_ln == end_row: + # Process mixed lines + for cn, c in enumerate(row): + if c: + + is_selected = False + if real_ln == start_row and real_ln == end_row: + is_selected = start_col <= cn <= end_col + elif real_ln == start_row: + is_selected = cn >= start_col + elif real_ln == end_row: + is_selected = cn <= end_col + else: + is_selected = True + + bgcolor = self.selection_color if is_selected else c.bg_color + + # Start of character rendering + ft.setBold(c.bold) + ft.setUnderline(c.underline) + qp.setFont(ft) + qp.fillRect(cn*cw, int(ht - 0.8*ch), + cw*c.char_width, lh, bgcolor) + qp.setPen(c.color if not c.reverse else c.bg_color) + qp.drawText(cn*cw, ht, c.char) + # End of character rendering + else: + # Process fully selected lines + bgcolor = self.selection_color + for cn, c in enumerate(row): + if c: + + # Start of character rendering + ft.setBold(c.bold) + ft.setUnderline(c.underline) + qp.setFont(ft) + qp.fillRect(cn*cw, int(ht - 0.8*ch), + cw*c.char_width, lh, bgcolor) + qp.setPen(c.color if not c.reverse else c.bg_color) + qp.drawText(cn*cw, ht, c.char) + # End of character rendering + else: + # Process non-selected lines + for cn, c in enumerate(row): + if c: + bgcolor = c.bg_color + + # Start of character rendering ft.setBold(c.bold) ft.setUnderline(c.underline) qp.setFont(ft) - if not c.reverse: - qp.fillRect(cn*cw, int(ht - 0.8*ch), cw*c.char_width, lh, - c.bg_color) - qp.setPen(c.color) - qp.drawText(cn*cw, ht, c.char) - else: - qp.fillRect(cn*cw, int(ht - 0.8*ch), cw*c.char_width, lh, - c.color) - qp.setPen(c.bg_color) - qp.drawText(cn*cw, ht, c.char) - else: - qp.setPen(fg_color) - ft.setBold(False) - ft.setUnderline(False) - qp.setFont(ft) - qp.drawText(ht, cn*cw, " ") - qp.end() + qp.fillRect(cn*cw, int(ht - 0.8*ch), + cw*c.char_width, lh, bgcolor) + qp.setPen(c.color if not c.reverse else c.bg_color) + qp.drawText(cn*cw, ht, c.char) + # End of character rendering + qp.end() self._painter_lock.unlock() + def _is_selected(self, col, row): + if not self._selection_start or not self._selection_end: + return False + start_col, start_row = self._selection_start + end_col, end_row = self._selection_end + if start_row <= row <= end_row: + if row == start_row and row == end_row: + return start_col <= col <= end_col + elif row == start_row: + return col >= start_col + elif row == end_row: + return col <= end_col + return True + return False + def _paint_cursor(self): if not self._buffer: return @@ -266,10 +399,10 @@ def _paint_cursor(self): ind_x = self._cursor_position.x ind_y = self._cursor_position.y # if cursor is at the right edge of screen, display half of it - x = int((ind_x if ind_x < self.row_len else (self.row_len - 0.5)) \ + x = int((ind_x if ind_x < self.row_len else (self.row_len - 0.5)) * self.char_width) - y = int((ind_y - self._buffer_display_offset) \ - * self.line_height + (self.line_height - self.char_height) \ + y = int((ind_y - self._buffer_display_offset) + * self.line_height + (self.line_height - self.char_height) + 0.2 * self.line_height) cw = self.char_width @@ -473,12 +606,30 @@ def keyPressEvent(self, event): elif key == Qt.Key_Delete or key == Qt.Key_Backspace: self.input(ControlChar.BS.value) elif key == Qt.Key_Escape: - self.input(ControlChar.ESC.value) + + # Deselect with ESC if there is a selection + if self.enable_selection_keys and self._selection_start is not None: + self.reset_selection() + else: + self.input(ControlChar.ESC.value) else: break # avoid the execution of 'return' return + elif modifiers == Qt.ControlModifier or modifiers == Qt.MetaModifier: - if key == Qt.Key_A: + + # Paste with Ctrl+V (or Command+V on macOS) + pasted = False + if self.enable_selection_keys and event.key() == Qt.Key_V: + pasted = self._paste_from_clipboard() + if pasted: + pass + + # Copy with Ctrl+C (or Command+C on macOS) + elif self.enable_selection_keys and event.key() == Qt.Key_C and self._selection_start is not None: + self._copy_selection() + + elif key == Qt.Key_A: self.input(ControlChar.SOH.value) elif key == Qt.Key_B: self.input(ControlChar.STX.value) @@ -539,6 +690,7 @@ def keyPressEvent(self, event): def showEvent(self, event): super().showEvent(event) + def resize(*args): self.resize(self.size().width(), self.size().height()) QTimer.singleShot(0, resize)