Compare commits

...

33 Commits

Author SHA1 Message Date
69dd5ed1e3 update to python3.12 2025-03-23 14:01:06 +01:00
8c740da879 horizontal scrolling 2024-11-24 09:20:57 +01:00
ddd377da7e change font size via mouse wheel 2024-11-23 08:55:25 +01:00
8cf02c8f6a cleanup 2024-11-09 08:11:59 +01:00
871cb4e08a seems to be working quite great 2024-10-24 19:09:51 +02:00
ed450424a5 somewhat working state 2024-08-03 08:52:37 +02:00
00d4f2317a somewhat working state 2024-07-06 10:22:22 +02:00
9c64acf77e the same line cache was used for the file view and the filter view 2024-06-30 08:24:28 +02:00
d561facb7e starting to write a better version of bigtext 2024-05-29 19:17:30 +02:00
329775fb26 call update instead of repaint
repaint causes an endless loop on windows and crashes the app
2024-05-13 18:03:59 +02:00
be53c209ea cache lines to speed up rendering 2024-04-23 10:28:44 +02:00
aa2bfa967e style fixes 2024-04-22 17:52:15 +02:00
2b91b19ef3 fix test test_column_to_char_ignore_nonspacing_mark_charaters 2024-04-22 17:51:12 +02:00
9c1b8298be implement snap back
Only works on Windows. In Linux this is disabled.
2024-04-21 17:37:35 +02:00
811e3c7f82 cleanup 2024-04-21 17:30:19 +02:00
6d7accffde remove unused import for glob 2024-04-14 20:58:27 +02:00
2cd6c2ec1b update QT to 6.7 2024-04-14 20:55:31 +02:00
3e793596c2 replace ScaledScrollBar with BigScrollBar
step 4 - add repeat actions

This has probably a problem. The repeat action is triggering updates asynchronously.
Which means we do not wait until it is done. Which means we can DOS ourselves.
2024-04-14 19:12:37 +02:00
7a574f7ed4 fix typo in word minimun 2024-04-14 09:37:52 +02:00
7d20bae74d replace ScaledScrollBar with BigScrollBar
step 3 - connect wheel event
2024-04-14 09:36:50 +02:00
9b9399f120 replace ScaledScrollBar with BigScrollBar
step 2 - connect the line up/down, page up/down events
2024-04-14 09:13:06 +02:00
3d6cf84cd7 replace ScaledScrollBar with BigScrollBar
step 1 - manually moving the slider
2024-04-13 08:47:36 +02:00
2b65e61e43 remove follow tail 2024-04-11 19:06:56 +02:00
6538e85f37 add positions of hits to the range slider 2024-03-28 20:21:07 +01:00
76f7baecf3 update PySide to 6.6.3 2024-03-27 19:18:29 +01:00
7f4f6ab004 use folder of the original file in "copy to file" action 2024-03-26 18:17:52 +01:00
270b3a8683 remove some debug logging 2024-03-26 18:15:30 +01:00
b8b4b4e790 make the original file name known to the filter view 2024-03-26 18:12:42 +01:00
66d6a728cc make it possible to activate highlighter only for specific file types
In stage 1 we use a glob pattern matching the file name.
Stage 2 (which I will maybe implement some day) might use some additional magic byte sequence for file type detection.
2024-03-25 19:23:24 +01:00
56189f4094 use transparent line background by default 2024-03-25 18:40:03 +01:00
5f30862a83 keep position on handle 2024-03-24 20:06:00 +01:00
017a51a24a remove code for drawing ticks 2024-03-24 19:59:38 +01:00
442d3173c8 don't show context menu entries for ranges when range slider is disabled 2024-03-24 16:46:15 +01:00
19 changed files with 957 additions and 95 deletions

4
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.11 (krowlog)" /> <option name="sdkName" value="Python 3.12 (krowlog)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (krowlog)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (krowlog)" project-jdk-type="Python SDK" />
</project> </project>

3
.idea/ravenlog.iml generated
View File

@@ -8,8 +8,9 @@
<excludeFolder url="file://$MODULE_DIR$/build" /> <excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/dist" /> <excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/icons-not-used" /> <excludeFolder url="file://$MODULE_DIR$/icons-not-used" />
<excludeFolder url="file://$MODULE_DIR$/venv312" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.11 (krowlog)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.12 (krowlog)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

View File

@@ -1,5 +1,5 @@
pip==24.0 pip==24.0
PySide6_Essentials==6.6.2 PySide6_Essentials==6.7.0
setuptools==69.2.0 setuptools==69.5.1
watchdog==4.0.0 watchdog==4.0.0
pyinstaller==6.5.0 pyinstaller==6.6.0

View File

View File

