Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline)

This commit is contained in:
Miguel Jacq 2025-11-20 17:02:41 +11:00
parent 511e7ae7b8
commit 243980e006
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
3 changed files with 275 additions and 37 deletions

View file

@ -1,6 +1,7 @@
# 0.4.1 # 0.4.1
* Allow time log entries to be edited directly in their table cells * 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 # 0.4

View file

@ -406,9 +406,14 @@ class MarkdownEditor(QTextEdit):
# Append the new marker # Append the new marker
new_line = f"{new_line}{time_str}" 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.beginEditBlock()
bc.select(QTextCursor.SelectionType.BlockUnderCursor) bc.setPosition(block_start)
bc.setPosition(block_end, QTextCursor.KeepAnchor)
bc.insertText(new_line) bc.insertText(new_line)
bc.endEditBlock() bc.endEditBlock()
@ -426,6 +431,35 @@ class MarkdownEditor(QTextEdit):
"""Public wrapper used by MainWindow for reminders.""" """Public wrapper used by MainWindow for reminders."""
return self._get_current_line() 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]: def _detect_list_type(self, line: str) -> tuple[str | None, str]:
""" """
Detect if line is a list item. Returns (list_type, prefix). Detect if line is a list item. Returns (list_type, prefix).
@ -559,47 +593,101 @@ class MarkdownEditor(QTextEdit):
self._update_code_block_row_backgrounds() self._update_code_block_row_backgrounds()
return 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() in (Qt.Key.Key_Home, Qt.Key.Key_Left): if event.key() == Qt.Key.Key_Backspace:
cursor = self.textCursor() cursor = self.textCursor()
# Let Backspace behave normally when deleting a selection.
if not cursor.hasSelection():
block = cursor.block() block = cursor.block()
line = block.text() prefix_len = self._list_prefix_length_for_block(block)
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))
if prefix_len > 0: if prefix_len > 0:
if event.key() == Qt.Key.Key_Home: block_start = block.position()
# Move to after the list marker line = block.text()
cursor.setPosition(block.position() + prefix_len) 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) 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 # Handle Home and Left arrow keys to keep the caret to the *right*
if pos_in_block > prefix_len: # of list prefixes (checkboxes / bullets / numbers).
# Allow normal left movement if we're past the prefix 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) super().keyPressEvent(event)
# Otherwise block the movement
cursor = self.textCursor()
block = cursor.block()
# 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:
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 return
# Handle Enter key for smart list continuation AND code blocks # Handle Enter key for smart list continuation AND code blocks

View file

@ -1659,3 +1659,152 @@ Unicode: 你好 café résumé
doc.setPlainText(text) doc.setPlainText(text)
assert highlighter is not None 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]