diff --git a/CMakeLists.txt b/CMakeLists.txt index 1396571..facd289 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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. @@ -345,6 +345,7 @@ if (BUILD_TESTS) tests/test_smart_newline.cc tests/test_reflow_undo.cc tests/test_syntax_highlighting.cc + tests/test_paste_split.cc # minimal engine sources required by Buffer PieceTable.cc diff --git a/ImGuiInputHandler.cc b/ImGuiInputHandler.cc index c36bff4..fd81382 100644 --- a/ImGuiInputHandler.cc +++ b/ImGuiInputHandler.cc @@ -8,6 +8,7 @@ #include "ImGuiInputHandler.h" #include "KKeymap.h" #include "Editor.h" +#include "PasteSplit.h" // Verbose k-prefix suffix logging for debugging macOS/SDL key translation // 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) { 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 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 diff --git a/PasteSplit.h b/PasteSplit.h new file mode 100644 index 0000000..8c90cc4 --- /dev/null +++ b/PasteSplit.h @@ -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 +#include + +#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 SplitPasteIntoCommands(const std::string &text) +{ + std::vector 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; +} diff --git a/tests/test_paste_split.cc b/tests/test_paste_split.cc new file mode 100644 index 0000000..3ea9280 --- /dev/null +++ b/tests/test_paste_split.cc @@ -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 &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"); +}