Fix GUI paste of CRLF/CR text: split all line endings

The ImGui Cmd+V/Ctrl+V paste handler split clipboard text only on '\n',
leaving '\r' inside each segment. Pasting text with CRLF (Windows) or bare
CR (classic-Mac / some macOS apps) line endings enqueued an InsertText arg
containing '\r', which the InsertText command rejects with "InsertText arg
must not contain newlines".

Extract the paste-to-commands logic into a pure, SDL-free helper
(PasteSplit.h) that treats '\n', '\r\n', and bare '\r' each as one line
break, and add unit tests covering all line-ending forms plus blank-line
and trailing-newline preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:20:03 -07:00
co-authored by Claude Opus 4.8
parent b60a8dc491
commit e2b255901e
4 changed files with 160 additions and 26 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.11.2")
set(KTE_VERSION "1.11.3")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -344,6 +344,7 @@ if (BUILD_TESTS)
tests/test_migration_coverage.cc
tests/test_smart_newline.cc
tests/test_reflow_undo.cc
tests/test_paste_split.cc
# minimal engine sources required by Buffer
PieceTable.cc
+6 -25
View File
@@ -8,6 +8,7 @@
#include "ImGuiInputHandler.h"
#include "KKeymap.h"
#include "Editor.h"
#include "PasteSplit.h"
static bool
@@ -376,32 +377,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (clip) {
std::string text(clip);
SDL_free(clip);
// Split on '\n' and enqueue as InsertText/Newline commands
// Turn line breaks (\n, \r\n, or bare \r) into Newline
// commands and the rest into InsertText; InsertText itself
// rejects any embedded '\r'/'\n'.
std::lock_guard<std::mutex> lk(mu_);
std::size_t start = 0;
while (start <= text.size()) {
std::size_t pos = text.find('\n', start);
std::string_view segment;
bool has_nl = (pos != std::string::npos);
if (has_nl) {
segment = std::string_view(text).substr(start, pos - start);
} else {
segment = std::string_view(text).substr(start);
}
if (!segment.empty()) {
MappedInput ins{
true, CommandId::InsertText, std::string(segment), 0
};
q_.push(ins);
}
if (has_nl) {
MappedInput nl{true, CommandId::Newline, std::string(), 0};
q_.push(nl);
start = pos + 1;
} else {
break;
}
}
for (const auto &cmd : SplitPasteIntoCommands(text))
q_.push(cmd);
// Suppress the corresponding TEXTINPUT that may follow
suppress_text_input_once_ = true;
return true; // consumed
+48
View File
@@ -0,0 +1,48 @@
/*
* PasteSplit.h - split pasted/clipboard text into editor commands
*
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
* across frontends. Line breaks in the pasted text must become Newline
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
*/
#pragma once
#include <string>
#include <vector>
#include "InputHandler.h"
// Translate a block of pasted text into a sequence of editor commands.
//
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
// emitted as a Newline command; the text between breaks becomes InsertText
// commands. Empty text segments are skipped, but line breaks are always
// emitted so blank lines round-trip correctly.
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
{
std::vector<MappedInput> out;
std::string segment;
auto flush_segment = [&]() {
if (!segment.empty()) {
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
segment.clear();
}
};
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (c == '\n' || c == '\r') {
flush_segment();
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
// Collapse a "\r\n" pair into one line break.
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
++i;
} else {
segment.push_back(c);
}
}
flush_segment();
return out;
}
+104
View File
@@ -0,0 +1,104 @@
// Tests for SplitPasteIntoCommands: pasted text must turn embedded line
// breaks into Newline commands so InsertText never receives a '\r' or '\n'.
#include "Test.h"
#include "PasteSplit.h"
namespace {
// Assert that no InsertText command carries an embedded newline/carriage
// return, mirroring the rejection in Command.cc's InsertText handler.
void
assert_no_newlines_in_inserts(const std::vector<MappedInput> &cmds)
{
for (const auto &c : cmds) {
if (c.id == CommandId::InsertText) {
ASSERT_TRUE(c.arg.find('\n') == std::string::npos);
ASSERT_TRUE(c.arg.find('\r') == std::string::npos);
}
}
}
} // namespace
TEST(PasteSplit_PlainTextNoNewline)
{
auto cmds = SplitPasteIntoCommands("hello world");
ASSERT_EQ(cmds.size(), 1u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
ASSERT_TRUE(cmds[0].arg == "hello world");
}
TEST(PasteSplit_UnixNewlines)
{
auto cmds = SplitPasteIntoCommands("a\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
// Regression: CRLF clipboards left a '\r' in the InsertText segment, which
// InsertText rejected with "InsertText arg must not contain newlines".
TEST(PasteSplit_WindowsCRLF)
{
auto cmds = SplitPasteIntoCommands("a\r\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
// Regression: a bare '\r' (classic Mac / some macOS apps) must also break lines.
TEST(PasteSplit_BareCR)
{
auto cmds = SplitPasteIntoCommands("a\rb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
TEST(PasteSplit_BlankLinesPreserved)
{
// Two line breaks between "a" and "b" => one empty line.
auto cmds = SplitPasteIntoCommands("a\n\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 4u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::Newline);
ASSERT_TRUE(cmds[3].id == CommandId::InsertText && cmds[3].arg == "b");
}
TEST(PasteSplit_TrailingNewline)
{
auto cmds = SplitPasteIntoCommands("abc\n");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 2u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "abc");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
}
TEST(PasteSplit_MultilineCRLF)
{
auto cmds = SplitPasteIntoCommands("line1\r\nline2\r\nline3");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 5u);
ASSERT_TRUE(cmds[0].arg == "line1");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].arg == "line2");
ASSERT_TRUE(cmds[3].id == CommandId::Newline);
ASSERT_TRUE(cmds[4].arg == "line3");
}