import math import os import time from typing import Callable from PyQt6 import QtGui from PyQt6.QtCore import * from PyQt6.QtGui import * from PyQt6.QtGui import QMouseEvent from PyQt6.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 logFileModel import LogFileModel 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.valueChanged.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 = [] 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 = 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 = 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() def h_scroll_event(self, left_offset: int): self._left_offset = left_offset # print("left_offset: %d" % left_offset) self.update() 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 = 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()) # 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"), directory=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: RavenUI.window.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()) # print("%s" % QFontDatabase.families()) # Courier New, DejaVu Sans Mono 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 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 left_offset = int(-1 * self._left_offset * self.char_width) y_line_offset = self.char_height; for l in self.lines: text = l.line().replace("\t", tab_string) 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))