Compare commits

...

53 Commits

Author SHA1 Message Date
d9c362419b handle tabs correctly when highlighting text
With the old solution I replaced tabs with four spaces and then did
some calculations to get the correct byte positions for a coordinate.
With the new solution (using FontMetric.horizontalAdvance) this is not
necessary anymore and we can use tha native tab.
But I cannot change the tab width. It is always 8 characters.
You can set tabStopDistance on horizontalAdvance(), but not on
elided_text() and I did not find a way to use it while rendering text.
2025-05-06 20:45:19 +02:00
bfd8ce841f add .envrc so that direnv can set the venv dir to the path 2025-05-06 20:17:10 +02:00
a41e5b79a3 add 'about qt' dialog 2025-05-06 20:14:59 +02:00
75578b6126 rename venv 2025-04-06 19:02:46 +02:00
9afc4d1d9c fix exception when selecting text by clicking behind the last character 2025-04-06 19:02:33 +02:00
d36724f3e7 replace watchdog with active polling thread
watchdog does not work on windows. For some
reason file modification events are not
emitted.
Fixed by replacing watchdog with a thread
that polls the modification date ever 0.5s.

Also fixed a bug that the hit view was not
properly destructed.
2025-04-06 09:34:08 +02:00
bcd525d787 ignore version file 2025-04-05 10:46:38 +02:00
8289042af4 remove version file from repository
I don't want to have to commit an ever changing version file.
2025-04-05 10:46:00 +02:00
0246a3fb19 only add versions on Windows
The version-file property only has any effect on Windows.
2025-04-05 10:42:59 +02:00
617c7f161f Update README.md 2025-03-26 10:31:37 +00:00
67f16571f1 add version info to compiled executable
This works on Windows, but it might not work on Linux.
Also the version number is not updated automatically.
I probably have to generate the file from a template, because
even though this is a python file you cannot add python code.
2025-03-26 11:17:07 +01:00
be5e0c9ae6 update instructions.md for python 3.12 2025-03-25 18:35:20 +01:00
51c02b3d55 add missing file selectionPos
forgot to add it in one of the previous commits
2025-03-25 17:04:37 +01:00
c19cdf6f41 feature: add changelog to about dialog 2025-03-24 20:36:18 +01:00
8ce0c1bf9e switch direction of the range start/end icons
With the old direction they were overlapping each other,
which made it impossible to move the end slider.
2025-03-24 20:07:35 +01:00
04a0310eee bump copyright year 2025-03-24 19:37:37 +01:00
3a2aa4f109 get version from git tag 2025-03-24 19:36:43 +01:00
9902be0a48 feature: follow the file and show always the end when new data comes 2025-03-24 19:20:49 +01:00
61132d242f fix: graphemes are not correctly highlighted
Graphemes don't all have the same width, not even when you use a monospace font.
For latin characters it usually works find to assume the same width. But emojis,
japanese or chinese characters have have different width. There are even some
ultra wide characters like 𒐫 or ﷽. There is also a thing
called 'half-width' character. E.g. the japanese 'a' can be ア or ア.

Fixed by actually computing the width of graphemes and using pixel.
2025-03-24 17:49:27 +01:00
21b2da1e69 update pyside and pyinstaller 2025-03-23 20:54:03 +01:00
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
39 changed files with 1478 additions and 327 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
PATH=$(pwd)/venv312/bin:$PATH

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ testbed
icons-not-used icons-not-used
venv* venv*
*.spec *.spec
/version.txt

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

@@ -17,7 +17,7 @@ KrowLog is a viewer for text files of arbitrary size.
* Select arbitrary strings (not just full lines). * Select arbitrary strings (not just full lines).
* Double click selects word. * Double click selects word.
* Triple click selects line. * Triple click selects line.
* Copy protection: Users is warned before creating a clipboard more than 5 MB in size. They can choose to copy the * Copy protection: Users are warned before creating a clipboard more than 5 MB in size. They can choose to copy the
selection into a new file instead. selection into a new file instead.
* Optionally open a new tab when saving selection as file. * Optionally open a new tab when saving selection as file.
* Panel for temporary notes * Panel for temporary notes

16
changelog.txt Normal file
View File

@@ -0,0 +1,16 @@
Next Version
* Feature: Add changelog.
* Fix: When the range sliders are overlapping the end slider cannot be moved.
* Feature: Get version number from git tags.
* Feature: You can now "follow" a file. When enabled the file is automatically reloaded
and scrolled to the end. Any manual scroll action disables "follow" mode.
* Feature: Better support for fonts and characters with non-uniform width.
* Fix: Cannot scroll to arbitrary positions in a file if the file is larger than 2GB
* Feature: File type specific highlighters.
0.2.1
* Feature: Show how many bytes are selected.
* Feature: Highlighters can be disabled.
* Feature: If a regex contains a group, then only the group is highlighted.
Using a filter expression like '(\d+)ms' will only hightlight the number.

View File

@@ -1,6 +1,8 @@
import os import os
krow_icon = "icons" + os.sep + "krowlog.svg" krow_icon = "icons" + os.sep + "krowlog.svg"
qt_icon = "icons" + os.sep + "qt-logo.png"
license_file = os.path.dirname(os.path.realpath(__file__)) + os.sep + "LICENSE" license_file = os.path.dirname(os.path.realpath(__file__)) + os.sep + "LICENSE"
changelog_file = os.path.dirname(os.path.realpath(__file__)) + os.sep + "changelog.txt"
tab_width = 4 tab_width = 4

View File

@@ -1,10 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 16 16">
<path style="fill: #dddddd; stroke: black; stroke-linejoin:round;" <path style="fill: #dddddd; stroke: black; stroke-linejoin:round;"
d="M8,13 d="M8,3
L1,3 L1,13
L15,3 L15,13
z z
"/> "/>
<line x1="0.5" y1="13.5" x2="15.5" y2="13.5" style="stroke:black;"/> <line x1="0.5" y1="2.5" x2="15.5" y2="2.5" style="stroke:black;"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -1,10 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 16 16">
<path style="fill: #dddddd; stroke: black; stroke-linejoin:round;" <path style="fill: #dddddd; stroke: black; stroke-linejoin:round;"
d="M8,3 d="M8,13
L1,13 L1,3
L15,13 L15,3
z z
"/> "/>
<line x1="0.5" y1="2.5" x2="15.5" y2="2.5" style="stroke:black;"/> <line x1="0.5" y1="13.5" x2="15.5" y2="13.5" style="stroke:black;"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -8,12 +8,12 @@ Just run `make_installer.py` with the following command. The distribution can be
to run this on all target platforms. to run this on all target platforms.
``` ```
venv311/bin/python make_installer.py venv312/bin/python make_installer.py
``` ```
## Update Python ## Update Python
1. install the latest python version. We need the dev version, because PyInstaller requires it. 1. install the latest python version. We need the dev version, because PyInstaller requires it.
`sudo apt install python-3.11-dev python-3.11-venv` `sudo apt install python-3.12-dev python-3.12-venv`
2. create new virtual environment with `python3.11 -m venv /path/to/venv` 2. create new virtual environment with `python3.12 -m venv /path/to/venv`
3. select the venv in PyCharm 3. select the venv in PyCharm

View File

@@ -10,8 +10,11 @@ from src import install
from src.pluginregistry import PluginRegistry from src.pluginregistry import PluginRegistry
import gettext import gettext
from src.ui.icon import Icon from src.ui.icon import Icon
from pathlib import Path
import os
__version__ = '0.2.1' version_file = Path(os.path.dirname(os.path.realpath(__file__)) + os.sep + "version.txt")
__version__ = version_file.read_text() if version_file.is_file() else "0.0.0"
gettext.install('krowlog', 'locale') gettext.install('krowlog', 'locale')

View File

