bouquin 0.4__tar.gz → 0.4.1__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 (31) hide show
  1. {bouquin-0.4 → bouquin-0.4.1}/PKG-INFO +1 -1
  2. {bouquin-0.4 → bouquin-0.4.1}/bouquin/locales/en.json +77 -76
  3. {bouquin-0.4 → bouquin-0.4.1}/bouquin/main_window.py +13 -0
  4. {bouquin-0.4 → bouquin-0.4.1}/bouquin/markdown_editor.py +139 -38
  5. {bouquin-0.4 → bouquin-0.4.1}/bouquin/tag_browser.py +4 -4
  6. {bouquin-0.4 → bouquin-0.4.1}/bouquin/time_log.py +127 -38
  7. {bouquin-0.4 → bouquin-0.4.1}/pyproject.toml +1 -1
  8. {bouquin-0.4 → bouquin-0.4.1}/LICENSE +0 -0
  9. {bouquin-0.4 → bouquin-0.4.1}/README.md +0 -0
  10. {bouquin-0.4 → bouquin-0.4.1}/bouquin/__init__.py +0 -0
  11. {bouquin-0.4 → bouquin-0.4.1}/bouquin/__main__.py +0 -0
  12. {bouquin-0.4 → bouquin-0.4.1}/bouquin/bug_report_dialog.py +0 -0
  13. {bouquin-0.4 → bouquin-0.4.1}/bouquin/db.py +0 -0
  14. {bouquin-0.4 → bouquin-0.4.1}/bouquin/find_bar.py +0 -0
  15. {bouquin-0.4 → bouquin-0.4.1}/bouquin/flow_layout.py +0 -0
  16. {bouquin-0.4 → bouquin-0.4.1}/bouquin/history_dialog.py +0 -0
  17. {bouquin-0.4 → bouquin-0.4.1}/bouquin/key_prompt.py +0 -0
  18. {bouquin-0.4 → bouquin-0.4.1}/bouquin/locales/fr.json +0 -0
  19. {bouquin-0.4 → bouquin-0.4.1}/bouquin/locales/it.json +0 -0
  20. {bouquin-0.4 → bouquin-0.4.1}/bouquin/lock_overlay.py +0 -0
  21. {bouquin-0.4 → bouquin-0.4.1}/bouquin/main.py +0 -0
  22. {bouquin-0.4 → bouquin-0.4.1}/bouquin/markdown_highlighter.py +0 -0
  23. {bouquin-0.4 → bouquin-0.4.1}/bouquin/save_dialog.py +0 -0
  24. {bouquin-0.4 → bouquin-0.4.1}/bouquin/search.py +0 -0
  25. {bouquin-0.4 → bouquin-0.4.1}/bouquin/settings.py +0 -0
  26. {bouquin-0.4 → bouquin-0.4.1}/bouquin/settings_dialog.py +0 -0
  27. {bouquin-0.4 → bouquin-0.4.1}/bouquin/statistics_dialog.py +0 -0
  28. {bouquin-0.4 → bouquin-0.4.1}/bouquin/strings.py +0 -0
  29. {bouquin-0.4 → bouquin-0.4.1}/bouquin/tags_widget.py +0 -0
  30. {bouquin-0.4 → bouquin-0.4.1}/bouquin/theme.py +0 -0
  31. {bouquin-0.4 → bouquin-0.4.1}/bouquin/toolbar.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.4
3
+ Version: 0.4.1
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
@@ -36,6 +36,7 @@
36
36
  "behaviour": "Behaviour",
37
37
  "never": "Never",
38
38
  "browse": "Browse",
39
+ "close_tab": "Close tab",
39
40
  "previous": "Previous",
40
41
  "previous_day": "Previous day",
41
42
  "next": "Next",
@@ -162,80 +163,80 @@
162
163
  "invalid_time_message": "Please enter a time in the format HH:MM",
163
164
  "dismiss": "Dismiss",
164
165
  "toolbar_alarm": "Set reminder alarm",
