bouquin 0.7.0__tar.gz → 0.7.2__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.
Files changed (45) hide show
  1. {bouquin-0.7.0 → bouquin-0.7.2}/PKG-INFO +1 -1
  2. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/db.py +128 -2
  3. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/locales/en.json +16 -0
  4. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/main_window.py +135 -10
  5. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/markdown_editor.py +39 -9
  6. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/reminders.py +161 -80
  7. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/settings.py +7 -1
  8. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/settings_dialog.py +84 -1
  9. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/statistics_dialog.py +177 -39
  10. {bouquin-0.7.0 → bouquin-0.7.2}/pyproject.toml +1 -1
  11. {bouquin-0.7.0 → bouquin-0.7.2}/LICENSE +0 -0
  12. {bouquin-0.7.0 → bouquin-0.7.2}/README.md +0 -0
  13. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/__init__.py +0 -0
  14. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/__main__.py +0 -0
  15. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/bug_report_dialog.py +0 -0
  16. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/code_block_editor_dialog.py +0 -0
  17. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/code_highlighter.py +0 -0
  18. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/document_utils.py +0 -0
  19. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/documents.py +0 -0
  20. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/find_bar.py +0 -0
  21. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/flow_layout.py +0 -0
  22. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/fonts/DejaVu.license +0 -0
  23. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/fonts/DejaVuSans.ttf +0 -0
  24. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/fonts/Noto.license +0 -0
  25. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/fonts/NotoSansSymbols2-Regular.ttf +0 -0
  26. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/history_dialog.py +0 -0
  27. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/icons/bouquin.svg +0 -0
  28. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/invoices.py +0 -0
  29. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/key_prompt.py +0 -0
  30. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/keys/mig5.asc +0 -0
  31. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/locales/fr.json +0 -0
  32. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/locales/it.json +0 -0
  33. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/lock_overlay.py +0 -0
  34. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/main.py +0 -0
  35. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/markdown_highlighter.py +0 -0
  36. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/pomodoro_timer.py +0 -0
  37. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/save_dialog.py +0 -0
  38. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/search.py +0 -0
  39. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/strings.py +0 -0
  40. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/tag_browser.py +0 -0
  41. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/tags_widget.py +0 -0
  42. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/theme.py +0 -0
  43. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/time_log.py +0 -0
  44. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/toolbar.py +0 -0
  45. {bouquin-0.7.0 → bouquin-0.7.2}/bouquin/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
@@ -95,6 +95,8 @@ class DBConfig:
95
95
  tags: bool = True
96
96
  time_log: bool = True
97
97
  reminders: bool = True
98
+ reminders_webhook_url: str = (None,)
99
+ reminders_webhook_secret: str = (None,)
98
100
  documents: bool = True
99
101
  invoicing: bool = False
100
102
  locale: str = "en"
@@ -971,7 +973,7 @@ class DBManager:
971
973
 
972
974
  # 2 & 3) total revisions + page with most revisions + per-date counts
973
975
  total_revisions = 0
974
- page_most_revisions = None
976
+ page_most_revisions: str | None = None
975
977
  page_most_revisions_count = 0
976
978
  revisions_by_date: Dict[_dt.date, int] = {}
977
979
 
@@ -1008,7 +1010,6 @@ class DBManager:
1008
1010
  words_by_date[d] = wc
1009
1011
 
1010
1012
  # tags + page with most tags
1011
-
1012
1013
  rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
1013
1014
  unique_tags = int(rows[0]["total_unique"]) if rows else 0
1014
1015
 
@@ -1029,6 +1030,119 @@ class DBManager:
1029
1030
  page_most_tags = None
1030
1031
  page_most_tags_count = 0
1031
1032
 