@@ -0,0 +1,461 @@
import argparse
import enum
import signal, os
import logging
import signal
import time
from io import TextIOWrapper
from typing import List
from PySide6 import QtCore, QtGui
from PySide6.QtGui import QPaintEvent, QPainter, QFont, QFontMetrics, QColor, QBrush, QWheelEvent
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QStatusBar, QGridLayout, QSizePolicy, QScrollBar
from PySide6.QtCore import QTimer, QPoint, Qt, QRect, QLine, Slot
import sys
from src.pluginregistry import PluginRegistry
import gettext
__version__ = '0.2.1'
from src.i18n import _
from src.ui.bigtext.BigScrollBar import BigScrollBar
from src.ui.bigtext.bigtext import InnerBigText
gettext.install('krowlog', 'locale')
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("main")
def register_signal_handler():
signal.signal(signal.SIGINT, stop_signal)
signal.signal(signal.SIGTERM, stop_signal)
def stop_signal(signum, _stackframe):
""" Handle terminate signal """
try:
log.info("Terminate signal received. %s", signum)
QtCore.QCoreApplication.quit()
except Exception:
log.exception("Exception occurred while terminating")
sys.exit(1)
sys.exit(0)
MAX_LINE_LENGTH = 4096
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("main")
class FileWithTimeout:
def __init__(self):
pass
def handler(signum, frame):
signame = signal.Signals(signum).name
print(f'Signal handler called with signal {signame} ({signum})')
raise OSError("Couldn't open device!")
def open(path: str, timeout_in_seconds: int = 10, mode="rb", encoding=None) -> TextIOWrapper:
# Set the signal handler and a 5-second alarm
signal.signal(signal.SIGALRM, FileWithTimeout.handler)
signal.alarm(timeout_in_seconds)
fd = open(path, mode=mode, encoding=encoding)
signal.alarm(0) # Disable the alarm
return fd
class MainWindow(QMainWindow):
def __init__(self, *args, **kwargs):
super(MainWindow, self).__init__(*args, **kwargs)
self.setWindowTitle(_("KrowLog"))
self.setMinimumWidth(800)
self.setMinimumHeight(880)
bigger_text = BiggerText(FileModel("testdata/testset.txt"))
self.setCentralWidget(bigger_text)
self.status_bar = QStatusBar(self)
self.setStatusBar(self.status_bar)
self.status_bar.setAutoFillBackground(True)
self.status_bar.showMessage("bla blub")
def apply_time_workaround():
# workaround to make signals work in QT apps.
# They do not work out of the box, because the main thread
# is running in C++ code once app.exec() is executed
# Forcing an empty lambda to be executed periodically gives
# control back to python and allows python to react to signals
timer = QTimer()
timer.timeout.connect(lambda: None)
timer.start(100)
class LineType(enum.IntEnum):
Full = 1
Begin = 2
Middle = 3
End = 4
class Line:
def __init__(self, byte_offset: int, byte_end: int, text: str, bytes: str, type: LineType = LineType.Full):
"""
:type byte_offset: int the offset of the first byte of this line
:type byte_end: int the offset of the last byte of this line
:type text: str the decoded text
:type continued: bool True if the previous line was too long and has been split
"""
self._byte_offset = byte_offset
self._byte_end = byte_end
self._text = text
self._bytes = bytes
self._type = type
def byte_offset(self) -> int:
return self._byte_offset
def byte_end(self) -> int:
return self._byte_end
def text(self) -> str:
return self._text
def bytes(self) -> str:
return self._bytes
def type(self) -> LineType:
return self._type
def __str__(self):
return "%s (%d->%d %s)" % (self._text, self._byte_offset, self._byte_end, self._type.name)
def __repr__(self):
return "%s (%d->%d %s)" % (repr(self._text), self._byte_offset, self._byte_end, self._type.name)
def __eq__(self, other):
if not isinstance(other, Line):
# don't attempt to compare against unrelated types
return NotImplemented
return (self._byte_offset == other._byte_offset
and self._byte_end == other._byte_end
and self._text == other._text
and self._type == other._type)
def __hash__(self):
# necessary for instances to behave sanely in dicts and sets.
return hash((self._byte_offset, self._byte_end, self._text, self._type))
class FileModel:
BUFFER_SIZE = 2 ** 16 # for production use this should be at least 4096, better 64kb
def __init__(self, file: str):
self.file = file
def _read_line(self, file: TextIOWrapper, read_offset: int, max_line_length: int, encoding: str,
previous_line_type: LineType) -> Line | None:
file.seek(read_offset)
buffer: str = file.read(self.BUFFER_SIZE)
if not buffer:
# end of file reached
return None
while True:
pos_of_newline = buffer.find(b"\n")
if pos_of_newline > max_line_length:
start_of_line = read_offset
end_of_line = read_offset + max_line_length - 1
line = buffer[0:max_line_length]
decoded_line = line.decode(encoding, errors="replace")
line_type = LineType.Begin if previous_line_type == LineType.End or previous_line_type == LineType.Full else LineType.Middle
return Line(start_of_line, end_of_line, decoded_line, line, line_type)
elif pos_of_newline >= 0:
start_of_line = read_offset
end_of_line = read_offset + pos_of_newline
line = buffer[0:pos_of_newline + 1]
decoded_line = line.decode(encoding, errors="replace")
line_type = LineType.Full if previous_line_type == LineType.End or previous_line_type == LineType.Full else LineType.End
return Line(start_of_line, end_of_line, decoded_line, line, line_type)
else:
# line does not end in this buffer
# read the next chunk and stitch the line together
raise "not yet implemented"
def read(self, file_offset: int, lines_to_read: int, max_line_length=512, encoding="utf8") -> [Line]:
lines: list[Line] = []
with FileWithTimeout.open(self.file, 5, 'rb') as f:
read_offset = max(0, file_offset - max_line_length * 6) # factor 6 is due to multibyte characters in utf8
f.seek(read_offset)
previous_line_type = LineType.Full
while len(lines) < lines_to_read:
l: Line | None = self._read_line(f, read_offset, max_line_length, encoding, previous_line_type)
if l is None:
break
if file_offset <= l.byte_end():
lines.append(l)
read_offset = l.byte_end() + 1
previous_line_type = l.type()
return lines
def get_selection(self, byte_start: int, byte_end: int):
with FileWithTimeout.open(self.file, 5, 'rb') as f:
start = min(byte_start, byte_end)
end = max(byte_start, byte_end)
f.seek(start)
b = f.read(end - start)
# print(f"read {end - start } bytes -> {b}")
return b.decode("utf-8", errors="replace")
class SelectionPos:
def __init__(self, index: int, is_in_left_half: bool, num_bytes_of_char: int):
self.index = index
self.is_in_left_half = is_in_left_half
self.num_bytes_of_char = num_bytes_of_char
def __repr__(self):
return f"{self.index}{'🞀' if self.is_in_left_half else '🞂'}({self.num_bytes_of_char})"
def pos(self):
return self.index + (0 if self.is_in_left_half else self.num_bytes_of_char)
class Selection:
def __init__(self, start: SelectionPos = SelectionPos(0, False, 0), end: SelectionPos = SelectionPos(0, False, 0)):
self.start = start
self.end = end
def __repr__(self):
return f"{self.start}:{self.end}"
def min_byte(self) -> int:
return min(self.start.pos(), self.end.pos())
def max_byte(self) -> int:
return max(self.start.pos(), self.end.pos())
class BiggerText(QWidget):
def __init__(self, model: FileModel):
super(BiggerText, 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 = BigScrollBar()
self.big_text_area = BiggerTextArea(self, model, self.v_scroll_bar)
self.big_text_area.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_area.h_scroll_event)
# self.v_scroll_bar.value_changed.connect(self.big_text_area.v_scroll_value_changed)
# self.v_scroll_bar.scroll_event.connect(self.big_text_area.v_scroll_event)
self.grid.addWidget(self.big_text_area, 0, 1)
self.grid.addWidget(self.h_scroll_bar, 1, 1)
self.grid.addWidget(self.v_scroll_bar, 0, 2)
class BiggerTextArea(QWidget):
def __init__(self, parent: BiggerText, model: FileModel, v_scroll_bar: BigScrollBar):
super(BiggerTextArea, self).__init__()
self.parent = parent
self._v_scroll_bar = v_scroll_bar
self._left_offset = 0
self.longest_line = 1
self._font_size = 20
self.selection = Selection()
self.mouse_pressed = False
self._encoding = "utf8"
self.file_model: FileModel = model
# font ="Andale Mono"
# font = "JetBrains Mono"
# font = "Monospace" # not found
# font = "ZedMono" # is not found
#font = "Noto Sans Mono"
font = "Noto Color Emoji"
qfont = QFont(font, self._font_size)
# qfont.setStyleHint(QFont.StyleHint.Monospace)
self.qfont = qfont
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier:
self.selection.start = self.to_byte_offset(e.position())
self.selection.end = self.selection.start
self.mouse_pressed = True
self.update()
def mouseReleaseEvent(self, event):
self.mouse_pressed = False
def mouseMoveEvent(self, event):
if self.mouse_pressed:
self.selection.end = self.to_byte_offset(event.position())
#print(f"selection: {self.selection} -> {self.file_model.get_selection(self.selection.min_byte(), self.selection.max_byte())}")
self.update()
def wheelEvent(self, event: QWheelEvent):
direction = 1 if event.angleDelta().y() < 0 else -1
if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
self._font_size = max(4, min(50, self._font_size - direction))
self.update()
else:
# print("wheel event fired :) %s" % (direction))
self.scroll_by_lines(direction * 3)
@Slot()
def h_scroll_event(self, left_offset: int):
self._left_offset = left_offset
# print("left_offset: %d" % left_offset)
self.update()
def to_byte_offset(self, pos: QPoint) -> SelectionPos:
line_number = self.y_pos_to_line_number_on_screen(pos.y())
line = self.lines_to_render[line_number]
text: str = line.text()
text = text.replace("\n", "").replace("\r", "")
elided_text = self.elided_text(text, pos.x())
byte_offset = line.byte_offset() + len(elided_text.encode("utf8"))
left_x_offset = self.font_metric.horizontalAdvance(elided_text)
next_char = ""
pos_is_in_left_half = False
bytes_of_char = 0
if len(text) > len(elided_text): # has another character
next_char = text[len(elided_text)]
char_with = self.font_metric.horizontalAdvance(next_char)
pos_is_in_left_half = pos.x() < (left_x_offset + char_with / 2)
bytes_of_char = len(next_char.encode("utf8"))
else:
# the position is after the last character / behind the end of the line
pass
print(
f"to_byte_offset({pos.x()}, {pos.y()}) -> {left_x_offset} -- elided_text '{elided_text}' next_char '{next_char}' -> byte_offset {byte_offset} pos_is_in_left_half: {pos_is_in_left_half}")
return SelectionPos(byte_offset, pos_is_in_left_half, bytes_of_char)
def elided_text(self, text: str, width: int):
w = width + self.font_metric.horizontalAdvance("")
elided_text = self.font_metric.elidedText(text + "", Qt.TextElideMode.ElideRight, w,
Qt.TextFlag.TextWrapAnywhere)
elided_text = elided_text[0:-1] if elided_text.endswith('') else elided_text # remove the trailing '…'
return elided_text
def y_pos_to_line_number_on_screen(self, y: int) -> int:
return int(y / self.char_height)
def update_longest_line(self, lines: [Line]):
for line in lines:
width_for_full_line = self.font_metric.horizontalAdvance(line.text())
# print("width_in_chars: %d" % width_in_chars)
if self.longest_line < width_for_full_line:
self.longest_line = width_for_full_line
maximum = max(0, self.longest_line - self.width() + 1)
self.parent.h_scroll_bar.setMaximum(round(maximum))
def paintEvent(self, event: QPaintEvent) -> None:
start_ns = time.process_time_ns()
painter = QPainter(self)
font = painter.font()
font.setPointSize(self._font_size)
painter.setFont(font)
# ---
self.font_metric = painter.fontMetrics()
self.char_height = self.font_metric.height()
lines_to_read = self.height() / self.char_height + 1
self.lines_to_render: [Line] = self.file_model.read(0, lines_to_read, 200, self._encoding)
self.update_longest_line(self.lines_to_render)
painter.setPen(QColor(0, 0, 0))
line_on_screen = 1
for line in self.lines_to_render:
x_start = -1
x_end = -1
# selection starts before line
if self.selection.min_byte() < line.byte_offset():
x_start = 0
# selection starts in line
if line.byte_offset() <= self.selection.min_byte() <= line.byte_end():
left_offset_in_bytes = self.selection.min_byte() - line.byte_offset()
bytes = line.bytes()[0:left_offset_in_bytes]
chars = bytes.decode(self._encoding, errors="replace")
x_start = self.font_metric.horizontalAdvance(chars)
#print(f"width({chars}) -> bounding_rect={self.font_metric.boundingRect(chars).width()}px or horizontalAdvance={self.font_metric.horizontalAdvance(chars)}")
# selection ends after line
if self.selection.max_byte() > line.byte_end():
x_end = self.width()
# selection ends in line
if line.byte_offset() <= self.selection.max_byte() <= line.byte_end():
left_offset_in_bytes = self.selection.max_byte() - line.byte_offset()
bytes = line.bytes()[0:left_offset_in_bytes]
x_end = self.font_metric.horizontalAdvance(bytes.decode(self._encoding, errors="replace")) - x_start
if x_start >= 0 and x_end >= 0:
#print(f"highlighting in line {line_on_screen} -- x_start: {x_start} -> x_end: {x_end}")
prev_brush = painter.brush()
prev_pen = painter.pen()
painter.setBrush(QBrush(QColor(0, 255, 255)))
painter.setPen(QColor(0, 0, 0, 0))
painter.drawRect(
QRect(x_start - self._left_offset,
int(line_on_screen * self.char_height + int(self.char_height * 0.1)), x_end,
-self.char_height))
painter.setBrush(prev_brush)
painter.setPen(prev_pen)
painter.drawText(QPoint(-self._left_offset, line_on_screen * self.char_height), line.text())
line_on_screen = line_on_screen + 1
painter.end()
if __name__ == "__main__":
app = QApplication(sys.argv)
apply_time_workaround()
window = MainWindow()
window.show()
register_signal_handler()
app.exec()

