diff --git a/CHANGELOG.md b/CHANGELOG.md index f9290ee..52542b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # 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) - * Add 'Close tab' nav item and shortcut # 0.4 diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 9b70cfd..f7a38ea 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -36,7 +36,6 @@ "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 b5dab36..c2f3d40 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -260,13 +260,6 @@ 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) @@ -527,12 +520,6 @@ 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 7fea40c..5cd357c 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -406,14 +406,9 @@ class MarkdownEditor(QTextEdit): # Append the new marker new_line = f"{new_line} ⏰ {time_str}" - # --- : only replace the block's text, not its newline --- - block_start = block.position() - block_end = block_start + len(line) - - bc = QTextCursor(self.document()) + bc = QTextCursor(block) bc.beginEditBlock() - bc.setPosition(block_start) - bc.setPosition(block_end, QTextCursor.KeepAnchor) + bc.select(QTextCursor.SelectionType.BlockUnderCursor) bc.insertText(new_line) bc.endEditBlock() @@ -431,35 +426,6 @@ 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). @@ -593,102 +559,48 @@ class MarkdownEditor(QTextEdit): self._update_code_block_row_backgrounds() return - # 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). + # Handle Home and Left arrow keys to prevent going left of list markers 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() - # Don't interfere with code blocks (they can contain literal - # markdown-looking text). - if self._is_inside_code_block(block): - return + # 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)) - prefix_len = self._list_prefix_length_for_block(block) if prefix_len > 0: - 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) + if event.key() == Qt.Key.Key_Home: + # Move to after the list marker + cursor.setPosition(block.position() + prefix_len) self.setTextCursor(cursor) - - return + 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 # Handle Enter key for smart list continuation AND code blocks if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: @@ -753,19 +665,6 @@ 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) diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index a5d12d0..83c17c0 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 9ff5da4..3cb30bf 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,6 +383,7 @@ 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: @@ -390,7 +391,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() @@ -402,7 +403,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() @@ -542,15 +543,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() @@ -559,9 +560,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) @@ -572,7 +573,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) @@ -916,7 +917,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) diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 9294773..197c1ab 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1659,152 +1659,3 @@ 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]