import sys import math import os import time from typing import Callable, List from PySide6 import QtGui from PySide6.QtCore import * from PySide6.QtGui import * from PySide6.QtGui import QMouseEvent from PySide6.QtWidgets import * import constants from ScaledScrollBar import ScaledScrollBar from conversion import humanbytes from highlight_selection import HighlightSelection from highlighted_range import HighlightedRange from highlightingdialog import HighlightingDialog from line import Line from logFileModel import LogFileModel from raven.pluginregistry import PluginRegistry from ravenui import RavenUI from settings import Settings from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class FileObserver(FileSystemEventHandler): def __init__(self, big_text): super(FileObserver, self).__init__() self.big_text = big_text def on_modified(self, event): # slow down the updates. This is needed, because the file is modified # constantly, which would lead to constant re-rendering, which would # block the UI thread and make the UI unresponsive. # Note: we don't miss events, because they are queued and de-duplicated time.sleep(0.5) self.big_text.update() class FileWatchdogThread(QRunnable): def __init__(self, big_text, file: str): super(FileWatchdogThread, self).__init__() self.file = file self.big_text = big_text self.observer = Observer() def run(self) -> None: self.observer.schedule(FileObserver(self.big_text), self.file) self.observer.start() def destruct(self): self.observer.stop() #self.observer.join(1) class BigText(QWidget): def __init__(self, model: LogFileModel): super(BigText, self).__init__() self.model = model self.watchdog = FileWatchdogThread(self, model.get_file()) QThreadPool.globalInstance().start(self.watchdog) self.grid = QGridLayout() self.grid.setContentsMargins(0, 0, 0, 0) self.grid.setHorizontalSpacing(0) self.grid.setVerticalSpacing(0) self.setLayout(self.grid) self.big_text = InnerBigText(self, model) self.big_text.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) self.h_scroll_bar = QScrollBar(Qt.Orientation.Horizontal) self.h_scroll_bar.setMinimum(0) self.h_scroll_bar.setMaximum(1) self.h_scroll_bar.valueChanged.connect(self.big_text.h_scroll_event) self.v_scroll_bar = ScaledScrollBar() # self.v_scroll_bar.setPageStep(1) self.v_scroll_bar.scaledValueChanged.connect(self.big_text.v_scroll_event) self.grid.addWidget(self.big_text, 0, 0) self.grid.addWidget(self.h_scroll_bar, 1, 0) self.grid.addWidget(self.v_scroll_bar, 0, 1) def get_file(self): return self.model.get_file() def add_line_click_listener(self, listener: Callable[[int], None]): """ :param listener: a callable, the parameter is the byte offset of the clicked line :return: """ self.big_text.line_click_listeners.append(listener) def scroll_to_byte(self, byte_offset: int): self.big_text.scroll_to_byte(byte_offset) def destruct(self): self.watchdog.destruct() pass class InnerBigText(QWidget): _byte_offset = 0 _left_offset = 0 scroll_lines = 0 longest_line = 0 def __init__(self, parent: BigText, model: LogFileModel): super(InnerBigText, self).__init__() self.model = model self.parent = parent self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._open_menu) self.update_font_metrics(QPainter(self)) self.lines = List[Line] self.selection_highlight = HighlightSelection() self._last_double_click_time = 0 self._last_double_click_line_number = -1 self.line_click_listeners: [Callable[[int], None]] = [] def keyPressEvent(self, e: QKeyEvent) -> None: # print("%s + %s" % (e.keyCombination().keyboardModifiers(), e.key())) if e.modifiers() == Qt.KeyboardModifier.NoModifier: lines_to_scroll = math.floor(self.lines_shown()) - 1 if e.key() == Qt.Key.Key_PageUp: self.scroll_by_lines(-lines_to_scroll) if e.key() == Qt.Key.Key_PageDown: self.scroll_by_lines(lines_to_scroll) if e.key() == 16777235: # page up self.scroll_by_lines(-3) if e.key() == 16777237: # page down self.scroll_by_lines(3) elif e.modifiers() == Qt.KeyboardModifier.ControlModifier and e.key() == 67: # ctrl + c self.copy_selection() elif e.modifiers() == Qt.KeyboardModifier.ControlModifier and e.key() == 65: # ctrl + a self._select_all() def wheelEvent(self, event: QWheelEvent): direction = 1 if event.angleDelta().y() < 0 else -1 if event.modifiers() == Qt.KeyboardModifier.ControlModifier: # self.model.settings.update_font_size(-direction) old_font_size = self.model.settings.getint_session('general', 'font_size') new_font_size = max(4, min(50, old_font_size - direction)) self.model.settings.set_session('general', 'font_size', str(new_font_size)) RavenUI.update_ui() self.update() else: # print("wheel event fired :) %s" % (direction)) self.scroll_by_lines(direction * 3) def _open_menu(self, position): menu = QMenu(self) copy_clipboard = QAction(QIcon.fromTheme("edit-copy"), self.tr("&Copy to Clipboard"), self, triggered=self.copy_selection) copy_clipboard.setShortcut("CTRL+C") copy_clipboard.setDisabled(not self._has_selection()) menu.addAction(copy_clipboard) copy_to_file = QAction(QIcon.fromTheme("document-save-as"), self.tr("Copy to &File"), self, triggered=self._copy_selection_to_file) copy_to_file.setDisabled(not self._has_selection()) menu.addAction(copy_to_file) select_all = QAction(QIcon.fromTheme("edit-select-all"), self.tr("Select &All"), self, triggered=self._select_all) select_all.setShortcut("CTRL+A") menu.addAction(select_all) manage_highlighting = QAction( self.tr("&Highlighter"), self, triggered=lambda: HighlightingDialog(self.model.settings).exec()) manage_highlighting.setShortcut("CTRL+H") menu.addAction(manage_highlighting) menu.exec(self.mapToGlobal(position)) def scroll_by_lines(self, scroll_lines: int): self.scroll_lines = scroll_lines self.update() self.parent.v_scroll_bar.setValue(self._byte_offset) def scroll_to_byte(self, byte_offset: int): self._byte_offset = min(byte_offset, self.model.byte_count()) self.update() self.parent.v_scroll_bar.setValue(self._byte_offset) def mousePressEvent(self, e: QtGui.QMouseEvent) -> None: if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.ShiftModifier: offset = self.to_byte_offset(e) self.selection_highlight.set_end_byte(offset) self.update() return if e.buttons() == Qt.MouseButton.LeftButton and (time.time() - self._last_double_click_time) < 0.5: # triple click: select line line_number = self.y_pos_to_line(e.pos().y()) if line_number == self._last_double_click_line_number and line_number < len(self.lines): line: Line = self.lines[line_number] self.selection_highlight.set_start(line.byte_offset()) self.selection_highlight.set_end_byte(line.byte_end()) self.update() return if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier: offset = self.to_byte_offset(e) self.selection_highlight.set_start(offset) self.selection_highlight.set_end_byte(offset) self.update() line_number = self.y_pos_to_line(e.pos().y()) if line_number < len(self.lines): line: Line = self.lines[line_number] for listener in self.line_click_listeners: listener(line.byte_offset()) def mouseDoubleClickEvent(self, e: QtGui.QMouseEvent) -> None: if e.buttons() == Qt.MouseButton.LeftButton: self._last_double_click_time = time.time() self._last_double_click_line_number = self.y_pos_to_line(e.pos().y()) offset = self.to_byte_offset(e) (_word, start_byte, end_byte) = self.model.read_word_at(offset) if start_byte >= 0 and end_byte >= 0: self.selection_highlight.set_start(start_byte) self.selection_highlight.set_end_byte(end_byte) else: self.selection_highlight.set_start(offset) self.selection_highlight.set_end_byte(offset) self.update() def mouseMoveEvent(self, e: QMouseEvent): if e.buttons() != Qt.MouseButton.LeftButton: return current_byte = self.to_byte_offset(e) if self.selection_highlight.end_byte != current_byte: self.selection_highlight.set_end_byte(current_byte) self.update() # print("-> %s,%s" %(self._selection_start_byte, self._selection_end_byte)) line_number = self.y_pos_to_line(e.pos().y()) column_in_line = self.x_pos_to_column(e.pos().x()) if line_number < 0: self.scroll_by_lines(-1) if line_number > int(self.lines_shown()): self.scroll_by_lines(1) if column_in_line <= 1: self._left_offset = max(0, self._left_offset - 2) self.update() if column_in_line + 1 >= self.columns_shown(): self._left_offset = self._left_offset + 2 self.update() @Slot() def h_scroll_event(self, left_offset: int): self._left_offset = left_offset # print("left_offset: %d" % left_offset) self.update() @Slot() def v_scroll_event(self, byte_offset: str): self._byte_offset = int(byte_offset) self.update() def update_longest_line(self, length: int): width_in_chars = self.width() / self.char_width # print("width_in_chars: %d" % width_in_chars) if self.longest_line < length: self.longest_line = length maximum = max(0, length - width_in_chars + 1) self.parent.h_scroll_bar.setMaximum(round(maximum)) def y_pos_to_line(self, y: int) -> int: return int(y / self.char_height) def x_pos_to_column(self, x: int) -> int: return round(x / self.char_width) def lines_shown(self) -> float: return self.height() / float(self.char_height) def columns_shown(self) -> float: return self.width() / float(self.char_width) def to_byte_offset(self, e: QMouseEvent) -> int: line_number = self.y_pos_to_line(e.pos().y()) if line_number < len(self.lines): line: Line = self.lines[line_number] column_in_line = self.x_pos_to_column(e.pos().x()) + self._left_offset column_in_line = min(column_in_line, line.length_in_columns()) # x was behind the last column of this line char_in_line = line.column_to_char(column_in_line) # print("%s in line %s lcolumn_in_line=%s" % (char_in_line, line_number, column_in_line)) byte_in_line = line.char_index_to_byte(char_in_line) current_byte = line.byte_offset() + byte_in_line # print("%s + %s = %s" % (line.byte_offset(), char_in_line, current_byte)) else: current_byte = self.model.byte_count() return current_byte def _has_selection(self): return self.selection_highlight.start_byte != self.selection_highlight.end_byte def copy_selection(self): if self._has_selection(): start = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte) end = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte) bytes_human_readable = humanbytes(end - start) if end - start > (1024 ** 2) * 5: you_sure = QMessageBox( QMessageBox.Icon.Warning, self.tr("data selection"), self.tr( "You have selected {0} of data.").format(bytes_human_readable)) you_sure.setStandardButtons(QMessageBox.StandardButton.Cancel) you_sure.addButton(QPushButton(self.tr("Copy {0} to Clipboard").format(bytes_human_readable)), QMessageBox.ButtonRole.AcceptRole) you_sure.addButton(QPushButton(self.tr("Write to File")), QMessageBox.ButtonRole.YesRole) you_sure.setDefaultButton(QMessageBox.StandardButton.Cancel) result = you_sure.exec() if result == 1: # second custom button has the number 1 self._copy_selection_to_file() if result == QMessageBox.StandardButton.Cancel.value: # abort return selected_text = self.model.read_range(start, end) cb = QApplication.clipboard() cb.setText(selected_text) def _copy_selection_to_file(self): if self._has_selection(): start = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte) end = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte) dialog = QFileDialog(self) (selected_file, _filter) = dialog.getSaveFileName( caption=self.tr("Save File"), dir=os.path.dirname(self.model.get_file()) ) if selected_file: self.model.write_range(start, end, selected_file) open_tab = self.model.settings.session.getboolean("general", "open_tab_on_save_as_file") if open_tab: PluginRegistry.execute("open_file", selected_file) def _select_all(self): self.selection_highlight.start_byte = 0 self.selection_highlight.end_byte = self.model.byte_count() self.update() def paintEvent(self, event: QPaintEvent) -> None: # print("paintEvent") painter = QPainter(self) # painter.setFont(self.model.settings.font()) # Courier New, DejaVu Sans Mono, Monospace, Liberation Mono, Noto Mono, Nimbus Mono L, Tlwg Mono, Ubuntu Mono, FreeMono, Mitra Mono font = "Courier New" if sys.platform == 'win32' or sys.platform == 'cygwin' else "Monospace" painter.setFont(QFont("Courier New", self.model.settings.getint_session('general', "font_size"))) painter.setPen(QColor(0, 0, 0)) self.update_font_metrics(painter) tab_string = " " * constants.tab_width lines_to_show = self.lines_shown() # print("%s / %s = %s" %(self.height(), float(self.char_height), lines_to_show)) self.lines = self.model.data(self._byte_offset, self.scroll_lines, lines_to_show) # print("lines_to_show: %d returned: %d" % (lines_to_show, len(self.lines))) self.scroll_lines = 0 self._byte_offset = self.lines[0].byte_offset() if len(self.lines) > 0 else 0 # print("new byte offset: ", self._byte_offset) # document length == maximum + pageStep + aFewBytesSoThatTheLastLineIsShown self.parent.v_scroll_bar.setMaximum(self.model.byte_count() - 1) for l in self.lines: self.update_longest_line(len(l.line())) highlighters = self.model.highlighters() if self.model.get_query_highlight(): highlighters = highlighters + [self.model.get_query_highlight()] highlighters = highlighters + [self.selection_highlight] # selection highlight should be last # draw hightlights first - some characters may overlap to the next line # by drawing the background hightlights first we prevent that the hightlight # draws over a character start = time.time() y_line_offset = self.char_height; for l in self.lines: highlight_ranges = [] for h in highlighters: optional_highlight_range = h.compute_highlight(l) if optional_highlight_range: highlight_ranges = highlight_ranges + optional_highlight_range self.draw_highlights(highlight_ranges, painter, y_line_offset) y_line_offset = y_line_offset + self.char_height end = time.time() # print("highlight duration: %.3f" %((end-start)*1000)) left_offset = int(-1 * self._left_offset * self.char_width) y_line_offset = self.char_height; for l in self.lines: text = l.line_tabs_replaced() painter.drawText(left_offset, y_line_offset, text) y_line_offset = y_line_offset + self.char_height painter.end() def draw_highlights(self, highlights: [HighlightedRange], painter: QPainter, y_line_offset: int): for highlight in highlights: if highlight.is_highlight_full_line(): left_offset = -1 * self._left_offset * self.char_width y1 = y_line_offset - self.char_height + self.char_height / 7 height = self.char_height full_width = Settings.max_line_length() * self.char_width rect = QRect(round(left_offset), round(y1), round(full_width), round(height)) self.highlight_background(painter, rect, highlight.get_brush_full_line()) for highlight in highlights: left_offset = self._left_offset * self.char_width x1 = highlight.get_start() * self.char_width width = highlight.get_width() * self.char_width y1 = y_line_offset - self.char_height + self.char_height / 7 height = self.char_height rect = QRect(round(x1 - left_offset), round(y1), round(width), round(height)) self.highlight_background(painter, rect, highlight.get_brush()) def highlight_background(self, painter: QPainter, rect: QRect, brush: QBrush): old_brush = painter.brush() old_pen = painter.pen() painter.setBrush(brush) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(rect, 3.0, 3.0) painter.setBrush(old_brush) painter.setPen(old_pen) def update_font_metrics(self, painter: QPainter): fm: QFontMetrics = painter.fontMetrics() self.char_height = fm.height() self.char_width = fm.averageCharWidth() # all chars have same width for monospace font text = "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" self.char_width = fm.horizontalAdvance(text) / float(len(text)) # print("font width=%s height=%s" % (self.char_width, self.char_height))