Break one line, not two, when Return lands on a blank line - #12
Conversation
smartReturn ends a paragraph with two breaks because a new paragraph in this editor stands apart from the one above it by a blank line. A line that is already blank has no paragraph to stand apart from, so that second break was a gap nobody asked for: every Return on a blank line grew the space in twos, an empty document included. Fall back to a single break when there is nothing but whitespace on either side of the caret on its line. The list and code fence branches above already inserted one break each and are untouched. Fixes omacom#11 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC
The new branch reads the line around cursorPosition, but a selection is deleted before the break goes in, and dragged right to left it leaves the caret at the far end of it. Selecting "\ntw" in "one\n\ntwo" backwards therefore inserted one break where the identical selection made left to right inserted two: the caret's line was blank, but the line the break landed on was not. Read the prefix from the start of the selection and the remainder from its end, so the test describes the line that survives it either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Return followed by Backspace on a blank line now eats a paragraph break. In The helper collapses two breaks whenever they sit behind the caret, so with this change it can undo more than the Return that produced them. Telling the two apart wants both ends of the pair: a paragraph break is what Return inserts only when the line before the pair is not blank, and only when the caret's own line does not continue past it. That is verified, not sketched — with it, Return then Backspace on the blank line returns to I did push df2f992, for the other thing. The new branch reads the line around Everything above was checked by driving the real key handler in a window on a throwaway VM. The suite is 13 passing on |
Backspace closes a paragraph break in one press, which is what you want at the head of a paragraph: "one\n\ntwo" with the caret on "two" joins to "onetwo". But the helper took any two breaks sitting behind the caret, and now that Return on a blank line writes a single break, those two are no longer always its doing. On the blank line of "one\n\ntwo" the caret is at 4, Return correctly gives "one\n\n\ntwo" with the caret at 5, and Backspace then read the pair at 3..5 and took both, leaving "one\ntwo". One press to open a line and one to close it again, and the separator that was there before you touched anything went with it. What the pair means is not written on the pair. Read the caret's own line, and then the lines on either side of it. Anything on the caret's line and the pair is a paragraph break and nothing else: Return writes two breaks only where the line it leaves behind is not blank, and a writer joining two paragraphs is the same shape read the other way round. Both breaks go, as they always did. A blank line is the case worth thinking about, because there Return may have written only one of the two. One reading settles it outright: a blank line above the pair, where Return writes a single break as well and so wrote neither of these. Past that the text stops answering. For the pair to be there at all the line above the caret must be empty, and both Returns leave exactly that, so "one\n\n\n\ntwo" with the caret at 5 is what Return makes of "one\n\ntwo" at the end of "one" and equally what it makes of "one\n\n\ntwo" on the blank line -- same document, same caret, nothing to tell them apart by. So this does not guess at which was pressed. It keeps the gap standing: both breaks go only while a blank line of the gap survives them, and otherwise one goes. The next paragraph below the caret leaves the gap none, and so does the end of the document, which is that same rule at its edge rather than a second rule of its own. Return then Backspace on the trailing blank line of "one\n" therefore leaves "one\n" and not "one", and on a document of three spaces leaves all three. The rule is chosen for the press it favours, and it costs something. Ending a paragraph -- Return at the end of a line, much the commonest of these presses -- goes back in one press, exactly as it always has. Return inside a gap of two or more blank lines is what pays: Backspace reads it as the paragraph-ending press it cannot be told apart from, so the writer gets back one blank line fewer than they had. Their gap is still a gap and one more Return returns the line, which is the trade -- a blank line out of several, against paragraphs run together or the line a document ended on. Ending the last paragraph of a document pays in the other currency, a second press to undo. Both readings are of whole lines, because a line of spaces is a blank line everywhere else in this file, and smartReturn writes a single break above one exactly as it does above an empty one. That is what keeps "one\n \ntwo" round-tripping. Reading the line above whole would cost something if it were read every time: "one\n \n\ntwo" would stop joining to "one\n two" on one press, though a two-break Return is precisely what writes that gap. But the line above is only ever asked about on a blank line, and on a blank line there is nothing to join, so asking it there and nowhere else costs nothing. closesParagraphBreaksOnBackspace drives the real key handler in a window over each of these, the caret included, the wide gap that pays for the rule as much as the presses that profit by it. Suite is 14 passing, and bin/build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXHc91hBNDVXyoaCpqXDae
Backspace had to answer for this change, and one press had to keep meaning one pressThank you for both halves of that review — the right-to-left fix you pushed, and the regression you found and deliberately did not push. The second one was right to raise: making Return write a single break on a blank line quietly made
Your guard, tested and rejectedI applied it verbatim first and ran a case table against it. It fixes the case you reported, and it still eats a break when the blank line is whitespace-only: Same with a tab. That is the same bug, and it is inside this PR's scope: the PR's own test treats a line of spaces as blank, and The two guards turned out to be mirror images — yours reads a whole line above the pair and one character below; the first one I committed read one character above and a whole line below. Neither read both. This one reads three whole lines: the caret's own line, and the lines above and below it only when that one is blank. There is no way to tell the two Returns apartMy first attempt claimed the line below the caret told them apart. It does not, and nothing does. Enumerating documents over a small alphabet and applying The first line is the case this PR exists to fix. So provenance decides nothing here — the reason the headline case takes one break was never that we know which Return wrote it. It is cost. One thing the enumeration did settle in the code's favour: of those 170 states, none has a blank line above the pair. A two-break Return provably cannot produce that, since it needs a non-blank head, and the head is what sits above the pair. That clause is decided by the text rather than chosen. The rule, and what it costs
The end of the document is that rule at its edge, not a second rule: after a blank last line there is no more gap either, so It optimises for ending a paragraph — Return at the end of a line still round-trips in one press, which is much the commonest of these presses, and which my first attempt broke. What it costs is a Return inside a gap of two or more blank lines: the writer gets back one blank line fewer than they had. The gap still separates the paragraphs and one more Return returns the line. That is the trade — a blank line out of several, against paragraphs run together or the line a document ended on. It is pinned as a test whose comment says it is the case that pays, rather than a case that works. Where Backspace should stop was your call to leave to the maintainer, so to be explicit about what changed from master: closing a gap of three or more blank lines now takes one press per line rather than two at a time, and A third defect, found on the wayMy first guard broke Return at the very head of a document: Tests
Every assertion is checked against two baselines — the PR without any guard, and the first guard I committed — so each one says which rule it detects rather than merely passing. Nine of eleven blocks fail under at least one; the two that fail under neither are anti-regression pins, and I am calling them that rather than counting them as coverage. 14 passing, |
Greptile SummaryThe PR changes Return on an already blank line to insert one newline rather than a paragraph-sized pair, while preserving paragraph, list, and fenced-code behavior. It also adjusts Backspace handling to distinguish paragraph breaks from individually inserted blank-line breaks and adds window-driven regression coverage.
Confidence Score: 5/5The PR appears safe to merge, with focused behavior changes backed by broad keyboard-driven regression coverage. The changed logic consistently bases Return on the line remaining after selection replacement and preserves the documented special branches, while the accompanying tests exercise both insertion and deletion across the relevant blank-line states.
|
| Filename | Overview |
|---|---|
| src/Main.qml | Updates Return and Backspace newline handling with selection-aware blank-line detection and guards for ambiguous paragraph gaps; no actionable defect was identified. |
| tests/tst_omawrite.cpp | Adds end-to-end QML keyboard tests covering the changed blank-line behavior and unaffected list, code-fence, selection, whitespace, and paragraph-break cases. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Return key] --> B{Special context?}
B -->|Code fence or list| C[Existing one-break behavior]
B -->|Normal text| D{Selection leaves a blank line?}
D -->|Yes| E[Insert one newline]
D -->|No| F[Insert two newlines]
G[Backspace after two newlines] --> H{Paragraph break can close safely?}
H -->|Yes| I[Remove both newlines]
H -->|No| J[Allow normal one-character deletion]
Reviews (1): Last reviewed commit: "Read both ends of the gap before Backspa..." | Re-trigger Greptile
|
Re-reviewed at I built my own case table rather than reading yours, driving the real key handler in a window through the same harness your tests use, and it turned up a third behaviour change from
It is also the case your comment names as the one that still works: "Return at the end of a line still round-trips in one press, which is much the commonest of these presses." That holds only when a blank line already follows the line. When the next line has text on it, I want to be clear that this is your rule doing what it says rather than a bug in the code implementing it — removing both breaks there would leave the heading and its body with nothing between them, which is exactly what "both breaks go only while a blank line of the gap survives them" forbids. So I have pushed nothing. But it belongs in the body beside the other two costs and in the test file beside the other two assertions, because the way it reads now, a maintainer weighing the trade is weighing two of three. Some scale for the trade, from enumerating every document over Three more, all of which behave identically on Worth knowing when reading the new comment at What I checked and what I did not: the diff and the surrounding key handling read by hand; the case table above and the full round-trip enumeration run through the real Reviewed by Claude Opus 5 and by Codex at xhigh reasoning as a second opinion. Codex reached the This is waiting on you for the body and the third assertion, and on the maintainer for the rule itself — whether the blank line the gap keeps is worth a heading that takes two presses to un-split. Nothing pushed to your branch. |
Fixes #11.
smartReturnends a paragraph with two breaks, which is right for a paragraph: a new one stands apart from the one above it by a blank line, so Return at the end oftwocorrectly givestwo\n\n. A line that is already blank has no paragraph to stand apart from, so that second break was a gap nobody asked for — every Return on a blank line grew the space in twos, and an empty document opened with two blank lines rather than one.The fallback now inserts a single break when there is nothing but whitespace on either side of the caret on its line. The list-item and code-fence branches above it already inserted one break each and are untouched. It reads the line around what the selection leaves behind rather than around the caret, so a selection dragged right to left gives the same answer as the same selection dragged left to right.
Backspace had to answer for it
deleteParagraphBreakBehindCursorcloses a whole paragraph break in one press, which is what you want at the head of a paragraph:one\n\ntwowith the caret ontwojoins toonetwo. It did that for any two breaks behind the caret — and once Return on a blank line writes a single break, those two are no longer always its own doing. On the blank line ofone\n\ntwo, Return then Backspace leftone\ntwo: one press to open a line, one to close it, and the separator that was there before you touched anything gone with it.So Backspace now reads three whole lines — the caret's own, and the lines above and below it only when that one is blank:
The end of the document is that rule at its edge rather than a second rule: after a blank last line there is no more gap either.
There is no way to do better than a rule here, and that is worth stating plainly. Enumerating documents over a small alphabet and applying
smartReturnat every caret position gives 5800 states with two breaks behind the caret, of which 170 are reachable by both a one-break and a two-break Return — identical text, identical caret.one\n\n\ntwowith the caret at 5 is one of them, and it is the state this PR's own fix produces. Which Return wrote a gap cannot be read back out of the document, so the rule is chosen on cost, not on provenance.What it costs: a Return inside a gap of two or more blank lines gives back one blank line fewer than the writer had. The gap still separates the paragraphs and one more Return returns the line. Two behaviours therefore change from master, both asserted: closing a gap of three or more blank lines takes one press per line rather than two at a time, and
one\n\ntwowith Return at the end takes two Backspaces to undo instead of one.One clause is decided by the text rather than chosen: a blank line above the pair proves a two-break Return did not write it, since that needs a non-blank head. None of the 170 ambiguous states has one.
Test
breaksLinesOnReturndrives a real window with the keyboard, covering both the bug and the branches that must not move:one\n\ntwo(on the blank line)one\n\n\ntwoone\n\ntwo(on the blank line)one\n\n\n\ntwoone\n\ntwo(end oftwo)one\n\ntwo\n\n\none\n \ntwo(between the spaces)one\n \n \ntwo- item- item\n-, then- item\n\nclosesParagraphBreaksOnBackspacecovers the other half in the same real window: the blank-line round trip, two Returns and two Backspaces, a whitespace-only blank line, the empty document, the end of a document and a whitespace-only one, Return at the very head of a document, the paragraph joins that must not move, and the wide gap that pays for the rule. It asserts the caret as well as the text.Every assertion is checked against two baselines — this PR with no Backspace guard, and an earlier guard that took one break on any blank line — so each says which rule it detects rather than merely passing. Nine of eleven blocks fail under at least one; the two that fail under neither are anti-regression pins rather than coverage.
make && ./tst_omawriteinbuild-tests: 14 passed, 0 failed.🤖 Generated with Claude Code
https://claude.ai/code/session_01TyQRJCyR76uk7XaNAB8jMC