Compare commits
26 Commits
0.2.1
...
9c64acf77e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c64acf77e | |||
| d561facb7e | |||
| 329775fb26 | |||
| be53c209ea | |||
| aa2bfa967e | |||
| 2b91b19ef3 | |||
| 9c1b8298be | |||
| 811e3c7f82 | |||
| 6d7accffde | |||
| 2cd6c2ec1b | |||
| 3e793596c2 | |||
| 7a574f7ed4 | |||
| 7d20bae74d | |||
| 9b9399f120 | |||
| 3d6cf84cd7 | |||
| 2b65e61e43 | |||
| 6538e85f37 | |||
| 76f7baecf3 | |||
| 7f4f6ab004 | |||
| 270b3a8683 | |||
| b8b4b4e790 | |||
| 66d6a728cc | |||
| 56189f4094 | |||
| 5f30862a83 | |||
| 017a51a24a | |||
| 442d3173c8 |
@@ -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
|
||||||
|
|||||||
0
src/new_big_text/__init__.py
Normal file
0
src/new_big_text/__init__.py
Normal file
273
src/new_big_text/bigger_text.py
Normal file
273
src/new_big_text/bigger_text.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
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
|
||||||
|
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QStatusBar
|
||||||
|
from PySide6.QtCore import QTimer, QPoint, Qt
|
||||||
|
import sys
|
||||||
|
from src.pluginregistry import PluginRegistry
|
||||||
|
import gettext
|
||||||
|
|
||||||
|
__version__ = '0.2.1'
|
||||||
|
|
||||||
|
from src.i18n import _
|
||||||
|
|
||||||
|
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(600)
|
||||||
|
self.setMinimumHeight(480)
|
||||||
|
bigger_text = BiggerText()
|
||||||
|
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, 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._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 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_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_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
|
||||||
|
|
||||||
|
|
||||||
|
class BiggerText(QWidget):
|
||||||
|
|
||||||
|
def __init__(self, ):
|
||||||
|
super(BiggerText, self).__init__()
|
||||||
|
# font ="Andale Mono"
|
||||||
|
# font = "JetBrains Mono"
|
||||||
|
# font = "Monospace" # not found
|
||||||
|
# font = "ZedMono" # is not found
|
||||||
|
# font = "Noto Sans Mono"
|
||||||
|
font = "Noto Color Emoji"
|
||||||
|
font_size = 20
|
||||||
|
|
||||||
|
qfont = QFont(font, 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:
|
||||||
|
offset = self.to_byte_offset(e.pos())
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def to_byte_offset(self, pos: QPoint):
|
||||||
|
line = self.y_pos_to_line(pos.y())
|
||||||
|
|
||||||
|
def y_pos_to_line(self, y: int) -> int:
|
||||||
|
return int(y / self.char_height)
|
||||||
|
|
||||||
|
def paintEvent(self, event: QPaintEvent) -> None:
|
||||||
|
start_ns = time.process_time_ns()
|
||||||
|
painter = QPainter(self)
|
||||||
|
font = painter.font()
|
||||||
|
font.setPointSize(20)
|
||||||
|
painter.setFont(font)
|
||||||
|
|
||||||
|
font_metric: QFontMetrics = painter.fontMetrics()
|
||||||
|
self.char_height = font_metric.height()
|
||||||
|
|
||||||
|
lines_to_render = [
|
||||||
|
"im0 ひらがな 王 フーバー 🔴🟢 7²",
|
||||||
|
"iiiiiiiiii",
|
||||||
|
"12345678",
|
||||||
|
"12345678",
|
||||||
|
"nonspacing marks:",
|
||||||
|
"next line consists of a%CC%88",
|
||||||
|
"äääääääääääääääääääääääääääääää|",
|
||||||
|
"アンドレアス",
|
||||||
|
"アンドレアス"
|
||||||
|
]
|
||||||
|
|
||||||
|
painter.setPen(QColor(0, 0, 0))
|
||||||
|
line_on_screen = 1
|
||||||
|
for line in lines_to_render:
|
||||||
|
painter.drawText(QPoint(0, line_on_screen * char_height), line)
|
||||||
|
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()
|
||||||
53
src/new_big_text/test_file_model.py
Normal file
53
src/new_big_text/test_file_model.py
Normal 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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
233
src/ui/bigtext/BigScrollBar.py
Normal file
233
src/ui/bigtext/BigScrollBar.py
Normal 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()
|
||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user