View File

@@ -0,0 +1,53 @@
import unittest
from src.new_big_text.bigger_text import FileModel, Line, LineType
class FileModelTestCase(unittest.TestCase):
def test_read_lines_with_offset_0(self):
fm = FileModel("testdata/readlines.txt")
actual_lines = fm.read(file_offset=0, lines_to_read=5, max_line_length=16, encoding="utf8")
self.assertEqual([
Line(0, 1, '1\n'),
Line(2, 4, '12\n'),
Line(5, 8, '123\n'),
Line(9, 13, '1234\n'),
Line(14, 19, '12345\n')
],
actual_lines)
def test_read_lines_with_offset_in_middle_of_line(self):
fm = FileModel("testdata/readlines.txt")
file_offset = "1\n12\n123\n".find("123") + 1
actual_lines = fm.read(
file_offset=file_offset, # at char 2 in line 3
lines_to_read=5,
max_line_length=16,
encoding="utf8")
self.assertEqual([
Line(5, 8, '123\n'),
Line(9, 13, '1234\n'),
Line(14, 19, '12345\n'),
Line(20, 26, '123456\n'),
Line(27, 34, '1234567\n')
],
actual_lines)
def test_read_long_line__buffer_larger_than_line(self):
fm = FileModel("testdata/longLines.txt")
fm.BUFFER_SIZE = 512
actual_lines = fm.read(
file_offset=0,
lines_to_read=3,
max_line_length=10,
encoding="utf8"
)
self.assertEqual([
Line(0, 9, "1aaaaaaaa-", LineType.Begin),
Line(10, 19, "bbbbbbbbb-", LineType.Middle),
Line(20, 27, "ccccccc\n", LineType.End),
], actual_lines)
if __name__ == '__main__':
unittest.main()

View File

@@ -55,9 +55,9 @@ def _new_recursive(current_action_id: str, items: [MenuContribution]) -> [MenuCo
for item in items: for item in items:
mc: MenuContribution = item mc: MenuContribution = item
print("%s checking %s" % (current_action_id, mc.action_id)) # print("%s checking %s" % (current_action_id, mc.action_id))
if mc.after == current_action_id: if mc.after == current_action_id:
print("%s adding %s" % (current_action_id, mc.action_id)) #print("%s adding %s" % (current_action_id, mc.action_id))
result.append(mc) result.append(mc)
result = result + _new_recursive(mc.action_id, items) result = result + _new_recursive(mc.action_id, items)
@@ -65,7 +65,7 @@ def _new_recursive(current_action_id: str, items: [MenuContribution]) -> [MenuCo
def _recursive_half_order_adder(result: [MenuContribution], items: [MenuContribution]): def _recursive_half_order_adder(result: [MenuContribution], items: [MenuContribution]):
print("%s -- %s" % ([mc.action_id for mc in result], [mc.action_id for mc in items])) #print("%s -- %s" % ([mc.action_id for mc in result], [mc.action_id for mc in items]))
for item in items: for item in items:
mc: MenuContribution = item mc: MenuContribution = item
if mc.after: if mc.after:

View File

@@ -38,7 +38,8 @@ class FilterTask(QRunnable):
on_before: Callable[[], None], on_before: Callable[[], None],
on_finish: Callable[[], None], on_finish: Callable[[], None],
show_only_matches: bool, show_only_matches: bool,
matches_separator: str matches_separator: str,
zoned_plugin_registry: ZonedPluginRegistry
): ):
super(FilterTask, self).__init__() super(FilterTask, self).__init__()
self.source_model = source_model self.source_model = source_model
@@ -53,6 +54,7 @@ class FilterTask(QRunnable):
self.filter_match_found_listeners = filter_match_found_listeners self.filter_match_found_listeners = filter_match_found_listeners
self.show_only_matches = show_only_matches self.show_only_matches = show_only_matches
self.matches_separator = matches_separator self.matches_separator = matches_separator
self.zoned_plugin_registry = zoned_plugin_registry
def only_matches(self, line: str, regex: re.Pattern): def only_matches(self, line: str, regex: re.Pattern):
result = "" result = ""
@@ -87,7 +89,10 @@ class FilterTask(QRunnable):
listener(-1, -1) # notify listeners that a new search started listener(-1, -1) # notify listeners that a new search started
hits_count = 0 hits_count = 0
hits_positions: set[float] = set(())
self.zoned_plugin_registry.execute("update_hit_positions", hits_positions)
last_progress_report = time.time() last_progress_report = time.time()
source_file_size = self.source_model.byte_count()
try: try:
with open(self.source_model.get_file(), "rb") as source: with open(self.source_model.get_file(), "rb") as source:
source.seek(self.source_model.range_start) source.seek(self.source_model.range_start)
@@ -121,6 +126,12 @@ class FilterTask(QRunnable):
target.write(line.encode("utf8")) target.write(line.encode("utf8"))
hits_count = hits_count + 1 hits_count = hits_count + 1
hits_positions_before = len(hits_positions)
hits_positions.add(round(source_line_offset / source_file_size, 3))
hits_positions_after = len(hits_positions)
if hits_positions_before != hits_positions_after:
self.zoned_plugin_registry.execute("update_hit_positions", hits_positions)
# sometime buffering can hide results for a while # sometime buffering can hide results for a while
# We force a flush periodically. # We force a flush periodically.
if line_count % 10000 == 0: if line_count % 10000 == 0:
@@ -225,7 +236,7 @@ class FilterWidget(QWidget):
(handle, self.tmp_filename) = tempfile.mkstemp() (handle, self.tmp_filename) = tempfile.mkstemp()
os.close(handle) os.close(handle)
self.filter_model = LogFileModel(self.tmp_filename, self.source_model.settings) self.filter_model = LogFileModel(self.tmp_filename, self.source_model.settings, source_model.get_file())
self.hits_view = BigText(self.filter_model, show_range_slider=False) self.hits_view = BigText(self.filter_model, show_range_slider=False)
self.layout.addWidget(filter_bar) self.layout.addWidget(filter_bar)
@@ -350,6 +361,7 @@ class FilterWidget(QWidget):
lambda: self.search_is_running.emit(True), lambda: self.search_is_running.emit(True),
lambda: self.search_is_running.emit(False), lambda: self.search_is_running.emit(False),
show_only_matches, show_only_matches,
self.matches_separator.text() self.matches_separator.text(),
self._zoned_plugin_registry
) )
QThreadPool.globalInstance().start(self.filter_task) QThreadPool.globalInstance().start(self.filter_task)