1033
+ # 5) Time logging stats (minutes / hours)
1034
+ time_minutes_by_date: Dict[_dt.date, int] = {}
1035
+ total_time_minutes = 0
1036
+ day_most_time: str | None = None
1037
+ day_most_time_minutes = 0
1038
+
1039
+ try:
1040
+ rows = cur.execute(
1041
+ """
1042
+ SELECT page_date, SUM(minutes) AS total_minutes
1043
+ FROM time_log
1044
+ GROUP BY page_date
1045
+ ORDER BY page_date;
1046
+ """
1047
+ ).fetchall()
1048
+ except Exception:
1049
+ rows = []
1050
+
1051
+ for r in rows:
1052
+ date_iso = r["page_date"]
1053
+ if not date_iso:
1054
+ continue
1055
+ m = int(r["total_minutes"] or 0)
1056
+ total_time_minutes += m
1057
+ if m > day_most_time_minutes:
1058
+ day_most_time_minutes = m
1059
+ day_most_time = date_iso
1060
+ try:
1061
+ d = _dt.date.fromisoformat(date_iso)
1062
+ except Exception: # nosec B112
1063
+ continue
1064
+ time_minutes_by_date[d] = m
1065
+
1066
+ # Project with most logged time
1067
+ project_most_minutes_name: str | None = None
1068
+ project_most_minutes = 0
1069
+
1070
+ try:
1071
+ rows = cur.execute(
1072
+ """
1073
+ SELECT p.name AS project_name,
1074
+ SUM(t.minutes) AS total_minutes
1075
+ FROM time_log t
1076
+ JOIN projects p ON p.id = t.project_id
1077
+ GROUP BY t.project_id, p.name
1078
+ ORDER BY total_minutes DESC, LOWER(project_name) ASC
1079
+ LIMIT 1;
1080
+ """
1081
+ ).fetchall()
1082
+ except Exception:
1083
+ rows = []
1084
+
1085
+ if rows:
1086
+ project_most_minutes_name = rows[0]["project_name"]
1087
+ project_most_minutes = int(rows[0]["total_minutes"] or 0)
1088
+
1089
+ # Activity with most logged time
1090
+ activity_most_minutes_name: str | None = None
1091
+ activity_most_minutes = 0
1092
+
1093
+ try:
1094
+ rows = cur.execute(
1095
+ """
1096
+ SELECT a.name AS activity_name,
1097
+ SUM(t.minutes) AS total_minutes
1098
+ FROM time_log t
1099
+ JOIN activities a ON a.id = t.activity_id
1100
+ GROUP BY t.activity_id, a.name
1101
+ ORDER BY total_minutes DESC, LOWER(activity_name) ASC
1102
+ LIMIT 1;
1103
+ """
1104
+ ).fetchall()
1105
+ except Exception:
1106
+ rows = []
1107
+
1108
+ if rows:
1109
+ activity_most_minutes_name = rows[0]["activity_name"]
1110
+ activity_most_minutes = int(rows[0]["total_minutes"] or 0)
1111
+
1112
+ # 6) Reminder stats
1113
+ reminders_by_date: Dict[_dt.date, int] = {}
1114
+ total_reminders = 0
1115
+ day_most_reminders: str | None = None
1116
+ day_most_reminders_count = 0
1117
+
1118
+ try:
1119
+ rows = cur.execute(
1120
+ """
1121
+ SELECT substr(created_at, 1, 10) AS date_iso,
1122
+ COUNT(*) AS c
1123
+ FROM reminders
1124
+ GROUP BY date_iso
1125
+ ORDER BY date_iso;
1126
+ """
1127
+ ).fetchall()
1128
+ except Exception:
1129
+ rows = []
1130
+
1131
+ for r in rows:
1132
+ date_iso = r["date_iso"]
1133
+ if not date_iso:
1134
+ continue
1135
+ c = int(r["c"] or 0)
1136
+ total_reminders += c
1137
+ if c > day_most_reminders_count:
1138
+ day_most_reminders_count = c
1139
+ day_most_reminders = date_iso
1140
+ try:
1141
+ d = _dt.date.fromisoformat(date_iso)
1142
+ except Exception: # nosec B112
1143
+ continue
1144
+ reminders_by_date[d] = c
1145
+
1032
1146
  return (
1033
1147
  pages_with_content,
1034
1148
  total_revisions,
@@ -1040,6 +1154,18 @@ class DBManager:
1040
1154
  page_most_tags,
1041
1155
  page_most_tags_count,
1042
1156
  revisions_by_date,
1157
+ time_minutes_by_date,
1158
+ total_time_minutes,
1159
+ day_most_time,
1160
+ day_most_time_minutes,
1161
+ project_most_minutes_name,
1162
+ project_most_minutes,
1163
+ activity_most_minutes_name,
1164
+ activity_most_minutes,
1165
+ reminders_by_date,
1166
+ total_reminders,
1167
+ day_most_reminders,
1168
+ day_most_reminders_count,
1043
1169
  )
1044
1170
 
1045
1171
  # -------- Time logging: projects & activities ---------------------