165
- "activities": "Activities",
166
- "activity": "Activity",
167
- "note": "Note",
168
- "activity_delete_error_message": "A problem occurred deleting the activity",
169
- "activity_delete_error_title": "Problem deleting activity",
170
- "activity_rename_error_message": "A problem occurred renaming the activity",
171
- "activity_rename_error_title": "Problem renaming activity",
172
- "activity_required_message": "An activity name is required",
173
- "activity_required_title": "Activity name required",
174
- "add_activity": "Add activity",
175
- "add_project": "Add project",
176
- "add_time_entry": "Add time entry",
177
- "time_period": "Time period",
178
- "by_day": "by day",
179
- "by_month": "by month",
180
- "by_week": "by week",
181
- "date_range": "Date range",
182
- "delete_activity": "Delete activity",
183
- "delete_activity_confirm": "Are you sure you want to delete this activity?",
184
- "delete_activity_title": "Delete activity - are you sure?",
185
- "delete_project": "Delete project",
186
- "delete_project_confirm": "Are you sure you want to delete this project?",
187
- "delete_project_title": "Delete project - are you sure?",
188
- "delete_time_entry": "Delete time entry",
189
- "group_by": "Group by",
190
- "hours": "Hours",
191
- "invalid_activity_message": "The activity is invalid",
192
- "invalid_activity_title": "Invalid activity",
193
- "invalid_project_message": "The project is invalid",
194
- "invalid_project_title": "Invalid project",
195
- "label_key": "Label",
196
- "manage_activities": "Manage activities",
197
- "manage_projects": "Manage projects",
198
- "manage_projects_activities": "Manage project activities",
199
- "open_time_log": "Open time log",
200
- "project": "Project",
201
- "project_delete_error_message": "A problem occurred deleting the project",
202
- "project_delete_error_title": "Problem deleting project",
203
- "project_rename_error_message": "A problem occurred renaming the project",
204
- "project_rename_error_title": "Problem renaming project",
205
- "project_required_message": "A project is required",
206
- "project_required_title": "Project required",
207
- "projects": "Projects",
208
- "rename_activity": "Rename activity",
209
- "rename_project": "Rename project",
210
- "run_report": "Run report",
211
- "add_project_label": "Add a project",
212
- "add_activity_label": "Add an activity",
213
- "select_activity_message": "Select an activity",
214
- "select_activity_title": "Select activity",
215
- "select_project_message": "Select a project",
216
- "select_project_title": "Select project",
217
- "time_log": "Time log",
218
- "time_log_collapsed_hint": "Time log",
219
- "time_log_date_label": "Time log date: {date}",
220
- "time_log_for": "Time log for {date}",
221
- "time_log_no_date": "Time log",
222
- "time_log_no_entries": "No time entries yet",
223
- "time_log_report": "Time log report",
224
- "time_log_report_title": "Time log for {project}",
225
- "time_log_report_meta": "From {start} to {end}, grouped {granularity}",
226
- "time_log_total_hours": "Total time spent",
227
- "time_log_with_total": "Time log ({hours:.2f}h)",
228
- "time_log_total_hours": "Total for day: {hours:.2f}h",
229
- "title_key": "title",
230
- "update_time_entry": "Update time entry",
231
- "time_report_total": "Total: {hours:.2f} hours",
232
- "no_report_title": "No report",
233
- "no_report_message": "Please run a report before exporting.",
234
- "total": "Total",
235
- "export_csv": "Export CSV",
236
- "export_csv_error_title": "Export failed",
237
- "export_csv_error_message": "Could not write CSV file:\n{error}",
238
- "export_pdf": "Export PDF",
239
- "export_pdf_error_title": "PDF export failed",
240
- "export_pdf_error_message": "Could not write PDF file:\n{error}"
166
+ "activities": "Activities",
167
+ "activity": "Activity",
168
+ "note": "Note",
169
+ "activity_delete_error_message": "A problem occurred deleting the activity",
170
+ "activity_delete_error_title": "Problem deleting activity",
171
+ "activity_rename_error_message": "A problem occurred renaming the activity",
172
+ "activity_rename_error_title": "Problem renaming activity",
173
+ "activity_required_message": "An activity name is required",
174
+ "activity_required_title": "Activity name required",
175
+ "add_activity": "Add activity",
176
+ "add_project": "Add project",
177
+ "add_time_entry": "Add time entry",
178
+ "time_period": "Time period",
179
+ "by_day": "by day",
180
+ "by_month": "by month",
181
+ "by_week": "by week",
182
+ "date_range": "Date range",
183
+ "delete_activity": "Delete activity",
184
+ "delete_activity_confirm": "Are you sure you want to delete this activity?",
185
+ "delete_activity_title": "Delete activity - are you sure?",
186
+ "delete_project": "Delete project",
187
+ "delete_project_confirm": "Are you sure you want to delete this project?",
188
+ "delete_project_title": "Delete project - are you sure?",
189
+ "delete_time_entry": "Delete time entry",
190
+ "group_by": "Group by",
191
+ "hours": "Hours",
192
+ "invalid_activity_message": "The activity is invalid",
193
+ "invalid_activity_title": "Invalid activity",
194
+ "invalid_project_message": "The project is invalid",
195
+ "invalid_project_title": "Invalid project",
196
+ "label_key": "Label",
197
+ "manage_activities": "Manage activities",
198
+ "manage_projects": "Manage projects",
199
+ "manage_projects_activities": "Manage project activities",
200
+ "open_time_log": "Open time log",
201
+ "project": "Project",
202
+ "project_delete_error_message": "A problem occurred deleting the project",
203
+ "project_delete_error_title": "Problem deleting project",
204
+ "project_rename_error_message": "A problem occurred renaming the project",
205
+ "project_rename_error_title": "Problem renaming project",
206
+ "project_required_message": "A project is required",
207
+ "project_required_title": "Project required",
208
+ "projects": "Projects",
209
+ "rename_activity": "Rename activity",
210
+ "rename_project": "Rename project",
211
+ "run_report": "Run report",
212
+ "add_project_label": "Add a project",
213
+ "add_activity_label": "Add an activity",
214
+ "select_activity_message": "Select an activity",
215
+ "select_activity_title": "Select activity",
216
+ "select_project_message": "Select a project",
217
+ "select_project_title": "Select project",
218
+ "time_log": "Time log",
219
+ "time_log_collapsed_hint": "Time log",
220
+ "time_log_date_label": "Time log date: {date}",
221
+ "time_log_for": "Time log for {date}",
222
+ "time_log_no_date": "Time log",
223
+ "time_log_no_entries": "No time entries yet",
224
+ "time_log_report": "Time log report",
225
+ "time_log_report_title": "Time log for {project}",
226
+ "time_log_report_meta": "From {start} to {end}, grouped {granularity}",
227
+ "time_log_total_hours": "Total time spent",
228
+ "time_log_with_total": "Time log ({hours:.2f}h)",
229
+ "time_log_total_hours": "Total for day: {hours:.2f}h",
230
+ "title_key": "title",
231
+ "update_time_entry": "Update time entry",
232
+ "time_report_total": "Total: {hours:.2f} hours",
233
+ "no_report_title": "No report",
234
+ "no_report_message": "Please run a report before exporting.",
235
+ "total": "Total",
236
+ "export_csv": "Export CSV",
237
+ "export_csv_error_title": "Export failed",
238
+ "export_csv_error_message": "Could not write CSV file:\n{error}",
239
+ "export_pdf": "Export PDF",
240
+ "export_pdf_error_title": "PDF export failed",
241
+ "export_pdf_error_message": "Could not write PDF file:\n{error}"
241
242
  }