View File

@@ -1,6 +1,7 @@
from PySide6.QtWidgets import * from PySide6.QtWidgets import *
from PySide6.QtCore import * from PySide6.QtCore import *
from src.pluginbase import PluginBase
from src.ui.bigtext.bigtext import BigText from src.ui.bigtext.bigtext import BigText
from src.plugins.logfile.filterviewsyncer import FilterViewSyncer from src.plugins.logfile.filterviewsyncer import FilterViewSyncer
from src.plugins.logfile.filterwidget import FilterWidget from src.plugins.logfile.filterwidget import FilterWidget
@@ -17,6 +18,7 @@ class FullTabWidget(Tab):
self._model = model self._model = model
self._zoned_plugin_registry = zoned_plugin_registry self._zoned_plugin_registry = zoned_plugin_registry
self.file_view = BigText(model) self.file_view = BigText(model)
self._zoned_plugin_registry.register_plugin("TabWidgetPlugin", TabWidgetPlugin(self.file_view))
self.filter_hit_view = FilterWidget(self._model, self._zoned_plugin_registry) self.filter_hit_view = FilterWidget(self._model, self._zoned_plugin_registry)
self.filter_view_syncer = FilterViewSyncer(self.file_view) self.filter_view_syncer = FilterViewSyncer(self.file_view)
self.filter_hit_view.add_line_click_listener(self.filter_view_syncer.click_listener) self.filter_hit_view.add_line_click_listener(self.filter_view_syncer.click_listener)
@@ -51,3 +53,12 @@ class FullTabWidget(Tab):
# overriding abstract method # overriding abstract method
def on_reveal(self): def on_reveal(self):
self.filter_hit_view.on_reveal() self.filter_hit_view.on_reveal()
class TabWidgetPlugin(PluginBase):
def __init__(self, file_view: BigText):
super(TabWidgetPlugin, self).__init__()
self._file_view = file_view
def update_hit_positions(self, hit_positions: set[float]):
self._file_view.update_hit_positions(hit_positions)

View File

@@ -0,0 +1,233 @@
import enum
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QWidget, QStylePainter, QStyle, QStyleOptionSlider
from PySide6.QtCore import Qt, QSize, QEvent, QRect, QPoint, Signal, QTimer
class BigScrollBar(QWidget):
value_changed = Signal(str)
"""Signal emitted when the range slider value changes.
**Note**: The value is a string and must be parsed into an int.
QT's signal api only supports 32bit integers. Ints larger
than 2**32-1 will overflow. Probably because there is some C/C++
code involved. We work around this by converting the python int
into a string."""
class ScrollEvent(enum.IntEnum):
PageUp = 1
PageDown = 2
LinesUp = 3
LinesDown = 4
scroll_event = Signal(ScrollEvent)
pressedControl = QStyle.SubControl.SC_None
click_offset = 0
scale = 10000
minimum = 0
value = 0
maximum = 100
def __init__(self):
super(BigScrollBar, self).__init__()
self.repeat_action_timer = QTimer()
self.repeat_action_control = QStyle.SubControl.SC_None
self.repeat_action_timer.setSingleShot(True)
self.repeat_action_timer.timeout.connect(self.execute_control)
self.repeat_action_timer.setInterval(50)
def setMinimum(self, min: int):
self.minimum = min
if self.value < self.minimum:
self.set_value(self.minimum)
def setMaximum(self, max: int):
self.maximum = max
if self.value > self.maximum:
self.set_value(self.maximum)
def paintEvent(self, event):
p = QStylePainter(self)
o = self.style_options()
# print(f"style_options: sliderPosition: {o.sliderPosition}")
p.drawComplexControl(QStyle.ComplexControl.CC_ScrollBar, o)
def style_options(self) -> QStyleOptionSlider:
o = QStyleOptionSlider()
o.initFrom(self)
o.orientation = Qt.Orientation.Vertical
o.subControls = QStyle.SubControl.SC_All
if self.pressedControl != QStyle.SubControl.SC_None:
o.activeSubControls = self.pressedControl # QStyle.SubControl.SC_None
o.state = o.state | QStyle.StateFlag.State_Sunken
o.singleStep = 1
# scale values to 10000
t = self.scale / self.maximum
o.minimum = self.minimum * t
o.maximum = self.maximum * t
o.sliderPosition = int(self.value * t)
# print(f"t={t}")
# print(f"({self.minimun}, {self.value}, {self.maximum}) -> ({o.minimum},{o.sliderPosition},{o.maximum})")
return o
def sizeHint(self):
scroll_bar_extend: int = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
scroll_bar_slider_min: int = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarSliderMin)
# print(f"scroll_bar_extend: {scroll_bar_extend}, scroll_bar_slider_min: {scroll_bar_slider_min}")
# for vertical
size = QSize(scroll_bar_extend, scroll_bar_extend * 2 + scroll_bar_slider_min)
opts = self.style_options()
return self.style().sizeFromContents(QStyle.ContentsType.CT_ScrollBar, opts, size, self)
def event(self, event: QEvent) -> bool:
# print(f"event type: {event.type()}")
if event.type() == QEvent.Type.MouseButtonPress:
pass
# print(f"mouse button pressed")
return super().event(event)
def mousePressEvent(self, event):
if self.repeat_action_timer.isActive():
self.stopRepeatAction()
if not (event.button() == Qt.MouseButton.LeftButton or event.button() == Qt.MouseButton.MiddleButton):
return
style_options = self.style_options()
self.pressedControl = self.style().hitTestComplexControl(QStyle.ComplexControl.CC_ScrollBar, style_options,
event.position().toPoint(), self)
# print(f"pressedControl {self.pressedControl}")
sr: QRect = self.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar, style_options,
QStyle.SubControl.SC_ScrollBarSlider, self)
click: QPoint = event.position().toPoint()
# print(f"pressYValue {pressYValue}")
if self.pressedControl == QStyle.SubControl.SC_ScrollBarSlider:
self.click_offset = click.y() - sr.y()
self.snap_back_position = self.value
if (self.pressedControl == QStyle.SubControl.SC_ScrollBarAddPage
or self.pressedControl == QStyle.SubControl.SC_ScrollBarSubPage) \
and event.button() == Qt.MouseButton.MiddleButton:
slider_length = sr.height()
self.set_value(self.pixelPosToRangeValue(event.position().toPoint().y()))
self.pressedControl = QStyle.SubControl.SC_ScrollBarSlider
self.click_offset = slider_length / 2
return
self.repeat_action_control = self.pressedControl
self.execute_control()
# self.repaint(self.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar, style_options, self.pressedControl))
def execute_control(self):
# print(f"execute_control: {self.repeat_action_control}")
trigger_repeat_action = True
match self.repeat_action_control:
case QStyle.SubControl.SC_ScrollBarAddPage:
self.scroll_event.emit(self.ScrollEvent.PageDown)
case QStyle.SubControl.SC_ScrollBarSubPage:
self.scroll_event.emit(self.ScrollEvent.PageUp)
if self.value <= self.minimum:
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarAddLine:
self.scroll_event.emit(self.ScrollEvent.LinesDown)
case QStyle.SubControl.SC_ScrollBarSubLine:
self.scroll_event.emit(self.ScrollEvent.LinesUp)
if self.value <= self.minimum:
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarFirst:
self.set_value(self.minimum)
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarLast:
self.set_value(self.maximum)
trigger_repeat_action = False
case _:
trigger_repeat_action = False
if trigger_repeat_action:
# print(f"schedule repeat action: {self.repeat_action_control}")
self.repeat_action_timer.start()
def mouseReleaseEvent(self, event):
self.pressedControl = QStyle.SubControl.SC_None
self.stopRepeatAction()
def mouseMoveEvent(self, event):
if self.pressedControl == QStyle.SubControl.SC_None:
return
if self.pressedControl == QStyle.SubControl.SC_ScrollBarSlider:
click: QPoint = event.position().toPoint()
new_position = self.pixelPosToRangeValue(click.y() - self.click_offset)
m = self.style().pixelMetric(QStyle.PixelMetric.PM_MaximumDragDistance, self.style_options(), self)
if m >= 0:
r: QRect = self.rect()
r.adjust(-m, -m, m, m)
if not r.contains(event.position().toPoint()):
new_position = self.snap_back_position
# print(f"move to value: {new_position}")
self.set_value(new_position)
# stop repeat action when pointer leaves control
pr: QRect = self.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar, self.style_options(),
self.pressedControl, self)
if not pr.contains(event.position().toPoint()):
self.stopRepeatAction()
def wheelEvent(self, event: QWheelEvent):
event.ignore()
# when using a touchpad we can have simultaneous horizontal and vertical movement
horizontal = abs(event.angleDelta().x()) > abs(event.angleDelta().y())
if horizontal:
return
scroll_event = self.ScrollEvent.LinesDown if event.angleDelta().y() < 0 else self.ScrollEvent.LinesUp
self.scroll_event.emit(scroll_event)
def pixelPosToRangeValue(self, pos: int):
opt: QStyleOptionSlider = self.style_options()
gr: QRect = self.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar, opt,
QStyle.SubControl.SC_ScrollBarGroove, self)
sr: QRect = self.style().subControlRect(QStyle.ComplexControl.CC_ScrollBar, opt,
QStyle.SubControl.SC_ScrollBarSlider, self)
# only for vertical scrollbars
slider_length = sr.height()
slider_min = gr.y()
slider_max = gr.bottom() - slider_length + 1
val = QStyle.sliderValueFromPosition(opt.minimum, opt.maximum, pos - slider_min,
slider_max - slider_min, opt.upsideDown)
t = self.scale / self.maximum
val = int(val / t)
# print(f"pixelPosToRangeValue({pos}) -> {val}")
return val
def stopRepeatAction(self):
self.repeat_action_control = QStyle.SubControl.SC_None
# print(f"stop repeat action: {self.repeat_action_control}")
self.repeat_action_timer.stop()
#self.update()
def set_value(self, value: int):
self.value = value
self.value_changed.emit(str(self.value))
self.update()