@@ -154,6 +154,11 @@
154
154
  "tag_already_exists_with_that_name": "A tag already exists with that name",
155
155
  "statistics": "Statistics",
156
156
  "main_window_statistics_accessible_flag": "Stat&istics",
157
+ "stats_group_pages": "Pages",
158
+ "stats_group_tags": "Tags",
159
+ "stats_group_documents": "Documents",
160
+ "stats_group_time_logging": "Time logging",
161
+ "stats_group_reminders": "Reminders",
157
162
  "stats_pages_with_content": "Pages with content (current version)",
158
163
  "stats_total_revisions": "Total revisions",
159
164
  "stats_page_most_revisions": "Page with most revisions",
@@ -168,6 +173,14 @@
168
173
  "stats_total_documents": "Total documents",
169
174
  "stats_date_most_documents": "Date with most documents",
170
175
  "stats_no_data": "No statistics available yet.",
176
+ "stats_time_total_hours": "Total hours logged",
177
+ "stats_time_day_most_hours": "Day with most hours logged",
178
+ "stats_time_project_most_hours": "Project with most hours logged",
179
+ "stats_time_activity_most_hours": "Activity with most hours logged",
180
+ "stats_total_reminders": "Total reminders",
181
+ "stats_date_most_reminders": "Day with most reminders",
182
+ "stats_metric_hours": "Hours",
183
+ "stats_metric_reminders": "Reminders",
171
184
  "select_notebook": "Select notebook",
172
185
  "bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
173
186
  "bug_report_placeholder": "Type your bug report here",
@@ -277,6 +290,9 @@
277
290
  "enable_tags_feature": "Enable Tags",
278
291
  "enable_time_log_feature": "Enable Time Logging",
279
292
  "enable_reminders_feature": "Enable Reminders",
293
+ "reminders_webhook_section_title": "Send Reminders to a webhook",
294
+ "reminders_webhook_url_label":"Webhook URL",
295
+ "reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
280
296
  "enable_documents_feature": "Enable storing of documents",
281
297
  "pomodoro_time_log_default_text": "Focus session",
282
298
  "toolbar_pomodoro_timer": "Time-logging timer",
@@ -58,7 +58,7 @@ from .key_prompt import KeyPrompt
58
58
  from .lock_overlay import LockOverlay
59
59
  from .markdown_editor import MarkdownEditor
60
60
  from .pomodoro_timer import PomodoroManager
61
- from .reminders import UpcomingRemindersWidget
61
+ from .reminders import UpcomingRemindersWidget, ReminderWebHook
62
62
  from .save_dialog import SaveDialog
63
63
  from .search import Search
64
64
  from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
@@ -115,6 +115,7 @@ class MainWindow(QMainWindow):
115
115
  self.tags.tagAdded.connect(self._on_tag_added)
116
116
 
117
117
  self.upcoming_reminders = UpcomingRemindersWidget(self.db)
118
+ self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
118
119
  self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
119
120
 
120
121
  # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
@@ -878,7 +879,74 @@ class MainWindow(QMainWindow):
878
879
  target_date = self._rollover_target_date(today)
879
880
  target_iso = target_date.toString("yyyy-MM-dd")
880
881
 