@@ -5,8 +5,8 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: RavenLog\n" "Project-Id-Version: RavenLog\n"
"POT-Creation-Date: 2024-03-24 12:00+0100\n" "POT-Creation-Date: 2025-03-24 19:02+0100\n"
"PO-Revision-Date: 2024-03-24 12:02+0100\n" "PO-Revision-Date: 2025-03-24 19:17+0100\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"Language: de\n" "Language: de\n"
@@ -15,10 +15,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Generated-By: pygettext.py 1.5\n" "Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.0.1\n" "X-Generator: Poedit 3.5\n"
#: src/mainwindow.py:32 src/plugins/krowlog/aboutdialog.py:30 #: src/mainwindow.py:32 src/new_big_text/bigger_text.py:75
#: src/plugins/krowlogplugin.py:85 #: src/plugins/krowlog/aboutdialog.py:30 src/plugins/krowlogplugin.py:85
msgid "KrowLog" msgid "KrowLog"
msgstr "KrowLog" msgstr "KrowLog"
@@ -146,31 +146,31 @@ msgstr "&Über KrowLog"
msgid "E&xit" msgid "E&xit"
msgstr "&Beenden" msgstr "&Beenden"
#: src/plugins/logfile/filterwidget.py:181 #: src/plugins/logfile/filterwidget.py:192
msgid "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
#: src/plugins/logfile/filterwidget.py:187 #: src/plugins/logfile/filterwidget.py:198
msgid "save query" msgid "save query"
msgstr "suche speichern" msgstr "suche speichern"
#: src/plugins/logfile/filterwidget.py:192 #: src/plugins/logfile/filterwidget.py:203
msgid "ignore case" msgid "ignore case"
msgstr "Groß-/Kleinschreibung ignorieren" msgstr "Groß-/Kleinschreibung ignorieren"
#: src/plugins/logfile/filterwidget.py:196 #: src/plugins/logfile/filterwidget.py:207
msgid "regex" msgid "regex"
msgstr "RegExp" msgstr "RegExp"
#: src/plugins/logfile/filterwidget.py:206 #: src/plugins/logfile/filterwidget.py:217
msgid "only matches" msgid "only matches"
msgstr "nur Treffer" msgstr "nur Treffer"
#: src/plugins/logfile/filterwidget.py:298 #: src/plugins/logfile/filterwidget.py:309
msgid "({hits} lines)" msgid "({hits} lines)"
msgstr "({hits} Zeilen)" msgstr "({hits} Zeilen)"
#: src/plugins/logfileplugin.py:35 src/ui/bigtext/bigtext.py:258 #: src/plugins/logfileplugin.py:35 src/ui/bigtext/bigtext.py:263
msgid "&Highlighter" msgid "&Highlighter"
msgstr "&Hervorhebungen" msgstr "&Hervorhebungen"
@@ -200,65 +200,69 @@ msgstr "Öffne Datei"
#: src/plugins/timediff/time_diff_menu_widget.py:32 #: src/plugins/timediff/time_diff_menu_widget.py:32
msgid "ms" msgid "ms"
msgstr "" msgstr "ms"
#: src/plugins/timediff/time_diff_menu_widget.py:33 #: src/plugins/timediff/time_diff_menu_widget.py:33
msgid "s" msgid "s"
msgstr "" msgstr "s"
#: src/plugins/timediff/time_diff_menu_widget.py:34 #: src/plugins/timediff/time_diff_menu_widget.py:34
msgid "m" msgid "m"
msgstr "" msgstr "m"
#: src/plugins/timediff/time_diff_menu_widget.py:35 #: src/plugins/timediff/time_diff_menu_widget.py:35
msgid "h" msgid "h"
msgstr "" msgstr "h"
#: src/ui/bigtext/bigtext.py:238 #: src/ui/bigtext/bigtext.py:243
msgid "&Copy to Clipboard" msgid "&Copy to Clipboard"
msgstr "In Zwischenablage &Kopieren" msgstr "In Zwischenablage &Kopieren"
#: src/ui/bigtext/bigtext.py:246 #: src/ui/bigtext/bigtext.py:251
msgid "Copy to &File" msgid "Copy to &File"
msgstr "In &Datei Kopieren" msgstr "In &Datei Kopieren"
#: src/ui/bigtext/bigtext.py:252 #: src/ui/bigtext/bigtext.py:257
msgid "Select &All" msgid "Select &All"
msgstr "&Alles Selektieren" msgstr "&Alles Selektieren"
#: src/ui/bigtext/bigtext.py:268 #: src/ui/bigtext/bigtext.py:270
msgid "&Follow"
msgstr "&Folgen"
#: src/ui/bigtext/bigtext.py:280
msgid "Set Range Start" msgid "Set Range Start"
msgstr "Setze Start des Anzeigebereichs" msgstr "Setze Start des Anzeigebereichs"
#: src/ui/bigtext/bigtext.py:276 #: src/ui/bigtext/bigtext.py:288
msgid "Set Range End" msgid "Set Range End"
msgstr "Setze Ende des Anzeigebereichs" msgstr "Setze Ende des Anzeigebereichs"
#: src/ui/bigtext/bigtext.py:283 #: src/ui/bigtext/bigtext.py:295
msgid "Reset Range" msgid "Reset Range"
msgstr "Anzeigebereich Zurücksetzen" msgstr "Anzeigebereich Zurücksetzen"
#: src/ui/bigtext/bigtext.py:458 #: src/ui/bigtext/bigtext.py:526
msgid "warning" msgid "warning"
msgstr "Achtung" msgstr "Achtung"
#: src/ui/bigtext/bigtext.py:459 #: src/ui/bigtext/bigtext.py:527
msgid "You have selected <b>{0}</b> of data." msgid "You have selected <b>{0}</b> of data."
msgstr "Du hast <b>{0}</b> selektiert." msgstr "Du hast <b>{0}</b> selektiert."
#: src/ui/bigtext/bigtext.py:464 #: src/ui/bigtext/bigtext.py:532
msgid "Copy {0} to Clipboard" msgid "Copy {0} to Clipboard"
msgstr "Kopiere {0} in die Zwischenablage" msgstr "Kopiere {0} in die Zwischenablage"
#: src/ui/bigtext/bigtext.py:468 #: src/ui/bigtext/bigtext.py:536
msgid "Write to File" msgid "Write to File"
msgstr "Schreibe in Datei" msgstr "Schreibe in Datei"
#: src/ui/bigtext/bigtext.py:492 #: src/ui/bigtext/bigtext.py:560
msgid "Save File" msgid "Save File"
msgstr "Speichere Datei" msgstr "Speichere Datei"
#: src/ui/bigtext/bigtext.py:529 #: src/ui/bigtext/bigtext.py:605
msgid "selected {0} - {1:,.0f}:{2:,.0f}" msgid "selected {0} - {1:,.0f}:{2:,.0f}"
msgstr "selektiert {0} - {1:,.0f}:{2:,.0f}" msgstr "selektiert {0} - {1:,.0f}:{2:,.0f}"
@@ -306,6 +310,10 @@ msgstr "Zeilenhintergrund"
msgid "Regular Expression" msgid "Regular Expression"
msgstr "Regulärer Ausdruck" msgstr "Regulärer Ausdruck"
#: src/ui/bigtext/newhighlightingdialog.py:185
msgid "File Type:"
msgstr "Dateityp:"
#: src/ui/colorbutton.py:20 #: src/ui/colorbutton.py:20
msgid "Strawberry Cream" msgid "Strawberry Cream"
msgstr "Strawberry Cream" msgstr "Strawberry Cream"
@@ -370,7 +378,7 @@ msgstr "Grau"
msgid "transparent" msgid "transparent"
msgstr "Transparent" msgstr "Transparent"
#: src/ui/rangeslider.py:180 #: src/ui/rangeslider.py:190
msgid "showing bytes {0} to {1} ({2})" msgid "showing bytes {0} to {1} ({2})"
msgstr "Anzeigebereich: Bytes {0} bis {1} ({2})" msgstr "Anzeigebereich: Bytes {0} bis {1} ({2})"

View File