View File

@@ -11,6 +11,7 @@ from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import * from PySide6.QtWidgets import *
from src.ui.ScaledScrollBar import ScaledScrollBar from src.ui.ScaledScrollBar import ScaledScrollBar
from src.ui.bigtext.BigScrollBar import BigScrollBar
from src.ui.bigtext.highlight_regex import HighlightRegex from src.ui.bigtext.highlight_regex import HighlightRegex
from src.ui.bigtext.highlight_selection import HighlightSelection from src.ui.bigtext.highlight_selection import HighlightSelection
from src.ui.bigtext.highlighted_range import HighlightedRange from src.ui.bigtext.highlighted_range import HighlightedRange
@@ -77,6 +78,7 @@ class BigText(QWidget):
def __init__(self, model: LogFileModel, show_range_slider=True): def __init__(self, model: LogFileModel, show_range_slider=True):
super(BigText, self).__init__() super(BigText, self).__init__()
self.show_range_slider = show_range_slider
self.model = model self.model = model
self.grid = QGridLayout() self.grid = QGridLayout()
@@ -85,12 +87,9 @@ class BigText(QWidget):
self.grid.setVerticalSpacing(0) self.grid.setVerticalSpacing(0)
self.setLayout(self.grid) self.setLayout(self.grid)
self.v_scroll_bar = ScaledScrollBar() self.v_scroll_bar = BigScrollBar()
self.range_limit = RangeSlider() self.big_text = InnerBigText(self, model, self.v_scroll_bar)
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.big_text.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding))
self.h_scroll_bar = QScrollBar(Qt.Orientation.Horizontal) self.h_scroll_bar = QScrollBar(Qt.Orientation.Horizontal)
@@ -99,12 +98,12 @@ class BigText(QWidget):
self.h_scroll_bar.valueChanged.connect(self.big_text.h_scroll_event) self.h_scroll_bar.valueChanged.connect(self.big_text.h_scroll_event)
# self.v_scroll_bar.setPageStep(1) # self.v_scroll_bar.setPageStep(1)
self.v_scroll_bar.scaledValueChanged.connect(self.big_text.v_scroll_event) self.v_scroll_bar.value_changed.connect(self.big_text.v_scroll_value_changed)
self.v_scroll_bar.scrolled_to_end.connect(self.big_text.v_scroll_update_follow_tail) self.v_scroll_bar.scroll_event.connect(self.big_text.v_scroll_event)
if show_range_slider: if show_range_slider:
self.range_limit = RangeSlider()
self.range_limit.value_changed.connect(self._range_limit_event)
self.grid.addWidget(self.range_limit, 0, 0) self.grid.addWidget(self.range_limit, 0, 0)
self.grid.addWidget(self.big_text, 0, 1) self.grid.addWidget(self.big_text, 0, 1)
self.grid.addWidget(self.h_scroll_bar, 1, 1) self.grid.addWidget(self.h_scroll_bar, 1, 1)
@@ -121,6 +120,10 @@ class BigText(QWidget):
def get_file(self): def get_file(self):
return self.model.get_file() return self.model.get_file()
def update_hit_positions(self, hit_positions: set[float]):
if self.range_limit:
self.range_limit.update_hit_positions(hit_positions)
def add_line_click_listener(self, listener: Callable[[int], None]): def add_line_click_listener(self, listener: Callable[[int], None]):
""" """
:param listener: a callable, the parameter is the byte offset of the clicked line :param listener: a callable, the parameter is the byte offset of the clicked line
@@ -149,14 +152,12 @@ class InnerBigText(QWidget):
_range_start = 0 _range_start = 0
_range_end = -1 _range_end = -1
def __init__(self, parent: BigText, model: LogFileModel, v_scaled_scrollbar: ScaledScrollBar, def __init__(self, parent: BigText, model: LogFileModel, v_scaled_scrollbar: ScaledScrollBar):
range_limit: RangeSlider):
super(InnerBigText, self).__init__() super(InnerBigText, self).__init__()
self.char_height = None self.char_height = None
self.char_width = None self.char_width = None
self.model = model self.model = model
self._v_scaled_scrollbar = v_scaled_scrollbar self._v_scaled_scrollbar = v_scaled_scrollbar
self._range_limit = range_limit
self.parent = parent self.parent = parent
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocusPolicy(Qt.FocusPolicy.WheelFocus) self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
@@ -169,8 +170,6 @@ class InnerBigText(QWidget):
self._last_double_click_time = 0 self._last_double_click_time = 0
self._last_double_click_line_number = -1 self._last_double_click_line_number = -1
self._follow_tail = False
self.highlight_selected_text = HighlightRegex( self.highlight_selected_text = HighlightRegex(
"", "",
is_regex=False, is_regex=False,
@@ -219,7 +218,6 @@ class InnerBigText(QWidget):
elif e.modifiers() == Qt.KeyboardModifier.ControlModifier and e.key() == 65: # ctrl + a elif e.modifiers() == Qt.KeyboardModifier.ControlModifier and e.key() == 65: # ctrl + a
self._select_all() self._select_all()
def wheelEvent(self, event: QWheelEvent): def wheelEvent(self, event: QWheelEvent):
direction = 1 if event.angleDelta().y() < 0 else -1 direction = 1 if event.angleDelta().y() < 0 else -1
if event.modifiers() == Qt.KeyboardModifier.ControlModifier: if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
@@ -262,30 +260,31 @@ class InnerBigText(QWidget):
manage_highlighting.setShortcut("CTRL+H") manage_highlighting.setShortcut("CTRL+H")
menu.addAction(manage_highlighting) menu.addAction(manage_highlighting)
menu.addSeparator() if self.parent.show_range_slider:
menu.addSeparator()
set_range_start = QAction( set_range_start = QAction(
Icon("icons/myicons/range-start.svg"), Icon("icons/myicons/range-start.svg"),
_("Set Range Start"), _("Set Range Start"),
self, self,
triggered=lambda: self._set_range_start_by_y_pos(position.y()) triggered=lambda: self._set_range_start_by_y_pos(position.y())
) )
menu.addAction(set_range_start) menu.addAction(set_range_start)
set_range_end = QAction( set_range_end = QAction(
Icon("icons/myicons/range-end.svg"), Icon("icons/myicons/range-end.svg"),
_("Set Range End"), _("Set Range End"),
self, self,
triggered=lambda: self._set_range_end_by_y_pos(position.y()) triggered=lambda: self._set_range_end_by_y_pos(position.y())
) )
menu.addAction(set_range_end) menu.addAction(set_range_end)
reset_range = QAction( reset_range = QAction(
_("Reset Range"), _("Reset Range"),
self, self,
triggered=lambda: self._reset_range() triggered=lambda: self._reset_range()
) )
menu.addAction(reset_range) menu.addAction(reset_range)
menu.exec(self.mapToGlobal(position)) menu.exec(self.mapToGlobal(position))
@@ -311,12 +310,12 @@ class InnerBigText(QWidget):
def scroll_by_lines(self, scroll_lines: int): def scroll_by_lines(self, scroll_lines: int):
self.scroll_lines = scroll_lines self.scroll_lines = scroll_lines
self.update() self.update()
self.parent.v_scroll_bar.setValue(self._byte_offset) self.parent.v_scroll_bar.set_value(self._byte_offset)
def scroll_to_byte(self, byte_offset: int): def scroll_to_byte(self, byte_offset: int):
self._byte_offset = min(byte_offset, self.model.byte_count()) self._byte_offset = min(byte_offset, self.model.byte_count())
self.update() self.update()
self.parent.v_scroll_bar.setValue(self._byte_offset) self.parent.v_scroll_bar.set_value(self._byte_offset)
# noinspection PyTypeChecker # noinspection PyTypeChecker
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None: def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
@@ -401,13 +400,21 @@ class InnerBigText(QWidget):
self.update() self.update()
@Slot() @Slot()
def v_scroll_event(self, byte_offset: str): def v_scroll_value_changed(self, byte_offset: str):
self._byte_offset = int(byte_offset) self._byte_offset = int(byte_offset)
self.update() self.update()
@Slot() @Slot()
def v_scroll_update_follow_tail(self, scrolled_to_end: bool): def v_scroll_event(self, event: BigScrollBar.ScrollEvent):
self._follow_tail = scrolled_to_end match event:
case BigScrollBar.ScrollEvent.LinesUp:
self.scroll_by_lines(-3)
case BigScrollBar.ScrollEvent.LinesDown:
self.scroll_by_lines(3)
case BigScrollBar.ScrollEvent.PageUp:
self.scroll_by_lines(-(int(self.lines_shown()) - 1))
case BigScrollBar.ScrollEvent.PageDown:
self.scroll_by_lines(int(self.lines_shown()) - 1)
def update_longest_line(self, length: int): def update_longest_line(self, length: int):
width_in_chars = self.width() / self.char_width width_in_chars = self.width() / self.char_width
@@ -491,7 +498,7 @@ class InnerBigText(QWidget):
(selected_file, _filter) = dialog.getSaveFileName( (selected_file, _filter) = dialog.getSaveFileName(
parent=self, parent=self,
caption=_("Save File"), caption=_("Save File"),
dir=os.path.dirname(self.model.get_file()) dir=os.path.dirname(self.model.get_original_file())
) )
if selected_file: if selected_file:
self.model.write_range(start, end, selected_file) self.model.write_range(start, end, selected_file)
@@ -533,13 +540,10 @@ class InnerBigText(QWidget):
PluginRegistry.execute("update_status_bar", "") PluginRegistry.execute("update_status_bar", "")
def _file_changed(self): def _file_changed(self):
if self._follow_tail:
self.scroll_to_byte(self.model.byte_count())
self.update() self.update()
def paintEvent(self, event: QPaintEvent) -> None: def paintEvent(self, event: QPaintEvent) -> None:
start_ns = time.process_time_ns() 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) painter = QPainter(self)
# font = "Courier New" if sys.platform == 'win32' or sys.platform == 'cygwin' else "Monospace" # 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.setFont(QFont("Courier New", self.model.settings.getint_session('general', "font_size")))
@@ -559,7 +563,8 @@ class InnerBigText(QWidget):
byte_count = self.model.byte_count() 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) 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.v_scroll_bar.setMaximum(vmax)
self.parent.range_limit.set_maximum(byte_count) if self.parent.show_range_slider:
self.parent.range_limit.set_maximum(byte_count)
for line in self.lines: for line in self.lines:
self.update_longest_line(len(line.line())) self.update_longest_line(len(line.line()))