881
- all_unchecked: list[str] = []
882
+ # Regexes for markdown headings and checkboxes
883
+ heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
884
+ unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
885
+
886
+ def _normalize_heading(text: str) -> str:
887
+ """
888
+ Strip trailing closing hashes and whitespace, e.g.
889
+ "## Foo ###" -> "Foo"
890
+ """
891
+ text = text.strip()
892
+ text = re.sub(r"\s+#+\s*$", "", text)
893
+ return text.strip()
894
+
895
+ def _insert_todos_under_heading(
896
+ target_lines: list[str],
897
+ heading_level: int,
898
+ heading_text: str,
899
+ todos: list[str],
900
+ ) -> list[str]:
901
+ """Ensure a heading exists and append todos to the end of its section."""
902
+ normalized = _normalize_heading(heading_text)
903
+
904
+ # 1) Find existing heading with same text (any level)
905
+ start_idx = None
906
+ effective_level = None
907
+ for idx, line in enumerate(target_lines):
908
+ m = heading_re.match(line)
909
+ if not m:
910
+ continue
911
+ level = len(m.group(1))
912
+ text = _normalize_heading(m.group(2))
913
+ if text == normalized:
914
+ start_idx = idx
915
+ effective_level = level
916
+ break
917
+
918
+ # 2) If not found, create a new heading at the end
919
+ if start_idx is None:
920
+ if target_lines and target_lines[-1].strip():
921
+ target_lines.append("") # blank line before new heading
922
+ target_lines.append(f"{'#' * heading_level} {heading_text}")
923
+ start_idx = len(target_lines) - 1
924
+ effective_level = heading_level
925
+
926
+ # 3) Find the end of this heading's section
927
+ end_idx = len(target_lines)
928
+ for i in range(start_idx + 1, len(target_lines)):
929
+ m = heading_re.match(target_lines[i])
930
+ if m and len(m.group(1)) <= effective_level:
931
+ end_idx = i
932
+ break
933
+
934
+ # 4) Insert before any trailing blank lines in the section
935
+ insert_at = end_idx
936
+ while (
937
+ insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
938
+ ):
939
+ insert_at -= 1
940
+
941
+ for todo in todos:
942
+ target_lines.insert(insert_at, todo)
943
+ insert_at += 1
944
+
945
+ return target_lines
946
+
947
+ # Collect moved todos as (heading_info, item_text)
948
+ # heading_info is either None or (level, heading_text)
949
+ moved_items: list[tuple[tuple[int, str] | None, str]] = []
882
950
  any_moved = False
883
951
 
884
952
  # Look back N days (yesterday = 1, up to `days_back`)
@@ -892,14 +960,24 @@ class MainWindow(QMainWindow):
892
960
  lines = text.split("\n")
893
961
  remaining_lines: list[str] = []
894
962
  moved_from_this_day = False
963
+ current_heading: tuple[int, str] | None = None
895
964
 
896
965
  for line in lines:
966
+ # Track the last seen heading (# / ## / ###)
967
+ m_head = heading_re.match(line)
968
+ if m_head:
969
+ level = len(m_head.group(1))
970
+ heading_text = _normalize_heading(m_head.group(2))
971
+ if level <= 3:
972
+ current_heading = (level, heading_text)
973
+ # Keep headings in the original day
974
+ remaining_lines.append(line)
975
+ continue
976
+
897
977
  # Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
898
- if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
899
- r"^\s*-\s*\[☐\]\s+", line
900
- ):
901
- item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
902
- all_unchecked.append(f"- [ ] {item_text}")
978
+ if unchecked_re.match(line):
979
+ item_text = unchecked_re.sub("", line)
980
+ moved_items.append((current_heading, item_text))
903
981
  moved_from_this_day = True
904
982
  any_moved = True
905
983
  else:
@@ -917,9 +995,45 @@ class MainWindow(QMainWindow):
917
995
  if not any_moved:
918
996
  return False
919
997
 
920
- # Append everything we collected to the *target* date
921
- unchecked_str = "\n".join(all_unchecked) + "\n"
922
- self._load_selected_date(target_iso, unchecked_str)
998
+ # --- Merge all moved items into the *target* date ---
999
+
1000
+ target_text = self.db.get_entry(target_iso) or ""
1001
+ target_lines = target_text.split("\n") if target_text else []
1002
+
1003
+ by_heading: dict[tuple[int, str], list[str]] = {}
1004
+ plain_items: list[str] = []
1005
+
1006
+ for heading_info, item_text in moved_items:
1007
+ todo_line = f"- [ ] {item_text}"
1008
+ if heading_info is None:
1009
+ # No heading above this checkbox in the source: behave as before
1010
+ plain_items.append(todo_line)
1011
+ else:
1012
+ by_heading.setdefault(heading_info, []).append(todo_line)
1013
+
1014
+ # First insert all items that have headings
1015
+ for (level, heading_text), todos in by_heading.items():
1016
+ target_lines = _insert_todos_under_heading(
1017
+ target_lines, level, heading_text, todos
1018
+ )
1019
+
1020
+ # Then append all items without headings at the end, like before
1021
+ if plain_items:
1022
+ if target_lines and target_lines[-1].strip():
1023
+ target_lines.append("") # one blank line before the "unsectioned" todos
1024
+ target_lines.extend(plain_items)
1025
+
1026
+ new_target_text = "\n".join(target_lines)
1027
+ if not new_target_text.endswith("\n"):
1028
+ new_target_text += "\n"
1029
+
1030
+ # Save the updated target date and load it into the editor
1031
+ self.db.save_new_version(
1032
+ target_iso,
1033
+ new_target_text,
1034
+ strings._("unchecked_checkbox_items_moved_to_next_day"),
1035
+ )
1036
+ self._load_selected_date(target_iso)
923
1037
  return True
924
1038
 
925
1039
  def _on_date_changed(self):
@@ -1222,6 +1336,11 @@ class MainWindow(QMainWindow):
1222
1336
  # Turned off -> cancel any running timer and remove the widget
1223
1337
  self.pomodoro_manager.cancel_timer()
1224
1338
 
1339
+ def _send_reminder_webhook(self, text: str):
1340
+ if self.cfg.reminders and self.cfg.reminders_webhook_url:
1341
+ reminder_webhook = ReminderWebHook(text)
1342
+ reminder_webhook._send()
1343
+
1225
1344
  def _show_flashing_reminder(self, text: str):
1226
1345
  """
1227
1346
  Show a small flashing dialog and request attention from the OS.
@@ -1450,6 +1569,12 @@ class MainWindow(QMainWindow):
1450
1569
  self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
1451
1570
  self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
1452
1571
  self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
1572
+ self.cfg.reminders_webhook_url = getattr(
1573
+ new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
1574
+ )
1575
+ self.cfg.reminders_webhook_secret = getattr(
1576
+ new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
1577
+ )
1453
1578
  self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
1454
1579
  self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
1455
1580
  self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
@@ -1317,15 +1317,43 @@ class MarkdownEditor(QTextEdit):
1317
1317
  if icon:
1318
1318
  # absolute document position of the icon
1319
1319
  doc_pos = block.position() + i
1320
- r = char_rect_at(doc_pos, icon)
1320
+ r_icon = char_rect_at(doc_pos, icon)
1321
1321
 
1322
- # ---------- Relax the hit area here ----------
1323
- # Expand the clickable area horizontally so you don't have to
1324
- # land exactly on the glyph. This makes the "checkbox zone"
1325
- # roughly 3× the glyph width, centered on it.
1326
- pad = r.width() # one glyph width on each side
1327
- hit_rect = r.adjusted(-pad, 0, pad, 0)
1328
- # ---------------------------------------------
1322
+ # --- Find where the first non-space "real text" starts ---
1323
+ first_idx = i + len(icon) + 1 # skip icon + trailing space
1324
+ while first_idx < len(text) and text[first_idx].isspace():
1325
+ first_idx += 1
1326
+
1327
+ # Start with some padding around the icon itself
1328
+ left_pad = r_icon.width() // 2
1329
+ right_pad = r_icon.width() // 2
1330
+
1331
+ hit_left = r_icon.left() - left_pad
1332
+
1333
+ # If there's actual text after the checkbox, clamp the
1334
+ # clickable area so it stops *before* the first letter.
1335
+ if first_idx < len(text):
1336
+ first_doc_pos = block.position() + first_idx
1337
+ c_first = QTextCursor(self.document())
1338
+ c_first.setPosition(first_doc_pos)
1339
+ first_x = self.cursorRect(c_first).x()
1340
+
1341
+ expanded_right = r_icon.right() + right_pad
1342
+ hit_right = min(expanded_right, first_x)
1343
+ else:
1344
+ # No text after the checkbox on this line
1345
+ hit_right = r_icon.right() + right_pad
1346
+
1347
+ # Make sure the rect is at least 1px wide
1348
+ if hit_right <= hit_left:
1349
+ hit_right = r_icon.right()
1350
+
1351
+ hit_rect = QRect(
1352
+ hit_left,
1353
+ r_icon.top(),
1354
+ max(1, hit_right - hit_left),
1355
+ r_icon.height(),
1356
+ )
1329
1357
 
1330
1358
  if hit_rect.contains(pt):
1331
1359
  # Build the replacement: swap ☐ <-> ☑ (keep trailing space)
@@ -1339,7 +1367,9 @@ class MarkdownEditor(QTextEdit):
1339
1367
  edit.setPosition(doc_pos)
1340
1368
  # icon + space
1341
1369
  edit.movePosition(
1342
- QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
1370
+ QTextCursor.Right,
1371
+ QTextCursor.KeepAnchor,
1372
+ len(icon) + 1,
1343
1373
  )
1344
1374
  edit.insertText(f"{new_icon} ")
1345
1375
  edit.endEditBlock()