@@ -5,7 +5,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-03-24 12:00+0100\n" "POT-Creation-Date: 2025-03-24 19:02+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -15,8 +15,8 @@ msgstr ""
"Generated-By: pygettext.py 1.5\n" "Generated-By: pygettext.py 1.5\n"
#: src/mainwindow.py:32 src/plugins/krowlog/aboutdialog.py:30 #: src/mainwindow.py:32 src/new_big_text/bigger_text.py:75
#: src/plugins/krowlogplugin.py:85 #: src/plugins/krowlog/aboutdialog.py:30 src/plugins/krowlogplugin.py:85
msgid "KrowLog" msgid "KrowLog"
msgstr "" msgstr ""
@@ -140,31 +140,31 @@ msgstr ""
msgid "E&xit" msgid "E&xit"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:181 #: src/plugins/logfile/filterwidget.py:192
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:187 #: src/plugins/logfile/filterwidget.py:198
msgid "save query" msgid "save query"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:192 #: src/plugins/logfile/filterwidget.py:203
msgid "ignore case" msgid "ignore case"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:196 #: src/plugins/logfile/filterwidget.py:207
msgid "regex" msgid "regex"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:206 #: src/plugins/logfile/filterwidget.py:217
msgid "only matches" msgid "only matches"
msgstr "" msgstr ""
#: src/plugins/logfile/filterwidget.py:298 #: src/plugins/logfile/filterwidget.py:309
msgid "({hits} lines)" msgid "({hits} lines)"
msgstr "" msgstr ""
#: src/plugins/logfileplugin.py:35 src/ui/bigtext/bigtext.py:258 #: src/plugins/logfileplugin.py:35 src/ui/bigtext/bigtext.py:263
msgid "&Highlighter" msgid "&Highlighter"
msgstr "" msgstr ""
@@ -208,51 +208,55 @@ msgstr ""
msgid "h" msgid "h"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:238 #: src/ui/bigtext/bigtext.py:243
msgid "&Copy to Clipboard" msgid "&Copy to Clipboard"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:246 #: src/ui/bigtext/bigtext.py:251
msgid "Copy to &File" msgid "Copy to &File"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:252 #: src/ui/bigtext/bigtext.py:257
msgid "Select &All" msgid "Select &All"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:268 #: src/ui/bigtext/bigtext.py:270
msgid "&Follow"
msgstr ""
#: src/ui/bigtext/bigtext.py:280
msgid "Set Range Start" msgid "Set Range Start"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:276 #: src/ui/bigtext/bigtext.py:288
msgid "Set Range End" msgid "Set Range End"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:283 #: src/ui/bigtext/bigtext.py:295
msgid "Reset Range" msgid "Reset Range"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:458 #: src/ui/bigtext/bigtext.py:526
msgid "warning" msgid "warning"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:459 #: src/ui/bigtext/bigtext.py:527
msgid "You have selected <b>{0}</b> of data." msgid "You have selected <b>{0}</b> of data."
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:464 #: src/ui/bigtext/bigtext.py:532
msgid "Copy {0} to Clipboard" msgid "Copy {0} to Clipboard"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:468 #: src/ui/bigtext/bigtext.py:536
msgid "Write to File" msgid "Write to File"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:492 #: src/ui/bigtext/bigtext.py:560
msgid "Save File" msgid "Save File"
msgstr "" msgstr ""
#: src/ui/bigtext/bigtext.py:529 #: src/ui/bigtext/bigtext.py:605
msgid "selected {0} - {1:,.0f}:{2:,.0f}" msgid "selected {0} - {1:,.0f}:{2:,.0f}"
msgstr "" msgstr ""
@@ -300,6 +304,10 @@ msgstr ""
msgid "Regular Expression" msgid "Regular Expression"
msgstr "" msgstr ""
#: src/ui/bigtext/newhighlightingdialog.py:185
msgid "File Type:"
msgstr ""
#: src/ui/colorbutton.py:20 #: src/ui/colorbutton.py:20
msgid "Strawberry Cream" msgid "Strawberry Cream"
msgstr "" msgstr ""
@@ -364,7 +372,7 @@ msgstr ""
msgid "transparent" msgid "transparent"
msgstr "" msgstr ""
#: src/ui/rangeslider.py:180 #: src/ui/rangeslider.py:190
msgid "showing bytes {0} to {1} ({2})" msgid "showing bytes {0} to {1} ({2})"
msgstr "" msgstr ""

View File

@@ -1,7 +1,10 @@
import PyInstaller.__main__ import PyInstaller.__main__
import os import os
import sys
PyInstaller.__main__.run([ os.system("git -C . describe --match \"*.*.*\" --tags > version.txt")
arguments = [
'krowlog.py', 'krowlog.py',
# '--onefile', # '--onefile',
'--noconfirm', '--noconfirm',
@@ -12,9 +15,14 @@ PyInstaller.__main__.run([
'--add-binary', 'icons' + os.pathsep + 'icons', '--add-binary', 'icons' + os.pathsep + 'icons',
'--add-binary', 'locales' + os.pathsep + 'locales', '--add-binary', 'locales' + os.pathsep + 'locales',
'--add-binary', 'LICENSE' + os.pathsep + '.', '--add-binary', 'LICENSE' + os.pathsep + '.',
'--add-binary', 'changelog.txt' + os.pathsep + '.',
'--add-binary', 'version.txt' + os.pathsep + '.',
'--hidden-import=krowlog', '--hidden-import=krowlog',
'--hidden-import=watchdog', '--hidden-import=__future__',
'--hidden-import=watchdog.observers',
'--hidden-import=watchdog.version',
'--hidden-import=configparser' '--hidden-import=configparser'
]) ]
if sys.platform == 'win32' or sys.platform == 'cygwin':
arguments.append('--version-file=version.py')
PyInstaller.__main__.run(arguments)

View File

@@ -1,5 +1,4 @@
pip==24.0 pip==25.0.1
PySide6_Essentials==6.6.2 PySide6_Essentials==6.8.2.1
setuptools==69.2.0 setuptools==77.0.3
watchdog==4.0.0 pyinstaller==6.12.0
pyinstaller==6.5.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

@@ -0,0 +1,79 @@
import textwrap
import PySide6
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import *
import constants
import krowlog
from src.ui.icon import Icon
from src.ui.label import Label
from src.ui.vbox import VBox
from src.i18n import _
class AboutQTDialog(QDialog):
"""Dialog for showing info about KrowLog"""
def __init__(self, parent=None):
super(AboutQTDialog, self).__init__(parent)
self.setWindowTitle(_("About QT"))
self.setModal(True)
# self.setMinimumWidth(850)
# self.setFixedHeight(400)
self.layout = QVBoxLayout(self)
text = f"""
<b>About QT</b>
<p>This program uses QT version {PySide6.QtCore.__version__}.</p>
<p>QT is a C++ toolkit for cross-platform application development.</p>
<p>Qt provides single-source portability across all major desktop
operating systems. It is also available for embedded Linux and other
embedded and mobile operating systems.</p>
<p>Qt is available under multiple licensing options designed to accommodate
the needs of our various users.</p>
<p>Qt licensed under our commercial license agreement is appropriate for
development of proprietary/commercial software where you do not want to
share any source code with third parties or otherwise cannot comply with
the terms of GNU (L)GPL.</p>
<p>Qt licensed under GNU (L)GPL is appropriate for the development of Qt
applications provided you can comply with the terms and conditions of the
respective licenses.</p>
Please see <a href="http://qt.io/licensing">qt.io/licensing</a> for an<
overview of Qt licensing.
<p>Copyright (C) 2025 The Qt Company Ltd and other contributors.</p>
<p>Qt and the Qt logo are trademarks of The Qt Company Ltd.</p>
<p>Qt is The Qt Company Ltd product developed as an open source project.
See <a href="http://qt.io">qt.io</a> for more information.</p>
"""
label = Label(text)
label.setWordWrap(True)
app_icon = QLabel()
app_icon.setPixmap(Icon(constants.qt_icon).pixmap(64, 64))
heading = QWidget(self)
hbox = QHBoxLayout(heading)
hbox.addWidget(app_icon)
hbox.addWidget(label)
hbox.addSpacerItem(QSpacerItem(1, 1, hData=QSizePolicy.Policy.Expanding))
heading.layout = hbox
self.layout.addWidget(heading)
buttons = QDialogButtonBox(self)
buttons.setStandardButtons(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.close)
self.layout.addWidget(buttons)
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)

View File

@@ -1,7 +1,6 @@
import textwrap import textwrap
import PySide6 import PySide6
from watchdog import version as watchdog_version
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QPalette from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import * from PySide6.QtWidgets import *
@@ -22,8 +21,8 @@ class AboutDialog(QDialog):
super(AboutDialog, self).__init__(parent) super(AboutDialog, self).__init__(parent)
self.setWindowTitle(_("About KrowLog")) self.setWindowTitle(_("About KrowLog"))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(650) self.setMinimumWidth(850)
self.setFixedHeight(300) self.setFixedHeight(400)
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
@@ -50,6 +49,7 @@ class AboutDialog(QDialog):
tabs.addTab(self._about(), _("About")) tabs.addTab(self._about(), _("About"))
tabs.addTab(self._libraries(), _("Libraries")) tabs.addTab(self._libraries(), _("Libraries"))
tabs.addTab(self._license(), _("License")) tabs.addTab(self._license(), _("License"))
tabs.addTab(self._changelog(), _("Changelog"))
self.layout.addWidget(tabs) self.layout.addWidget(tabs)
@@ -63,7 +63,7 @@ class AboutDialog(QDialog):
result.layout = QVBoxLayout(result) result.layout = QVBoxLayout(result)
label = Label("{0}<br>{1}<br>{2}".format( label = Label("{0}<br>{1}<br>{2}".format(
_("KrowLog is a viewer for log files of arbitrary size."), _("KrowLog is a viewer for log files of arbitrary size."),
_("(c) 2022-2024 Andreas Huber"), _("(c) 2022-2025 Andreas Huber"),
_("License: LGPL v3") _("License: LGPL v3")
)) ))
result.layout.addWidget(label) result.layout.addWidget(label)
@@ -74,11 +74,9 @@ class AboutDialog(QDialog):
<ul> <ul>
<li>PySide6-Essentials {pyside} (LGPL v3) - <a href="https://doc.qt.io/qtforpython-6/">https://doc.qt.io/qtforpython-6/</a></li> <li>PySide6-Essentials {pyside} (LGPL v3) - <a href="https://doc.qt.io/qtforpython-6/">https://doc.qt.io/qtforpython-6/</a></li>
<li>Qt6 {qt} (LGPL v3) - <a href="https://code.qt.io/cgit/qt/qtbase.git/">https://code.qt.io/cgit/qt/qtbase.git/</a></li> <li>Qt6 {qt} (LGPL v3) - <a href="https://code.qt.io/cgit/qt/qtbase.git/">https://code.qt.io/cgit/qt/qtbase.git/</a></li>
<li>watchdog {watchdog} (Apache 2.0) - <a href="https://github.com/gorakhargosh/watchdog">https://github.com/gorakhargosh/watchdog</a></li>
</ul>""".format( </ul>""".format(
pyside=PySide6.__version__, pyside=PySide6.__version__,
qt=PySide6.QtCore.__version__, qt=PySide6.QtCore.__version__)
watchdog=watchdog_version.VERSION_STRING)
label = textwrap.dedent(dependencies) label = textwrap.dedent(dependencies)
result = QWidget() result = QWidget()
@@ -106,3 +104,25 @@ class AboutDialog(QDialog):
panel.setBackgroundRole(QPalette.ColorRole.Light) panel.setBackgroundRole(QPalette.ColorRole.Light)
result.layout.addWidget(panel) result.layout.addWidget(panel)
return result return result
def _changelog(self) -> QWidget:
with open(constants.changelog_file, 'r') as file:
text = file.read()
result = QWidget()
result.layout = QVBoxLayout(result)
result.layout.setContentsMargins(0, 0, 0, 0)
result.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
label = Label(text)
label.setFont(QFont("Monospace"))
label.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
panel = QScrollArea(result)
panel.setContentsMargins(0, 0, 0, 0)
panel.setViewportMargins(0, 0, 0, 0)
panel.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
panel.setWidget(label)
panel.setBackgroundRole(QPalette.ColorRole.Light)
result.layout.addWidget(panel)
return result

View File

@@ -5,6 +5,7 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDockWidget, QMessageBox from PySide6.QtWidgets import QDockWidget, QMessageBox
import constants import constants
from src.plugins.krowlog.about_qt_dialog import AboutQTDialog
from src.plugins.krowlog.aboutdialog import AboutDialog from src.plugins.krowlog.aboutdialog import AboutDialog
from src.mainwindow import MainWindow from src.mainwindow import MainWindow
from src.pluginbase import PluginBase from src.pluginbase import PluginBase
@@ -36,6 +37,7 @@ class KrowLogPlugin(PluginBase):
return [ return [
MenuContribution("file", action=self._action_close(), action_id="close application", after="<last>"), MenuContribution("file", action=self._action_close(), action_id="close application", after="<last>"),
MenuContribution("help", action=self._action_about(), action_id="open about dialog", after="<last>"), MenuContribution("help", action=self._action_about(), action_id="open about dialog", after="<last>"),
MenuContribution("help", action=self._action_about_qt(), action_id="open about QT dialog", after="<last>"),
MenuContribution("settings", menu=self._sub_menu_languages(), action_id="recent files menu"), MenuContribution("settings", menu=self._sub_menu_languages(), action_id="recent files menu"),
] ]
@@ -108,6 +110,14 @@ class KrowLogPlugin(PluginBase):
) )
return about_action return about_action
def _action_about_qt(self) -> RAction:
action = RAction(
_("&About QT"),
action=lambda: AboutQTDialog().exec(),
icon_file=constants.qt_icon
)
return action
def _action_close(self) -> RAction: def _action_close(self) -> RAction:
icon = "close" if sys.platform == 'win32' or sys.platform == 'cygwin' else "exit" icon = "close" if sys.platform == 'win32' or sys.platform == 'cygwin' else "exit"
close_action = RAction(_("E&xit"), action=lambda: self.main_window.destruct(), shortcut='Ctrl+X', close_action = RAction(_("E&xit"), action=lambda: self.main_window.destruct(), shortcut='Ctrl+X',

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,8 +236,8 @@ 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, show_follow_action=False)
self.layout.addWidget(filter_bar) self.layout.addWidget(filter_bar)
self.layout.addWidget(self.hits_view) self.layout.addWidget(self.hits_view)
@@ -268,6 +279,7 @@ class FilterWidget(QWidget):
def destruct(self): def destruct(self):
self._cancel_search() self._cancel_search()
self.hits_view.destruct()
os.remove(self.tmp_filename) os.remove(self.tmp_filename)
def _cancel_search(self): def _cancel_search(self):
@@ -350,6 +362,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,246 @@
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."""
user_interaction = Signal()
"""signals that the user changed a value (the opposite is that some code changed the value) """
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.user_interaction.emit()
self.scroll_event.emit(self.ScrollEvent.PageDown)
case QStyle.SubControl.SC_ScrollBarSubPage:
self.user_interaction.emit()
self.scroll_event.emit(self.ScrollEvent.PageUp)
if self.value <= self.minimum:
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarAddLine:
self.user_interaction.emit()
self.scroll_event.emit(self.ScrollEvent.LinesDown)
case QStyle.SubControl.SC_ScrollBarSubLine:
self.user_interaction.emit()
self.scroll_event.emit(self.ScrollEvent.LinesUp)
if self.value <= self.minimum:
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarFirst:
self.user_interaction.emit()
self.set_value(self.minimum)
trigger_repeat_action = False
case QStyle.SubControl.SC_ScrollBarLast:
self.user_interaction.emit()
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
self.user_interaction.emit()
# 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, emit_change_event=True):
changed = self.value != value
self.value = value
if emit_change_event and changed:
# print(f"emitting value changed: {self.value}")
self.value_changed.emit(str(self.value))
self.update()

View File

@@ -11,72 +11,53 @@ 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
from src.ui.bigtext.line import Line from src.ui.bigtext.line import Line
from src.ui.bigtext.logFileModel import LogFileModel from src.ui.bigtext.logFileModel import LogFileModel
from src.ui.bigtext.newhighlightingdialog import NewHighlightingDialog from src.ui.bigtext.newhighlightingdialog import NewHighlightingDialog
from src.ui.bigtext.selectionPos import SelectionPos
from src.ui.icon import Icon from src.ui.icon import Icon
from src.ui.rangeslider import RangeSlider from src.ui.rangeslider import RangeSlider
from src.util.conversion import humanbytes from src.util.conversion import humanbytes
from src.pluginregistry import PluginRegistry from src.pluginregistry import PluginRegistry
from threading import Event
from src.settings.settings import Settings
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from src.i18n import _ from src.i18n import _
log = logging.getLogger("bigtext") log = logging.getLogger("bigtext")
class FileObserver(FileSystemEventHandler):
def __init__(self, big_text):
super(FileObserver, self).__init__()
self.big_text = big_text
self._last_mtime = -1
def on_modified(self, event):
# slow down the updates. This is needed, because the file is modified
# constantly, which would lead to constant re-rendering, which would
# block the UI thread and make the UI unresponsive.
# Note: we don't miss events, because they are queued and de-duplicated
if not event.is_directory:
try:
mtime = os.stat(event.src_path).st_mtime
if mtime != self._last_mtime:
self._last_mtime = mtime
time.sleep(0.5)
self.big_text.trigger_update.emit()
except FileNotFoundError:
# ignore: happens when closing the application, because tmp files are deleted,
# which triggers a modification event
pass
class FileWatchdogThread(QRunnable): class FileWatchdogThread(QRunnable):
def __init__(self, big_text, file: str): def __init__(self, big_text, file: str):
super(FileWatchdogThread, self).__init__() super(FileWatchdogThread, self).__init__()
self.file = file self.file = file
self.big_text = big_text self.big_text = big_text
self.observer = Observer() self.stop = Event()
def run(self) -> None: def run(self) -> None:
self.observer.schedule(FileObserver(self.big_text), self.file) _last_mtime = None
self.observer.start() while not self.stop.is_set():
mtime = os.stat(self.file).st_mtime
if mtime != _last_mtime:
_last_mtime = mtime
self.big_text.trigger_update.emit()
self.stop.wait(0.5)
def destruct(self): def destruct(self):
self.observer.stop() self.stop.set()
# self.observer.join(1)
class BigText(QWidget): class BigText(QWidget):
trigger_update = Signal() trigger_update = Signal()
def __init__(self, model: LogFileModel, show_range_slider=True): def __init__(self, model: LogFileModel, show_range_slider=True, show_follow_action=True):
super(BigText, self).__init__() super(BigText, self).__init__()
self.show_range_slider = show_range_slider
self.show_follow_action = show_follow_action
self.model = model self.model = model
self.grid = QGridLayout() self.grid = QGridLayout()
@@ -85,12 +66,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 +77,13 @@ 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)
self.v_scroll_bar.user_interaction.connect(self.big_text.user_scroll_interaction)
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 +100,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
@@ -142,21 +125,21 @@ class BigText(QWidget):
# noinspection PyArgumentList,PyTypeChecker # noinspection PyArgumentList,PyTypeChecker
class InnerBigText(QWidget): class InnerBigText(QWidget):
_byte_offset = 0 _byte_offset = 0
_left_offset = 0 # number of characters the horizontal scrollbar was moved to the right _left_offset = 0 # number of pixels the horizontal scrollbar was moved to the right
scroll_lines = 0 scroll_lines = 0
longest_line = 0 longest_line = 0
_range_start = 0 _range_start = 0
_range_end = -1 _range_end = -1
def __init__(self, parent: BigText, model: LogFileModel, v_scaled_scrollbar: ScaledScrollBar, _follow = False
range_limit: RangeSlider):
def __init__(self, parent: BigText, model: LogFileModel, v_scaled_scrollbar: ScaledScrollBar):
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 +152,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 +200,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,6 +242,13 @@ class InnerBigText(QWidget):
manage_highlighting.setShortcut("CTRL+H") manage_highlighting.setShortcut("CTRL+H")
menu.addAction(manage_highlighting) menu.addAction(manage_highlighting)
if self.parent.show_follow_action:
follow = QAction(_("&Follow"), self, triggered=self._toggle_follow)
follow.setCheckable(True)
follow.setChecked(self._follow)
menu.addAction(follow)
if self.parent.show_range_slider:
menu.addSeparator() menu.addSeparator()
set_range_start = QAction( set_range_start = QAction(
@@ -308,21 +295,25 @@ class InnerBigText(QWidget):
self.parent.range_limit.set_range_start(0) self.parent.range_limit.set_range_start(0)
self.parent.range_limit.set_range_end(self.model.byte_count()) self.parent.range_limit.set_range_end(self.model.byte_count())
def user_scroll_interaction(self):
self._follow = False
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._follow = False
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:
if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.ShiftModifier: if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.ShiftModifier:
offset = self.to_byte_offset(e) selection_pos = self.to_byte_offset(e)
self.selection_highlight.set_end_byte(offset) self.selection_highlight.set_end_byte(selection_pos)
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
return return
@@ -332,16 +323,16 @@ class InnerBigText(QWidget):
line_number = self.y_pos_to_line(e.pos().y()) line_number = self.y_pos_to_line(e.pos().y())
if line_number == self._last_double_click_line_number and line_number < len(self.lines): if line_number == self._last_double_click_line_number and line_number < len(self.lines):
line: Line = self.lines[line_number] line: Line = self.lines[line_number]
self.selection_highlight.set_start(line.byte_offset()) self.selection_highlight.set_start(SelectionPos(line.byte_offset(), True, 1))
self.selection_highlight.set_end_byte(line.byte_end()) self.selection_highlight.set_end_byte(SelectionPos(line.byte_end() - 1, False, 1))
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
return return
if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier: if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier:
offset = self.to_byte_offset(e) selection_pos = self.to_byte_offset(e)
self.selection_highlight.set_start(offset) self.selection_highlight.set_start(selection_pos)
self.selection_highlight.set_end_byte(offset) self.selection_highlight.set_end_byte(selection_pos)
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
@@ -356,14 +347,17 @@ class InnerBigText(QWidget):
self._last_double_click_time = time.time() self._last_double_click_time = time.time()
self._last_double_click_line_number = self.y_pos_to_line(e.pos().y()) self._last_double_click_line_number = self.y_pos_to_line(e.pos().y())
offset = self.to_byte_offset(e) selection_pos = self.to_byte_offset(e)
(_word, start_byte, end_byte) = self.model.read_word_at(offset) (word, start_byte, end_byte) = self.model.read_word_at(selection_pos.pos())
if start_byte >= 0 and end_byte >= 0: if start_byte >= 0 and end_byte >= 0:
self.selection_highlight.set_start(start_byte) bytes_of_first_char = len(f"{word[0]}".encode("utf8"))
self.selection_highlight.set_end_byte(end_byte) self.selection_highlight.set_start(SelectionPos(start_byte, True, bytes_of_first_char))
bytes_of_last_char = len(f"{word[-1]}".encode("utf8"))
self.selection_highlight.set_end_byte(
SelectionPos(end_byte - bytes_of_last_char, False, bytes_of_last_char))
else: else:
self.selection_highlight.set_start(offset) self.selection_highlight.set_start(selection_pos)
self.selection_highlight.set_end_byte(offset) self.selection_highlight.set_end_byte(selection_pos)
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
@@ -373,10 +367,10 @@ class InnerBigText(QWidget):
if e.buttons() != Qt.MouseButton.LeftButton: if e.buttons() != Qt.MouseButton.LeftButton:
return return
current_byte = self.to_byte_offset(e) selection_pos = self.to_byte_offset(e)
if self.selection_highlight.end_byte != current_byte: if self.selection_highlight.max_byte() != selection_pos.pos():
self.selection_highlight.set_end_byte(current_byte) self.selection_highlight.set_end_byte(selection_pos)
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
# print("-> %s,%s" %(self._selection_start_byte, self._selection_end_byte)) # print("-> %s,%s" %(self._selection_start_byte, self._selection_end_byte))
@@ -387,10 +381,16 @@ class InnerBigText(QWidget):
self.scroll_by_lines(-1) self.scroll_by_lines(-1)
if line_number > int(self.lines_shown()): if line_number > int(self.lines_shown()):
self.scroll_by_lines(1) self.scroll_by_lines(1)
if column_in_line <= 1: # if column_in_line <= 1:
# self._left_offset = max(0, self._left_offset - 2)
# self.update()
if e.pos().x() <= 1:
self._left_offset = max(0, self._left_offset - 2) self._left_offset = max(0, self._left_offset - 2)
self.update() self.update()
if column_in_line + 1 >= self.columns_shown(): # if column_in_line + 1 >= self.columns_shown():
# self._left_offset = self._left_offset + 2
# self.update()
if e.pos().x() + 1 >= self.width():
self._left_offset = self._left_offset + 2 self._left_offset = self._left_offset + 2
self.update() self.update()
@@ -401,20 +401,29 @@ 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, line: Line):
def update_longest_line(self, length: int):
width_in_chars = self.width() / self.char_width
# print("width_in_chars: %d" % width_in_chars) # print("width_in_chars: %d" % width_in_chars)
if self.longest_line < length: text_width_in_px = line.get_width_in_px(self.font_metric);
self.longest_line = length if self.longest_line < text_width_in_px:
maximum = max(0, length - width_in_chars + 1) self.longest_line = text_width_in_px
maximum = max(0, text_width_in_px - self.width() + 1)
self.parent.h_scroll_bar.setMaximum(round(maximum)) self.parent.h_scroll_bar.setMaximum(round(maximum))
def y_pos_to_line(self, y: int) -> int: def y_pos_to_line(self, y: int) -> int:
@@ -429,29 +438,63 @@ class InnerBigText(QWidget):
def columns_shown(self) -> float: def columns_shown(self) -> float:
return self.width() / float(self.char_width) return self.width() / float(self.char_width)
def to_byte_offset(self, e: QMouseEvent) -> int: def to_byte_offset(self, e: QMouseEvent) -> SelectionPos:
x = e.pos().x() + self._left_offset
line_number = self.y_pos_to_line(e.pos().y()) line_number = self.y_pos_to_line(e.pos().y())
if line_number < len(self.lines): if line_number < len(self.lines):
line: Line = self.lines[line_number] line: Line = self.lines[line_number]
column_in_line = self.x_pos_to_column(e.pos().x()) + self._left_offset
column_in_line = min(column_in_line, line.length_in_columns()) # x was behind the last column of this line text: str = line.line()
char_in_line = line.column_to_char(column_in_line) text = text.replace("\n", "").replace("\r", "")
# print("%s in line %s column_in_line=%s" % (char_in_line, line_number, column_in_line))
byte_in_line = line.char_index_to_byte(char_in_line) elided_text: str = self.elided_text(text, x)
current_byte = line.byte_offset() + byte_in_line byte_offset = line.byte_offset() + len(elided_text.encode("utf8"))
# print("%s + %s = %s" % (line.byte_offset(), char_in_line, current_byte))
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_width = self.font_metric.horizontalAdvance(next_char)
pos_is_in_left_half = x < (left_x_offset + char_width / 2)
bytes_of_char = len(next_char.encode("utf8"))
else:
# print(f"{x} is after last char, elided_text={elided_text}")
# the position is after the last character / behind the end of the line
pass
# print(f"{x} -> {byte_offset} {'left' if pos_is_in_left_half else 'right'} bytes_of_char={bytes_of_char}")
return SelectionPos(byte_offset, pos_is_in_left_half, bytes_of_char)
# column_in_line = self.x_pos_to_column(e.pos().x()) + self._left_offset
# column_in_line = min(column_in_line, line.length_in_columns()) # x was behind the last column of this line
# char_in_line = line.column_to_char(column_in_line)
# # print("%s in line %s column_in_line=%s" % (char_in_line, line_number, column_in_line))
# byte_in_line = line.char_index_to_byte(char_in_line)
# current_byte = line.byte_offset() + byte_in_line
# # print("%s + %s = %s" % (line.byte_offset(), char_in_line, current_byte))
else: else:
current_byte = self.model.byte_count() current_byte = self.model.byte_count()
return current_byte return SelectionPos(current_byte, True, 1)
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 _has_selection(self): def _has_selection(self):
return self.selection_highlight.start_byte != self.selection_highlight.end_byte return self.selection_highlight.min_byte() != self.selection_highlight.max_byte()
def copy_selection(self): def copy_selection(self):
if self._has_selection(): if self._has_selection():
start = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte) start = self.selection_highlight.min_byte()
end = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte) end = self.selection_highlight.max_byte()
bytes_human_readable = humanbytes(end - start) bytes_human_readable = humanbytes(end - start)
if end - start > (1024 ** 2) * 5: if end - start > (1024 ** 2) * 5:
you_sure = QMessageBox( you_sure = QMessageBox(
@@ -485,13 +528,13 @@ class InnerBigText(QWidget):
def _copy_selection_to_file(self): def _copy_selection_to_file(self):
if self._has_selection(): if self._has_selection():
start = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte) start = self.selection_highlight.min_byte()
end = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte) end = self.selection_highlight.max_byte()
dialog = QFileDialog(self) dialog = QFileDialog(self)
(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)
@@ -500,18 +543,25 @@ class InnerBigText(QWidget):
PluginRegistry.execute("open_file", selected_file) PluginRegistry.execute("open_file", selected_file)
def _select_all(self): def _select_all(self):
self.selection_highlight.start_byte = self.model.get_line_start_at(self._range_start) start_byte = self.model.get_line_start_at(self._range_start)
if self._range_end < 0 or self.model.byte_count() <= self._range_end: if self._range_end < 0 or self.model.byte_count() <= self._range_end:
self.selection_highlight.end_byte = self.model.byte_count() end_byte = self.model.byte_count()
else: else:
self.selection_highlight.end_byte = self.model.get_line_start_at(self._range_end) end_byte = self.model.get_line_start_at(self._range_end)
self.selection_highlight.set_start(SelectionPos(start_byte, True, 1))
self.selection_highlight.set_end_byte(SelectionPos(end_byte, False, 1))
self._update_highlight_selected_text() self._update_highlight_selected_text()
self.update() self.update()
def _toggle_follow(self):
self._follow = not self._follow
self.update()
def _update_highlight_selected_text(self): def _update_highlight_selected_text(self):
start_byte = min(self.selection_highlight.start_byte, self.selection_highlight.end_byte) start_byte = self.selection_highlight.min_byte()
end_byte = max(self.selection_highlight.start_byte, self.selection_highlight.end_byte) end_byte = self.selection_highlight.max_byte()
self._update_status_bar(start_byte, end_byte) self._update_status_bar(start_byte, end_byte)
@@ -533,22 +583,35 @@ 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"))) # "Courier New"
# "JetBrains Mono"
# "Noto Sans Mono"
# "Noto Color Emoji"
# "Andale Mono"
qfont = QFont("Monospace", self.model.settings.getint_session('general', "font_size"))
qfont.setStyleHint(QFont.StyleHint.Monospace)
painter.setFont(qfont)
self.font_metric = painter.fontMetrics()
painter.setPen(QColor(0, 0, 0)) painter.setPen(QColor(0, 0, 0))
self.update_font_metrics(painter) self.update_font_metrics(painter)
lines_to_show = math.ceil(self.lines_shown()) lines_to_show = math.ceil(self.lines_shown())
# print("%s / %s = %s" %(self.height(), float(self.char_height), lines_to_show)) # print("%s / %s = %s" %(self.height(), float(self.char_height), lines_to_show))
if self._range_end >= 0 and self._follow:
self.scroll_lines = 0
self._byte_offset = self.model.byte_count() - 1
self.parent.v_scroll_bar.set_value(self._byte_offset)
self.lines = self.model.data(self._byte_offset, self.scroll_lines, lines_to_show, self._range_start, self.lines = self.model.data(self._byte_offset, self.scroll_lines, lines_to_show, self._range_start,
self._range_end) self._range_end)
# print("lines_to_show: %d returned: %d" % (lines_to_show, len(self.lines))) # print("lines_to_show: %d returned: %d" % (lines_to_show, len(self.lines)))
@@ -559,10 +622,11 @@ 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)
if self.parent.show_range_slider:
self.parent.range_limit.set_maximum(byte_count) 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(line)
highlighters = self.model.highlighters() highlighters = self.model.highlighters()
if self.model.get_query_highlight(): if self.model.get_query_highlight():
@@ -581,23 +645,24 @@ class InnerBigText(QWidget):
if optional_highlight_range: if optional_highlight_range:
highlight_ranges = highlight_ranges + optional_highlight_range highlight_ranges = highlight_ranges + optional_highlight_range
self.draw_highlights(highlight_ranges, painter, y_line_offset) self.draw_highlights(highlight_ranges, painter, y_line_offset, line)
y_line_offset = y_line_offset + self.char_height y_line_offset = y_line_offset + self.char_height
left_offset = int(-1 * self._left_offset * self.char_width) # left_offset = int(-1 * self._left_offset * self.char_width)
left_offset = int(-1 * self._left_offset)
y_line_offset = self.char_height y_line_offset = self.char_height
for line in self.lines: for line in self.lines:
text = line.line_prepared_for_display() text = line.line_prepared_for_display()
text = text[self._left_offset:self._left_offset + math.ceil( # text = text[self._left_offset:self._left_offset + math.ceil(
self.columns_shown())] # reduce string to the visible section before drawing # self.columns_shown())] # reduce string to the visible section before drawing
painter.drawText(0, y_line_offset, text) painter.drawText(-self._left_offset, y_line_offset, text)
y_line_offset = y_line_offset + self.char_height y_line_offset = y_line_offset + self.char_height
painter.end() painter.end()
end_ns = time.process_time_ns() end_ns = time.process_time_ns()
# print(f"paint took {(end_ns - start_ns) / 1000000.0}") # print(f"paint took {(end_ns - start_ns) / 1000000.0}")
def draw_highlights(self, highlights: [HighlightedRange], painter: QPainter, y_line_offset: int): def draw_highlights(self, highlights: [HighlightedRange], painter: QPainter, y_line_offset: int, line: Line):
for highlight in highlights: for highlight in highlights:
if highlight.is_highlight_full_line(): if highlight.is_highlight_full_line():
@@ -607,15 +672,17 @@ class InnerBigText(QWidget):
self.highlight_background(painter, rect, highlight.get_brush_full_line()) self.highlight_background(painter, rect, highlight.get_brush_full_line())
for highlight in highlights: for highlight in highlights:
left_offset = self._left_offset * self.char_width
x1 = highlight.get_start() * self.char_width x1 = self.font_metric.horizontalAdvance(
width = highlight.get_width() * self.char_width line.prefix_bytes(highlight.get_start()).decode("utf8", errors="replace"))
width = self.font_metric.horizontalAdvance(
line.substr_bytes(highlight.get_start(), highlight.get_width()).decode("utf8", errors="replace"))
y1 = y_line_offset - self.char_height + self.char_height / 7 y1 = y_line_offset - self.char_height + self.char_height / 7
height = self.char_height height = self.char_height
left = round(x1 - left_offset) left = round(x1 - self._left_offset)
if x1 + width < left_offset \ if x1 + width < self._left_offset \
or x1 > left_offset + self.width(): or x1 > self._left_offset + self.width():
# too far left or too far right # too far left or too far right
continue continue

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)
@@ -69,12 +71,15 @@ class HighlightRegex(Highlight):
# but we only want to highlight the groups # but we only want to highlight the groups
first_group = 1 if len(match.groups()) > 0 else 0 first_group = 1 if len(match.groups()) > 0 else 0
for i in range(first_group, len(match.groups()) + 1): for i in range(first_group, len(match.groups()) + 1):
start_column = line.char_to_column(match.start(i)) start_char_index = match.start(i)
end_column = line.char_to_column(match.end(i)) start_byte_index = len(line.prefix(start_char_index).encode("utf8"))
end_char_index = match.end(i)
width = len(line.substr(start_char_index, end_char_index - start_char_index).encode("utf8"))
# print(f"highlight: {start_column}:{end_column} - {match.group(i)}") # print(f"highlight: {start_column}:{end_column} - {match.group(i)}")
result.append(HighlightedRange( result.append(HighlightedRange(
start_column, start_byte_index,
end_column - start_column, width,
highlight_full_line=True, highlight_full_line=True,
brush=self._brush_hit, brush=self._brush_hit,
brush_full_line=self._brush_line brush_full_line=self._brush_line
@@ -99,3 +104,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

@@ -7,50 +7,45 @@ from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from src.settings.settings import Settings from src.settings.settings import Settings
from src.ui.bigtext.selectionPos import SelectionPos
class HighlightSelection(Highlight): class HighlightSelection(Highlight):
start_byte = 0 start = SelectionPos(0, False, 0)
end_byte = 0 end = SelectionPos(0, False, 0)
def set_start(self, start_byte): def set_start(self, start: SelectionPos):
self.start_byte = start_byte self.start = start
def set_end_byte(self, end_byte): def set_end_byte(self, end: SelectionPos):
self.end_byte = end_byte self.end = 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())
def compute_highlight(self, line: Line) -> Optional[List[HighlightedRange]]: def compute_highlight(self, line: Line) -> Optional[List[HighlightedRange]]:
begin = min(self.start_byte, self.end_byte) begin = self.min_byte()
end = max(self.start_byte, self.end_byte) end = self.max_byte()
if line.intersects(begin, end): if line.intersects(begin, end):
if line.includes_byte(begin): if line.includes_byte(begin):
start_byte_in_line = begin - line.byte_offset() start_byte_in_line = begin - line.byte_offset()
else: else:
start_byte_in_line = 0 start_byte_in_line = 0
start_char = line.byte_index_to_char_index(start_byte_in_line)
if line.includes_byte(end): if line.includes_byte(end):
length_in_bytes = end - line.byte_offset() - start_byte_in_line length_in_bytes = end - line.byte_offset() - start_byte_in_line
end_char = line.byte_index_to_char_index(start_byte_in_line + length_in_bytes)
else: else:
# renders the highlighting to the end of the line # renders the highlighting to the end of the line
# this is how selections usually behave # this is how selections usually behave
length_in_bytes = Settings.max_line_length() - start_byte_in_line length_in_bytes = Settings.max_line_length() - start_byte_in_line
# note: this mixes chars and bytes, but that should not matter, because # print(f"compute_highlight: {line.substr_bytes(begin, end)} begin={begin} end={end} start_byte_in_line={start_byte_in_line} length_in_bytes={length_in_bytes}")
# it just means that we render the highlight into the invisible range on the right return [HighlightedRange(start_byte_in_line, length_in_bytes, brush=QBrush(QColor(156, 215, 255, 192)),
end_char = start_char + length_in_bytes
start_column = line.char_to_column(start_char)
end_column = line.char_to_column(end_char)
if end_column >= 0:
length_in_columns = end_column - start_column
else:
length_in_columns = 4096
return [HighlightedRange(start_column, length_in_columns, brush=QBrush(QColor(156, 215, 255, 192)),
pen=Qt.PenStyle.NoPen)] pen=Qt.PenStyle.NoPen)]
else: else:
return None return None

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

@@ -1,16 +1,22 @@
import unicodedata import unicodedata
from PySide6.QtGui import QFontMetrics
import constants import constants
class Line: class Line:
def __init__(self, byte_offset: int, byte_end: int, line: str): def __init__(self, byte_offset: int, byte_end: int, line: str, bytes: str):
self._byte_offset = byte_offset self._byte_offset = byte_offset
self._byte_end = byte_end self._byte_end = byte_end
self._line = line self._line = line
self._bytes = bytes
self._cache_char_to_column() self._cache_char_to_column()
def get_width_in_px(self, font_metric: QFontMetrics):
return font_metric.horizontalAdvance(self.line_prepared_for_display())
def byte_offset(self) -> int: def byte_offset(self) -> int:
return self._byte_offset return self._byte_offset
@@ -37,7 +43,8 @@ class Line:
return len(prefix_chars) return len(prefix_chars)
def line_prepared_for_display(self) -> str: def line_prepared_for_display(self) -> str:
line = self._line_tabs_replaced() # line = self._line_tabs_replaced()
line = self._line
line = self._replace_control_chars_with_pictures(line) line = self._replace_control_chars_with_pictures(line)
return line return line
@@ -102,7 +109,7 @@ class Line:
if not result in self._column_to_char_cache: if not result in self._column_to_char_cache:
self._column_to_char_cache[result] = i self._column_to_char_cache[result] = i
current_char = self._line[i] current_char = self._line[i]
if current_char == "\t": if False and current_char == "\t":
result = result + constants.tab_width - result % constants.tab_width result = result + constants.tab_width - result % constants.tab_width
else: else:
result = result + 1 result = result + 1
@@ -116,7 +123,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
@@ -131,11 +138,20 @@ class Line:
def prefix(self, index: int) -> str: def prefix(self, index: int) -> str:
return self._line[0:index] return self._line[0:index]
def prefix_bytes(self, byte_index: int) -> str:
return self._bytes[0:byte_index]
def substr(self, offset: int, length: int) -> str: def substr(self, offset: int, length: int) -> str:
return self._line[offset:offset+length] return self._line[offset:offset+length]
def substr_bytes(self, byte_offset: int, byte_length: int) -> str:
return self._bytes[byte_offset:byte_offset + byte_length]
def suffix(self, index: int) -> str: def suffix(self, index: int) -> str:
return self._line[index:] return self._line[index:]
def suffix_bytes(self, byte_index: int) -> str:
return self._bytes[byte_index:]
def __str__(self): def __str__(self):
return "%s (%d->%d)" % (self._line, self._byte_offset, self._byte_end) return "%s (%d->%d)" % (self._line, self._byte_offset, self._byte_end)

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:
line: Line | None = self._line_cache.get(offset)
if line is None:
line_bytes = f.readline()
if not line_bytes:
break
new_offset = f.tell() new_offset = f.tell()
if 0 <= range_end < new_offset: if 0 <= range_end < new_offset:
break break
line = Line(offset, new_offset, l.decode("utf8", errors="ignore")) line = Line(offset, new_offset, line_bytes.decode("utf8", errors="ignore"), line_bytes)
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

@@ -0,0 +1,11 @@
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)

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:
""" """
@@ -78,20 +78,6 @@ class MyTestCase(unittest.TestCase):
self.assertEqual(2, line.char_to_column(4)) # z̈ self.assertEqual(2, line.char_to_column(4)) # z̈
self.assertEqual(2, line.char_to_column(5)) # z̈ self.assertEqual(2, line.char_to_column(5)) # z̈
def test_line_tabs_replaced(self):
byte_offset = 123
text = "\ta\tb" # will be rendered as: ....abc where . represents a whitespace column
expected = " a b"
line = Line(byte_offset=byte_offset, byte_end=byte_offset + len(text.encode("utf8")), line=text)
self.assertEqual(expected, line.line_prepared_for_display())
def test_line_tabs_replaced_performance(self):
byte_offset = 123
text = "a\t" * 10000
expected = "a " * 10000
line = Line(byte_offset=byte_offset, byte_end=byte_offset + len(text.encode("utf8")), line=text)
self.assertEqual(expected, line.line_prepared_for_display())
def test_byte_index_to_char_index(self): def test_byte_index_to_char_index(self):
byte_offset = 123 byte_offset = 123
text = "x\u0308y\u0308z\u0308\t\u0308a" text = "x\u0308y\u0308z\u0308\t\u0308a"
@@ -119,7 +105,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
@@ -12,6 +12,7 @@ from src.util import conversion
from src.util.color import to_qcolor from src.util.color import to_qcolor
from src.i18n import _ from src.i18n import _
class RangeSliderHandle(): class RangeSliderHandle():
def __init__(self, value: int): def __init__(self, value: int):
self.value = value self.value = value
@@ -44,8 +45,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 +53,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,45 +67,37 @@ 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, direction=-1)
self._draw_handle(painter, self.lower_value) self._draw_handle(painter, self.upper_value)
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:
painter.setBrush(to_qcolor("50ade8")) painter.setBrush(to_qcolor("50ade8"))
painter.setPen(to_qcolor("dddddd")) painter.setPen(to_qcolor("dddddd"))
# the 1px wide grey center line
rect = QRect(round(10), self._handle_width, round(1), rect = QRect(round(10), self._handle_width, round(1),
self.height() - 2 * self._handle_width) self.height() - 2 * self._handle_width)
painter.drawRoundedRect(rect, 3.0, 3.0) painter.drawRoundedRect(rect, 3.0, 3.0)
# the blue line
rect = QRect(round(7), rect = QRect(round(7),
self._value_to_pixel(self.lower_value.value) + self._handle_width, self._value_to_pixel(self.lower_value.value),
round(6), round(6),
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) - 1 * 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)
h = self._handle_width - 1 # height of the handle
painter.setBrush(to_qcolor("dddddd")) painter.setBrush(to_qcolor("dddddd"))
painter.setPen(to_qcolor("444444")) painter.setPen(to_qcolor("444444"))
@@ -112,21 +105,32 @@ class RangeSlider(QWidget):
painter.drawLine(2, y_pixel, 18, y_pixel) painter.drawLine(2, y_pixel, 18, y_pixel)
painter.setRenderHint(PySide6.QtGui.QPainter.RenderHint.Antialiasing, True) painter.setRenderHint(PySide6.QtGui.QPainter.RenderHint.Antialiasing, True)
painter.drawPolygon( painter.drawPolygon(
(QPoint(10, y_pixel), QPoint(18, y_pixel + 12 * direction), QPoint(2, y_pixel + 12 * direction))) (QPoint(10, y_pixel), QPoint(18, y_pixel + h * direction), QPoint(2, y_pixel + h * direction)))
def _draw_handle_circle(self, painter: QPainter, handle: RangeSliderHandle) -> None:
y_pixel = self._value_to_pixel(handle.value)
painter.setBrush(to_qcolor("dddddd"))
painter.setPen(to_qcolor("444444"))
painter.setRenderHint(PySide6.QtGui.QPainter.RenderHint.Antialiasing, True)
painter.drawEllipse(QPoint(self._width / 2, y_pixel), self._handle_width / 2 - 1, self._handle_width / 2 - 1)
def _value_to_pixel(self, value: int) -> int: def _value_to_pixel(self, value: int) -> int:
value_percent = value / self.max_value value_percent = value / self.max_value
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))
@@ -141,12 +145,14 @@ class RangeSlider(QWidget):
def mousePressEvent(self, e: QtGui.QMouseEvent) -> None: def mousePressEvent(self, e: QtGui.QMouseEvent) -> None:
if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier: if e.buttons() == Qt.MouseButton.LeftButton and e.modifiers() == Qt.KeyboardModifier.NoModifier:
pos: QPoint = e.pos() pos: QPoint = e.pos()
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)
if self._is_on_handle(self.upper_value, pos.y(), direction=-1): 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):
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 +160,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)

View File

@@ -1,4 +1,4 @@
01234 012345
01234567890123456789 01234567890123456789
012345678901234567890123456789012345678901234567890123456789 012345678901234567890123456789012345678901234567890123456789
tab indentation: tab indentation:
@@ -22,6 +22,25 @@ x◌᷍◌◌᷍◌x
Control characters: Control characters:
 
------------------------------ ------------------------------
wide and half width characters:
123456789
アンドレアス
アンドレアス アンドレアス アンドレアス アンドレアス アンドレアス アンドレアス アンドレアス
アンドレアス
canadian aboriginal:
ᑭᓇᑐᐃᓐᓇᑦᑎᐊᖅᒥᒃ
simplified chinese:
任何人不得使为奴隶或奴
Thai:
ทุกคนมีสิทธิที่จะได้
Nastaliq Urdu (rl):
چونکہ یہ تمام
Braille:
⠑⠧⠑⠗⠽⠕⠝⠑
Arabic (rl):
ولما كانت
------------------------------
👍🏿 dark thumbs up (U+1F44D + U+1F3FF - THUMBS UP SIGN + EMOJI MODIFIER FITZPATRICK TYPE-6) 👍🏿 dark thumbs up (U+1F44D + U+1F3FF - THUMBS UP SIGN + EMOJI MODIFIER FITZPATRICK TYPE-6)
ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------< ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------ä---------ä----------<
2019-08-07 00:00:10,391 [catalina-exec-40] INFO c.r.c.u.l.PerformancePointcut - Executed HealthCheckController.checkOperativeness in 1 ms successful. [jv3fw7r2.m1u5] 2019-08-07 00:00:10,391 [catalina-exec-40] INFO c.r.c.u.l.PerformancePointcut - Executed HealthCheckController.checkOperativeness in 1 ms successful. [jv3fw7r2.m1u5]
@@ -158,3 +177,7 @@ ä---------ä----------ä---------ä----------ä---------ä----------ä--
17 17
18 18
19 19
アンドレアス
アンドレアス

28
version.py Normal file
View File

@@ -0,0 +1,28 @@
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(0, 2, 1, 0),
prodvers=(0, 2, 1, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo(
[
StringTable(
u'040904B0',
[StringStruct(u'CompanyName', u''),
StringStruct(u'FileDescription', u'KrowLog is a viewer for log files of arbitrary size.'),
StringStruct(u'FileVersion', u'0.2.1' ),
StringStruct(u'InternalName', u'krowlog'),
StringStruct(u'LegalCopyright', u'\xa9 Andreas Huber'),
StringStruct(u'OriginalFilename', u'krowlog.exe'),
StringStruct(u'ProductName', u'KrowLog'),
StringStruct(u'ProductVersion', u'0.2.1-dev')])
]),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)