Files
krowlog/src/ui/bigtext/bigtext.py

641 lines
26 KiB
Python

import logging
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 *
from src.ui.ScaledScrollBar import ScaledScrollBar
from src.ui.bigtext.highlight_regex import HighlightRegex
from src.ui.bigtext.highlight_selection import HighlightSelection
from src.ui.bigtext.highlighted_range import HighlightedRange
from src.ui.bigtext.line import Line
from src.ui.bigtext.logFileModel import LogFileModel
from src.ui.bigtext.newhighlightingdialog import NewHighlightingDialog
from src.ui.icon import Icon
from src.ui.rangeslider import RangeSlider
from src.util.conversion import humanbytes
from src.pluginregistry import PluginRegistry
from src.settings.settings import Settings
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from src.i18n import _
log = logging.getLogger("bigtext")
class FileObserver(FileSystemEventHandler):
def __init__(self, big_text):
super(FileObserver, self).__init__()
self.big_text = big_text
self._last_mtime = -1
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
if not event.is_directory:
try:
mtime = os.stat(event.src_path).st_mtime
if mtime != self._last_mtime:
self._last_mtime = mtime
time.sleep(0.5)
self.big_text.trigger_update.emit()
except FileNotFoundError:
# ignore: happens when closing the application, because tmp files are deleted,
# which triggers a modification event
pass
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):
trigger_update = Signal()
def __init__(self, model: LogFileModel, show_range_slider=True):
super(BigText, self).__init__()
self.model = model
self.grid = QGridLayout()
self.grid.setContentsMargins(0, 0, 0, 0)
self.grid.setHorizontalSpacing(0)
self.grid.setVerticalSpacing(0)
self.setLayout(self.grid)
self.v_scroll_bar = ScaledScrollBar()
self.range_limit = RangeSlider()
self.range_limit.value_changed.connect(self._range_limit_event)
self.big_text = InnerBigText(self, model, self.v_scroll_bar, self.range_limit)
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.setPageStep(1)
self.v_scroll_bar.scaledValueChanged.connect(self.big_text.v_scroll_event)
self.v_scroll_bar.scrolled_to_end.connect(self.big_text.v_scroll_update_follow_tail)
if show_range_slider:
self.grid.addWidget(self.range_limit, 0, 0)
self.grid.addWidget(self.big_text, 0, 1)
self.grid.addWidget(self.h_scroll_bar, 1, 1)
self.grid.addWidget(self.v_scroll_bar, 0, 2)
self.watchdog = FileWatchdogThread(self, model.get_file())
QThreadPool.globalInstance().start(self.watchdog)
self.trigger_update.connect(self.big_text._file_changed)
def _range_limit_event(self, range_start: str, range_end: str):
# print("-> %s, %s -- range limit event" %(range_start, range_end))
self.big_text.set_range(int(range_start), int(range_end))
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 clear_selection_highlight(self):
self.big_text.clear_selection_highlight()
def destruct(self):
self.watchdog.destruct()
pass
# noinspection PyArgumentList,PyTypeChecker
class InnerBigText(QWidget):
_byte_offset = 0
_left_offset = 0 # number of characters the horizontal scrollbar was moved to the right
scroll_lines = 0
longest_line = 0
_range_start = 0
_range_end = -1
def __init__(self, parent: BigText, model: LogFileModel, v_scaled_scrollbar: ScaledScrollBar,
range_limit: RangeSlider):
super(InnerBigText, self).__init__()
self.char_height = None
self.char_width = None
self.model = model
self._v_scaled_scrollbar = v_scaled_scrollbar
self._range_limit = range_limit
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._follow_tail = False
self.highlight_selected_text = HighlightRegex(
"",
is_regex=False,
ignore_case=True,
hit_background_color="d7efffc0") # same blue as the selection highlight, but with lower saturation
self.line_click_listeners: [Callable[[int], None]] = []
def set_range(self, range_start: int, range_end: int):
self._range_start = range_start
self._range_end = range_end
self.model.setRange(range_start, range_end)
self._v_scaled_scrollbar.setMinimum(range_start)
self._v_scaled_scrollbar.setMaximum(range_end)
self._set_byte_offset(self._byte_offset)
def _set_byte_offset(self, byte_offset: int):
self._byte_offset = min(max(byte_offset, self._range_start), self._range_end)
self.update()
def clear_selection_highlight(self):
self.selection_highlight.start_byte = 0
self.selection_highlight.end_byte = 0
self._update_highlight_selected_text()
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 | Qt.KeyboardModifier.ShiftModifier and e.key() == 67: # ctrl + shift + c
self._copy_selection_to_file()
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))
PluginRegistry.execute("update_ui")
self.update()
else:
# print("wheel event fired :) %s" % (direction))
self.scroll_by_lines(direction * 3)
def _open_menu(self, position: QPoint):
menu = QMenu(self)
copy_clipboard = QAction(Icon.fromTheme("edit-copy", "icons/myicons/edit-copy.svg"), _("&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(Icon.fromTheme("document-save-as", "icons/myicons/document-save-as.svg"),
_("Copy to &File"), self,
triggered=self._copy_selection_to_file)
copy_to_file.setDisabled(not self._has_selection())
copy_to_file.setShortcut("Shift+CTRL+C")
menu.addAction(copy_to_file)
select_all = QAction(Icon("icons/myicons/select-all.svg"), _("Select &All"), self,
triggered=self._select_all)
select_all.setShortcut("CTRL+A")
menu.addAction(select_all)
manage_highlighting = QAction(
_("&Highlighter"),
self,
triggered=lambda: NewHighlightingDialog(self.model.settings).exec())
manage_highlighting.setShortcut("CTRL+H")
menu.addAction(manage_highlighting)
menu.addSeparator()
set_range_start = QAction(
Icon("icons/myicons/range-start.svg"),
_("Set Range Start"),
self,
triggered=lambda: self._set_range_start_by_y_pos(position.y())
)
menu.addAction(set_range_start)
set_range_end = QAction(
Icon("icons/myicons/range-end.svg"),
_("Set Range End"),
self,
triggered=lambda: self._set_range_end_by_y_pos(position.y())
)
menu.addAction(set_range_end)
reset_range = QAction(
_("Reset Range"),
self,
triggered=lambda: self._reset_range()
)
menu.addAction(reset_range)
menu.exec(self.mapToGlobal(position))
def _set_range_start_by_y_pos(self, y_pos: int) -> None:
line_number = self.y_pos_to_line(y_pos)
self.lines = self.model.data(self._byte_offset, 0, line_number + 1, self._range_start,
self._range_end)
range_start = self.lines[line_number].byte_offset()
self.parent.range_limit.set_range_start(range_start)
def _set_range_end_by_y_pos(self, y_pos: int) -> None:
line_number = self.y_pos_to_line(y_pos)
self.lines = self.model.data(self._byte_offset, 0, line_number + 1, self._range_start,
self._range_end)
range_end = self.lines[line_number].byte_end() + 1
self.parent.range_limit.set_range_end(range_end)
def _reset_range(self):
self.parent.range_limit.set_range_start(0)
self.parent.range_limit.set_range_end(self.model.byte_count())
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)
# noinspection PyTypeChecker
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_highlight_selected_text()
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_highlight_selected_text()
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_highlight_selected_text()
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_highlight_selected_text()
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_highlight_selected_text()
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()
@Slot()
def v_scroll_update_follow_tail(self, scrolled_to_end: bool):
self._follow_tail = scrolled_to_end
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 column_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,
_("warning"),
_(
"You have selected <b>{0}</b> of data.").format(bytes_human_readable))
# noinspection PyTypeChecker
you_sure.setStandardButtons(QMessageBox.Cancel)
copy_btn = you_sure.addButton(_("Copy {0} to Clipboard").format(bytes_human_readable),
QMessageBox.ActionRole)
copy_btn.setIcon(Icon.fromTheme("edit-copy", "icons/myicons/edit-copy.svg"))
write_btn = you_sure.addButton(_("Write to File"), QMessageBox.ActionRole)
write_btn.setIcon(Icon.fromTheme("document-save-as", "icons/myicons/document-save-as.svg"))
you_sure.setDefaultButton(QMessageBox.StandardButton.Cancel)
you_sure.exec()
if you_sure.clickedButton() == write_btn:
self._copy_selection_to_file()
return
if you_sure.clickedButton() != copy_btn:
# 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(
parent=self,
caption=_("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 = self.model.get_line_start_at(self._range_start)
if self._range_end < 0 or self.model.byte_count() <= self._range_end:
self.selection_highlight.end_byte = self.model.byte_count()
else:
self.selection_highlight.end_byte = self.model.get_line_start_at(self._range_end)
self._update_highlight_selected_text()
self.update()
def _update_highlight_selected_text(self):
start_byte = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte)
end_byte = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte)
self._update_status_bar(start_byte, end_byte)
if abs(start_byte - end_byte) < 1024:
query = self.model.read_range(start_byte, end_byte)
if query.find("\n") < 0:
self.highlight_selected_text.set_query(query)
return
self.highlight_selected_text.set_query("")
def _update_status_bar(self, start_byte: int, end_byte: int):
if end_byte - start_byte > 0:
bytes_human_readable = humanbytes(end_byte - start_byte)
PluginRegistry.execute("update_status_bar",
_("selected {0} - {1:,.0f}:{2:,.0f}").format(bytes_human_readable, start_byte,
end_byte - 1))
else:
PluginRegistry.execute("update_status_bar", "")
def _file_changed(self):
if self._follow_tail:
self.scroll_to_byte(self.model.byte_count())
self.update()
def paintEvent(self, event: QPaintEvent) -> None:
start_ns = time.process_time_ns()
# print(f"paint {self.model.get_file()} at {self._byte_offset} with follow_tail={self._follow_tail}")
painter = QPainter(self)
# 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)
lines_to_show = math.ceil(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, self._range_start,
self._range_end)
# 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
byte_count = self.model.byte_count()
vmax = byte_count - 1 if self._range_end < 0 else min(self._range_end, self.model.byte_count() - 1)
self.parent.v_scroll_bar.setMaximum(vmax)
self.parent.range_limit.set_maximum(byte_count)
for line in self.lines:
self.update_longest_line(len(line.line()))
highlighters = self.model.highlighters()
if self.model.get_query_highlight():
highlighters = highlighters + [self.model.get_query_highlight()]
highlighters = highlighters + [self.highlight_selected_text]
highlighters = highlighters + [self.selection_highlight] # selection highlight should be last
# draw highlights first - some characters may overlap to the next line
# by drawing the background highlights first we prevent that the highlight
# draws over a character
y_line_offset = self.char_height
for line in self.lines:
highlight_ranges = []
for h in highlighters:
optional_highlight_range = h.compute_highlight(line)
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 line in self.lines:
text = line.line_prepared_for_display()
text = text[self._left_offset:self._left_offset + math.ceil(
self.columns_shown())] # reduce string to the visible section before drawing
painter.drawText(0, y_line_offset, text)
y_line_offset = y_line_offset + self.char_height
painter.end()
end_ns = time.process_time_ns()
# print(f"paint took {(end_ns - start_ns) / 1000000.0}")
def draw_highlights(self, highlights: [HighlightedRange], painter: QPainter, y_line_offset: int):
for highlight in highlights:
if highlight.is_highlight_full_line():
y1 = y_line_offset - self.char_height + self.char_height / 7
height = self.char_height
rect = QRect(0, round(y1), self.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
left = round(x1 - left_offset)
if x1 + width < left_offset \
or x1 > left_offset + self.width():
# too far left or too far right
continue
rect = QRect(left, 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 = "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012"
self.char_width = fm.horizontalAdvance(text) / float(len(text))
# print("font width=%s height=%s" % (self.char_width, self.char_height))