From 511e7ae7b8913dc0e10c41ec482b2c5e778a7c25 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 20 Nov 2025 17:01:58 +1100 Subject: [PATCH 1/3] Add keyboard shortcuts for tag and time log dialogs, remove reset of note text --- bouquin/tag_browser.py | 8 ++++---- bouquin/time_log.py | 33 ++++++++++++++++----------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index 83c17c0..a5d12d0 100644 --- a/bouquin/tag_browser.py +++ b/bouquin/tag_browser.py @@ -52,21 +52,21 @@ class TagBrowserDialog(QDialog): # Tag management buttons btn_row = QHBoxLayout() - self.add_tag_btn = QPushButton(strings._("add_a_tag")) + self.add_tag_btn = QPushButton("&" + strings._("add_a_tag")) self.add_tag_btn.clicked.connect(self._add_a_tag) btn_row.addWidget(self.add_tag_btn) - self.edit_name_btn = QPushButton(strings._("edit_tag_name")) + self.edit_name_btn = QPushButton("&" + strings._("edit_tag_name")) self.edit_name_btn.clicked.connect(self._edit_tag_name) self.edit_name_btn.setEnabled(False) btn_row.addWidget(self.edit_name_btn) - self.change_color_btn = QPushButton(strings._("change_color")) + self.change_color_btn = QPushButton("&" + strings._("change_color")) self.change_color_btn.clicked.connect(self._change_tag_color) self.change_color_btn.setEnabled(False) btn_row.addWidget(self.change_color_btn) - self.delete_btn = QPushButton(strings._("delete_tag")) + self.delete_btn = QPushButton("&" + strings._("delete_tag")) self.delete_btn.clicked.connect(self._delete_tag) self.delete_btn.setEnabled(False) btn_row.addWidget(self.delete_btn) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 3cb30bf..9ff5da4 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -231,14 +231,14 @@ class TimeLogDialog(QDialog): # --- Buttons for entry btn_row = QHBoxLayout() - self.add_update_btn = QPushButton(strings._("add_time_entry")) + self.add_update_btn = QPushButton("&" + strings._("add_time_entry")) self.add_update_btn.clicked.connect(self._on_add_or_update) - self.delete_btn = QPushButton(strings._("delete_time_entry")) + self.delete_btn = QPushButton("&" + strings._("delete_time_entry")) self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.setEnabled(False) - self.report_btn = QPushButton(strings._("run_report")) + self.report_btn = QPushButton("&" + strings._("run_report")) self.report_btn.clicked.connect(self._on_run_report) btn_row.addStretch(1) @@ -274,7 +274,7 @@ class TimeLogDialog(QDialog): # --- Close button close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton(strings._("close")) + close_btn = QPushButton("&" + strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) @@ -333,7 +333,7 @@ class TimeLogDialog(QDialog): self._current_entry_id = None self.delete_btn.setEnabled(False) - self.add_update_btn.setText(strings._("add_time_entry")) + self.add_update_btn.setText("&" + strings._("add_time_entry")) # ----- Actions ----------------------------------------------------- @@ -383,7 +383,6 @@ class TimeLogDialog(QDialog): self._current_entry_id, proj_id, activity_id, minutes, note ) - self.note.setText("") self._reload_entries() def _on_row_selected(self) -> None: @@ -391,7 +390,7 @@ class TimeLogDialog(QDialog): if not items: self._current_entry_id = None self.delete_btn.setEnabled(False) - self.add_update_btn.setText(strings._("add_time_entry")) + self.add_update_btn.setText("&" + strings._("add_time_entry")) return row = items[0].row() @@ -403,7 +402,7 @@ class TimeLogDialog(QDialog): self._current_entry_id = int(entry_id) self.delete_btn.setEnabled(True) - self.add_update_btn.setText(strings._("update_time_entry")) + self.add_update_btn.setText("&" + strings._("update_time_entry")) # push values into the editors proj_name = proj_item.text() @@ -543,15 +542,15 @@ class TimeCodeManagerDialog(QDialog): proj_layout.addWidget(self.project_list, 1) proj_btn_row = QHBoxLayout() - self.proj_add_btn = QPushButton(strings._("add_project")) - self.proj_rename_btn = QPushButton(strings._("rename_project")) - self.proj_delete_btn = QPushButton(strings._("delete_project")) + self.proj_add_btn = QPushButton("&" + strings._("add_project")) + self.proj_rename_btn = QPushButton("&" + strings._("rename_project")) + self.proj_delete_btn = QPushButton("&" + strings._("delete_project")) proj_btn_row.addWidget(self.proj_add_btn) proj_btn_row.addWidget(self.proj_rename_btn) proj_btn_row.addWidget(self.proj_delete_btn) proj_layout.addLayout(proj_btn_row) - self.tabs.addTab(proj_tab, strings._("projects")) + self.tabs.addTab(proj_tab, "&" + strings._("projects")) # Activities tab act_tab = QWidget() @@ -560,9 +559,9 @@ class TimeCodeManagerDialog(QDialog): act_layout.addWidget(self.activity_list, 1) act_btn_row = QHBoxLayout() - self.act_add_btn = QPushButton(strings._("add_activity")) - self.act_rename_btn = QPushButton(strings._("rename_activity")) - self.act_delete_btn = QPushButton(strings._("delete_activity")) + self.act_add_btn = QPushButton("&" + strings._("add_activity")) + self.act_rename_btn = QPushButton("&" + strings._("rename_activity")) + self.act_delete_btn = QPushButton("&" + strings._("delete_activity")) act_btn_row.addWidget(self.act_add_btn) act_btn_row.addWidget(self.act_rename_btn) act_btn_row.addWidget(self.act_delete_btn) @@ -573,7 +572,7 @@ class TimeCodeManagerDialog(QDialog): # Close close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton(strings._("close")) + close_btn = QPushButton("&" + strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) @@ -917,7 +916,7 @@ class TimeReportDialog(QDialog): # Close close_row = QHBoxLayout() close_row.addStretch(1) - close_btn = QPushButton(strings._("close")) + close_btn = QPushButton("&" + strings._("close")) close_btn.clicked.connect(self.accept) close_row.addWidget(close_btn) root.addLayout(close_row) From 243980e006888c45c7eff783696e823d1853b2db Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 20 Nov 2025 17:02:41 +1100 Subject: [PATCH 2/3] Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) --- CHANGELOG.md | 1 + bouquin/markdown_editor.py | 162 ++++++++++++++++++++++++++-------- tests/test_markdown_editor.py | 149 +++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52542b5..378e13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # 0.4.1 * Allow time log entries to be edited directly in their table cells + * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) # 0.4 diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 5cd357c..6436ec8 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -406,9 +406,14 @@ class MarkdownEditor(QTextEdit): # Append the new marker new_line = f"{new_line} ⏰ {time_str}" - bc = QTextCursor(block) + # --- : only replace the block's text, not its newline --- + block_start = block.position() + block_end = block_start + len(line) + + bc = QTextCursor(self.document()) bc.beginEditBlock() - bc.select(QTextCursor.SelectionType.BlockUnderCursor) + bc.setPosition(block_start) + bc.setPosition(block_end, QTextCursor.KeepAnchor) bc.insertText(new_line) bc.endEditBlock() @@ -426,6 +431,35 @@ class MarkdownEditor(QTextEdit): """Public wrapper used by MainWindow for reminders.""" return self._get_current_line() + def _list_prefix_length_for_block(self, block) -> int: + """Return the length (in chars) of the visual list prefix for the given + block (including leading indentation), or 0 if it's not a list item. + """ + line = block.text() + stripped = line.lstrip() + leading_spaces = len(line) - len(stripped) + + # Checkbox (Unicode display) + if stripped.startswith( + f"{self._CHECK_UNCHECKED_DISPLAY} " + ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "): + return leading_spaces + 2 # icon + space + + # Unicode bullet + if stripped.startswith(f"{self._BULLET_DISPLAY} "): + return leading_spaces + 2 # bullet + space + + # Markdown bullet list (-, *, +) + if re.match(r"^[-*+]\s", stripped): + return leading_spaces + 2 # marker + space + + # Numbered list: e.g. "1. " + m = re.match(r"^(\d+\.\s)", stripped) + if m: + return leading_spaces + leading_spaces + (len(m.group(1)) - leading_spaces) + + return 0 + def _detect_list_type(self, line: str) -> tuple[str | None, str]: """ Detect if line is a list item. Returns (list_type, prefix). @@ -559,48 +593,102 @@ class MarkdownEditor(QTextEdit): self._update_code_block_row_backgrounds() return - # Handle Home and Left arrow keys to prevent going left of list markers + # Handle Backspace on empty list items so the marker itself can be deleted + if event.key() == Qt.Key.Key_Backspace: + cursor = self.textCursor() + # Let Backspace behave normally when deleting a selection. + if not cursor.hasSelection(): + block = cursor.block() + prefix_len = self._list_prefix_length_for_block(block) + + if prefix_len > 0: + block_start = block.position() + line = block.text() + pos_in_block = cursor.position() - block_start + after_text = line[prefix_len:] + + # If there is no real content after the marker, treat Backspace + # as "remove the list marker". + if after_text.strip() == "" and pos_in_block >= prefix_len: + cursor.beginEditBlock() + cursor.setPosition(block_start) + cursor.setPosition( + block_start + prefix_len, QTextCursor.KeepAnchor + ) + cursor.removeSelectedText() + cursor.endEditBlock() + self.setTextCursor(cursor) + return + + # Handle Home and Left arrow keys to keep the caret to the *right* + # of list prefixes (checkboxes / bullets / numbers). if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left): + # Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of + # document / word-left) – we don't interfere with those. + if event.modifiers() & Qt.ControlModifier: + pass + else: + cursor = self.textCursor() + block = cursor.block() + prefix_len = self._list_prefix_length_for_block(block) + + if prefix_len > 0: + block_start = block.position() + pos_in_block = cursor.position() - block_start + target = block_start + prefix_len + + if event.key() == Qt.Key.Key_Home: + # Home should jump to just after the prefix; with Shift + # it should *select* back to that position. + if event.modifiers() & Qt.ShiftModifier: + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) + self.setTextCursor(cursor) + return + + # Left arrow: don't allow the caret to move into the prefix + # region; snap it to just after the marker instead. + if event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len: + if event.modifiers() & Qt.ShiftModifier: + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) + self.setTextCursor(cursor) + return + + # After moving vertically, make sure we don't land *inside* a list + # prefix. We let QTextEdit perform the move first and then adjust. + if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not ( + event.modifiers() & Qt.ControlModifier + ): + super().keyPressEvent(event) + cursor = self.textCursor() block = cursor.block() - line = block.text() - pos_in_block = cursor.position() - block.position() - # Detect list prefix length - prefix_len = 0 - stripped = line.lstrip() - leading_spaces = len(line) - len(stripped) - - # Check for checkbox (Unicode display format) - if stripped.startswith( - f"{self._CHECK_UNCHECKED_DISPLAY} " - ) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "): - prefix_len = leading_spaces + 2 # icon + space - # Check for Unicode bullet - elif stripped.startswith(f"{self._BULLET_DISPLAY} "): - prefix_len = leading_spaces + 2 # bullet + space - # Check for markdown bullet list (-, *, +) - elif re.match(r"^[-*+]\s", stripped): - prefix_len = leading_spaces + 2 # marker + space - # Check for numbered list - elif re.match(r"^\d+\.\s", stripped): - match = re.match(r"^(\d+\.\s)", stripped) - if match: - prefix_len = leading_spaces + len(match.group(1)) + # Don't interfere with code blocks (they can contain literal + # markdown-looking text). + if self._is_inside_code_block(block): + return + prefix_len = self._list_prefix_length_for_block(block) if prefix_len > 0: - if event.key() == Qt.Key.Key_Home: - # Move to after the list marker - cursor.setPosition(block.position() + prefix_len) + block_start = block.position() + pos_in_block = cursor.position() - block_start + if pos_in_block < prefix_len: + target = block_start + prefix_len + if event.modifiers() & Qt.ShiftModifier: + # Preserve the current anchor while snapping the visual + # caret to just after the marker. + anchor = cursor.anchor() + cursor.setPosition(anchor) + cursor.setPosition(target, QTextCursor.KeepAnchor) + else: + cursor.setPosition(target) self.setTextCursor(cursor) - return - elif event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len: - # Prevent moving left of the list marker - if pos_in_block > prefix_len: - # Allow normal left movement if we're past the prefix - super().keyPressEvent(event) - # Otherwise block the movement - return + + return # Handle Enter key for smart list continuation AND code blocks if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 197c1ab..9294773 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1659,3 +1659,152 @@ Unicode: 你好 café résumé doc.setPlainText(text) assert highlighter is not None + + +@pytest.mark.parametrize( + "markdown_line", + [ + "- [ ] Task", # checkbox + "- Task", # bullet + "1. Task", # numbered + ], +) +def test_home_on_list_line_moves_to_text_start(qtbot, editor, markdown_line): + """Home on a list line should jump to just after the list marker.""" + editor.from_markdown(markdown_line) + + # Put caret at end of the line + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press Home (no modifiers) + qtbot.keyPress(editor, Qt.Key_Home) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + # The first character of the user text is the 'T' in "Task" + logical_start = line.index("Task") + + assert not c.hasSelection() + assert pos_in_block == logical_start + + +@pytest.mark.parametrize( + "markdown_line", + [ + "- [ ] Task", # checkbox + "- Task", # bullet + "1. Task", # numbered + ], +) +def test_shift_home_on_list_line_selects_text_after_marker( + qtbot, editor, markdown_line +): + """ + Shift+Home from the end of a list line should select the text after the marker, + not the marker itself. + """ + editor.from_markdown(markdown_line) + + # Put caret at end of the line + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Shift+Home: extend selection back to "logical home" + qtbot.keyPress(editor, Qt.Key_Home, Qt.ShiftModifier) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + block_start = block.position() + + logical_start = line.index("Task") + expected_start = block_start + logical_start + expected_end = block_start + len(line) + + assert c.hasSelection() + assert c.selectionStart() == expected_start + assert c.selectionEnd() == expected_end + # Selected text is exactly the user-visible text, not the marker + assert c.selectedText() == line[logical_start:] + + +def test_up_from_below_checkbox_moves_to_text_start(qtbot, editor): + """ + Up from the line below a checkbox should land to the right of the checkbox, + where the text starts, not to the left of the marker. + """ + editor.from_markdown("- [ ] Task\nSecond line") + + # Put caret somewhere on the second line (end of document is fine) + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + # Press Up to move to the checkbox line + qtbot.keyPress(editor, Qt.Key_Up) + qtbot.wait(0) + + c = editor.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + logical_start = line.index("Task") + assert pos_in_block >= logical_start + + +def test_backspace_on_empty_checkbox_removes_marker(qtbot, editor): + """ + When a checkbox line has no text after the marker, Backspace at/after the + text position should delete the marker itself, leaving a plain empty line. + """ + editor.from_markdown("- [ ] ") + + # Put caret at end of the checkbox line (after the marker) + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Backspace) + qtbot.wait(0) + + first_block = editor.document().firstBlock() + # Marker should be gone + assert first_block.text() == "" + assert editor._CHECK_UNCHECKED_DISPLAY not in editor.toPlainText() + + +def test_insert_alarm_marker_on_checkbox_line_does_not_merge_lines(editor, qtbot): + # Two checkbox lines + editor.from_markdown("- [ ] Test\n- [ ] Foobar") + + # Move caret to second line ("Foobar") + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.Down) + editor.setTextCursor(cursor) + + # Add an alarm to the second line + editor.insert_alarm_marker("16:54") + qtbot.wait(0) + + lines = lines_keep(editor) + + # Still two separate lines + assert len(lines) == 2 + + # First line unchanged (no alarm) + assert "Test" in lines[0] + assert "⏰" not in lines[0] + + # Second line has the alarm marker + assert "Foobar" in lines[1] + assert "⏰ 16:54" in lines[1] From cff5f864e46435561b2f2f96adb4616bed6bbe91 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 20 Nov 2025 17:18:45 +1100 Subject: [PATCH 3/3] Add 'Close tab' nav item and shortcut. Add extra newline after headings --- CHANGELOG.md | 1 + bouquin/locales/en.json | 1 + bouquin/main_window.py | 13 +++++++++++++ bouquin/markdown_editor.py | 13 +++++++++++++ 4 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 378e13b..f9290ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Allow time log entries to be edited directly in their table cells * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) + * Add 'Close tab' nav item and shortcut # 0.4 diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index f7a38ea..9b70cfd 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -36,6 +36,7 @@ "behaviour": "Behaviour", "never": "Never", "browse": "Browse", + "close_tab": "Close tab", "previous": "Previous", "previous_day": "Previous day", "next": "Next", diff --git a/bouquin/main_window.py b/bouquin/main_window.py index c2f3d40..b5dab36 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -260,6 +260,13 @@ class MainWindow(QMainWindow): nav_menu.addAction(act_today) self.addAction(act_today) + act_close_tab = QAction(strings._("close_tab"), self) + act_close_tab.setShortcut("Ctrl+W") + act_close_tab.setShortcutContext(Qt.ApplicationShortcut) + act_close_tab.triggered.connect(self._close_current_tab) + nav_menu.addAction(act_close_tab) + self.addAction(act_close_tab) + act_find = QAction(strings._("find_on_page"), self) act_find.setShortcut(QKeySequence.Find) act_find.triggered.connect(self.findBar.show_bar) @@ -520,6 +527,12 @@ class MainWindow(QMainWindow): self.tab_widget.removeTab(index) + def _close_current_tab(self): + """Close the currently active tab via shortcuts (Ctrl+W).""" + idx = self.tab_widget.currentIndex() + if idx >= 0: + self._close_tab(idx) + def _on_tab_changed(self, index: int): """Handle tab change - reconnect toolbar and sync UI.""" if index < 0: diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 6436ec8..7fea40c 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -753,6 +753,19 @@ class MarkdownEditor(QTextEdit): super().keyPressEvent(event) return + # Auto-insert an extra blank line after headings (#, ##, ###) + # when pressing Enter at the end of the line. + if re.match(r"^#{1,3}\s+", stripped) and pos_in_block >= len(line_text): + cursor.beginEditBlock() + # First blank line: visual separator between heading and body + cursor.insertBlock() + # Second blank line: where body text will start (caret ends here) + cursor.insertBlock() + cursor.endEditBlock() + + self.setTextCursor(cursor) + return + # Check for list continuation list_type, prefix = self._detect_list_type(current_line)