diff --git a/icons/ionicons/stopwatch-outline.svg b/icons/ionicons/stopwatch-outline.svg new file mode 100644 index 0000000..16196a7 --- /dev/null +++ b/icons/ionicons/stopwatch-outline.svg @@ -0,0 +1,12 @@ + + ionicons-v5-g + + + + + + \ No newline at end of file diff --git a/icons/ionicons/stopwatch.svg b/icons/ionicons/stopwatch.svg new file mode 100644 index 0000000..1a62773 --- /dev/null +++ b/icons/ionicons/stopwatch.svg @@ -0,0 +1,5 @@ + + ionicons-v5-g + + + \ No newline at end of file diff --git a/main.py b/main.py index 3836579..98686cb 100644 --- a/main.py +++ b/main.py @@ -54,6 +54,7 @@ if __name__ == "__main__": PluginRegistry.load_plugin("OpenFilePlugin") PluginRegistry.load_plugin("LogFilePlugin") PluginRegistry.load_plugin("NotesPlugin") + PluginRegistry.load_plugin("TimeDiffPlugin") window = PluginRegistry.execute_single("create_main_window") window.show() diff --git a/src/mainwindow.py b/src/mainwindow.py index 19451c6..d87553e 100644 --- a/src/mainwindow.py +++ b/src/mainwindow.py @@ -24,11 +24,6 @@ MAX_LINE_LENGTH = 4096 logging.basicConfig(level=logging.INFO) log = logging.getLogger("main") - -def flat_map(array: List[List]) -> List: - return reduce(list.__add__, array) - - class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) @@ -55,7 +50,7 @@ class MainWindow(QMainWindow): def create_dynamic_menu_bar(self) -> QMenuBar: menu_bar = QMenuBar() - menu_contributions: [MenuContribution] = flat_map(PluginRegistry.execute("get_menu_contributions")) + menu_contributions: [MenuContribution] = PluginRegistry.execute_flat_map("get_menu_contributions") menu_contributions.append(MenuContribution("settings", action=self._action_highlighter())) menu_contributions.append(MenuContribution("settings", action=self._action_highlight_search_terms())) menu_contributions.append(MenuContribution("settings", action=self._action_new_tab())) diff --git a/src/plugins/domain/menucontribution.py b/src/plugins/domain/menucontribution.py index 6164c5c..5496d12 100644 --- a/src/plugins/domain/menucontribution.py +++ b/src/plugins/domain/menucontribution.py @@ -33,7 +33,7 @@ def sort_menu_contributions(menu_contributions: [MenuContribution]) -> [MenuCont result = [] items = _sort_by_action_id(menu_contributions[:]) - _recursive_half_order_adder(result, items, None) + _recursive_half_order_adder(result, items) # add remaining items to the end (ordered by their action_id) # This resolves cycles. @@ -42,10 +42,10 @@ def sort_menu_contributions(menu_contributions: [MenuContribution]) -> [MenuCont return result -def _recursive_half_order_adder(result: [MenuContribution], items: [MenuContribution], parent): +def _recursive_half_order_adder(result: [MenuContribution], items: [MenuContribution]): for item in items: mc: MenuContribution = item if not mc.after: result.append(mc) items.remove(mc) - _recursive_half_order_adder(result, items, mc.action_id) + _recursive_half_order_adder(result, items) diff --git a/src/plugins/domain/raction.py b/src/plugins/domain/raction.py index ee403b0..1d3fbdc 100644 --- a/src/plugins/domain/raction.py +++ b/src/plugins/domain/raction.py @@ -1,7 +1,7 @@ from typing import Callable from PySide6.QtGui import QAction, QIcon -from PySide6.QtWidgets import QMenu +from PySide6.QtWidgets import QMenu, QPushButton, QWidget class RAction(): @@ -86,3 +86,20 @@ class RAction(): self._update_check_state() return action + + def to_qpushbutton(self, parent: QWidget) -> QPushButton: + button = QPushButton(parent) + if self.label: + button.setText(self.label) + if self.icon_from_theme: + button.setIcon(QIcon.fromTheme(self.icon_from_theme)) + if self.icon_file: + button.setIcon(QIcon(self.icon_file)) + if self.shortcut: + button.setShortcut(self.shortcut) + if self.action: + button.pressed.connect(self.action) + if self.checkable: + button.setChecked(self.checked) + button.setCheckable(self.checkable) + return button diff --git a/src/plugins/logfile/filterwidget.py b/src/plugins/logfile/filterwidget.py index 00a1481..dec0f84 100644 --- a/src/plugins/logfile/filterwidget.py +++ b/src/plugins/logfile/filterwidget.py @@ -8,13 +8,17 @@ from typing import Optional, Callable from PySide6.QtCore import QRunnable, QThreadPool, Signal from PySide6.QtGui import QIcon from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QComboBox, \ - QSizePolicy, QProgressBar + QSizePolicy, QProgressBar, QMenu, QMenuBar +from src.plugins.domain.raction import RAction +from src.plugins.logfile.preprocesslineshook import PreProcessLinesHook from src.ui.bigtext.bigtext import BigText from src.ui.bigtext.logFileModel import LogFileModel from src.i18n import _ from src.pluginregistry import PluginRegistry +from src.ui.hbox import HBox +from src.zonedpluginregistry import ZonedPluginRegistry class FilterTask(QRunnable): @@ -27,6 +31,7 @@ class FilterTask(QRunnable): regex: re.Pattern, lock: threading.RLock, filter_match_found_listeners: Callable[[int], None], + pre_process_lines_hooks: [PreProcessLinesHook], progress_handler: Callable[[float], None], on_before: Callable[[], None], on_finish: Callable[[], None] @@ -36,6 +41,7 @@ class FilterTask(QRunnable): self.filter_model = filter_model self.regex = regex self.progress_handler = progress_handler + self.pre_process_lines_hooks = pre_process_lines_hooks self.on_before = on_before self.on_finish = on_finish self.lock = lock @@ -71,7 +77,12 @@ class FilterTask(QRunnable): target_line_offset = target.tell() for listener in self.filter_match_found_listeners: listener(target_line_offset, source_line_offset) - target.write(l) + + for hook in self.pre_process_lines_hooks: + h: PreProcessLinesHook = hook + line = h.pre_process_line(line, target) + + target.write(line.encode("utf8")) # sometime buffering can hide results for a while # We force a flush periodically. @@ -100,9 +111,10 @@ class FilterWidget(QWidget): search_is_running = Signal(bool) signal_update_progress = Signal(float) - def __init__(self, source_model: LogFileModel): + def __init__(self, source_model: LogFileModel, zoned_plugin_registry: ZonedPluginRegistry): super(FilterWidget, self).__init__() self.source_model = source_model + self._zoned_plugin_registry = zoned_plugin_registry self._lock = threading.RLock() @@ -131,6 +143,8 @@ class FilterWidget(QWidget): self.btn_bookmark.setToolTip(_("save query")) self.btn_bookmark.pressed.connect(self._save_query) + self.menu = self._create_stuff_menu() + self.ignore_case = QCheckBox(_("ignore case")) self.ignore_case.setChecked(True) self.ignore_case.stateChanged.connect(self.filter_changed) @@ -146,6 +160,7 @@ class FilterWidget(QWidget): filter_bar.layout.addWidget(self.progress_bar) filter_bar.layout.addWidget(self.btn_cancel_search) filter_bar.layout.addWidget(self.btn_bookmark) + filter_bar.layout.addWidget(self.menu) filter_bar.layout.addWidget(self.ignore_case) filter_bar.layout.addWidget(self.is_regex) @@ -162,6 +177,16 @@ class FilterWidget(QWidget): def on_reveal(self): self._reload_save_queries() + def _create_stuff_menu(self): + menu = HBox() + actions = self._zoned_plugin_registry.execute_flat_map("get_filter_widget_actions") + + for action in actions: + raction: RAction = action; + button = raction.to_qpushbutton(menu) + menu.addWidget(button) + return menu + def _reload_save_queries(self): saved_queries = PluginRegistry.execute_single("saved_queries") for saved_query in saved_queries: @@ -238,12 +263,15 @@ class FilterWidget(QWidget): self.source_model.set_query_highlight(query, ignore_case, is_regex) self.filter_model.set_query_highlight(query, ignore_case, is_regex) + pre_process_lines_hooks = self._zoned_plugin_registry.execute_remove_falsy("get_pre_process_lines_hook") + self.filter_task = FilterTask( self.source_model, self.filter_model, regex, self._lock, self.filter_match_found_listeners, + pre_process_lines_hooks, self.progress_handler, lambda: self.search_is_running.emit(True), lambda: self.search_is_running.emit(False) diff --git a/src/plugins/logfile/fulltabwidget.py b/src/plugins/logfile/fulltabwidget.py index 08fff5d..a855a16 100644 --- a/src/plugins/logfile/fulltabwidget.py +++ b/src/plugins/logfile/fulltabwidget.py @@ -7,15 +7,17 @@ from src.plugins.logfile.filterwidget import FilterWidget from src.ui.bigtext.logFileModel import LogFileModel from src.plugins.krowlog.Tab import Tab from src.util.conversion import humanbytes +from src.zonedpluginregistry import ZonedPluginRegistry class FullTabWidget(Tab): - def __init__(self, model: LogFileModel, unique_id: str, title: str): + def __init__(self, model: LogFileModel, unique_id: str, title: str, zoned_plugin_registry: ZonedPluginRegistry): super(FullTabWidget, self).__init__(unique_id, title) self._model = model + self._zoned_plugin_registry = zoned_plugin_registry self.file_view = BigText(model) - self.filter_hit_view = FilterWidget(self._model) + self.filter_hit_view = FilterWidget(self._model, self._zoned_plugin_registry) 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_filter_match_found_listener(self.filter_view_syncer.match_found) diff --git a/src/plugins/logfile/preprocesslineshook.py b/src/plugins/logfile/preprocesslineshook.py new file mode 100644 index 0000000..ce2c8da --- /dev/null +++ b/src/plugins/logfile/preprocesslineshook.py @@ -0,0 +1,11 @@ +from abc import abstractmethod +from typing import List, BinaryIO + +from src.ui.bigtext.line import Line + + +class PreProcessLinesHook: + + @abstractmethod + def pre_process_line(self, line: str, file_io: BinaryIO) -> str: + return line diff --git a/src/plugins/logfileplugin.py b/src/plugins/logfileplugin.py index 1694d95..192b54b 100644 --- a/src/plugins/logfileplugin.py +++ b/src/plugins/logfileplugin.py @@ -3,6 +3,7 @@ from typing import Optional from PySide6.QtWidgets import QMessageBox +from src.pluginregistry import PluginRegistry from src.plugins.logfile.fulltabwidget import FullTabWidget from src.ui.bigtext.logFileModel import LogFileModel from src.pluginbase import PluginBase @@ -31,7 +32,8 @@ class LogFilePlugin(PluginBase): filename = os.path.basename(realpath) model = LogFileModel(file, self.settings) - tab = FullTabWidget(model, unique_id=realpath, title=filename) + tab = FullTabWidget(model, unique_id=realpath, title=filename, + zoned_plugin_registry=PluginRegistry.create_zoned_plugin_registry()) return tab diff --git a/src/plugins/timediff/__init__.py b/src/plugins/timediff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/plugins/timediff/time_diff_pre_process_lines_hook.py b/src/plugins/timediff/time_diff_pre_process_lines_hook.py new file mode 100644 index 0000000..ffd7fec --- /dev/null +++ b/src/plugins/timediff/time_diff_pre_process_lines_hook.py @@ -0,0 +1,75 @@ +from typing import List, Optional, BinaryIO + +from src.plugins.logfile.preprocesslineshook import PreProcessLinesHook +from src.ui.bigtext.line import Line +from re import compile +from datetime import datetime, timedelta + + +class TimeDiffPreProcessLinesHook(PreProcessLinesHook): + def __init__(self): + super(TimeDiffPreProcessLinesHook, self).__init__() + self.date_pattern = compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}([,.]\d{3})") + self.prev_time: Optional[datetime] = None + + def pre_process_line(self, line: str, file_io: BinaryIO) -> str: + time = self._parse_time(line) + if time: + if self.prev_time: + time_diff = time - self.prev_time + if time_diff.total_seconds() > 0: + line_ending = self.parse_line_ending(line) + time_diff_str = self.time_diff_to_str(time_diff) + + time_diff_line = f"⏱ {time_diff_str}{line_ending}" + file_io.write(time_diff_line.encode("utf8")) + self.prev_time = time + return line + + def time_diff_to_str(self, time_diff: timedelta) -> str: + total_seconds = time_diff.total_seconds() + hours = int(total_seconds / 3600) + minutes = int((total_seconds % 3600) / 60) + seconds = int(total_seconds % 60) + milliseconds = int((total_seconds * 1000) % 1000) + if hours > 0: + result = f"{hours:3}:{minutes:02}:{seconds:02}.{milliseconds:03}" + elif minutes > 0: + result = f" {minutes:2}:{seconds:02}.{milliseconds:03}" + elif seconds: + result = f" {seconds:2}.{milliseconds:03}" + else: + result = f" 0.{milliseconds:03}" + + return result + + def parse_line_ending(self, line): + if line[-2:] == "\r\n": + return "\r\n" + return line[-1] + + def _parse_time(self, line: str) -> Optional[datetime]: + + m = self.date_pattern.match(line) + if m: + date_string = m.group(0).replace(" ", "T").replace(",", ".") + time = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%f") + return time + return None + + +if __name__ == "__main__": + t = TimeDiffPreProcessLinesHook() + texts = ["2022-02-22T12:00:00,123 foo bar", "2022-02-22T12:00:00.124 baz", + "should not match 2022-02-22T12:00:00,130 other", + "2022-02-22 12:00:00,130 yet another"] + + lines = [] + byte_offset = 0 + for text in texts: + lines.append(Line(byte_offset=byte_offset, byte_end=byte_offset + len(text.encode("utf8")), line=text)) + byte_offset = byte_offset + len(text.encode("utf8")) + + ls = t.pre_process_lines(lines) + for l in ls: + print(l.line()) diff --git a/src/plugins/timediffplugin.py b/src/plugins/timediffplugin.py new file mode 100644 index 0000000..89acaa1 --- /dev/null +++ b/src/plugins/timediffplugin.py @@ -0,0 +1,34 @@ +from typing import Optional + +from PySide6.QtGui import QIcon + +from src.pluginbase import PluginBase +from src.plugins.domain.raction import RAction +from src.plugins.logfile.preprocesslineshook import PreProcessLinesHook +from src.plugins.timediff.time_diff_pre_process_lines_hook import TimeDiffPreProcessLinesHook +from src.i18n import _ + + +class TimeDiffPlugin(PluginBase): + def __init__(self): + super(TimeDiffPlugin, self).__init__() + self.time_diff_state = False + self.time_diff_action = RAction(_(""), lambda: self._toggle_time_diff(), + icon_file="icons/ionicons/stopwatch-outline.svg", checkable=True) + + def copy(self): + return TimeDiffPlugin() + + def get_filter_widget_actions(self) -> [RAction]: + return [ + self.time_diff_action + ] + + def get_pre_process_lines_hook(self) -> Optional[PreProcessLinesHook]: + if self.time_diff_state: + return TimeDiffPreProcessLinesHook() + return None + + def _toggle_time_diff(self): + self.time_diff_state = not self.time_diff_state + self.time_diff_action.set_checked(self.time_diff_state) diff --git a/src/ui/bigtext/preProcessLinesModel.py b/src/ui/bigtext/preProcessLinesModel.py new file mode 100644 index 0000000..b4e1bb9 --- /dev/null +++ b/src/ui/bigtext/preProcessLinesModel.py @@ -0,0 +1,18 @@ +from typing import List + +from src.plugins.logfile.preprocesslineshook import PreProcessLinesHook + + +class PreProcessLinesModel: + def __init__(self): + self._pre_process_lines_hooks: List[PreProcessLinesHook] = [] + + def add_hook(self, hook: PreProcessLinesHook): + self._pre_process_lines_hooks.append(hook) + + def add_hooks(self, hooks: List[PreProcessLinesHook]): + for hook in hooks: + self.add_hook(hook) + + def get_hooks(self) -> List[PreProcessLinesHook]: + return self._pre_process_lines_hooks.copy() diff --git a/src/ui/hbox.py b/src/ui/hbox.py index 71a4ea1..3b17ae4 100644 --- a/src/ui/hbox.py +++ b/src/ui/hbox.py @@ -5,5 +5,9 @@ class HBox(QWidget): def __init__(self, *widgets: QWidget): super(HBox, self).__init__() self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) for widget in widgets: self.layout.addWidget(widget) + + def addWidget(self, widget: QWidget): + self.layout.addWidget(widget) diff --git a/testbed/scribble.py b/testbed/scribble.py index cfe81f7..e3dcdd3 100644 --- a/testbed/scribble.py +++ b/testbed/scribble.py @@ -1,10 +1,24 @@ # extract icons from dll on windows # https://mail.python.org/pipermail/python-win32/2009-April/009078.html +import sys -import PySide6.QtCore +from PySide6.QtWidgets import QApplication, QWidget, QHBoxLayout, QComboBox, QMainWindow -# Prints PySide6 version -print(PySide6.__version__) -# Prints the Qt version used to compile PySide6 -print(PySide6.QtCore.__version__) +def text_changed(): + print("text changed") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = QMainWindow() + + query_field = QComboBox() + query_field.setEditable(True) + query_field.setInsertPolicy(QComboBox.InsertAtTop) + query_field.lineEdit().textChanged.connect(text_changed) + window.setCentralWidget(query_field) + + window.show() + + app.exec()