View File

@@ -1,3 +1,4 @@
import fnmatch
from typing import Optional from typing import Optional
from src.ui.bigtext.highlight import Highlight from src.ui.bigtext.highlight import Highlight
@@ -12,7 +13,7 @@ import re
class HighlightRegex(Highlight): class HighlightRegex(Highlight):
def __init__(self, query: str, ignore_case: bool, is_regex: bool, hit_background_color: str = "None", def __init__(self, query: str, ignore_case: bool, is_regex: bool, hit_background_color: str = "None",
line_background_color: str = "None", active: bool = True): line_background_color: str = "None", active: bool = True, activated_for_file_type: str = "*"):
self.active = active self.active = active
self.query = query self.query = query
self.ignore_case = ignore_case self.ignore_case = ignore_case
@@ -20,6 +21,7 @@ class HighlightRegex(Highlight):
self.regex = self._get_regex() self.regex = self._get_regex()
self.hit_background_color = hit_background_color self.hit_background_color = hit_background_color
self.line_background_color = line_background_color self.line_background_color = line_background_color
self.activated_for_file_type = activated_for_file_type
self._brush_hit = self.brush(self.hit_background_color) self._brush_hit = self.brush(self.hit_background_color)
self._brush_line = self.brush(self.line_background_color) self._brush_line = self.brush(self.line_background_color)
@@ -99,3 +101,15 @@ class HighlightRegex(Highlight):
alpha = int(color[6:8], 16) alpha = int(color[6:8], 16)
return QBrush(QColor(red, green, blue, alpha)) return QBrush(QColor(red, green, blue, alpha))
return QBrush() return QBrush()
def set_activated_for_file_type(self, activated_for_file_type: str):
self.activated_for_file_type = activated_for_file_type
def file_type_matches(self, file_name: str) -> bool:
if self.activated_for_file_type is None or len(self.activated_for_file_type) == 0:
return True
glob_patterns: [str] = self.activated_for_file_type.split(",") # support multiple globs like: "*.txt, *.csv"
for glob_pattern in glob_patterns:
if fnmatch.fnmatch(file_name, glob_pattern.strip()):
return True
return False

View File

@@ -25,6 +25,7 @@ class Highlighting:
is_regex = session.getboolean(section, "is-regex", fallback=False) is_regex = session.getboolean(section, "is-regex", fallback=False)
line_background_color = session.get(section, "line.background.color", fallback="None") line_background_color = session.get(section, "line.background.color", fallback="None")
hit_background_color = session.get(section, "hit.background.color", fallback="None") hit_background_color = session.get(section, "hit.background.color", fallback="None")
activated_for_file_type = session.get(section, "activated-for-file-type", fallback="*")
try: try:
highlight = HighlightRegex( highlight = HighlightRegex(
@@ -33,7 +34,8 @@ class Highlighting:
is_regex=is_regex, is_regex=is_regex,
hit_background_color=hit_background_color, hit_background_color=hit_background_color,
line_background_color=line_background_color, line_background_color=line_background_color,
active=active active=active,
activated_for_file_type=activated_for_file_type
) )
result.append(highlight) result.append(highlight)
except: except:
@@ -57,6 +59,7 @@ class Highlighting:
settings.session.set(section, "is-regex", str(highlighter.is_regex)) settings.session.set(section, "is-regex", str(highlighter.is_regex))
settings.session.set(section, "line.background.color", highlighter.line_background_color) settings.session.set(section, "line.background.color", highlighter.line_background_color)
settings.session.set(section, "hit.background.color", highlighter.hit_background_color) settings.session.set(section, "hit.background.color", highlighter.hit_background_color)
settings.session.set(section, "activated-for-file-type", highlighter.activated_for_file_type)
@staticmethod @staticmethod
def remove_highlighting_sections(settings: Settings): def remove_highlighting_sections(settings: Settings):

View File

@@ -116,7 +116,7 @@ class Line:
# todo there are many other character combinations that should be skipped # todo there are many other character combinations that should be skipped
while i < len(self._line) and unicodedata.category(self._line[i]) == "Mn": while i < len(self._line) and unicodedata.category(self._line[i]) == "Mn":
self._char_to_column_cache[i] = result - 1 self._char_to_column_cache[i] = result - 1
if not result in self._column_to_char_cache: if (result - 1) not in self._column_to_char_cache:
self._column_to_char_cache[result - 1] = i self._column_to_char_cache[result - 1] = i
i = i + 1 i = i + 1