@@ -260,6 +260,13 @@ class MainWindow(QMainWindow):
260
260
  nav_menu.addAction(act_today)
261
261
  self.addAction(act_today)
262
262
 
263
+ act_close_tab = QAction(strings._("close_tab"), self)
264
+ act_close_tab.setShortcut("Ctrl+W")
265
+ act_close_tab.setShortcutContext(Qt.ApplicationShortcut)
266
+ act_close_tab.triggered.connect(self._close_current_tab)
267
+ nav_menu.addAction(act_close_tab)
268
+ self.addAction(act_close_tab)
269
+
263
270
  act_find = QAction(strings._("find_on_page"), self)
264
271
  act_find.setShortcut(QKeySequence.Find)
265
272
  act_find.triggered.connect(self.findBar.show_bar)
@@ -520,6 +527,12 @@ class MainWindow(QMainWindow):
520
527
 
521
528
  self.tab_widget.removeTab(index)
522
529
 
530
+ def _close_current_tab(self):
531
+ """Close the currently active tab via shortcuts (Ctrl+W)."""
532
+ idx = self.tab_widget.currentIndex()
533
+ if idx >= 0:
534
+ self._close_tab(idx)
535
+
523
536
  def _on_tab_changed(self, index: int):
524
537
  """Handle tab change - reconnect toolbar and sync UI."""
525
538
  if index < 0:
@@ -406,9 +406,14 @@ class MarkdownEditor(QTextEdit):
406
406
  # Append the new marker
407
407
  new_line = f"{new_line} ⏰ {time_str}"
408
408
 
409
- bc = QTextCursor(block)
409
+ # --- : only replace the block's text, not its newline ---
410
+ block_start = block.position()
411
+ block_end = block_start + len(line)
412
+
413
+ bc = QTextCursor(self.document())
410
414
  bc.beginEditBlock()
411
- bc.select(QTextCursor.SelectionType.BlockUnderCursor)
415
+ bc.setPosition(block_start)
416
+ bc.setPosition(block_end, QTextCursor.KeepAnchor)
412
417
  bc.insertText(new_line)
