bouquin 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bouquin might be problematic. Click here for more details.
- {bouquin-0.1.1 → bouquin-0.1.3}/PKG-INFO +13 -13
- {bouquin-0.1.1 → bouquin-0.1.3}/README.md +10 -12
- bouquin-0.1.3/bouquin/db.py +226 -0
- bouquin-0.1.3/bouquin/editor.py +248 -0
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/key_prompt.py +2 -2
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/main.py +2 -1
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/main_window.py +188 -27
- bouquin-0.1.3/bouquin/search.py +195 -0
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/settings.py +3 -1
- bouquin-0.1.3/bouquin/settings_dialog.py +169 -0
- bouquin-0.1.3/bouquin/toolbar.py +148 -0
- {bouquin-0.1.1 → bouquin-0.1.3}/pyproject.toml +2 -1
- bouquin-0.1.1/bouquin/db.py +0 -111
- bouquin-0.1.1/bouquin/highlighter.py +0 -112
- bouquin-0.1.1/bouquin/settings_dialog.py +0 -100
- {bouquin-0.1.1 → bouquin-0.1.3}/LICENSE +0 -0
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/__init__.py +0 -0
- {bouquin-0.1.1 → bouquin-0.1.3}/bouquin/__main__.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
|
5
|
+
Home-page: https://git.mig5.net/mig5/bouquin
|
|
5
6
|
License: GPL-3.0-or-later
|
|
6
7
|
Author: Miguel Jacq
|
|
7
8
|
Author-email: mig@mig5.net
|
|
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
16
|
Requires-Dist: pyside6 (>=6.8.1,<7.0.0)
|
|
16
17
|
Requires-Dist: sqlcipher3-wheels (>=0.5.5.post0,<0.6.0)
|
|
18
|
+
Project-URL: Repository, https://git.mig5.net/mig5/bouquin
|
|
17
19
|
Description-Content-Type: text/markdown
|
|
18
20
|
|
|
19
21
|
# Bouquin
|
|
@@ -27,7 +29,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
|
|
|
27
29
|
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
|
|
28
30
|
|
|
29
31
|
To increase security, the SQLCipher key is requested when the app is opened, and is not written
|
|
30
|
-
to disk.
|
|
32
|
+
to disk unless the user configures it to be in the settings.
|
|
31
33
|
|
|
32
34
|
There is deliberately no network connectivity or syncing intended.
|
|
33
35
|
|
|
@@ -37,23 +39,21 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
37
39
|
|
|
38
40
|
## Features
|
|
39
41
|
|
|
42
|
+
* Data is encrypted at rest
|
|
43
|
+
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
|
40
44
|
* Every 'page' is linked to the calendar day
|
|
41
|
-
*
|
|
45
|
+
* Text is HTML with basic styling
|
|
46
|
+
* Search
|
|
42
47
|
* Automatic periodic saving (or explicitly save)
|
|
43
|
-
* Navigating from one day to the next automatically saves
|
|
44
|
-
* Basic keyboard shortcuts
|
|
45
48
|
* Transparent integrity checking of the database when it opens
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
## Yet to do
|
|
49
|
-
|
|
50
|
-
* Search
|
|
51
|
-
* Taxonomy/tagging
|
|
52
|
-
* Export to other formats (plaintext, json, sql etc)
|
|
49
|
+
* Rekey the database (change the password)
|
|
50
|
+
* Export the database to json, txt, html or csv
|
|
53
51
|
|
|
54
52
|
|
|
55
53
|
## How to install
|
|
56
54
|
|
|
55
|
+
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
|
56
|
+
|
|
57
57
|
### From source
|
|
58
58
|
|
|
59
59
|
* Clone this repo or download the tarball from the releases page
|
|
@@ -65,7 +65,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
65
65
|
|
|
66
66
|
* Download the whl and run it
|
|
67
67
|
|
|
68
|
-
### From PyPi
|
|
68
|
+
### From PyPi/pip
|
|
69
69
|
|
|
70
70
|
* `pip install bouquin`
|
|
71
71
|
|
|
@@ -9,7 +9,7 @@ It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a dr
|
|
|
9
9
|
for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
|
|
10
10
|
|
|
11
11
|
To increase security, the SQLCipher key is requested when the app is opened, and is not written
|
|
12
|
-
to disk.
|
|
12
|
+
to disk unless the user configures it to be in the settings.
|
|
13
13
|
|
|
14
14
|
There is deliberately no network connectivity or syncing intended.
|
|
15
15
|
|
|
@@ -19,23 +19,21 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
19
19
|
|
|
20
20
|
## Features
|
|
21
21
|
|
|
22
|
+
* Data is encrypted at rest
|
|
23
|
+
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
|
22
24
|
* Every 'page' is linked to the calendar day
|
|
23
|
-
*
|
|
25
|
+
* Text is HTML with basic styling
|
|
26
|
+
* Search
|
|
24
27
|
* Automatic periodic saving (or explicitly save)
|
|
25
|
-
* Navigating from one day to the next automatically saves
|
|
26
|
-
* Basic keyboard shortcuts
|
|
27
28
|
* Transparent integrity checking of the database when it opens
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## Yet to do
|
|
31
|
-
|
|
32
|
-
* Search
|
|
33
|
-
* Taxonomy/tagging
|
|
34
|
-
* Export to other formats (plaintext, json, sql etc)
|
|
29
|
+
* Rekey the database (change the password)
|
|
30
|
+
* Export the database to json, txt, html or csv
|
|
35
31
|
|
|
36
32
|
|
|
37
33
|
## How to install
|
|
38
34
|
|
|
35
|
+
Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions).
|
|
36
|
+
|
|
39
37
|
### From source
|
|
40
38
|
|
|
41
39
|
* Clone this repo or download the tarball from the releases page
|
|
@@ -47,7 +45,7 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
47
45
|
|
|
48
46
|
* Download the whl and run it
|
|
49
47
|
|
|
50
|
-
### From PyPi
|
|
48
|
+
### From PyPi/pip
|
|
51
49
|
|
|
52
50
|
* `pip install bouquin`
|
|
53
51
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from sqlcipher3 import dbapi2 as sqlite
|
|
11
|
+
from typing import List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
Entry = Tuple[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DBConfig:
|
|
18
|
+
path: Path
|
|
19
|
+
key: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DBManager:
|
|
23
|
+
def __init__(self, cfg: DBConfig):
|
|
24
|
+
self.cfg = cfg
|
|
25
|
+
self.conn: sqlite.Connection | None = None
|
|
26
|
+
|
|
27
|
+
def connect(self) -> bool:
|
|
28
|
+
# Ensure parent dir exists
|
|
29
|
+
self.cfg.path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.conn = sqlite.connect(str(self.cfg.path))
|
|
31
|
+
self.conn.row_factory = sqlite.Row
|
|
32
|
+
cur = self.conn.cursor()
|
|
33
|
+
cur.execute(f"PRAGMA key = '{self.cfg.key}';")
|
|
34
|
+
cur.execute("PRAGMA journal_mode = WAL;")
|
|
35
|
+
self.conn.commit()
|
|
36
|
+
try:
|
|
37
|
+
self._integrity_ok()
|
|
38
|
+
except Exception:
|
|
39
|
+
self.conn.close()
|
|
40
|
+
self.conn = None
|
|
41
|
+
return False
|
|
42
|
+
self._ensure_schema()
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def _integrity_ok(self) -> bool:
|
|
46
|
+
cur = self.conn.cursor()
|
|
47
|
+
cur.execute("PRAGMA cipher_integrity_check;")
|
|
48
|
+
rows = cur.fetchall()
|
|
49
|
+
|
|
50
|
+
# OK
|
|
51
|
+
if not rows:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Not OK
|
|
55
|
+
details = "; ".join(str(r[0]) for r in rows if r and r[0] is not None)
|
|
56
|
+
raise sqlite.IntegrityError(
|
|
57
|
+
"SQLCipher integrity check failed"
|
|
58
|
+
+ (f": {details}" if details else f" ({len(rows)} issue(s) reported)")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _ensure_schema(self) -> None:
|
|
62
|
+
cur = self.conn.cursor()
|
|
63
|
+
cur.execute(
|
|
64
|
+
"""
|
|
65
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
66
|
+
date TEXT PRIMARY KEY, -- ISO yyyy-MM-dd
|
|
67
|
+
content TEXT NOT NULL
|
|
68
|
+
);
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
cur.execute("PRAGMA user_version = 1;")
|
|
72
|
+
self.conn.commit()
|
|
73
|
+
|
|
74
|
+
def rekey(self, new_key: str) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Change the SQLCipher passphrase in-place, then reopen the connection
|
|
77
|
+
with the new key to verify.
|
|
78
|
+
"""
|
|
79
|
+
if self.conn is None:
|
|
80
|
+
raise RuntimeError("Database is not connected")
|
|
81
|
+
cur = self.conn.cursor()
|
|
82
|
+
# Change the encryption key of the currently open database
|
|
83
|
+
cur.execute(f"PRAGMA rekey = '{new_key}';")
|
|
84
|
+
self.conn.commit()
|
|
85
|
+
|
|
86
|
+
# Close and reopen with the new key to verify and restore PRAGMAs
|
|
87
|
+
self.conn.close()
|
|
88
|
+
self.conn = None
|
|
89
|
+
self.cfg.key = new_key
|
|
90
|
+
if not self.connect():
|
|
91
|
+
raise sqlite.Error("Re-open failed after rekey")
|
|
92
|
+
|
|
93
|
+
def get_entry(self, date_iso: str) -> str:
|
|
94
|
+
cur = self.conn.cursor()
|
|
95
|
+
cur.execute("SELECT content FROM entries WHERE date = ?;", (date_iso,))
|
|
96
|
+
row = cur.fetchone()
|
|
97
|
+
return row[0] if row else ""
|
|
98
|
+
|
|
99
|
+
def upsert_entry(self, date_iso: str, content: str) -> None:
|
|
100
|
+
cur = self.conn.cursor()
|
|
101
|
+
cur.execute(
|
|
102
|
+
"""
|
|
103
|
+
INSERT INTO entries(date, content) VALUES(?, ?)
|
|
104
|
+
ON CONFLICT(date) DO UPDATE SET content = excluded.content;
|
|
105
|
+
""",
|
|
106
|
+
(date_iso, content),
|
|
107
|
+
)
|
|
108
|
+
self.conn.commit()
|
|
109
|
+
|
|
110
|
+
def search_entries(self, text: str) -> list[str]:
|
|
111
|
+
cur = self.conn.cursor()
|
|
112
|
+
pattern = f"%{text}%"
|
|
113
|
+
return cur.execute(
|
|
114
|
+
"SELECT * FROM entries WHERE TRIM(content) LIKE ?", (pattern,)
|
|
115
|
+
).fetchall()
|
|
116
|
+
|
|
117
|
+
def dates_with_content(self) -> list[str]:
|
|
118
|
+
cur = self.conn.cursor()
|
|
119
|
+
cur.execute("SELECT date FROM entries WHERE TRIM(content) <> '';")
|
|
120
|
+
return [r[0] for r in cur.fetchall()]
|
|
121
|
+
|
|
122
|
+
def get_all_entries(self) -> List[Entry]:
|
|
123
|
+
cur = self.conn.cursor()
|
|
124
|
+
rows = cur.execute("SELECT date, content FROM entries ORDER BY date").fetchall()
|
|
125
|
+
return [(row["date"], row["content"]) for row in rows]
|
|
126
|
+
|
|
127
|
+
def export_json(
|
|
128
|
+
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
|
129
|
+
) -> None:
|
|
130
|
+
data = [{"date": d, "content": c} for d, c in entries]
|
|
131
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
132
|
+
if pretty:
|
|
133
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
134
|
+
else:
|
|
135
|
+
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
|
136
|
+
|
|
137
|
+
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
|
138
|
+
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
|
139
|
+
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
|
140
|
+
writer = csv.writer(f)
|
|
141
|
+
writer.writerow(["date", "content"]) # header
|
|
142
|
+
writer.writerows(entries)
|
|
143
|
+
|
|
144
|
+
def export_txt(
|
|
145
|
+
self,
|
|
146
|
+
entries: Sequence[Entry],
|
|
147
|
+
file_path: str,
|
|
148
|
+
separator: str = "\n\n— — — — —\n\n",
|
|
149
|
+
strip_html: bool = True,
|
|
150
|
+
) -> None:
|
|
151
|
+
import re, html as _html
|
|
152
|
+
|
|
153
|
+
# Precompiled patterns
|
|
154
|
+
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
|
155
|
+
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
156
|
+
BR_RE = re.compile(r"(?i)<br\\s*/?>")
|
|
157
|
+
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
|
|
158
|
+
TAG_RE = re.compile(r"<[^>]+>")
|
|
159
|
+
WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
|
|
160
|
+
MULTINEWLINE_RE = re.compile(r"\\n{3,}")
|
|
161
|
+
|
|
162
|
+
def _strip(s: str) -> str:
|
|
163
|
+
# 1) Remove <style> and <script> blocks *including their contents*
|
|
164
|
+
s = STYLE_SCRIPT_RE.sub("", s)
|
|
165
|
+
# 2) Remove HTML comments
|
|
166
|
+
s = COMMENT_RE.sub("", s)
|
|
167
|
+
# 3) Turn some block-ish boundaries into newlines before removing tags
|
|
168
|
+
s = BR_RE.sub("\n", s)
|
|
169
|
+
s = BLOCK_END_RE.sub("\n", s)
|
|
170
|
+
# 4) Drop remaining tags
|
|
171
|
+
s = TAG_RE.sub("", s)
|
|
172
|
+
# 5) Unescape entities ( etc.)
|
|
173
|
+
s = _html.unescape(s)
|
|
174
|
+
# 6) Tidy whitespace
|
|
175
|
+
s = WS_ENDS_RE.sub("\n", s)
|
|
176
|
+
s = MULTINEWLINE_RE.sub("\n\n", s)
|
|
177
|
+
return s.strip()
|
|
178
|
+
|
|
179
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
180
|
+
for i, (d, c) in enumerate(entries):
|
|
181
|
+
body = _strip(c) if strip_html else c
|
|
182
|
+
f.write(f"{d}\n{body}\n")
|
|
183
|
+
if i < len(entries) - 1:
|
|
184
|
+
f.write(separator)
|
|
185
|
+
|
|
186
|
+
def export_html(
|
|
187
|
+
self, entries: Sequence[Entry], file_path: str, title: str = "Entries export"
|
|
188
|
+
) -> None:
|
|
189
|
+
parts = [
|
|
190
|
+
"<!doctype html>",
|
|
191
|
+
'<html lang="en">',
|
|
192
|
+
'<meta charset="utf-8">',
|
|
193
|
+
f"<title>{html.escape(title)}</title>",
|
|
194
|
+
"<style>body{font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:900px;margin:auto;}",
|
|
195
|
+
"article{padding:16px 0;border-bottom:1px solid #ddd;} time{font-weight:600;color:#333;} section{margin-top:8px;}</style>",
|
|
196
|
+
"<body>",
|
|
197
|
+
f"<h1>{html.escape(title)}</h1>",
|
|
198
|
+
]
|
|
199
|
+
for d, c in entries:
|
|
200
|
+
parts.append(
|
|
201
|
+
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
|
|
202
|
+
)
|
|
203
|
+
parts.append("</body></html>")
|
|
204
|
+
|
|
205
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
206
|
+
f.write("\n".join(parts))
|
|
207
|
+
|
|
208
|
+
def export_by_extension(self, file_path: str) -> None:
|
|
209
|
+
entries = self.get_all_entries()
|
|
210
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
211
|
+
|
|
212
|
+
if ext == ".json":
|
|
213
|
+
self.export_json(entries, file_path)
|
|
214
|
+
elif ext == ".csv":
|
|
215
|
+
self.export_csv(entries, file_path)
|
|
216
|
+
elif ext == ".txt":
|
|
217
|
+
self.export_txt(entries, file_path)
|
|
218
|
+
elif ext in {".html", ".htm"}:
|
|
219
|
+
self.export_html(entries, file_path)
|
|
220
|
+
else:
|
|
221
|
+
raise ValueError(f"Unsupported extension: {ext}")
|
|
222
|
+
|
|
223
|
+
def close(self) -> None:
|
|
224
|
+
if self.conn is not None:
|
|
225
|
+
self.conn.close()
|
|
226
|
+
self.conn = None
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtGui import (
|
|
4
|
+
QColor,
|
|
5
|
+
QDesktopServices,
|
|
6
|
+
QFont,
|
|
7
|
+
QFontDatabase,
|
|
8
|
+
QTextCharFormat,
|
|
9
|
+
QTextCursor,
|
|
10
|
+
QTextListFormat,
|
|
11
|
+
QTextBlockFormat,
|
|
12
|
+
)
|
|
13
|
+
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
|
|
14
|
+
from PySide6.QtWidgets import QTextEdit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Editor(QTextEdit):
|
|
18
|
+
linkActivated = Signal(str)
|
|
19
|
+
|
|
20
|
+
_URL_RX = QRegularExpression(r"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
|
|
21
|
+
|
|
22
|
+
def __init__(self, *args, **kwargs):
|
|
23
|
+
super().__init__(*args, **kwargs)
|
|
24
|
+
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
|
25
|
+
self.setTabStopDistance(tab_w)
|
|
26
|
+
|
|
27
|
+
self.setTextInteractionFlags(
|
|
28
|
+
Qt.TextInteractionFlag.TextEditorInteraction
|
|
29
|
+
| Qt.TextInteractionFlag.LinksAccessibleByMouse
|
|
30
|
+
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
self.setAcceptRichText(True)
|
|
34
|
+
|
|
35
|
+
# Turn raw URLs into anchors
|
|
36
|
+
self._linkifying = False
|
|
37
|
+
self.textChanged.connect(self._linkify_document)
|
|
38
|
+
self.viewport().setMouseTracking(True)
|
|
39
|
+
|
|
40
|
+
def _linkify_document(self):
|
|
41
|
+
if self._linkifying:
|
|
42
|
+
return
|
|
43
|
+
self._linkifying = True
|
|
44
|
+
|
|
45
|
+
doc = self.document()
|
|
46
|
+
cur = QTextCursor(doc)
|
|
47
|
+
cur.beginEditBlock()
|
|
48
|
+
|
|
49
|
+
block = doc.begin()
|
|
50
|
+
while block.isValid():
|
|
51
|
+
text = block.text()
|
|
52
|
+
it = self._URL_RX.globalMatch(text)
|
|
53
|
+
while it.hasNext():
|
|
54
|
+
m = it.next()
|
|
55
|
+
start = block.position() + m.capturedStart()
|
|
56
|
+
end = start + m.capturedLength()
|
|
57
|
+
|
|
58
|
+
cur.setPosition(start)
|
|
59
|
+
cur.setPosition(end, QTextCursor.KeepAnchor)
|
|
60
|
+
|
|
61
|
+
fmt = cur.charFormat()
|
|
62
|
+
if fmt.isAnchor(): # already linkified; skip
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
href = m.captured(0)
|
|
66
|
+
if href.startswith("www."):
|
|
67
|
+
href = "https://" + href
|
|
68
|
+
|
|
69
|
+
fmt.setAnchor(True)
|
|
70
|
+
# Qt 6: use setAnchorHref; for compatibility, also set names.
|
|
71
|
+
try:
|
|
72
|
+
fmt.setAnchorHref(href)
|
|
73
|
+
except AttributeError:
|
|
74
|
+
fmt.setAnchorNames([href])
|
|
75
|
+
|
|
76
|
+
fmt.setFontUnderline(True)
|
|
77
|
+
fmt.setForeground(Qt.blue)
|
|
78
|
+
cur.setCharFormat(fmt)
|
|
79
|
+
|
|
80
|
+
block = block.next()
|
|
81
|
+
|
|
82
|
+
cur.endEditBlock()
|
|
83
|
+
self._linkifying = False
|
|
84
|
+
|
|
85
|
+
def mouseReleaseEvent(self, e):
|
|
86
|
+
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
|
|
87
|
+
href = self.anchorAt(e.pos())
|
|
88
|
+
if href:
|
|
89
|
+
QDesktopServices.openUrl(QUrl.fromUserInput(href))
|
|
90
|
+
self.linkActivated.emit(href)
|
|
91
|
+
return
|
|
92
|
+
super().mouseReleaseEvent(e)
|
|
93
|
+
|
|
94
|
+
def mouseMoveEvent(self, e):
|
|
95
|
+
if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
|
|
96
|
+
self.viewport().setCursor(Qt.PointingHandCursor)
|
|
97
|
+
else:
|
|
98
|
+
self.viewport().setCursor(Qt.IBeamCursor)
|
|
99
|
+
super().mouseMoveEvent(e)
|
|
100
|
+
|
|
101
|
+
def keyPressEvent(self, e):
|
|
102
|
+
key = e.key()
|
|
103
|
+
|
|
104
|
+
# Pre-insert: stop link/format bleed for “word boundary” keys
|
|
105
|
+
if key in (Qt.Key_Space, Qt.Key_Tab):
|
|
106
|
+
self._break_anchor_for_next_char()
|
|
107
|
+
return super().keyPressEvent(e)
|
|
108
|
+
|
|
109
|
+
# When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
|
|
110
|
+
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
111
|
+
super().keyPressEvent(e) # create the new (possibly empty) paragraph
|
|
112
|
+
|
|
113
|
+
# If we're on an empty block, clear the insertion char format so the
|
|
114
|
+
# *next* Enter will create another new line (not consume the press to reset formatting).
|
|
115
|
+
c = self.textCursor()
|
|
116
|
+
block = c.block()
|
|
117
|
+
if block.length() == 1:
|
|
118
|
+
self._clear_insertion_char_format()
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
return super().keyPressEvent(e)
|
|
122
|
+
|
|
123
|
+
def _clear_insertion_char_format(self):
|
|
124
|
+
"""Reset inline typing format (keeps lists, alignment, margins, etc.)."""
|
|
125
|
+
nf = QTextCharFormat()
|
|
126
|
+
self.setCurrentCharFormat(nf)
|
|
127
|
+
|
|
128
|
+
def _break_anchor_for_next_char(self):
|
|
129
|
+
c = self.textCursor()
|
|
130
|
+
fmt = c.charFormat()
|
|
131
|
+
if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
|
|
132
|
+
# clone, then strip just the link-specific bits so the next char is plain text
|
|
133
|
+
nf = QTextCharFormat(fmt)
|
|
134
|
+
nf.setAnchor(False)
|
|
135
|
+
nf.setFontUnderline(False)
|
|
136
|
+
nf.clearForeground()
|
|
137
|
+
try:
|
|
138
|
+
nf.setAnchorHref("")
|
|
139
|
+
except AttributeError:
|
|
140
|
+
nf.setAnchorNames([])
|
|
141
|
+
self.setCurrentCharFormat(nf)
|
|
142
|
+
|
|
143
|
+
def merge_on_sel(self, fmt):
|
|
144
|
+
"""
|
|
145
|
+
Sets the styling on the selected characters.
|
|
146
|
+
"""
|
|
147
|
+
cursor = self.textCursor()
|
|
148
|
+
if not cursor.hasSelection():
|
|
149
|
+
cursor.select(cursor.SelectionType.WordUnderCursor)
|
|
150
|
+
cursor.mergeCharFormat(fmt)
|
|
151
|
+
self.mergeCurrentCharFormat(fmt)
|
|
152
|
+
|
|
153
|
+
@Slot()
|
|
154
|
+
def apply_weight(self):
|
|
155
|
+
cur = self.currentCharFormat()
|
|
156
|
+
fmt = QTextCharFormat()
|
|
157
|
+
weight = (
|
|
158
|
+
QFont.Weight.Normal
|
|
159
|
+
if cur.fontWeight() == QFont.Weight.Bold
|
|
160
|
+
else QFont.Weight.Bold
|
|
161
|
+
)
|
|
162
|
+
fmt.setFontWeight(weight)
|
|
163
|
+
self.merge_on_sel(fmt)
|
|
164
|
+
|
|
165
|
+
@Slot()
|
|
166
|
+
def apply_italic(self):
|
|
167
|
+
cur = self.currentCharFormat()
|
|
168
|
+
fmt = QTextCharFormat()
|
|
169
|
+
fmt.setFontItalic(not cur.fontItalic())
|
|
170
|
+
self.merge_on_sel(fmt)
|
|
171
|
+
|
|
172
|
+
@Slot()
|
|
173
|
+
def apply_underline(self):
|
|
174
|
+
cur = self.currentCharFormat()
|
|
175
|
+
fmt = QTextCharFormat()
|
|
176
|
+
fmt.setFontUnderline(not cur.fontUnderline())
|
|
177
|
+
self.merge_on_sel(fmt)
|
|
178
|
+
|
|
179
|
+
@Slot()
|
|
180
|
+
def apply_strikethrough(self):
|
|
181
|
+
cur = self.currentCharFormat()
|
|
182
|
+
fmt = QTextCharFormat()
|
|
183
|
+
fmt.setFontStrikeOut(not cur.fontStrikeOut())
|
|
184
|
+
self.merge_on_sel(fmt)
|
|
185
|
+
|
|
186
|
+
@Slot()
|
|
187
|
+
def apply_code(self):
|
|
188
|
+
c = self.textCursor()
|
|
189
|
+
if not c.hasSelection():
|
|
190
|
+
c.select(c.SelectionType.BlockUnderCursor)
|
|
191
|
+
|
|
192
|
+
bf = QTextBlockFormat()
|
|
193
|
+
bf.setLeftMargin(12)
|
|
194
|
+
bf.setRightMargin(12)
|
|
195
|
+
bf.setTopMargin(6)
|
|
196
|
+
bf.setBottomMargin(6)
|
|
197
|
+
bf.setBackground(QColor(245, 245, 245))
|
|
198
|
+
bf.setNonBreakableLines(True)
|
|
199
|
+
|
|
200
|
+
cf = QTextCharFormat()
|
|
201
|
+
mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
|
202
|
+
cf.setFont(mono)
|
|
203
|
+
cf.setFontFixedPitch(True)
|
|
204
|
+
|
|
205
|
+
# If the current block already looks like a code block, remove styling
|
|
206
|
+
cur_bf = c.blockFormat()
|
|
207
|
+
is_code = (
|
|
208
|
+
cur_bf.nonBreakableLines()
|
|
209
|
+
and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
|
|
210
|
+
)
|
|
211
|
+
if is_code:
|
|
212
|
+
# clear: margins/background/wrapping
|
|
213
|
+
bf = QTextBlockFormat()
|
|
214
|
+
cf = QTextCharFormat()
|
|
215
|
+
|
|
216
|
+
c.mergeBlockFormat(bf)
|
|
217
|
+
c.mergeBlockCharFormat(cf)
|
|
218
|
+
|
|
219
|
+
@Slot(int)
|
|
220
|
+
def apply_heading(self, size):
|
|
221
|
+
fmt = QTextCharFormat()
|
|
222
|
+
if size:
|
|
223
|
+
fmt.setFontWeight(QFont.Weight.Bold)
|
|
224
|
+
fmt.setFontPointSize(size)
|
|
225
|
+
else:
|
|
226
|
+
fmt.setFontWeight(QFont.Weight.Normal)
|
|
227
|
+
fmt.setFontPointSize(self.font().pointSizeF())
|
|
228
|
+
self.merge_on_sel(fmt)
|
|
229
|
+
|
|
230
|
+
def toggle_bullets(self):
|
|
231
|
+
c = self.textCursor()
|
|
232
|
+
lst = c.currentList()
|
|
233
|
+
if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
|
|
234
|
+
lst.remove(c.block())
|
|
235
|
+
return
|
|
236
|
+
fmt = QTextListFormat()
|
|
237
|
+
fmt.setStyle(QTextListFormat.Style.ListDisc)
|
|
238
|
+
c.createList(fmt)
|
|
239
|
+
|
|
240
|
+
def toggle_numbers(self):
|
|
241
|
+
c = self.textCursor()
|
|
242
|
+
lst = c.currentList()
|
|
243
|
+
if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
|
|
244
|
+
lst.remove(c.block())
|
|
245
|
+
return
|
|
246
|
+
fmt = QTextListFormat()
|
|
247
|
+
fmt.setStyle(QTextListFormat.Style.ListDecimal)
|
|
248
|
+
c.createList(fmt)
|
|
@@ -14,8 +14,8 @@ class KeyPrompt(QDialog):
|
|
|
14
14
|
def __init__(
|
|
15
15
|
self,
|
|
16
16
|
parent=None,
|
|
17
|
-
title: str = "
|
|
18
|
-
message: str = "Enter
|
|
17
|
+
title: str = "Enter key",
|
|
18
|
+
message: str = "Enter key",
|
|
19
19
|
):
|
|
20
20
|
super().__init__(parent)
|
|
21
21
|
self.setWindowTitle(title)
|