View File

@@ -7,6 +7,7 @@ from src.ui.bigtext.highlighting import Highlighting
from src.ui.bigtext.line import Line from src.ui.bigtext.line import Line
import os import os
from src.settings.settings import Settings from src.settings.settings import Settings
from functools import lru_cache
class LogFileModel: class LogFileModel:
@@ -21,21 +22,32 @@ class LogFileModel:
range_start = 0 range_start = 0
range_end = -1 range_end = -1
def __init__(self, file: str, settings: Settings): def __init__(self, file: str, settings: Settings, original_file: str = False):
"""
:param file:
:param settings:
:param original_file: used in the filter widget to denote the original file, the one being filtered, because 'file' points to the tmp file
"""
self.settings = settings self.settings = settings
self._file = os.path.realpath(file) self._file = os.path.realpath(file)
self._original_file = os.path.realpath(original_file) if original_file else self._file
self._file_name = os.path.basename(self._original_file)
self._line_cache = {}
def highlighters(self): def highlighters(self):
all_highlighters = Highlighting.read_config(self.settings) all_highlighters = Highlighting.read_config(self.settings)
active_highlighters = [] active_highlighters = []
for h in all_highlighters: for h in all_highlighters:
if h.is_active(): if h.is_active() and h.file_type_matches(self._file_name):
active_highlighters.append(h) active_highlighters.append(h)
return active_highlighters return active_highlighters
def get_file(self): def get_file(self):
return self._file return self._file
def get_original_file(self):
return self._original_file
def __str__(self): def __str__(self):
return self._file return self._file
@@ -123,6 +135,14 @@ class LogFileModel:
def _is_word_char(self, char: str) -> bool: def _is_word_char(self, char: str) -> bool:
return re.match(r"\w", char) is not None return re.match(r"\w", char) is not None
def prune_cache(self, range_start: int, range_end: int):
print(f"cache size: {len(self._line_cache.keys())}")
for key in list(self._line_cache.keys()):
line = self._line_cache[key]
if range_start > line.byte_end() or line.byte_offset() > range_end:
del self._line_cache[key]
def data(self, byte_offset: int, scroll_lines: int, lines: int, range_start: int, range_end: int) -> List[Line]: def data(self, byte_offset: int, scroll_lines: int, lines: int, range_start: int, range_end: int) -> List[Line]:
# print("data(%s, %s, %s)" % (byte_offset, scroll_lines, lines)) # print("data(%s, %s, %s)" % (byte_offset, scroll_lines, lines))
lines_before_offset: List[Line] = [] lines_before_offset: List[Line] = []
@@ -138,19 +158,36 @@ class LogFileModel:
offset = max(0, offset = max(0,
max(range_start - self.settings.max_line_length(), offset - self.settings.max_line_length())) max(range_start - self.settings.max_line_length(), offset - self.settings.max_line_length()))
self.prune_cache(range_start, range_end)
previous_line_is_complete = False
f.seek(offset) f.seek(offset)
while l := f.readline(): while True:
new_offset = f.tell() line: Line | None = self._line_cache.get(offset)
if 0 <= range_end < new_offset: if line is None:
break line_bytes = f.readline()
line = Line(offset, new_offset, l.decode("utf8", errors="ignore")) if not line_bytes:
break
new_offset = f.tell()
if 0 <= range_end < new_offset:
break
line = Line(offset, new_offset, line_bytes.decode("utf8", errors="ignore"))
if previous_line_is_complete: # only cache lines when we know they are complete
self._line_cache[offset] = line
offset = new_offset
previous_line_is_complete = True
else:
# print(f"loaded cached line at offset {offset}")
offset = line.byte_end() # line.byte_end() returns the end byte +1
f.seek(offset)
previous_line_is_complete = True
if line.byte_end() <= byte_offset: # line.byte_end() returns the end byte +1 if line.byte_end() <= byte_offset: # line.byte_end() returns the end byte +1
if line.byte_offset() >= range_start: # only add if in range if line.byte_offset() >= range_start: # only add if in range
lines_before_offset.append(line) lines_before_offset.append(line)
else: else:
lines_after_offset.append(line) lines_after_offset.append(line)
offset = f.tell()
if len(lines_after_offset) >= lines_to_find: if len(lines_after_offset) >= lines_to_find:
break break

View File

@@ -118,7 +118,7 @@ class NewHighlightingDialog(QDialog):
def _new_highlighter(self): def _new_highlighter(self):
highlight_regex = HighlightRegex("", ignore_case=True, is_regex=True, hit_background_color="ccb400", highlight_regex = HighlightRegex("", ignore_case=True, is_regex=True, hit_background_color="ccb400",
line_background_color="fff080") line_background_color="None")
self._add_highlight_regex_to_list(highlight_regex, select=True) self._add_highlight_regex_to_list(highlight_regex, select=True)
@@ -139,7 +139,7 @@ class HighlightListItemWidget(QWidget):
self.active = QCheckBox("") self.active = QCheckBox("")
self.active.setChecked(highlight_regex.is_active()) self.active.setChecked(highlight_regex.is_active())
self.active.stateChanged.connect(self._change_active_state) self.active.stateChanged.connect(self._change_active_state)
self.layout.addWidget(self.active, row, 0, 3, 1, alignment=QtCore.Qt.AlignmentFlag.AlignVCenter) self.layout.addWidget(self.active, row, 0, 4, 1, alignment=QtCore.Qt.AlignmentFlag.AlignVCenter)
query = QLineEdit(self) query = QLineEdit(self)
query.setText(highlight_regex.query) query.setText(highlight_regex.query)
@@ -181,6 +181,18 @@ class HighlightListItemWidget(QWidget):
is_regex.setEnabled(highlight_regex.is_active()) is_regex.setEnabled(highlight_regex.is_active())
self.layout.addWidget(is_regex, row, 3) self.layout.addWidget(is_regex, row, 3)
row = row + 1
activated_for_file_type_label = QLabel(_("File Type:"), self)
activated_for_file_type_label.setEnabled(highlight_regex.is_active())
self.layout.addWidget(activated_for_file_type_label, row, 1)
activated_for_file_type = QLineEdit(self)
activated_for_file_type.setEnabled(highlight_regex.is_active())
activated_for_file_type.setText(highlight_regex.activated_for_file_type)
activated_for_file_type.textChanged[str].connect(
lambda: highlight_regex.set_activated_for_file_type(activated_for_file_type.text()))
self.layout.addWidget(activated_for_file_type, row, 2)
def _change_active_state(self): def _change_active_state(self):
active = self.active.isChecked() active = self.active.isChecked()
self.highlight_regex.set_active(active) self.highlight_regex.set_active(active)

View File

@@ -31,9 +31,9 @@ class MyTestCase(unittest.TestCase):
self.assertEqual(9, line.column_to_char(15)) # tab self.assertEqual(9, line.column_to_char(15)) # tab
self.assertEqual(10, line.column_to_char(16)) # g self.assertEqual(10, line.column_to_char(16)) # g
def test_column_to_char_ignore_nonspacing_mark_charaters(self): def test_column_to_char_ignore_nonspacing_mark_characters(self):
""" """
nonspacing mark charaters are those little decorations that are applied to the previous character, nonspacing mark characters are those little decorations that are applied to the previous character,
e.g. x\u0308 to make ẍ e.g. x\u0308 to make ẍ
:return: :return:
""" """
@@ -63,7 +63,7 @@ class MyTestCase(unittest.TestCase):
def test_char_to_column_ignore_nonspacing_mark_charaters(self): def test_char_to_column_ignore_nonspacing_mark_charaters(self):
""" """
nonspacing mark charaters are those little decorations that are applied to the previous character, nonspacing mark characters are those little decorations that are applied to the previous character,
e.g. x\u0308 to make ẍ e.g. x\u0308 to make ẍ
:return: :return:
""" """
@@ -119,7 +119,7 @@ class MyTestCase(unittest.TestCase):
for i in range(128): for i in range(128):
if unicodedata.category(chr(i)) == "Cc": if unicodedata.category(chr(i)) == "Cc":
# print(i, " -> ", ord(chr(i)), " --> ", chr(9216 + i)) # print(i, " -> ", ord(chr(i)), " --> ", chr(9216 + i))
if not i in [9, 10, 11, 13]: if i not in [9, 10, 11, 13]:
text = text + chr(i) text = text + chr(i)
line = Line(byte_offset=byte_offset, byte_end=byte_offset + len(text.encode("utf8")), line=text) line = Line(byte_offset=byte_offset, byte_end=byte_offset + len(text.encode("utf8")), line=text)

View File

@@ -4,7 +4,7 @@ from enum import Enum
import PySide6 import PySide6
from PySide6 import QtGui from PySide6 import QtGui
from PySide6.QtCore import QRect, QPoint, Signal from PySide6.QtCore import QRect, QPoint, Signal
from PySide6.QtGui import QPainter, Qt from PySide6.QtGui import QPainter, Qt, QColor, QPen
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget
from src.pluginregistry import PluginRegistry from src.pluginregistry import PluginRegistry
@@ -44,8 +44,6 @@ class RangeSlider(QWidget):
super(RangeSlider, self).__init__() super(RangeSlider, self).__init__()
self.setFixedWidth(self._width) self.setFixedWidth(self._width)
self.draw_ticks = False
self.min_value = 0 self.min_value = 0
self.max_value = 100 self.max_value = 100
@@ -54,6 +52,8 @@ class RangeSlider(QWidget):
self.selected_handle = None self.selected_handle = None
self.selection_drag_range = (self.min_value, self.max_value) self.selection_drag_range = (self.min_value, self.max_value)
self.drag_y_offset_in_handle = 0
self._hit_positions: set[float] = set(())
def set_maximum(self, max: int): def set_maximum(self, max: int):
if self.max_value == max: if self.max_value == max:
@@ -66,14 +66,16 @@ class RangeSlider(QWidget):
self.upper_value.value = max self.upper_value.value = max
self._emit_value_changed() self._emit_value_changed()
def update_hit_positions(self, hit_positions: set[float]):
# print(f"updated hit positions in range slider:{len(hit_positions)} -> {hit_positions}")
self._hit_positions = hit_positions
def paintEvent(self, event: PySide6.QtGui.QPaintEvent) -> None: def paintEvent(self, event: PySide6.QtGui.QPaintEvent) -> None:
painter = QPainter(self) painter = QPainter(self)
self._draw_background(painter) self._draw_background(painter)
if self.draw_ticks: self._draw_hits(painter)
self._draw_ticks(painter)
self._draw_handle(painter, self.lower_value) self._draw_handle(painter, self.lower_value)
self._draw_handle(painter, self.upper_value, direction=-1) self._draw_handle(painter, self.upper_value, direction=-1)
painter.end() painter.end()
def _draw_background(self, painter: QPainter) -> None: def _draw_background(self, painter: QPainter) -> None:
@@ -90,19 +92,6 @@ class RangeSlider(QWidget):
self._value_to_pixel(self.upper_value.value - self.lower_value.value) - 2 * self._handle_width) self._value_to_pixel(self.upper_value.value - self.lower_value.value) - 2 * self._handle_width)
painter.drawRoundedRect(rect, 3.0, 3.0) painter.drawRoundedRect(rect, 3.0, 3.0)
def _draw_ticks(self, painter: QPainter) -> None:
painter.setPen(to_qcolor("333333"))
min_tick_distance = 25
full_height = self.height() - 2 * self._handle_width
ticks = math.floor(full_height / min_tick_distance)
actual_tick_distance = full_height / ticks
print(f"ticks {ticks}")
y = actual_tick_distance + self._handle_width
while y < full_height:
painter.drawLine(8, y, 12, y)
y = y + actual_tick_distance
def _draw_handle(self, painter: QPainter, handle: RangeSliderHandle, direction=1) -> None: def _draw_handle(self, painter: QPainter, handle: RangeSliderHandle, direction=1) -> None:
y_pixel = self._value_to_pixel(handle.value) y_pixel = self._value_to_pixel(handle.value)
@@ -127,6 +116,25 @@ class RangeSlider(QWidget):
pixel = (self.height() - 2 * self._handle_width) * value_percent + self._handle_width pixel = (self.height() - 2 * self._handle_width) * value_percent + self._handle_width
return pixel return pixel
def _draw_hits(self, painter: QPainter) -> None:
color = to_qcolor("000000")
color.setAlpha(192)
pen = QPen(color)
pen.setWidth(1)
painter.setPen(pen)
# compute where to draw a line and then deduplicate then, so that we don't draw lines multiple times
# this is for performance and because we use transparency and drawing a line multiple times would make it
# darker
paint_at_y_positions: set[int] = set(())
for hit_position in self._hit_positions:
y = (self.height() - 2 * self._handle_width) * hit_position + self._handle_width
y = round(y)
paint_at_y_positions.add(y)
for y in paint_at_y_positions:
painter.drawLine(2, y, 18, y)
def _pixel_to_value(self, pixel: int) -> int: def _pixel_to_value(self, pixel: int) -> int:
pixel_percent = (pixel - self._handle_width) / (self.height() - 2 * self._handle_width) pixel_percent = (pixel - self._handle_width) / (self.height() - 2 * self._handle_width)
return int(math.floor(self.max_value * pixel_percent)) return int(math.floor(self.max_value * pixel_percent))
@@ -144,9 +152,11 @@ class RangeSlider(QWidget):
if self._is_on_handle(self.lower_value, pos.y(), direction=1): if self._is_on_handle(self.lower_value, pos.y(), direction=1):
self.selected_handle = self.lower_value self.selected_handle = self.lower_value
self.selection_drag_range = (self.min_value, self.upper_value.value) self.selection_drag_range = (self.min_value, self.upper_value.value)
self.drag_y_offset_in_handle = self.selected_handle.value - self._pixel_to_value(pos.y())
if self._is_on_handle(self.upper_value, pos.y(), direction=-1): if self._is_on_handle(self.upper_value, pos.y(), direction=-1):
self.selected_handle = self.upper_value self.selected_handle = self.upper_value
self.selection_drag_range = (self.lower_value.value, self.max_value) self.selection_drag_range = (self.lower_value.value, self.max_value)
self.drag_y_offset_in_handle = self.selected_handle.value - self._pixel_to_value(pos.y())
def mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent) -> None: def mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent) -> None:
self.selected_handle = None self.selected_handle = None
@@ -154,7 +164,7 @@ class RangeSlider(QWidget):
def mouseMoveEvent(self, e: PySide6.QtGui.QMouseEvent) -> None: def mouseMoveEvent(self, e: PySide6.QtGui.QMouseEvent) -> None:
if self.selected_handle != None: if self.selected_handle != None:
pos: QPoint = e.pos() pos: QPoint = e.pos()
value = self._pixel_to_value(pos.y()) value = self._pixel_to_value(pos.y()) + self.drag_y_offset_in_handle
if self.selection_drag_range[0] <= value <= self.selection_drag_range[1]: if self.selection_drag_range[0] <= value <= self.selection_drag_range[1]:
self.selected_handle.value = value self.selected_handle.value = value
# print("%s, %s" %(self.lower_value.value, self.upper_value.value)) # print("%s, %s" %(self.lower_value.value, self.upper_value.value))

View File

@@ -7,12 +7,22 @@ def is_hex_color(color: str):
return re.match("[0-9a-f]{6}", color, flags=re.IGNORECASE) return re.match("[0-9a-f]{6}", color, flags=re.IGNORECASE)
def is_hex_color_with_alpha(color: str):
return re.match("[0-9a-f]{8}", color, flags=re.IGNORECASE)
def to_qcolor(color: str): def to_qcolor(color: str):
if is_hex_color(color): if is_hex_color(color):
red = int(color[0:2], 16) red = int(color[0:2], 16)
green = int(color[2:4], 16) green = int(color[2:4], 16)
blue = int(color[4:6], 16) blue = int(color[4:6], 16)
return QColor(red, green, blue) return QColor(red, green, blue)
if is_hex_color_with_alpha(color):
red = int(color[0:2], 16)
green = int(color[2:4], 16)
blue = int(color[4:6], 16)
alpha = int(color[6:8], 16)
return QColor(red, green, blue, alpha)
elif color in QColor().colorNames(): elif color in QColor().colorNames():
return QColor(color) return QColor(color)
return QColor(255, 255, 255) return QColor(255, 255, 255)