413
418
  bc.endEditBlock()
414
419
 
@@ -426,6 +431,35 @@ class MarkdownEditor(QTextEdit):
426
431
  """Public wrapper used by MainWindow for reminders."""
427
432
  return self._get_current_line()
428
433
 
434
+ def _list_prefix_length_for_block(self, block) -> int:
435
+ """Return the length (in chars) of the visual list prefix for the given
436
+ block (including leading indentation), or 0 if it's not a list item.
437
+ """
438
+ line = block.text()
439
+ stripped = line.lstrip()
440
+ leading_spaces = len(line) - len(stripped)
441
+
442
+ # Checkbox (Unicode display)
443
+ if stripped.startswith(
444
+ f"{self._CHECK_UNCHECKED_DISPLAY} "
445
+ ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "):
446
+ return leading_spaces + 2 # icon + space
447
+
448
+ # Unicode bullet
449
+ if stripped.startswith(f"{self._BULLET_DISPLAY} "):
450
+ return leading_spaces + 2 # bullet + space
451
+
452
+ # Markdown bullet list (-, *, +)
453
+ if re.match(r"^[-*+]\s", stripped):
454
+ return leading_spaces + 2 # marker + space
455
+
456
+ # Numbered list: e.g. "1. "
457
+ m = re.match(r"^(\d+\.\s)", stripped)
458
+ if m:
459
+ return leading_spaces + leading_spaces + (len(m.group(1)) - leading_spaces)
460
+
461
+ return 0
462
+
429
463
  def _detect_list_type(self, line: str) -> tuple[str | None, str]:
430
464
  """
431
465
  Detect if line is a list item. Returns (list_type, prefix).
@@ -559,48 +593,102 @@ class MarkdownEditor(QTextEdit):
559
593
  self._update_code_block_row_backgrounds()
560
594
  return
561
595
 
562
- # Handle Home and Left arrow keys to prevent going left of list markers
596
+ # Handle Backspace on empty list items so the marker itself can be deleted
597
+ if event.key() == Qt.Key.Key_Backspace:
598
+ cursor = self.textCursor()
599
+ # Let Backspace behave normally when deleting a selection.
600
+ if not cursor.hasSelection():
601
+ block = cursor.block()
602
+ prefix_len = self._list_prefix_length_for_block(block)
603
+
604
+ if prefix_len > 0:
605
+ block_start = block.position()
606
+ line = block.text()
607
+ pos_in_block = cursor.position() - block_start
608
+ after_text = line[prefix_len:]
609
+
610
+ # If there is no real content after the marker, treat Backspace
611
+ # as "remove the list marker".
612
+ if after_text.strip() == "" and pos_in_block >= prefix_len:
613
+ cursor.beginEditBlock()
614
+ cursor.setPosition(block_start)
615
+ cursor.setPosition(
616
+ block_start + prefix_len, QTextCursor.KeepAnchor
617
+ )
618
+ cursor.removeSelectedText()
619
+ cursor.endEditBlock()
620
+ self.setTextCursor(cursor)
621
+ return
622
+
623
+ # Handle Home and Left arrow keys to keep the caret to the *right*
624
+ # of list prefixes (checkboxes / bullets / numbers).
563
625
  if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
626
+ # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
627
+ # document / word-left) – we don't interfere with those.
628
+ if event.modifiers() & Qt.ControlModifier:
629
+ pass
630
+ else:
631
+ cursor = self.textCursor()
632
+ block = cursor.block()
633
+ prefix_len = self._list_prefix_length_for_block(block)
634
+
635
+ if prefix_len > 0:
636
+ block_start = block.position()
637
+ pos_in_block = cursor.position() - block_start
638
+ target = block_start + prefix_len
639
+
640
+ if event.key() == Qt.Key.Key_Home:
641
+ # Home should jump to just after the prefix; with Shift
642
+ # it should *select* back to that position.
643
+ if event.modifiers() & Qt.ShiftModifier:
644
+ cursor.setPosition(target, QTextCursor.KeepAnchor)
645
+ else:
646
+ cursor.setPosition(target)
647
+ self.setTextCursor(cursor)
648
+ return
649
+
650
+ # Left arrow: don't allow the caret to move into the prefix
651
+ # region; snap it to just after the marker instead.
652
+ if event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len:
653
+ if event.modifiers() & Qt.ShiftModifier:
654
+ cursor.setPosition(target, QTextCursor.KeepAnchor)
655
+ else:
656
+ cursor.setPosition(target)
657
+ self.setTextCursor(cursor)
658
+ return
659
+
660
+ # After moving vertically, make sure we don't land *inside* a list
661
+ # prefix. We let QTextEdit perform the move first and then adjust.
662
+ if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not (
663
+ event.modifiers() & Qt.ControlModifier
664
+ ):
665
+ super().keyPressEvent(event)
666
+
564
667
  cursor = self.textCursor()
565
668
  block = cursor.block()
566
- line = block.text()
567
- pos_in_block = cursor.position() - block.position()
568
-
569
- # Detect list prefix length
570
- prefix_len = 0
571
- stripped = line.lstrip()
572
- leading_spaces = len(line) - len(stripped)
573
-
574
- # Check for checkbox (Unicode display format)
575
- if stripped.startswith(
576
- f"{self._CHECK_UNCHECKED_DISPLAY} "
577
- ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "):
578
- prefix_len = leading_spaces + 2 # icon + space
579
- # Check for Unicode bullet
580
- elif stripped.startswith(f"{self._BULLET_DISPLAY} "):
581
- prefix_len = leading_spaces + 2 # bullet + space
582
- # Check for markdown bullet list (-, *, +)
583
- elif re.match(r"^[-*+]\s", stripped):
584
- prefix_len = leading_spaces + 2 # marker + space
585
- # Check for numbered list
586
- elif re.match(r"^\d+\.\s", stripped):
587
- match = re.match(r"^(\d+\.\s)", stripped)
588
- if match:
589
- prefix_len = leading_spaces + len(match.group(1))
590
669
 
670
+ # Don't interfere with code blocks (they can contain literal
671
+ # markdown-looking text).
672
+ if self._is_inside_code_block(block):
673
+ return
674
+
675
+ prefix_len = self._list_prefix_length_for_block(block)
591
676
  if prefix_len > 0:
592
- if event.key() == Qt.Key.Key_Home:
593
- # Move to after the list marker
594
- cursor.setPosition(block.position() + prefix_len)
677
+ block_start = block.position()
678
+ pos_in_block = cursor.position() - block_start
679
+ if pos_in_block < prefix_len:
680
+ target = block_start + prefix_len
681
+ if event.modifiers() & Qt.ShiftModifier:
682
+ # Preserve the current anchor while snapping the visual
683
+ # caret to just after the marker.
684
+ anchor = cursor.anchor()
685
+ cursor.setPosition(anchor)
686
+ cursor.setPosition(target, QTextCursor.KeepAnchor)
687
+ else:
688
+ cursor.setPosition(target)
595
689
  self.setTextCursor(cursor)
596
- return
597
- elif event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len:
598
- # Prevent moving left of the list marker
599
- if pos_in_block > prefix_len:
600
- # Allow normal left movement if we're past the prefix
601
- super().keyPressEvent(event)
602
- # Otherwise block the movement
603
- return
690
+
691
+ return
604
692
 
605
693
  # Handle Enter key for smart list continuation AND code blocks
606
694
  if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
@@ -665,6 +753,19 @@ class MarkdownEditor(QTextEdit):
665
753
  super().keyPressEvent(event)
666
754
  return
667
755
 
756
+ # Auto-insert an extra blank line after headings (#, ##, ###)
757
+ # when pressing Enter at the end of the line.
758
+ if re.match(r"^#{1,3}\s+", stripped) and pos_in_block >= len(line_text):
759
+ cursor.beginEditBlock()
760
+ # First blank line: visual separator between heading and body
761
+ cursor.insertBlock()
762
+ # Second blank line: where body text will start (caret ends here)
763
+ cursor.insertBlock()
764
+ cursor.endEditBlock()
765
+
766
+ self.setTextCursor(cursor)
767
+ return
768
+
668
769
  # Check for list continuation
669
770
  list_type, prefix = self._detect_list_type(current_line)
670
771
 
@@ -52,21 +52,21 @@ class TagBrowserDialog(QDialog):
52
52
  # Tag management buttons
53
53
  btn_row = QHBoxLayout()
54
54
 
55
- self.add_tag_btn = QPushButton(strings._("add_a_tag"))
55
+ self.add_tag_btn = QPushButton("&" + strings._("add_a_tag"))
56
56
  self.add_tag_btn.clicked.connect(self._add_a_tag)
57
57
  btn_row.addWidget(self.add_tag_btn)
58
58
 
59
- self.edit_name_btn = QPushButton(strings._("edit_tag_name"))
59
+ self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name"))
60
60
  self.edit_name_btn.clicked.connect(self._edit_tag_name)
61
61
  self.edit_name_btn.setEnabled(False)
62
62
  btn_row.addWidget(self.edit_name_btn)
63
63
 
64
- self.change_color_btn = QPushButton(strings._("change_color"))
64
+ self.change_color_btn = QPushButton("&" + strings._("change_color"))
65
65
  self.change_color_btn.clicked.connect(self._change_tag_color)
66
66
  self.change_color_btn.setEnabled(False)
67
67
  btn_row.addWidget(self.change_color_btn)
68
68
 
69
- self.delete_btn = QPushButton(strings._("delete_tag"))
69
+ self.delete_btn = QPushButton("&" + strings._("delete_tag"))
70
70
  self.delete_btn.clicked.connect(self._delete_tag)
71
71
  self.delete_btn.setEnabled(False)
72
72
  btn_row.addWidget(self.delete_btn)
@@ -181,6 +181,9 @@ class TimeLogDialog(QDialog):
181
181
  self._db = db
182
182
  self._date_iso = date_iso
183
183
  self._current_entry_id: Optional[int] = None
184
+ # Guard flag used when repopulating the table so we don’t treat
185
+ # programmatic item changes as user edits.
186
+ self._reloading_entries: bool = False
184
187
 
185
188
  self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
186
189
  self.resize(900, 600)
@@ -228,14 +231,14 @@ class TimeLogDialog(QDialog):
228
231
 
229
232
  # --- Buttons for entry
230
233
  btn_row = QHBoxLayout()
231
- self.add_update_btn = QPushButton(strings._("add_time_entry"))
234
+ self.add_update_btn = QPushButton("&" + strings._("add_time_entry"))
232
235
  self.add_update_btn.clicked.connect(self._on_add_or_update)
233
236
 
234
- self.delete_btn = QPushButton(strings._("delete_time_entry"))
237
+ self.delete_btn = QPushButton("&" + strings._("delete_time_entry"))
235
238
  self.delete_btn.clicked.connect(self._on_delete_entry)
236
239
  self.delete_btn.setEnabled(False)
237
240
 
238
- self.report_btn = QPushButton(strings._("run_report"))
241
+ self.report_btn = QPushButton("&" + strings._("run_report"))
239
242
  self.report_btn.clicked.connect(self._on_run_report)
240
243
 
241
244
  btn_row.addStretch(1)
@@ -264,12 +267,14 @@ class TimeLogDialog(QDialog):
264
267
  self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
265
268
  self.table.setSelectionMode(QAbstractItemView.SingleSelection)
266
269
  self.table.itemSelectionChanged.connect(self._on_row_selected)
270
+ # When a cell is edited inline, commit the change back to the DB.
271
+ self.table.itemChanged.connect(self._on_table_item_changed)
267
272
  root.addWidget(self.table, 1)
268
273
 
269
274
  # --- Close button
270
275
  close_row = QHBoxLayout()
271
276
  close_row.addStretch(1)
272
- close_btn = QPushButton(strings._("close"))
277
+ close_btn = QPushButton("&" + strings._("close"))
273
278
  close_btn.clicked.connect(self.accept)
274
279
  close_row.addWidget(close_btn)
275
280
  root.addLayout(close_row)
@@ -293,32 +298,42 @@ class TimeLogDialog(QDialog):
293
298
  self.activity_edit.setCompleter(completer)
294
299
 
295
300
  def _reload_entries(self) -> None:
296
- rows = self._db.time_log_for_date(self._date_iso)
297
- self.table.setRowCount(len(rows))
298
- for row_idx, r in enumerate(rows):
299
- entry_id = r[0]
300
- project_name = r[3]
301
- activity_name = r[5]
302
- note = r[7] or ""
303
- minutes = r[6]
304
- hours = minutes / 60.0
305
-
306
- item_proj = QTableWidgetItem(project_name)
307
- item_act = QTableWidgetItem(activity_name)
308
- item_note = QTableWidgetItem(note)
309
- item_hours = QTableWidgetItem(f"{hours:.2f}")
301
+ """Reload the table from the database.
310
302
 
311
- # store the entry id on the first column
312
- item_proj.setData(Qt.ItemDataRole.UserRole, entry_id)
313
-
314
- self.table.setItem(row_idx, 0, item_proj)
315
- self.table.setItem(row_idx, 1, item_act)
316
- self.table.setItem(row_idx, 2, item_note)
317
- self.table.setItem(row_idx, 3, item_hours)
303
+ While we are repopulating the QTableWidget we temporarily disable the
304
+ itemChanged handler so that programmatic changes do not get written
305
+ back to the database.
306
+ """
307
+ self._reloading_entries = True
308
+ try:
309
+ rows = self._db.time_log_for_date(self._date_iso)
310
+ self.table.setRowCount(len(rows))
311
+ for row_idx, r in enumerate(rows):
312
+ entry_id = r[0]
313
+ project_name = r[3]
314
+ activity_name = r[5]
315
+ note = r[7] or ""
316
+ minutes = r[6]
317
+ hours = minutes / 60.0
318
+
319
+ item_proj = QTableWidgetItem(project_name)
320
+ item_act = QTableWidgetItem(activity_name)
321
+ item_note = QTableWidgetItem(note)
322
+ item_hours = QTableWidgetItem(f"{hours:.2f}")
323
+
324
+ # store the entry id on the first column
325
+ item_proj.setData(Qt.ItemDataRole.UserRole, entry_id)
326
+
327
+ self.table.setItem(row_idx, 0, item_proj)
328
+ self.table.setItem(row_idx, 1, item_act)
329
+ self.table.setItem(row_idx, 2, item_note)
330
+ self.table.setItem(row_idx, 3, item_hours)
331
+ finally:
332
+ self._reloading_entries = False
318
333
 
319
334
  self._current_entry_id = None
320
335
  self.delete_btn.setEnabled(False)
321
- self.add_update_btn.setText(strings._("add_time_entry"))
336
+ self.add_update_btn.setText("&" + strings._("add_time_entry"))
322
337
 
323
338
  # ----- Actions -----------------------------------------------------
324
339
 
@@ -368,7 +383,6 @@ class TimeLogDialog(QDialog):
368
383
  self._current_entry_id, proj_id, activity_id, minutes, note
369
384
  )
370
385
 
371
- self.note.setText("")
372
386
  self._reload_entries()
373
387
 
374
388
  def _on_row_selected(self) -> None:
@@ -376,7 +390,7 @@ class TimeLogDialog(QDialog):
376
390
  if not items:
377
391
  self._current_entry_id = None
378
392
  self.delete_btn.setEnabled(False)
379
- self.add_update_btn.setText(strings._("add_time_entry"))
393
+ self.add_update_btn.setText("&" + strings._("add_time_entry"))
380
394
  return
381
395
 
382
396
  row = items[0].row()
@@ -388,7 +402,7 @@ class TimeLogDialog(QDialog):
388
402
 
389
403
  self._current_entry_id = int(entry_id)
390
404
  self.delete_btn.setEnabled(True)
391
- self.add_update_btn.setText(strings._("update_time_entry"))
405
+ self.add_update_btn.setText("&" + strings._("update_time_entry"))
392
406
 
393
407
  # push values into the editors
394
408
  proj_name = proj_item.text()
@@ -405,6 +419,81 @@ class TimeLogDialog(QDialog):
405
419
  self.note.setText(note)
406
420
  self.hours_spin.setValue(hours)
407
421
 
422
+ def _on_table_item_changed(self, item: QTableWidgetItem) -> None:
423
+ """Commit inline edits in the table back to the database.
424
+
425
+ Editing a cell should behave like selecting that row and pressing
426
+ the Add/Update button, so we reuse the same validation and DB logic.
427
+ """
428
+ if self._reloading_entries:
429
+ # Ignore changes that come from _reload_entries().
430
+ return
431
+
432
+ if item is None:
433
+ return
434
+
435
+ row = item.row()
436
+
437
+ proj_item = self.table.item(row, 0)
438
+ act_item = self.table.item(row, 1)
439
+ note_item = self.table.item(row, 2)
440
+ hours_item = self.table.item(row, 3)
441
+
442
+ if proj_item is None or act_item is None or hours_item is None:
443
+ # Incomplete row – nothing to do.
444
+ return
445
+
446
+ # Recover the entry id from the hidden UserRole on the project cell
447
+ entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
448
+ self._current_entry_id = int(entry_id) if entry_id is not None else None
449
+
450
+ # Push values into the editors (similar to _on_row_selected).
451
+ proj_name = proj_item.text()
452
+ act_name = act_item.text()
453
+ note_text = note_item.text() if note_item is not None else ""
454
+ hours_text = hours_item.text()
455
+
456
+ # Set project combo by name, creating a project on the fly if needed.
457
+ idx = self.project_combo.findText(proj_name, Qt.MatchFixedString)
458
+ if idx < 0 and proj_name:
459
+ # Allow creating a new project directly from the table.
460
+ proj_id = self._db.add_project(proj_name)
461
+ self._reload_projects()
462
+ idx = self.project_combo.findData(proj_id)
463
+ if idx >= 0:
464
+ self.project_combo.setCurrentIndex(idx)
465
+ else:
466
+ self.project_combo.setCurrentIndex(-1)
467
+
468
+ self.activity_edit.setText(act_name)
469
+ self.note.setText(note_text)
470
+
471
+ # Parse hours; if invalid, show the same style of warning as elsewhere.
472
+ try:
473
+ hours = float(hours_text)
474
+ except ValueError:
475
+ QMessageBox.warning(
476
+ self,
477
+ strings._("invalid_time_title"),
478
+ strings._("invalid_time_message"),
479
+ )
480
+ # Reset table back to the last known-good state.
481
+ self._reload_entries()
482
+ return
483
+
484
+ self.hours_spin.setValue(hours)
485
+
486
+ # Mirror button state to reflect whether we're updating or adding.
487
+ if self._current_entry_id is None:
488
+ self.delete_btn.setEnabled(False)
489
+ self.add_update_btn.setText(strings._("add_time_entry"))
490
+ else:
491
+ self.delete_btn.setEnabled(True)
492
+ self.add_update_btn.setText(strings._("update_time_entry"))
493
+
494
+ # Finally, reuse the existing validation + DB logic.
495
+ self._on_add_or_update()
496
+
408
497
  def _on_delete_entry(self) -> None:
409
498
  if self._current_entry_id is None:
410
499
  return
@@ -453,15 +542,15 @@ class TimeCodeManagerDialog(QDialog):
453
542
  proj_layout.addWidget(self.project_list, 1)
454
543
 
455
544
  proj_btn_row = QHBoxLayout()
456
- self.proj_add_btn = QPushButton(strings._("add_project"))
457
- self.proj_rename_btn = QPushButton(strings._("rename_project"))
458
- self.proj_delete_btn = QPushButton(strings._("delete_project"))
545
+ self.proj_add_btn = QPushButton("&" + strings._("add_project"))
546
+ self.proj_rename_btn = QPushButton("&" + strings._("rename_project"))
547
+ self.proj_delete_btn = QPushButton("&" + strings._("delete_project"))
459
548
  proj_btn_row.addWidget(self.proj_add_btn)
460
549
  proj_btn_row.addWidget(self.proj_rename_btn)
461
550
  proj_btn_row.addWidget(self.proj_delete_btn)
462
551
  proj_layout.addLayout(proj_btn_row)
463
552
 
464
- self.tabs.addTab(proj_tab, strings._("projects"))
553
+ self.tabs.addTab(proj_tab, "&" + strings._("projects"))
465
554
 
466
555
  # Activities tab
467
556
  act_tab = QWidget()
@@ -470,9 +559,9 @@ class TimeCodeManagerDialog(QDialog):
470
559
  act_layout.addWidget(self.activity_list, 1)
471
560
 
472
561
  act_btn_row = QHBoxLayout()
473
- self.act_add_btn = QPushButton(strings._("add_activity"))
474
- self.act_rename_btn = QPushButton(strings._("rename_activity"))
475
- self.act_delete_btn = QPushButton(strings._("delete_activity"))
562
+ self.act_add_btn = QPushButton("&" + strings._("add_activity"))
563
+ self.act_rename_btn = QPushButton("&" + strings._("rename_activity"))
564
+ self.act_delete_btn = QPushButton("&" + strings._("delete_activity"))
476
565
  act_btn_row.addWidget(self.act_add_btn)
477
566
  act_btn_row.addWidget(self.act_rename_btn)
478
567
  act_btn_row.addWidget(self.act_delete_btn)
@@ -483,7 +572,7 @@ class TimeCodeManagerDialog(QDialog):
483
572
  # Close
484
573
  close_row = QHBoxLayout()
485
574
  close_row.addStretch(1)
486
- close_btn = QPushButton(strings._("close"))
575
+ close_btn = QPushButton("&" + strings._("close"))
487
576
  close_btn.clicked.connect(self.accept)
488
577
  close_row.addWidget(close_btn)
489
578
  root.addLayout(close_row)
@@ -827,7 +916,7 @@ class TimeReportDialog(QDialog):
827
916
  # Close
828
917
  close_row = QHBoxLayout()
829
918
  close_row.addStretch(1)
830
- close_btn = QPushButton(strings._("close"))
919
+ close_btn = QPushButton("&" + strings._("close"))
831
920
  close_btn.clicked.connect(self.accept)
832
921
  close_row.addWidget(close_btn)
833
922
  root.addLayout(close_row)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.4"
3
+ version = "0.4.1"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes