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:
+2
-1
@@ -4,7 +4,7 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
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.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
@@ -345,6 +345,7 @@ if (BUILD_TESTS)
|
|||||||
tests/test_smart_newline.cc
|
tests/test_smart_newline.cc
|
||||||
tests/test_reflow_undo.cc
|
tests/test_reflow_undo.cc
|
||||||
tests/test_syntax_highlighting.cc
|
tests/test_syntax_highlighting.cc
|
||||||
|
tests/test_paste_split.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
|
|||||||
+6
-25
@@ -8,6 +8,7 @@
|
|||||||
#include "ImGuiInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||||
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||||
@@ -390,32 +391,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (clip) {
|
if (clip) {
|
||||||
std::string text(clip);
|
std::string text(clip);
|
||||||
SDL_free(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::lock_guard<std::mutex> lk(mu_);
|
||||||
std::size_t start = 0;
|
for (const auto &cmd : SplitPasteIntoCommands(text))
|
||||||
while (start <= text.size()) {
|
q_.push(cmd);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress the corresponding TEXTINPUT that may follow
|
// Suppress the corresponding TEXTINPUT that may follow
|
||||||
suppress_text_input_once_ = true;
|
suppress_text_input_once_ = true;
|
||||||
return true; // consumed
|
return true; // consumed
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user