25 Commits

Author SHA1 Message Date
cc8df36bdf Implement branching undo system with tests and updates.
- Added branching model for undo/redo, enabling multiple redo paths and branch selection.
- Updated `UndoNode` to include `parent` and refined hierarchical navigation.
- Extended `UndoSystem` with branching logic for redo operations, supporting sibling branch selection.
- Overhauled tests to validate branching behavior and tree invariants.
- Refined editor command logic for undo/redo with repeat counts and branch selection.
- Enabled test-only introspection hooks for undo tree validation.
- Updated CMake to include test definitions (`KTE_TESTS` flag).
2026-02-10 23:13:00 -08:00
1c0f04f076 Bump version to 1.6.0.
- Linear undo
- Multicursor support
- Reflow numbered lists
2026-02-10 22:41:20 -08:00
ac0eadc345 Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete).
- Added `UndoSystem` integration into the editor/commands pipeline.
- Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management.
- Refactored to ensure consistent cursor behavior during undo/redo actions.
- Updated CMake to include new tests.
2026-02-10 22:39:55 -08:00
f3bdced3d4 Add visual-line mode support with tests and UI integration.
- Introduced visual-line mode for multi-line selection and edits.
- Implemented commands, rendering, and keyboard shortcuts.
- Added tests for broadcast operations in visual-line mode.
2026-02-10 22:07:13 -08:00
2551388420 Support numbered lists in reflow-paragraph.
Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic.
2026-02-10 21:23:20 -08:00
d2d155f211 Fix data race.
+ Add thread-safety with mutexes in `PieceTable` and `Buffer`
+ Bump version to 1.5.9
2026-01-28 01:03:58 -08:00
8634eb78f0 Refactor Init method across all frontends to include argc and argv for improved argument handling consistency. 2026-01-12 10:35:24 -08:00
6eb240a0c4 Refactor ImGui editor layout and scrolling logic for improved precision and consistency. 2026-01-11 15:34:56 -08:00
4c402f5ef3 Replace Greek and Mathematical Operators font fallback with Iosevka Extended for improved font handling. 2026-01-11 12:07:24 -08:00
a8abda4b87 Unicode improvements and version bump.
- Added full UTF-8 support for terminal rendering, including multi-width character handling.
- Improved font handling in ImGui with expanded glyph support (Greek, Mathematical Operators).
- Updated locale initialization to enable proper character rendering.
- Bumped version to 1.5.8.
2026-01-11 11:39:08 -08:00
7347556aa2 Add missing cmake for macos. 2026-01-02 10:39:33 -08:00
289e155c98 bump version 2026-01-02 09:50:08 -08:00
147a52f3d4 center cursor 2026-01-01 21:59:20 -08:00
dda7541e2f adding berkeley mono as the default. 2026-01-01 20:10:22 -08:00
2408f5494c bump version 2026-01-01 19:13:07 -08:00
2542690eca updating jump to line 2026-01-01 19:12:46 -08:00
cc0c187481 Improve macOS app build process and bundle handling.
- Updated `make-app-release` script to use `macdeployqt` with proper verbosity and bundle fixup.
- Introduced post-build fixup using CMake's `BundleUtilities` to internalize non-Qt dylibs.
- Enhanced macOS bundle RPATH settings for accurate Framework resolution.
- Added optional `kge_fixup_bundle` CMake target for post-build handling.
- Refined `default.nix` to load Nixpkgs in a default argument.
2025-12-09 18:49:16 -08:00
a8dcfbec58 Fix C-k c handling. 2025-12-08 15:28:45 -08:00
65705e3354 bump version 2025-12-07 15:25:50 -08:00
e1f9a9eb6a Preserve cursor position on buffer reload.
- Remember and restore the cursor's position after reloading a buffer, clamping if necessary.
- Improve user experience by maintaining editing context.
2025-12-07 15:25:40 -08:00
c9f34003f2 Add unit testing plan documentation.
- Introduced comprehensive test plan to guide development and ensure coverage.
- Documented test principles, execution harness, build steps, and test catalog.
- Categorized test cases by functionality (e.g., filesystem I/O, PieceTable semantics, buffer editing, undo system, etc.).
- Outlined regression tests and performance/stress scenarios.
- Provided a phased roadmap for implementing planned test cases.
2025-12-07 12:34:47 -08:00
f450ef825c Replace individual test binaries with unified test runner.
- Removed standalone test executables (`test_undo`, `test_buffer_save`, `test_buffer_open_nonexistent_save`, etc.).
- Introduced `kte_tests` as a unified test runner.
- Migrated existing tests to a new minimal, reusable framework in `tests/Test.h`.
- Updated `CMakeLists.txt` to build a single `kte_tests` executable.
- Simplified dependencies, reducing the need for ncurses/GUI in test builds.
2025-12-07 00:37:16 -08:00
f6f0c11be4 Add PieceTable-based buffer tests and improvements for file I/O and editing.
- Introduced comprehensive tests:
  - `test_buffer_open_nonexistent_save.cc`: Save after opening a non-existent file.
  - `test_buffer_save.cc`: Save buffer contents to disk.
  - `test_buffer_save_existing.cc`: Save after opening existing files.
- Implemented `PieceTable::WriteToStream()` to directly stream content without full materialization.
- Updated `Buffer::Save` and `Buffer::SaveAs` to use efficient streaming via `PieceTable`.
- Enhanced editing commands (`Insert`, `Delete`, `Replace`, etc.) to use PieceTable APIs, ensuring proper undo and save functionality.
2025-12-07 00:30:11 -08:00
657c9bbc19 bump version 2025-12-06 11:40:27 -08:00
3493695165 Add support for creating a new empty buffer (C-k i).
- Introduced `BufferNew` command to create and switch to a new unnamed buffer.
- Registered `BufferNew` in the command registry and updated keymap and help text.
- Implemented `cmd_buffer_new()` to handle buffer creation and switching logic.
2025-12-06 11:40:00 -08:00
51 changed files with 8617 additions and 1115 deletions

View File

@@ -1,5 +1,6 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" /> <option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
</state> </state>
</component> </component>

3
.idea/editor.xml generated
View File

@@ -19,7 +19,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
@@ -58,6 +58,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />

2
.idea/kte.iml generated
View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4"> <module classpath="CIDR" type="CPP_MODULE" version="4">
<component name="FacetManager"> <component name="FacetManager">
<facet type="Python" name="Python facet"> <facet type="Python" name="Python facet">
<configuration sdkName="" /> <configuration sdkName="" />

View File

@@ -301,12 +301,13 @@ Buffer::Save(std::string &err) const
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno)); err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
// Write the entire buffer in a single block to minimize I/O calls. // Stream the content directly from the piece table to avoid relying on
const char *data = content_.Data(); // full materialization, which may yield an empty pointer when size > 0.
const auto size = static_cast<std::streamsize>(content_.Size()); if (content_.Size() > 0) {
if (data != nullptr && size > 0) { content_.WriteToStream(out);
out.write(data, size);
} }
// Ensure data hits the OS buffers
out.flush();
if (!out.good()) { if (!out.good()) {
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno)); err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
return false; return false;
@@ -345,12 +346,12 @@ Buffer::SaveAs(const std::string &path, std::string &err)
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno)); err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
// Write whole content in a single I/O operation // Stream content without forcing full materialization
const char *data = content_.Data(); if (content_.Size() > 0) {
const auto size = static_cast<std::streamsize>(content_.Size()); content_.WriteToStream(out);
if (data != nullptr && size > 0) {
out.write(data, size);
} }
// Ensure data hits the OS buffers
out.flush();
if (!out.good()) { if (!out.good()) {
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno)); err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
return false; return false;
@@ -411,6 +412,7 @@ Buffer::GetLineView(std::size_t row) const
void void
Buffer::ensure_rows_cache() const Buffer::ensure_rows_cache() const
{ {
std::lock_guard<std::mutex> lock(buffer_mutex_);
if (!rows_cache_dirty_) if (!rows_cache_dirty_)
return; return;
rows_.clear(); rows_.clear();
@@ -453,8 +455,8 @@ Buffer::delete_text(int row, int col, std::size_t len)
const std::size_t L = line.size(); const std::size_t L = line.size();
if (c < L) { if (c < L) {
const std::size_t take = std::min(remaining, L - c); const std::size_t take = std::min(remaining, L - c);
c += take; c += take;
remaining -= take; remaining -= take;
} }
if (remaining == 0) if (remaining == 0)
break; break;
@@ -462,8 +464,8 @@ Buffer::delete_text(int row, int col, std::size_t len)
if (r + 1 < lc) { if (r + 1 < lc) {
if (remaining > 0) { if (remaining > 0) {
remaining -= 1; // the newline remaining -= 1; // the newline
r += 1; r += 1;
c = 0; c = 0;
} }
} else { } else {
// At last line and still remaining: delete to EOF // At last line and still remaining: delete to EOF

View File

@@ -14,6 +14,7 @@
#include <cstdint> #include <cstdint>
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
#include <mutex>
// Forward declaration for swap journal integration // Forward declaration for swap journal integration
namespace kte { namespace kte {
@@ -369,6 +370,54 @@ public:
} }
// Visual-line selection support (multicursor/visual mode)
void VisualLineClear()
{
visual_line_active_ = false;
}
void VisualLineStart()
{
visual_line_active_ = true;
visual_line_anchor_y_ = cury_;
visual_line_active_y_ = cury_;
}
void VisualLineToggle()
{
if (visual_line_active_)
VisualLineClear();
else
VisualLineStart();
}
[[nodiscard]] bool VisualLineActive() const
{
return visual_line_active_;
}
void VisualLineSetActiveY(std::size_t y)
{
visual_line_active_y_ = y;
}
[[nodiscard]] std::size_t VisualLineStartY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
}
[[nodiscard]] std::size_t VisualLineEndY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
}
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)
@@ -465,11 +514,14 @@ private:
std::size_t content_LineCount_() const; std::size_t content_LineCount_() const;
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool dirty_ = false; bool dirty_ = false;
bool read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0; std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state // Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_; std::unique_ptr<struct UndoTree> undo_tree_;
@@ -482,4 +534,6 @@ private:
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager // Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr; kte::SwapRecorder *swap_rec_ = nullptr;
mutable std::mutex buffer_mutex_;
}; };

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.5.1") set(KTE_VERSION "1.6.0")
# 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.
@@ -63,7 +63,7 @@ endif ()
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}") message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
if (${BUILD_GUI}) if (BUILD_GUI)
include(cmake/imgui.cmake) include(cmake/imgui.cmake)
endif () endif ()
@@ -208,6 +208,7 @@ set(FONT_HEADERS
fonts/Syne.h fonts/Syne.h
fonts/Triplicate.h fonts/Triplicate.h
fonts/Unispace.h fonts/Unispace.h
fonts/BerkeleyMono.h
) )
set(COMMON_HEADERS set(COMMON_HEADERS
@@ -255,6 +256,7 @@ if (BUILD_GUI)
ImGuiFrontend.h ImGuiFrontend.h
ImGuiInputHandler.h ImGuiInputHandler.h
ImGuiRenderer.h ImGuiRenderer.h
fonts/BerkeleyMono.h
) )
endif () endif ()
endif () endif ()
@@ -292,30 +294,49 @@ install(TARGETS kte
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
if (BUILD_TESTS) if (BUILD_TESTS)
# test_undo executable for testing undo/redo system # Unified unit test runner
add_executable(test_undo add_executable(kte_tests
test_undo.cc tests/TestRunner.cc
${COMMON_SOURCES} tests/Test.h
${COMMON_HEADERS} tests/test_buffer_io.cc
tests/test_piece_table.cc
tests/test_search.cc
tests/test_reflow_paragraph.cc
tests/test_undo.cc
tests/test_visual_line_mode.cc
# minimal engine sources required by Buffer
PieceTable.cc
Buffer.cc
Editor.cc
Command.cc
HelpText.cc
Swap.cc
OptimizedSearch.cc
UndoNode.cc
UndoTree.cc
UndoSystem.cc
${SYNTAX_SOURCES}
) )
if (KTE_UNDO_DEBUG) # Allow test-only introspection hooks (guarded in headers) without affecting production builds.
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
endif ()
# Allow tests to include project headers like "Buffer.h"
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(test_undo ${CURSES_LIBRARIES}) # Keep tests free of ncurses/GUI deps
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR) if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR}) target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
endif () endif ()
if (TREESITTER_LIBRARY) if (TREESITTER_LIBRARY)
target_link_libraries(test_undo ${TREESITTER_LIBRARY}) target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
endif () endif ()
endif () endif ()
endif () endif ()
if (${BUILD_GUI}) if (BUILD_GUI)
# ImGui::CreateContext(); # ImGui::CreateContext();
# ImGuiIO& io = ImGui::GetIO(); # ImGuiIO& io = ImGui::GetIO();
@@ -370,12 +391,18 @@ if (${BUILD_GUI})
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist ${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
@ONLY) @ONLY)
# Ensure proper macOS bundle properties and RPATH so our bundled
# frameworks are preferred over system/Homebrew ones.
set_target_properties(kge PROPERTIES set_target_properties(kge PROPERTIES
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID} MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
MACOSX_BUNDLE_BUNDLE_NAME "kge" MACOSX_BUNDLE_BUNDLE_NAME "kge"
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE} MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist") MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
# Prefer the app's bundled frameworks at runtime
INSTALL_RPATH "@executable_path/../Frameworks"
BUILD_WITH_INSTALL_RPATH TRUE
)
add_dependencies(kge kte) add_dependencies(kge kte)
add_custom_command(TARGET kge POST_BUILD add_custom_command(TARGET kge POST_BUILD
@@ -399,4 +426,20 @@ if (${BUILD_GUI})
# Install kge man page only when GUI is built # Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons) install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
# Optional post-build bundle fixup (can also be run from scripts).
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
if (APPLE AND TARGET kge)
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
if (IS_BUNDLE)
add_custom_target(kge_fixup_bundle ALL
COMMAND ${CMAKE_COMMAND}
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
VERBATIM)
add_dependencies(kge_fixup_bundle kge)
endif ()
endif ()
endif () endif ()

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ enum class CommandId {
VisualFontPickerToggle, VisualFontPickerToggle,
// Buffers // Buffers
BufferSwitchStart, // begin buffer switch prompt BufferSwitchStart, // begin buffer switch prompt
BufferNew, // create a new empty, unnamed buffer (C-k i)
BufferClose, BufferClose,
BufferNext, BufferNext,
BufferPrev, BufferPrev,
@@ -46,6 +47,7 @@ enum class CommandId {
MoveFileStart, // move to beginning of file MoveFileStart, // move to beginning of file
MoveFileEnd, // move to end of file MoveFileEnd, // move to end of file
ToggleMark, // toggle mark at cursor ToggleMark, // toggle mark at cursor
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
JumpToMark, // jump to mark, set mark to previous cursor JumpToMark, // jump to mark, set mark to previous cursor
KillRegion, // kill region between mark and cursor (to kill ring) KillRegion, // kill region between mark and cursor (to kill ring)
CopyRegion, // copy region to kill ring (Alt-w) CopyRegion, // copy region to kill ring (Alt-w)

View File

@@ -197,9 +197,11 @@ Editor::OpenFile(const std::string &path, std::string &err)
eng->InvalidateFrom(0); eng->InvalidateFrom(0);
} }
} }
return true; // Defensive: ensure any active prompt is closed after a successful open
} CancelPrompt();
} return true;
}
}
Buffer b; Buffer b;
if (!b.OpenFromFile(path, err)) { if (!b.OpenFromFile(path, err)) {
@@ -237,8 +239,10 @@ Editor::OpenFile(const std::string &path, std::string &err)
} }
// Add as a new buffer and switch to it // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
SwitchTo(idx); SwitchTo(idx);
return true; // Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
} }

View File

@@ -12,7 +12,7 @@ public:
virtual ~Frontend() = default; virtual ~Frontend() = default;
// Initialize the frontend (create window/terminal, etc.) // Initialize the frontend (create window/terminal, etc.)
virtual bool Init(Editor &ed) = 0; virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit. // Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
virtual void Step(Editor &ed, bool &running) = 0; virtual void Step(Editor &ed, bool &running) = 0;

View File

@@ -31,6 +31,7 @@ HelpText::Text()
" C-k c Close current buffer\n" " C-k c Close current buffer\n"
" C-k d Kill to end of line\n" " C-k d Kill to end of line\n"
" C-k e Open file (prompt)\n" " C-k e Open file (prompt)\n"
" C-k i New empty buffer\n"
" C-k f Flush kill ring\n" " C-k f Flush kill ring\n"
" C-k g Jump to line\n" " C-k g Jump to line\n"
" C-k h Show this help\n" " C-k h Show this help\n"

View File

@@ -18,6 +18,7 @@
#include "GUITheme.h" #include "GUITheme.h"
#include "fonts/Font.h" // embedded default font (DefaultFont) #include "fonts/Font.h" // embedded default font (DefaultFont)
#include "fonts/FontRegistry.h" #include "fonts/FontRegistry.h"
#include "fonts/IosevkaExtended.h"
#include "syntax/HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h" #include "syntax/NullHighlighter.h"
@@ -29,8 +30,10 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible) static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool bool
GUIFrontend::Init(Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
// Attach editor to input handler for editor-owned features (e.g., universal argument) // Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed); input_.Attach(&ed);
// editor dimensions will be initialized during the first Step() frame // editor dimensions will be initialized during the first Step() frame
@@ -261,11 +264,11 @@ GUIFrontend::Step(Editor &ed, bool &running)
// Update editor logical rows/cols using current ImGui metrics and display size // Update editor logical rows/cols using current ImGui metrics and display size
{ {
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
float line_h = ImGui::GetTextLineHeightWithSpacing(); float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x; float ch_w = ImGui::CalcTextSize("M").x;
if (line_h <= 0.0f) if (row_h <= 0.0f)
line_h = 16.0f; row_h = 16.0f;
if (ch_w <= 0.0f) if (ch_w <= 0.0f)
ch_w = 8.0f; ch_w = 8.0f;
// Prefer ImGui IO display size; fall back to cached SDL window size // Prefer ImGui IO display size; fall back to cached SDL window size
@@ -273,20 +276,20 @@ GUIFrontend::Step(Editor &ed, bool &running)
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_); float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
// Account for the GUI window padding and the status bar height used in ImGuiRenderer. // Account for the GUI window padding and the status bar height used in ImGuiRenderer.
// ImGuiRenderer pushes WindowPadding = (6,6) every frame, so use the same constants here
// to avoid mismatches that would cause premature scrolling.
const float pad_x = 6.0f; const float pad_x = 6.0f;
const float pad_y = 6.0f; const float pad_y = 6.0f;
// Status bar reserves one frame height (with spacing) inside the window
float status_h = ImGui::GetFrameHeightWithSpacing();
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x); // Use the same logic as ImGuiRenderer for available height and status bar reservation.
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h); float wanted_bar_h = ImGui::GetFrameHeight();
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
// Visible content rows inside the scroll child // Visible content rows inside the scroll child
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h)); auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
// Editor::Rows includes the status line; add 1 back for it. // Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = std::max<std::size_t>(1, content_rows + 1); std::size_t rows = content_rows + 1;
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w))); std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
// Only update if changed to avoid churn // Only update if changed to avoid churn
@@ -357,14 +360,32 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
{ {
const ImGuiIO &io = ImGui::GetIO(); const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear(); io.Fonts->Clear();
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
ImFontConfig config;
config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData, kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize, kte::Fonts::DefaultFontSize,
size_px); size_px,
if (!font) { &config,
font = io.Fonts->AddFontDefault(); io.Fonts->GetGlyphRangesDefault());
}
(void) font; // Merge Greek and Mathematical symbols from IosevkaExtended
config.MergeMode = true;
static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic
0x2200, 0x22FF, // Mathematical Operators
0,
};
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
size_px,
&config,
extended_ranges);
io.Fonts->Build(); io.Fonts->Build();
return true; return true;
} }

View File

@@ -17,7 +17,7 @@ public:
~GUIFrontend() override = default; ~GUIFrontend() override = default;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;

View File

@@ -158,16 +158,17 @@ map_key(const SDL_Keycode key,
ascii_key = static_cast<int>(key); ascii_key = static_cast<int>(key);
} }
bool ctrl2 = (mod & KMOD_CTRL) != 0; bool ctrl2 = (mod & KMOD_CTRL) != 0;
// If user typed a literal 'C' (or '^') as a control qualifier, keep k-prefix active // If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') { // Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
k_ctrl_pending = true; if (ascii_key == 'C' || ascii_key == '^') {
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT k_ctrl_pending = true;
if (ed) // Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
ed->SetStatus("C-k C _"); if (ed)
suppress_textinput_once = true; ed->SetStatus("C-k C _");
out.hasCommand = false; suppress_textinput_once = true;
return true; out.hasCommand = false;
} return true;
}
// Otherwise, consume the k-prefix now for the actual suffix // Otherwise, consume the k-prefix now for the actual suffix
k_prefix = false; k_prefix = false;
if (ascii_key != 0) { if (ascii_key != 0) {
@@ -472,16 +473,16 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
ascii_key = static_cast<int>(c0); ascii_key = static_cast<int>(c0);
} }
if (ascii_key != 0) { if (ascii_key != 0) {
// Qualifier via TEXTINPUT: 'C' or '^' // Qualifier via TEXTINPUT: uppercase 'C' or '^' only
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') { if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending_ = true; k_ctrl_pending_ = true;
if (ed_) if (ed_)
ed_->SetStatus("C-k C _"); ed_->SetStatus("C-k C _");
// Keep k-prefix active; do not emit a command // Keep k-prefix active; do not emit a command
k_prefix_ = true; k_prefix_ = true;
produced = true; produced = true;
break; break;
} }
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case // Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
CommandId id; CommandId id;
bool pass_ctrl = k_ctrl_pending_; bool pass_ctrl = k_ctrl_pending_;

View File

@@ -94,8 +94,17 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y)); ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
} }
// Reserve space for status bar at bottom // Reserve space for status bar at bottom.
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false, // We calculate a height that is an exact multiple of the line height
// to avoid partial lines and "scroll past end" jitter.
float total_avail_h = ImGui::GetContentRegionAvail().y;
float wanted_bar_h = ImGui::GetFrameHeight();
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
float real_bar_h = total_avail_h - child_h_plan;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Get child window position and scroll for click handling // Get child window position and scroll for click handling
@@ -138,160 +147,87 @@ ImGuiRenderer::Draw(Editor &ed)
} }
prev_buf_rowoffs = buf_rowoffs; prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs; prev_buf_coloffs = buf_coloffs;
// Synchronize cursor and scrolling.
// Ensure the cursor is visible, but avoid aggressive centering so that
// the same lines remain visible until the cursor actually goes off-screen.
{
// Compute visible row range using the child window height
float child_h = ImGui::GetWindowHeight();
long first_row = static_cast<long>(scroll_y / row_h);
long vis_rows = static_cast<long>(child_h / row_h);
if (vis_rows < 1)
vis_rows = 1;
long last_row = first_row + vis_rows - 1;
long cyr = static_cast<long>(cy);
if (cyr < first_row) {
// Scroll just enough to bring the cursor line to the top
float target = static_cast<float>(cyr) * row_h;
if (target < 0.f)
target = 0.f;
float max_y = ImGui::GetScrollMaxY();
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
} else if (cyr > last_row) {
// Scroll just enough to bring the cursor line to the bottom
long new_first = cyr - vis_rows + 1;
if (new_first < 0)
new_first = 0;
float target = static_cast<float>(new_first) * row_h;
float max_y = ImGui::GetScrollMaxY();
if (target < 0.f)
target = 0.f;
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
}
// Horizontal scroll: ensure cursor column is visible
float child_w = ImGui::GetWindowWidth();
long vis_cols = static_cast<long>(child_w / space_w);
if (vis_cols < 1)
vis_cols = 1;
long first_col = static_cast<long>(scroll_x / space_w);
long last_col = first_col + vis_cols - 1;
// Compute cursor's rendered X position (accounting for tabs)
std::size_t cursor_rx = 0;
if (cy < lines.size()) {
std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = 8;
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
if (cur_line[i] == '\t') {
cursor_rx += tabw - (cursor_rx % tabw);
} else {
cursor_rx += 1;
}
}
}
long cxr = static_cast<long>(cursor_rx);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
// Center horizontally if possible
target_x -= (child_w / 2.0f);
if (target_x < 0.f)
target_x = 0.f;
float max_x = ImGui::GetScrollMaxX();
if (max_x >= 0.f && target_x > max_x)
target_x = max_x;
ImGui::SetScrollX(target_x);
scroll_x = ImGui::GetScrollX();
}
// Phase 3: prefetch visible viewport highlights and warm around in background
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
// Cache current horizontal offset in rendered columns for click handling // Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
// Handle mouse click before rendering to avoid dependent on drawn items // Mark selection state (mark -> cursor), in source coordinates
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line highlight range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false;
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Compute content-relative position accounting for scroll // Convert mouse pos to buffer row
// mp.y - child_window_pos.y gives us pixels from top of child window
// Adding scroll_y gives us pixels from top of content (buffer row 0)
float content_y = (mp.y - child_window_pos.y) + scroll_y; float content_y = (mp.y - child_window_pos.y) + scroll_y;
long by_l = static_cast<long>(content_y / row_h); long by_l = static_cast<long>(content_y / row_h);
if (by_l < 0) if (by_l < 0)
by_l = 0; by_l = 0;
// Convert to buffer row
std::size_t by = static_cast<std::size_t>(by_l); std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size()) { if (by >= lines.size())
if (!lines.empty()) by = lines.empty() ? 0 : (lines.size() - 1);
by = lines.size() - 1;
else
by = 0;
}
// Compute click X position relative to left edge of child window (in pixels) // Convert mouse pos to rendered x
// This gives us the visual offset from the start of displayed content
float visual_x = mp.x - child_window_pos.x; float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f) if (visual_x < 0.0f)
visual_x = 0.0f; visual_x = 0.0f;
// Convert visual pixel offset to rendered column, then add coloffs_now
// to get the absolute rendered column in the buffer
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now; std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
// Empty buffer guard: if there are no lines yet, just move to 0:0 // Convert rendered column to source column
if (lines.empty()) { if (lines.empty())
Execute(ed, CommandId::MoveCursorTo, std::string("0:0")); return {0, 0};
} else { std::string line_clicked = static_cast<std::string>(lines[by]);
// Convert rendered column (clicked_rx) to source column accounting for tabs const std::size_t tabw = 8;
std::string line_clicked = static_cast<std::string>(lines[by]); std::size_t rx = 0;
const std::size_t tabw = 8; std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
// Iterate through source columns, computing rendered position, to find closest match float clicked_rx_f = static_cast<float>(clicked_rx);
std::size_t rx = 0; // rendered column position for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
std::size_t best_col = 0; float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
float best_dist = std::numeric_limits<float>::infinity(); if (dist < best_dist) {
float clicked_rx_f = static_cast<float>(clicked_rx); best_dist = dist;
best_col = i;
for (std::size_t i = 0; i <= line_clicked.size(); ++i) { }
// Check current position if (i < line_clicked.size()) {
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx)); rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
// Advance to next position if not at end
if (i < line_clicked.size()) {
if (line_clicked[i] == '\t') {
rx += (tabw - (rx % tabw));
} else {
rx += 1;
}
}
} }
// Dispatch absolute buffer coordinates (row:col)
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
} }
return {by, best_col};
};
// Mouse-driven selection: set mark on press, update cursor on drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true;
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by);
}
}
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting = false;
} }
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line
@@ -370,6 +306,51 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
// Draw selection background (over search highlight; under text)
if (sel_active || vsel_active) {
bool line_has = false;
std::size_t sx = 0, ex = 0;
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
sx = 0;
ex = line.size();
line_has = ex > sx;
} else if (i < sel_sy || i > sel_ey) {
line_has = false;
} else if (sel_sy == sel_ey) {
sx = sel_sx;
ex = sel_ex;
line_has = ex > sx;
} else if (i == sel_sy) {
sx = sel_sx;
ex = line.size();
line_has = ex > sx;
} else if (i == sel_ey) {
sx = 0;
ex = std::min(sel_ex, line.size());
line_has = ex > sx;
} else {
sx = 0;
ex = line.size();
line_has = ex > sx;
}
if (line_has) {
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) {
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
}
// Emit entire line to an expanded buffer (tabs -> spaces) // Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) { for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src]; char c = line[src];
@@ -489,23 +470,98 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
// Synchronize cursor and scrolling after rendering all lines so content size is known.
{
float child_h_actual = ImGui::GetWindowHeight();
float child_w_actual = ImGui::GetWindowWidth();
float scroll_y_now = ImGui::GetScrollY();
float scroll_x_now = ImGui::GetScrollX();
long first_row = static_cast<long>(scroll_y_now / row_h);
long vis_rows = static_cast<long>(std::round(child_h_actual / row_h));
if (vis_rows < 1)
vis_rows = 1;
long last_row = first_row + vis_rows - 1;
long cyr = static_cast<long>(cy);
if (cyr < first_row) {
float target = static_cast<float>(cyr) * row_h;
if (target < 0.f)
target = 0.f;
float max_y = ImGui::GetScrollMaxY();
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
first_row = static_cast<long>(target / row_h);
last_row = first_row + vis_rows - 1;
} else if (cyr > last_row) {
long new_first = cyr - vis_rows + 1;
if (new_first < 0)
new_first = 0;
float target = static_cast<float>(new_first) * row_h;
float max_y = ImGui::GetScrollMaxY();
if (target < 0.f)
target = 0.f;
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
first_row = static_cast<long>(target / row_h);
last_row = first_row + vis_rows - 1;
}
// Horizontal scroll: ensure cursor column is visible
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w));
if (vis_cols < 1)
vis_cols = 1;
long first_col = static_cast<long>(scroll_x_now / space_w);
long last_col = first_col + vis_cols - 1;
std::size_t cursor_rx = 0;
if (cy < lines.size()) {
std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = 8;
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
if (cur_line[i] == '\t') {
cursor_rx += tabw - (cursor_rx % tabw);
} else {
cursor_rx += 1;
}
}
}
long cxr = static_cast<long>(cursor_rx);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
target_x -= (child_w_actual / 2.0f);
if (target_x < 0.f)
target_x = 0.f;
float max_x = ImGui::GetScrollMaxX();
if (max_x >= 0.f && target_x > max_x)
target_x = max_x;
ImGui::SetScrollX(target_x);
}
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
// Status bar spanning full width // Status bar area starting right after the scroll child
ImGui::Separator();
// Compute full content width and draw a filled background rectangle
ImVec2 win_pos = ImGui::GetWindowPos(); ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin(); ImVec2 win_sz = ImGui::GetWindowSize();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax(); float x0 = win_pos.x;
float x0 = win_pos.x + cr_min.x; float x1 = win_pos.x + win_sz.x;
float x1 = win_pos.x + cr_max.x; float y0 = ImGui::GetCursorScreenPos().y;
ImVec2 cursor = ImGui::GetCursorScreenPos(); float bar_h = real_bar_h;
float bar_h = ImGui::GetFrameHeight();
ImVec2 p0(x0, cursor.y); ImVec2 p0(x0, y0);
ImVec2 p1(x1, cursor.y + bar_h); ImVec2 p1(x1, y0 + bar_h);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive); ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// If a prompt is active, replace the entire status bar with the prompt text // If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) { if (ed.PromptActive()) {
std::string label = ed.PromptLabel(); std::string label = ed.PromptLabel();
@@ -560,7 +616,7 @@ ImGuiRenderer::Draw(Editor &ed)
(size_t) std::max<size_t>( (size_t) std::max<size_t>(
1, (size_t) (tail.size() / 4))) 1, (size_t) (tail.size() / 4)))
: 1; : 1;
start += skip; start += skip;
std::string candidate = tail.substr(start); std::string candidate = tail.substr(start);
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str()); ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
if (cand_sz.x <= avail_px) { if (cand_sz.x <= avail_px) {
@@ -591,11 +647,9 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str()); ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true); ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(final_msg.c_str()); ImGui::TextUnformatted(final_msg.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} else { } else {
// Build left text // Build left text
std::string left; std::string left;
@@ -618,11 +672,11 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount(); std::size_t total = ed.BufferCount();
if (total > 0) { if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "["; left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1)); left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/"; left += "/";
left += std::to_string(static_cast<unsigned long long>(total)); left += std::to_string(static_cast<unsigned long long>(total));
left += "] "; left += "] ";
} }
} }
left += fname; left += fname;
@@ -631,9 +685,9 @@ ImGuiRenderer::Draw(Editor &ed)
// Append total line count as "<n>L" // Append total line count as "<n>L"
{ {
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size()); unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
} }
// Build right text (cursor/mark) // Build right text (cursor/mark)
@@ -671,20 +725,21 @@ ImGuiRenderer::Draw(Editor &ed)
float max_left = std::max(0.0f, right_x - left_x - pad); float max_left = std::max(0.0f, right_x - left_x - pad);
if (max_left < left_sz.x && max_left > 10.0f) { if (max_left < left_sz.x && max_left > 10.0f) {
// Render a clipped left using a child region // Render a clipped left using a child region
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true); ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
true);
ImGui::TextUnformatted(left.c_str()); ImGui::TextUnformatted(left.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
} }
} else { } else {
// Draw left normally // Draw left normally
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::TextUnformatted(left.c_str()); ImGui::TextUnformatted(left.c_str());
} }
// Draw right // Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x), ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
p0.y + (bar_h - right_sz.y) * 0.5f)); y0 + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str()); ImGui::TextUnformatted(right.c_str());
// Draw middle message centered in remaining space // Draw middle message centered in remaining space
@@ -696,14 +751,12 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str()); ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f); float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region // Clip to middle region
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true); ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(msg.c_str()); ImGui::TextUnformatted(msg.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
} }
} }
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} }
} }

View File

@@ -42,6 +42,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'a': case 'a':
out = CommandId::MarkAllAndJumpEnd; out = CommandId::MarkAllAndJumpEnd;
return true; return true;
case 'i':
out = CommandId::BufferNew; // C-k i new empty buffer
return true;
case 'k': case 'k':
out = CommandId::CenterOnCursor; // C-k k center current line out = CommandId::CenterOnCursor; // C-k k center current line
return true; return true;
@@ -111,6 +114,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=': case '=':
out = CommandId::IndentRegion; out = CommandId::IndentRegion;
return true; return true;
case '/':
out = CommandId::VisualLineModeToggle;
return true;
case ';': case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true; return true;

View File

@@ -1,6 +1,7 @@
#include <algorithm> #include <algorithm>
#include <utility> #include <utility>
#include <limits> #include <limits>
#include <ostream>
#include "PieceTable.h" #include "PieceTable.h"
@@ -217,9 +218,9 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
std::size_t expectStart = last.start + last.len; std::size_t expectStart = last.start + last.len;
if (expectStart == start) { if (expectStart == start) {
last.len += len; last.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -230,7 +231,7 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
pieces_.push_back(Piece{src, start, len}); pieces_.push_back(Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -250,9 +251,9 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
Piece &first = pieces_.front(); Piece &first = pieces_.front();
if (first.src == src && start + len == first.start) { if (first.src == src && start + len == first.start) {
first.start = start; first.start = start;
first.len += len; first.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -261,7 +262,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
} }
pieces_.insert(pieces_.begin(), Piece{src, start, len}); pieces_.insert(pieces_.begin(), Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -272,6 +273,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
void void
PieceTable::materialize() const PieceTable::materialize() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) { if (!dirty_) {
return; return;
} }
@@ -347,6 +349,7 @@ PieceTable::coalesceNeighbors(std::size_t index)
void void
PieceTable::InvalidateLineIndex() const PieceTable::InvalidateLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true; line_index_dirty_ = true;
} }
@@ -354,22 +357,29 @@ PieceTable::InvalidateLineIndex() const
void void
PieceTable::RebuildLineIndex() const PieceTable::RebuildLineIndex() const
{ {
if (!line_index_dirty_) std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) {
return; return;
}
line_index_.clear(); line_index_.clear();
line_index_.push_back(0); line_index_.push_back(0);
std::size_t pos = 0; std::size_t pos = 0;
for (const auto &pc: pieces_) { for (const auto &pc: pieces_) {
const std::string &src = pc.src == Source::Original ? original_ : add_; const std::string &src = pc.src == Source::Original ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start); const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
for (std::size_t j = 0; j < pc.len; ++j) { for (std::size_t j = 0; j < pc.len; ++j) {
if (base[j] == '\n') { if (base[j] == '\n') {
// next line starts after the newline // next line starts after the newline
line_index_.push_back(pos + j + 1); line_index_.push_back(pos + j + 1);
} }
} }
pos += pc.len; pos += pc.len;
} }
line_index_dirty_ = false; line_index_dirty_ = false;
} }
@@ -390,7 +400,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
if (pieces_.empty()) { if (pieces_.empty()) {
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
@@ -404,7 +414,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
// insert at end // insert at end
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
coalesceNeighbors(pieces_.size() - 1); coalesceNeighbors(pieces_.size() - 1);
maybeConsolidate(); maybeConsolidate();
@@ -432,7 +442,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end()); pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end());
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0)) // Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
std::size_t ins_index = idx + (inner > 0 ? 1 : 0); std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
@@ -487,13 +497,13 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
// entire piece removed // entire piece removed
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx)); pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
// stay at same idx for next piece // stay at same idx for next piece
inner = 0; inner = 0;
remaining -= take; remaining -= take;
continue; continue;
} }
// After modifying current idx, next deletion continues at beginning of the next logical region // After modifying current idx, next deletion continues at beginning of the next logical region
inner = 0; inner = 0;
remaining -= take; remaining -= take;
if (remaining == 0) if (remaining == 0)
break; break;
@@ -502,7 +512,7 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
} }
total_size_ -= len; total_size_ -= len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
if (idx < pieces_.size()) if (idx < pieces_.size())
coalesceNeighbors(idx); coalesceNeighbors(idx);
@@ -691,14 +701,18 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
len = total_size_ - byte_offset; len = total_size_ - byte_offset;
// Fast path: return cached value if version/offset/len match // Fast path: return cached value if version/offset/len match
if (range_cache_.valid && range_cache_.version == version_ && {
range_cache_.off == byte_offset && range_cache_.len == len) { std::lock_guard<std::mutex> lock(mutex_);
return range_cache_.data; if (range_cache_.valid && range_cache_.version == version_ &&
range_cache_.off == byte_offset && range_cache_.len == len) {
return range_cache_.data;
}
} }
std::string out; std::string out;
out.reserve(len); out.reserve(len);
if (!dirty_) { if (!dirty_) {
std::lock_guard<std::mutex> lock(mutex_);
// Already materialized; slice directly // Already materialized; slice directly
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len); out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
} else { } else {
@@ -713,8 +727,8 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner); const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner);
out.append(base, take); out.append(base, take);
remaining -= take; remaining -= take;
inner = 0; inner = 0;
idx += 1; idx += 1;
} else { } else {
break; break;
} }
@@ -722,11 +736,14 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
} }
// Update cache // Update cache
range_cache_.valid = true; {
range_cache_.version = version_; std::lock_guard<std::mutex> lock(mutex_);
range_cache_.off = byte_offset; range_cache_.valid = true;
range_cache_.len = len; range_cache_.version = version_;
range_cache_.data = out; range_cache_.off = byte_offset;
range_cache_.len = len;
range_cache_.data = out;
}
return out; return out;
} }
@@ -738,22 +755,46 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max(); return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
if (start > total_size_) if (start > total_size_)
return std::numeric_limits<std::size_t>::max(); return std::numeric_limits<std::size_t>::max();
if (find_cache_.valid && {
find_cache_.version == version_ && std::lock_guard<std::mutex> lock(mutex_);
find_cache_.needle == needle && if (find_cache_.valid &&
find_cache_.start == start) { find_cache_.version == version_ &&
return find_cache_.result; find_cache_.needle == needle &&
find_cache_.start == start) {
return find_cache_.result;
}
} }
materialize(); materialize();
auto pos = materialized_.find(needle, start); std::size_t pos;
if (pos == std::string::npos) {
pos = std::numeric_limits<std::size_t>::max(); std::lock_guard<std::mutex> lock(mutex_);
// Update cache pos = materialized_.find(needle, start);
find_cache_.valid = true; if (pos == std::string::npos)
find_cache_.version = version_; pos = std::numeric_limits<std::size_t>::max();
find_cache_.needle = needle; // Update cache
find_cache_.start = start; find_cache_.valid = true;
find_cache_.result = pos; find_cache_.version = version_;
find_cache_.needle = needle;
find_cache_.start = start;
find_cache_.result = pos;
}
return pos; return pos;
} }
void
PieceTable::WriteToStream(std::ostream &out) const
{
// Stream the content piece-by-piece without forcing full materialization
// No lock needed for original_ and add_ if they are not being modified.
// Since this is a const method and kte's piece table isn't modified by multiple threads
// (only queried), we just iterate pieces_.
for (const auto &p: pieces_) {
if (p.len == 0)
continue;
const std::string &src = (p.src == Source::Original) ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
out.write(base, static_cast<std::streamsize>(p.len));
}
}

View File

@@ -5,8 +5,10 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <ostream>
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <mutex>
class PieceTable { class PieceTable {
@@ -100,6 +102,9 @@ public:
// Simple search utility; returns byte offset or npos // Simple search utility; returns byte offset or npos
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const; [[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
// Stream out content without materializing the entire buffer
void WriteToStream(std::ostream &out) const;
// Heuristic configuration // Heuristic configuration
void SetConsolidationParams(std::size_t piece_limit, void SetConsolidationParams(std::size_t piece_limit,
std::size_t small_piece_threshold, std::size_t small_piece_threshold,
@@ -177,4 +182,6 @@ private:
mutable RangeCache range_cache_; mutable RangeCache range_cache_;
mutable FindCache find_cache_; mutable FindCache find_cache_;
mutable std::mutex mutex_;
}; };

View File

@@ -142,13 +142,13 @@ protected:
p.save(); p.save();
p.setClipRect(viewport); p.setClipRect(viewport);
// Iterate visible lines // Iterate visible lines
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) { for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
// Materialize the Buffer::Line into a std::string for // Materialize the Buffer::Line into a std::string for
// regex/iterator usage and general string ops. // regex/iterator usage and general string ops.
const std::string line = static_cast<std::string>(lines[i]); const std::string line = static_cast<std::string>(lines[i]);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h; const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent(); const int baseline = y + fm.ascent();
// Helper: convert src col -> rx with tab expansion // Helper: convert src col -> rx with tab expansion
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t { auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
@@ -453,11 +453,11 @@ protected:
std::size_t total = ed_->BufferCount(); std::size_t total = ed_->BufferCount();
if (total > 0) { if (total > 0) {
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
left += QStringLiteral(" ["); left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1)); left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/"); left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total)); left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] "); left += QStringLiteral("] ");
} else { } else {
left += QStringLiteral(" "); left += QStringLiteral(" ");
} }
@@ -477,9 +477,9 @@ protected:
// total lines suffix " <n>L" // total lines suffix " <n>L"
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size()); unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += QStringLiteral(" "); left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount)); left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L"); left += QStringLiteral("L");
} }
// Build right segment: cursor and mark // Build right segment: cursor and mark
@@ -602,12 +602,12 @@ protected:
int d_cols = 0; int d_cols = 0;
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs( if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
h_scroll_accum_))) { h_scroll_accum_))) {
d_rows = static_cast<int>(v_scroll_accum_); d_rows = static_cast<int>(v_scroll_accum_);
v_scroll_accum_ -= d_rows; v_scroll_accum_ -= d_rows;
} }
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs( if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
v_scroll_accum_))) { v_scroll_accum_))) {
d_cols = static_cast<int>(h_scroll_accum_); d_cols = static_cast<int>(h_scroll_accum_);
h_scroll_accum_ -= d_cols; h_scroll_accum_ -= d_cols;
} }
@@ -658,11 +658,9 @@ private:
} // namespace } // namespace
bool bool
GUIFrontend::Init(Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
int argc = 0; app_ = new QApplication(argc, argv);
char **argv = nullptr;
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_); window_ = new MainWindow(input_);
window_->show(); window_->show();

View File

@@ -18,7 +18,7 @@ public:
~GUIFrontend() override = default; ~GUIFrontend() override = default;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;

View File

@@ -8,8 +8,10 @@
bool bool
TerminalFrontend::Init(Editor &ed) TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};
@@ -73,6 +75,7 @@ TerminalFrontend::Init(Editor &ed)
have_old_sigint_ = true; have_old_sigint_ = true;
} }
} }
return true; return true;
} }

View File

@@ -21,7 +21,7 @@ public:
// Adjust if your terminal needs a different threshold. // Adjust if your terminal needs a different threshold.
static constexpr int kEscDelayMs = 50; static constexpr int kEscDelayMs = 50;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;

View File

@@ -3,6 +3,7 @@
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Command.h"
#include "Editor.h" #include "Editor.h"
namespace { namespace {
@@ -23,6 +24,7 @@ map_key_to_command(const int ch,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
bool &k_ctrl_pending, bool &k_ctrl_pending,
bool &mouse_selecting,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
@@ -54,12 +56,33 @@ map_key_to_command(const int ch,
} }
#endif #endif
// React to left button click/press // React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
REPORT_MOUSE_POSITION))) {
char buf[64]; char buf[64];
// Use screen coordinates; command handler will translate via offsets // Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x); std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0}; const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
return true; const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
if (pressed) {
mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
if (Buffer *b = ed->CurrentBuffer()) {
b->SetMark(b->Curx(), b->Cury());
}
out.hasCommand = false;
return true;
}
if (mouse_selecting && moved) {
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false;
return true;
}
if (released) {
mouse_selecting = false;
out.hasCommand = false;
return true;
}
} }
} }
// No actionable mouse event // No actionable mouse event
@@ -178,8 +201,9 @@ map_key_to_command(const int ch,
ctrl = true; ctrl = true;
ascii_key = 'a' + (ch - 1); ascii_key = 'a' + (ch - 1);
} }
// If user typed literal 'C'/'c' or '^' as a qualifier, keep k-prefix and set pending // If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') { // Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending = true; k_ctrl_pending = true;
if (ed) if (ed)
ed->SetStatus("C-k C _"); ed->SetStatus("C-k C _");
@@ -291,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out)
ch, ch,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_,
ed_, ed_,
out); out);
if (!consumed) if (!consumed)

View File

@@ -30,5 +30,8 @@ private:
// Simple meta (ESC) state for ESC sequences like ESC b/f // Simple meta (ESC) state for ESC sequences like ESC b/f
bool esc_meta_ = false; bool esc_meta_ = false;
// Mouse drag selection state
bool mouse_selecting_ = false;
Editor *ed_ = nullptr; // attached editor for uarg handling Editor *ed_ = nullptr; // attached editor for uarg handling
}; };

View File

@@ -1,3 +1,6 @@
#include <clocale>
#define _XOPEN_SOURCE_EXTENDED 1
#include <cwchar>
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
@@ -104,9 +107,44 @@ TerminalRenderer::Draw(Editor &ed)
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0; const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0; const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
bool hl_on = false;
bool cur_on = false; // Mark selection (mark -> cursor), in source coordinates
int written = 0; bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line selection range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool {
(void) sx;
if (vsel_active) {
if (y >= vsel_sy && y <= vsel_ey)
return true;
}
if (!sel_active)
return false;
if (y < sel_sy || y > sel_ey)
return false;
if (sel_sy == sel_ey)
return sx >= sel_sx && sx < sel_ex;
if (y == sel_sy)
return sx >= sel_sx;
if (y == sel_ey)
return sx < sel_ex;
return true;
};
int written = 0;
if (li < lines.size()) { if (li < lines.size()) {
std::string line = static_cast<std::string>(lines[li]); std::string line = static_cast<std::string>(lines[li]);
src_i = 0; src_i = 0;
@@ -153,39 +191,50 @@ TerminalRenderer::Draw(Editor &ed)
} }
return kte::TokenKind::Default; return kte::TokenKind::Default;
}; };
auto apply_token_attr = [&](kte::TokenKind k) { auto token_attr = [&](kte::TokenKind k) -> attr_t {
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
switch (k) { switch (k) {
case kte::TokenKind::Keyword: case kte::TokenKind::Keyword:
case kte::TokenKind::Type: case kte::TokenKind::Type:
case kte::TokenKind::Constant: case kte::TokenKind::Constant:
case kte::TokenKind::Function: case kte::TokenKind::Function:
attron(A_BOLD); return A_BOLD;
break; case kte::TokenKind::Comment:
case kte::TokenKind::Comment: return A_DIM;
attron(A_DIM); case kte::TokenKind::String:
break; case kte::TokenKind::Char:
case kte::TokenKind::String: case kte::TokenKind::Number:
case kte::TokenKind::Char: return A_UNDERLINE;
case kte::TokenKind::Number: default:
// standout a bit using A_UNDERLINE if available return 0;
attron(A_UNDERLINE);
break;
default:
break;
} }
}; };
while (written < cols) { while (written < cols) {
char ch = ' ';
bool from_src = false; bool from_src = false;
wchar_t wch = L' ';
int wch_len = 1;
int disp_w = 1;
if (src_i < line.size()) { if (src_i < line.size()) {
unsigned char c = static_cast<unsigned char>(line[src_i]); // Decode UTF-8
if (c == '\t') { std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(
&wch, &line[src_i], line.size() - src_i, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
// Invalid or incomplete; treat as single byte
wch = static_cast<unsigned char>(line[src_i]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col % tabw); std::size_t next_tab = tabw - (render_col % tabw);
if (render_col + next_tab <= coloffs) { if (render_col + next_tab <= coloffs) {
render_col += next_tab; render_col += next_tab;
++src_i; src_i += wch_len;
continue; continue;
} }
// Emit spaces for tab // Emit spaces for tab
@@ -194,102 +243,94 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t to_skip = std::min<std::size_t>( std::size_t to_skip = std::min<std::size_t>(
next_tab, coloffs - render_col); next_tab, coloffs - render_col);
render_col += to_skip; render_col += to_skip;
next_tab -= to_skip; next_tab -= to_skip;
} }
// Now render visible spaces // Now render visible spaces
while (next_tab > 0 && written < cols) { while (next_tab > 0 && written < cols) {
bool in_sel = is_src_in_sel(li, src_i);
bool in_hl = search_mode && is_src_in_hl(src_i); bool in_hl = search_mode && is_src_in_hl(src_i);
bool in_cur = bool in_cur =
has_current && li == cur_my && src_i >= cur_mx has_current && li == cur_my && src_i >= cur_mx
&& src_i < cur_mend; &&
// Toggle highlight attributes src_i < cur_mend;
int attr = 0; attr_t a = A_NORMAL;
if (in_hl) a |= token_attr(token_at(src_i));
attr |= A_STANDOUT; if (in_sel) {
if (in_cur) a |= A_REVERSE;
attr |= A_BOLD; } else {
if ((attr & A_STANDOUT) && !hl_on) { if (in_hl)
attron(A_STANDOUT); a |= A_STANDOUT;
hl_on = true; if (in_cur)
} a |= A_BOLD;
if (!(attr & A_STANDOUT) && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if ((attr & A_BOLD) && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!(attr & A_BOLD) && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
// Apply syntax attribute only if not in search highlight
if (!in_hl) {
apply_token_attr(token_at(src_i));
} }
attrset(a);
addch(' '); addch(' ');
++written; ++written;
++render_col; ++render_col;
--next_tab; --next_tab;
} }
++src_i; src_i += wch_len;
continue; continue;
} else { } else {
// normal char // normal char
disp_w = wcwidth(wch);
if (disp_w < 0)
disp_w = 1; // non-printable or similar
if (render_col < coloffs) { if (render_col < coloffs) {
++render_col; render_col += disp_w;
++src_i; src_i += wch_len;
continue; continue;
} }
ch = static_cast<char>(c);
from_src = true; from_src = true;
} }
} else { } else {
// beyond EOL, fill spaces // beyond EOL, fill spaces
ch = ' '; wch = L' ';
wch_len = 1;
disp_w = 1;
from_src = false; from_src = false;
} }
if (written + disp_w > cols) {
// would overflow, just break
break;
}
bool in_sel = from_src && is_src_in_sel(li, src_i);
bool in_hl = search_mode && from_src && is_src_in_hl(src_i); bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur = bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < src_i < cur_mend;
cur_mend; attr_t a = A_NORMAL;
if (in_hl && !hl_on) {
attron(A_STANDOUT);
hl_on = true;
}
if (!in_hl && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (in_cur && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!in_cur && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
if (!in_hl && from_src) {
apply_token_attr(token_at(src_i));
}
addch(static_cast<unsigned char>(ch));
++written;
++render_col;
if (from_src) if (from_src)
++src_i; a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
if (from_src) {
cchar_t cch;
wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch);
} else {
addch(' ');
}
written += disp_w;
render_col += disp_w;
if (from_src)
src_i += wch_len;
if (src_i >= line.size() && written >= cols) if (src_i >= line.size() && written >= cols)
break; break;
} }
} }
if (hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
attrset(A_NORMAL); attrset(A_NORMAL);
clrtoeol(); clrtoeol();
} }
@@ -297,23 +338,35 @@ TerminalRenderer::Draw(Editor &ed)
// Place terminal cursor at logical position accounting for tabs and coloffs. // Place terminal cursor at logical position accounting for tabs and coloffs.
// Recompute the rendered X using the same logic as the drawing loop to avoid // Recompute the rendered X using the same logic as the drawing loop to avoid
// any drift between the command-layer computation and the terminal renderer. // any drift between the command-layer computation and the terminal renderer.
std::size_t cy = buf->Cury(); std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs()); int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0; std::size_t rx_recomputed = 0;
if (cy < lines.size()) { if (cy < lines.size()) {
const std::string line_for_cursor = static_cast<std::string>(lines[cy]); const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
std::size_t src_i_cur = 0; std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0; std::size_t render_col_cur = 0;
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) { while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
unsigned char ccur = static_cast<unsigned char>(line_for_cursor[src_i_cur]); std::mbstate_t state = std::mbstate_t();
if (ccur == '\t') { wchar_t wch;
std::size_t next_tab = tabw - (render_col_cur % tabw); size_t res = std::mbrtowc(
render_col_cur += next_tab; &wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
++src_i_cur; &state);
if (res == (size_t) -1 || res == (size_t) -2) {
render_col_cur += 1;
src_i_cur += 1;
} else if (res == 0) {
src_i_cur += 1;
} else { } else {
++render_col_cur; if (wch == L'\t') {
++src_i_cur; std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else {
int dw = wcwidth(wch);
render_col_cur += (dw < 0) ? 1 : dw;
}
src_i_cur += res;
} }
} }
rx_recomputed = render_col_cur; rx_recomputed = render_col_cur;
@@ -403,9 +456,9 @@ TerminalRenderer::Draw(Editor &ed)
{ {
const char *app = "kte"; const char *app = "kte";
left.reserve(256); left.reserve(256);
left += app; left += app;
left += " "; left += " ";
left += KTE_VERSION_STR; // already includes leading 'v' left += KTE_VERSION_STR; // already includes leading 'v'
const Buffer *b = buf; const Buffer *b = buf;
std::string fname; std::string fname;
if (b) { if (b) {
@@ -426,11 +479,11 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount(); std::size_t total = ed.BufferCount();
if (total > 0) { if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
left += "["; left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1)); left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/"; left += "/";
left += std::to_string(static_cast<unsigned long long>(total)); left += std::to_string(static_cast<unsigned long long>(total));
left += "] "; left += "] ";
} }
} }
left += fname; left += fname;
@@ -442,9 +495,9 @@ TerminalRenderer::Draw(Editor &ed)
// Append total line count as "<n>L" // Append total line count as "<n>L"
if (b) { if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Rows().size()); unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
} }
} }

View File

@@ -4,8 +4,10 @@
bool bool
TestFrontend::Init(Editor &ed) TestFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
ed.SetDimensions(24, 80); ed.SetDimensions(24, 80);
return true; return true;
} }

View File

@@ -13,7 +13,7 @@ public:
~TestFrontend() override = default; ~TestFrontend() override = default;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;

View File

@@ -16,6 +16,7 @@ struct UndoNode {
int row{}; int row{};
int col{}; int col{};
std::string text; std::string text;
UndoNode *child = nullptr; // next in current timeline UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *next = nullptr; // redo branch UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
}; };

View File

@@ -20,10 +20,11 @@ public:
available_.pop(); available_.pop();
// Node comes zeroed; ensure links are reset // Node comes zeroed; ensure links are reset
node->text.clear(); node->text.clear();
node->child = nullptr; node->parent = nullptr;
node->next = nullptr; node->child = nullptr;
node->row = node->col = 0; node->next = nullptr;
node->type = UndoType{}; node->row = node->col = 0;
node->type = UndoType{};
return node; return node;
} }
@@ -34,10 +35,11 @@ public:
return; return;
// Clear heavy fields to free memory held by strings // Clear heavy fields to free memory held by strings
node->text.clear(); node->text.clear();
node->child = nullptr; node->parent = nullptr;
node->next = nullptr; node->child = nullptr;
node->row = node->col = 0; node->next = nullptr;
node->type = UndoType{}; node->row = node->col = 0;
node->type = UndoType{};
available_.push(node); available_.push(node);
} }

View File

@@ -11,66 +11,225 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
void void
UndoSystem::Begin(UndoType type) UndoSystem::Begin(UndoType type)
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented if (!buf_)
(void) type; return;
const int row = static_cast<int>(buf_->Cury());
const int col = static_cast<int>(buf_->Curx());
// Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
if (always_standalone) {
commit();
}
if (tree_.pending) {
if (tree_.pending->type == type) {
// Typed-run coalescing rules.
switch (type) {
case UndoType::Insert:
case UndoType::Paste: {
// Cursor must be at the end of the pending insert.
if (tree_.pending->row == row
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
pending_mode_ = PendingAppendMode::Append;
return;
}
break;
}
case UndoType::Delete: {
if (tree_.pending->row == row) {
// Two common delete shapes:
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
// 2) delete-run: cursor stays, always deleting at the same col
if (col == tree_.pending->col) {
pending_mode_ = PendingAppendMode::Append;
return;
}
if (col + 1 == tree_.pending->col) {
// Extend a backspace run to the left; update the start column now.
tree_.pending->col = col;
pending_mode_ = PendingAppendMode::Prepend;
return;
}
}
break;
}
case UndoType::Newline:
case UndoType::DeleteRow:
break;
}
}
// Can't coalesce: seal the previous pending step.
commit();
}
// Start a new pending node.
tree_.pending = new UndoNode{};
tree_.pending->type = type;
tree_.pending->row = row;
tree_.pending->col = col;
tree_.pending->text.clear();
tree_.pending->parent = nullptr;
tree_.pending->child = nullptr;
tree_.pending->next = nullptr;
pending_mode_ = PendingAppendMode::Append;
} }
void void
UndoSystem::Append(char ch) UndoSystem::Append(char ch)
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented if (!tree_.pending)
(void) ch; return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
} else {
tree_.pending->text.push_back(ch);
}
} }
void void
UndoSystem::Append(std::string_view text) UndoSystem::Append(std::string_view text)
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented if (!tree_.pending)
(void) text; return;
if (text.empty())
return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(0, text.data(), text.size());
} else {
tree_.pending->text.append(text.data(), text.size());
}
} }
void void
UndoSystem::commit() UndoSystem::commit()
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented if (!tree_.pending)
return;
// Drop empty text batches for text-based operations.
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|| tree_.pending->type == UndoType::Paste)
&& tree_.pending->text.empty()) {
delete tree_.pending;
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
return;
}
if (!tree_.root) {
tree_.root = tree_.pending;
tree_.pending->parent = nullptr;
tree_.current = tree_.pending;
} else if (!tree_.current) {
// We are at the "pre-first-edit" state (undo past the first node).
// In branching history, preserve the existing root chain as an alternate branch.
tree_.pending->parent = nullptr;
tree_.pending->next = tree_.root;
tree_.root = tree_.pending;
tree_.current = tree_.pending;
} else {
// Branching semantics: attach as a new redo branch under current.
// Make the new edit the active child by inserting it at the head.
tree_.pending->parent = tree_.current;
if (!tree_.current->child) {
tree_.current->child = tree_.pending;
} else {
tree_.pending->next = tree_.current->child;
tree_.current->child = tree_.pending;
}
tree_.current = tree_.pending;
}
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
update_dirty_flag();
} }
void void
UndoSystem::undo() UndoSystem::undo()
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented // Seal any in-progress typed run before undo.
commit();
if (!tree_.current)
return;
debug_log("undo");
apply(tree_.current, -1);
tree_.current = tree_.current->parent;
update_dirty_flag();
} }
void void
UndoSystem::redo() UndoSystem::redo(int branch_index)
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented commit();
UndoNode **head = nullptr;
if (!tree_.current) {
head = &tree_.root;
} else {
head = &tree_.current->child;
}
if (!head || !*head)
return;
if (branch_index < 0)
branch_index = 0;
// Select the Nth sibling from the branch list and make it the active head.
UndoNode *prev = nullptr;
UndoNode *sel = *head;
for (int i = 0; i < branch_index && sel; ++i) {
prev = sel;
sel = sel->next;
}
if (!sel)
return;
if (prev) {
prev->next = sel->next;
sel->next = *head;
*head = sel;
}
debug_log("redo");
apply(*head, +1);
tree_.current = *head;
update_dirty_flag();
} }
void void
UndoSystem::mark_saved() UndoSystem::mark_saved()
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented commit();
tree_.saved = tree_.current;
update_dirty_flag();
} }
void void
UndoSystem::discard_pending() UndoSystem::discard_pending()
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented if (tree_.pending) {
delete tree_.pending;
tree_.pending = nullptr;
}
pending_mode_ = PendingAppendMode::Append;
} }
void void
UndoSystem::clear() UndoSystem::clear()
{ {
// STUB: Undo system incomplete - disabled until it can be properly implemented discard_pending();
free_node(tree_.root);
tree_.root = nullptr;
tree_.current = nullptr;
tree_.saved = nullptr;
update_dirty_flag();
} }
@@ -79,34 +238,46 @@ UndoSystem::apply(const UndoNode *node, int direction)
{ {
if (!node) if (!node)
return; return;
// Cursor positioning: keep the point at a sensible location after undo/redo.
// Low-level Buffer edit primitives do not move the cursor.
switch (node->type) { switch (node->type) {
case UndoType::Insert: case UndoType::Insert:
case UndoType::Paste: case UndoType::Paste:
if (direction > 0) { if (direction > 0) {
buf_->insert_text(node->row, node->col, node->text); buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
} else { } else {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Delete: case UndoType::Delete:
if (direction > 0) { if (direction > 0) {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_text(node->row, node->col, node->text); buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Newline: case UndoType::Newline:
if (direction > 0) { if (direction > 0) {
buf_->split_line(node->row, node->col); buf_->split_line(node->row, node->col);
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
} else { } else {
buf_->join_lines(node->row); buf_->join_lines(node->row);
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::DeleteRow: case UndoType::DeleteRow:
if (direction > 0) { if (direction > 0) {
buf_->delete_row(node->row); buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_row(node->row, node->text); buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} }
break; break;
} }

View File

@@ -22,7 +22,10 @@ public:
void undo(); void undo();
void redo(); // Redo the current node's active child branch.
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
void redo(int branch_index = 0);
void mark_saved(); void mark_saved();
@@ -32,7 +35,20 @@ public:
void UpdateBufferReference(Buffer &new_buf); void UpdateBufferReference(Buffer &new_buf);
#if defined(KTE_TESTS)
// Test-only introspection hook.
const UndoTree &TreeForTests() const
{
return tree_;
}
#endif
private: private:
enum class PendingAppendMode : std::uint8_t {
Append,
Prepend,
};
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node); void free_node(UndoNode *node);
@@ -48,6 +64,8 @@ private:
void update_dirty_flag(); void update_dirty_flag();
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
Buffer *buf_; Buffer *buf_;
UndoTree &tree_; UndoTree &tree_;
}; };

78
cmake/fix_bundle.cmake Normal file
View File

@@ -0,0 +1,78 @@
cmake_minimum_required(VERSION 3.15)
# Fix up a macOS .app bundle by copying non-Qt dylibs into
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
#
# Usage:
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
if (NOT APP_BUNDLE)
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
endif ()
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
if (NOT EXISTS "${EXECUTABLE}")
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
endif ()
include(BundleUtilities)
# Directories to search when resolving prerequisites. We include Homebrew so that
# if any deps are currently resolved from there, fixup_bundle will copy them into
# the bundle and rewrite install names to be self-contained.
set(DIRS
"/usr/local/lib"
"/opt/homebrew/lib"
"/opt/homebrew/opt"
)
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
# libs it references recursively. Qt frameworks already live in the bundle after
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
# dbus, etc.).
# fixup_bundle often fails if copied libraries are read-only.
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
set(gp_otool "/usr/bin/otool")
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
set(CMAKE_OTOOL "/usr/bin/otool")
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
fixup_bundle("${APP_DIR}" "" "${DIRS}")
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
# for it to be allowed to run.
# We sign deep, but sometimes explicit signing of components is more reliable.
message(STATUS "Re-signing ${APP_DIR} after fixup...")
# 1. Sign dylibs in Frameworks
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
foreach (DYLIB ${DYLIBS})
message(STATUS "Signing ${DYLIB}...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
endforeach ()
# 2. Sign nested executables
message(STATUS "Signing nested kte...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
# 3. Sign the main executable explicitly
message(STATUS "Signing main kge...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
# 4. Sign the main bundle
execute_process(
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
RESULT_VARIABLE CODESIGN_RESULT
)
if (NOT CODESIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
endif ()
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")

View File

@@ -1,5 +1,6 @@
{ {
lib, pkgs ? import <nixpkgs> {},
lib ? pkgs.lib,
stdenv, stdenv,
cmake, cmake,
ncurses, ncurses,

163
docs/plans/test-plan.md Normal file
View File

@@ -0,0 +1,163 @@
### Unit testing plan (headless, no interactive frontend)
#### Principles
- Headless-only: exercise core components directly (`PieceTable`, `Buffer`, `UndoSystem`, `OptimizedSearch`, and minimal `Editor` flows) without starting `kte` or `kge`.
- Deterministic and fast: avoid timers, GUI, environment-specific behavior; prefer in-memory operations and temporary files.
- Regression-focused: encode prior failures (save/newline mismatch, legacy `rows_` writes) as explicit tests to prevent recurrences.
#### Harness and execution
- Single binary: use target `kte_tests` (already present) to compile and run all tests under `tests/` with the minimal in-tree framework (`tests/Test.h`, `tests/TestRunner.cc`).
- No GUI/ncurses deps: link only engine sources (PieceTable/Buffer/Undo/Search/Undo* and syntax minimal set), not frontends.
- How to build/run:
- Debug profile:
```
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-debug -DBUILD_TESTS=ON && \
cmake --build /Users/kyle/src/kte/cmake-build-debug --target kte_tests && \
/Users/kyle/src/kte/cmake-build-debug/kte_tests
```
- Release profile:
```
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-release -DBUILD_TESTS=ON && \
cmake --build /Users/kyle/src/kte/cmake-build-release --target kte_tests && \
/Users/kyle/src/kte/cmake-build-release/kte_tests
```
---
### Test catalog (summary table)
The table below catalogs all unit tests defined in this plan. It is headless-only and maps directly to the suites AH described later. “Implemented” reflects current coverage in `kte_tests`.
| Suite | ID | Name | Description (1line) | Headless | Implemented |
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
| A | 6 | Large file streaming | 14 MiB with periodic newlines; size and content integrity. | Yes | Planned |
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
| B | 6 | UTF8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesnt corrupt state. | Yes | Planned |
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
### Test suites and cases
#### A) Filesystem I/O via Buffer
1) SaveAs then Save (append)
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
- Assert file bytes equal exact expected string.
2) Open existing then Save
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
- Assert file bytes updated exactly.
3) Open non-existent then SaveAs
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
- Read back exact bytes.
4) Trailing newline preservation
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
5) Empty buffer saves
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
6) Large file streaming
- Insert ~14 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
7) Path normalization and tilde expansion
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
8) Error propagation (guarded)
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
#### B) PieceTable semantics
1) Line counting and deletion across lines
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
2) Position conversions
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
3) Delete spanning newlines
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
4) Split/join equivalence
- Split at various columns; then join adjacent lines; verify bytes equal original.
5) WriteToStream vs materialized `Data()`
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
6) UTF-8 bytes stability
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
#### C) Buffer editing helpers and rows cache correctness
1) `insert_text`/`delete_text`
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
2) `split_line` and `join_lines`
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
3) `insert_row`/`delete_row`
- Replace a paragraph by deleting N rows then inserting N rows; verify bytes and `LineCount`.
4) Cache invalidation
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
#### D) UndoSystem semantics
1) Grouped contiguous insert undo
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
2) Delete/newline undo/redo
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
3) Mark saved and dirty flag
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
#### E) Search algorithms
1) Parity with `std::string::find`
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
2) Large text
- Random ASCII text ~1 MiB; random patterns; results match reference.
#### F) Editor non-interactive flows (no frontend)
1) Open and reload
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
2) Read-only toggle
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
3) Prompt lifecycle (headless)
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
#### G) Regression tests for reported bugs
1) “Saved only newline”
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
2) Backspace crash path
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
3) Overwrite-confirm path behavior
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
#### H) Performance/stress sanity
1) Many small edits
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
2) Consolidation heuristics
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
---
### Coverage roadmap
- Phase 1 (already implemented and passing):
- Buffer I/O basics (A.1A.3), PieceTable basics (B.1B.2), Search parity (E.1).
- Phase 2 (add next):
- Buffer I/O edge cases (A.4A.7), deeper PieceTable ops (B.3B.6), Buffer helpers and cache (C.1C.4), Undo semantics (D.1D.2), Regression set (G.1G.3).
- Phase 3:
- Editor flows (F.1F.3), performance/stress (H.1H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
### Notes
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.

5438
fonts/BerkeleyMono.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
#include "Font.h" #include "Font.h"
#include "IosevkaExtended.h"
#include "imgui.h" #include "imgui.h"
@@ -8,16 +9,32 @@ Font::Load(const float size) const
{ {
const ImGuiIO &io = ImGui::GetIO(); const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear(); io.Fonts->Clear();
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
ImFontConfig config;
config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF(
this->data_, this->data_,
this->size_, this->size_,
size); size,
&config,
io.Fonts->GetGlyphRangesDefault());
if (!font) { // Merge Greek and Mathematical symbols from IosevkaExtended as fallback
font = io.Fonts->AddFontDefault(); config.MergeMode = true;
} static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic
0x2200, 0x22FF, // Mathematical Operators
0,
};
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
size,
&config,
extended_ranges);
(void) font;
io.Fonts->Build(); io.Fonts->Build();
} }
} // namespace kte::Fonts } // namespace kte::Fonts

View File

@@ -3,12 +3,12 @@
#include <string> #include <string>
#include <utility> #include <utility>
#include "BrassMonoCode.h" #include "BerkeleyMono.h"
namespace kte::Fonts { namespace kte::Fonts {
// Provide default embedded font aliases used by GUIFrontend fallback loader // Provide default embedded font aliases used by GUIFrontend fallback loader
inline const unsigned int DefaultFontSize = BrassMonoCode::DefaultFontBoldCompressedSize; inline const unsigned int DefaultFontSize = BerkeleyMono::DefaultFontRegularCompressedSize;
inline const unsigned int *DefaultFontData = BrassMonoCode::DefaultFontBoldCompressedData; inline const unsigned int *DefaultFontData = BerkeleyMono::DefaultFontRegularCompressedData;
class Font { class Font {
public: public:

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "B612Mono.h" #include "B612Mono.h"
#include "BerkeleyMono.h"
#include "BrassMono.h" #include "BrassMono.h"
#include "BrassMonoCode.h" #include "BrassMonoCode.h"
#include "FiraCode.h" #include "FiraCode.h"

View File

@@ -7,28 +7,38 @@ InstallDefaultFonts()
{ {
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"default", "default",
BrassMono::DefaultFontBoldCompressedData, BerkeleyMono::DefaultFontBoldCompressedData,
BrassMono::DefaultFontBoldCompressedSize BerkeleyMono::DefaultFontBoldCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"b612", "b612",
B612Mono::DefaultFontRegularCompressedData, B612Mono::DefaultFontRegularCompressedData,
B612Mono::DefaultFontRegularCompressedSize B612Mono::DefaultFontRegularCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>(
"berkeley",
BerkeleyMono::DefaultFontRegularCompressedData,
BerkeleyMono::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"berkeley-bold",
BerkeleyMono::DefaultFontBoldCompressedData,
BerkeleyMono::DefaultFontBoldCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmono", "brassmono",
BrassMono::DefaultFontRegularCompressedData, BrassMono::DefaultFontRegularCompressedData,
BrassMono::DefaultFontRegularCompressedSize BrassMono::DefaultFontRegularCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmono-bold", "brassmono-bold",
BrassMono::DefaultFontBoldCompressedData, BrassMono::DefaultFontBoldCompressedData,
BrassMono::DefaultFontBoldCompressedSize BrassMono::DefaultFontBoldCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmonocode", "brassmonocode",
BrassMonoCode::DefaultFontRegularCompressedData, BrassMonoCode::DefaultFontRegularCompressedData,
BrassMonoCode::DefaultFontRegularCompressedSize BrassMonoCode::DefaultFontRegularCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmonocode-bold", "brassmonocode-bold",

19
main.cc
View File

@@ -1,3 +1,4 @@
#include <clocale>
#include <cctype> #include <cctype>
#include <cerrno> #include <cerrno>
#include <cstdio> #include <cstdio>
@@ -111,8 +112,10 @@ RunStressHighlighter(unsigned seconds)
int int
main(int argc, const char *argv[]) main(int argc, char *argv[])
{ {
std::setlocale(LC_ALL, "");
Editor editor; Editor editor;
// CLI parsing using getopt_long // CLI parsing using getopt_long
@@ -133,7 +136,7 @@ main(int argc, const char *argv[])
int opt; int opt;
int long_index = 0; int long_index = 0;
unsigned stress_seconds = 0; unsigned stress_seconds = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) { while ((opt = getopt_long(argc, argv, "gthV", long_opts, &long_index)) != -1) {
switch (opt) { switch (opt) {
case 'g': case 'g':
req_gui = true; req_gui = true;
@@ -192,13 +195,11 @@ main(int argc, const char *argv[])
} else if (req_term) { } else if (req_term) {
use_gui = false; use_gui = false;
} else { } else {
// Default depends on build target: kge defaults to GUI, kte to terminal
// Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI) #if defined(KTE_DEFAULT_GUI)
use_gui = true; use_gui = true;
#else #else
use_gui = false; use_gui = false;
#endif #endif
} }
#endif #endif
@@ -302,11 +303,13 @@ main(int argc, const char *argv[])
} }
#endif #endif
if (!fe->Init(editor)) { if (!fe->Init(argc, argv, editor)) {
std::cerr << "kte: failed to initialize frontend" << std::endl; std::cerr << "kte: failed to initialize frontend" << std::endl;
return 1; return 1;
} }
Execute(editor, CommandId::CenterOnCursor);
bool running = true; bool running = true;
while (running) { while (running) {
fe->Step(editor, running); fe->Step(editor, running);

View File

@@ -16,14 +16,18 @@ open .
cd .. cd ..
mkdir -p cmake-build-release-qt mkdir -p cmake-build-release-qt
cmake -S . -B cmake-build-release -DBUILD_GUI=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cd cmake-build-release-qt cd cmake-build-release-qt
make clean make clean
rm -fr kge.app* kge-qt.app* rm -fr kge.app* kge-qt.app*
make make
mv kge.app kge-qt.app mv -f kge.app kge-qt.app
macdeployqt kge-qt.app -always-overwrite # Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
macdeployqt kge-qt.app -always-overwrite -verbose=3
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
zip -r kge-qt.app.zip kge-qt.app zip -r kge-qt.app.zip kge-qt.app
sha256sum kge-qt.app.zip sha256sum kge-qt.app.zip
open . open .

View File

@@ -1,74 +0,0 @@
// Verify OptimizedSearch against std::string reference across patterns and sizes
#include <cassert>
#include <cstddef>
#include <random>
#include <string>
#include <vector>
#include "OptimizedSearch.h"
static std::vector<std::size_t>
ref_find_all(const std::string &text, const std::string &pat)
{
std::vector<std::size_t> res;
if (pat.empty())
return res;
std::size_t from = 0;
while (true) {
auto p = text.find(pat, from);
if (p == std::string::npos)
break;
res.push_back(p);
from = p + pat.size(); // non-overlapping
}
return res;
}
static void
run_case(std::size_t textLen, std::size_t patLen, unsigned seed)
{
std::mt19937 rng(seed);
std::uniform_int_distribution<int> dist('a', 'z');
std::string text(textLen, '\0');
for (auto &ch: text)
ch = static_cast<char>(dist(rng));
std::string pat(patLen, '\0');
for (auto &ch: pat)
ch = static_cast<char>(dist(rng));
// Guarantee at least one match when possible
if (textLen >= patLen && patLen > 0) {
std::size_t pos = textLen / 3;
if (pos + patLen <= text.size())
std::copy(pat.begin(), pat.end(), text.begin() + static_cast<long>(pos));
}
OptimizedSearch os;
auto got = os.find_all(text, pat, 0);
auto ref = ref_find_all(text, pat);
assert(got == ref);
}
int
main()
{
// Edge cases
run_case(0, 0, 1);
run_case(0, 1, 2);
run_case(1, 0, 3);
run_case(1, 1, 4);
// Various sizes
for (std::size_t t = 128; t <= 4096; t *= 2) {
for (std::size_t p = 1; p <= 64; p *= 2) {
run_case(t, p, static_cast<unsigned>(t + p));
}
}
// Larger random
run_case(100000, 16, 12345);
run_case(250000, 32, 67890);
return 0;
}

View File

@@ -1,338 +0,0 @@
#include <cassert>
#include <fstream>
#include <iostream>
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include "TestFrontend.h"
int
main()
{
// Install default commands
InstallDefaultCommands();
Editor editor;
TestFrontend frontend;
// Initialize frontend
if (!frontend.Init(editor)) {
std::cerr << "Failed to initialize frontend\n";
return 1;
}
// Create a temporary test file
std::string err;
const char *tmpfile = "/tmp/kte_test_undo.txt";
{
std::ofstream f(tmpfile);
if (!f) {
std::cerr << "Failed to create temp file\n";
return 1;
}
f << "\n"; // Write one newline so file isn't empty
f.close();
}
if (!editor.OpenFile(tmpfile, err)) {
std::cerr << "Failed to open test file: " << err << "\n";
return 1;
}
Buffer *buf = editor.CurrentBuffer();
assert(buf != nullptr);
// Initialize cursor to (0,0) explicitly
buf->SetCursor(0, 0);
std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n";
bool running = true;
// Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_insert = std::string(buf->Rows()[0]);
assert(line_after_insert == "Hello");
std::cout << " Buffer content: '" << line_after_insert << "'\n";
std::cout << " ✓ Text insertion verified\n\n";
// Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_undo = std::string(buf->Rows()[0]);
assert(line_after_undo == "");
std::cout << " Buffer content: '" << line_after_undo << "'\n";
std::cout << " ✓ Undo successful - text removed\n\n";
// Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_redo = std::string(buf->Rows()[0]);
assert(line_after_redo == "Hello");
std::cout << " Buffer content: '" << line_after_redo << "'\n";
std::cout << " ✓ Redo successful - text restored\n\n";
// Test 4: Branching behavior redo is discarded after new edits
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
// Ensure buffer is empty before starting this scenario
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Type a contiguous word 'abc' (single batch)
frontend.Input().QueueText("abc");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Undo once should remove the whole batch and leave empty
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Now type new text 'X' this should create a new branch and discard old redo chain
frontend.Input().QueueText("X");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Attempt Redo should be a no-op (redo branch was discarded by new edit)
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Undo and Redo along the new branch should still work
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
// Clear buffer state for next tests: undo to empty if needed
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 5: UTF-8 insertion and undo/redo round-trip
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
frontend.Input().QueueText(utf8_text);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
// Undo should remove the entire contiguous insertion batch
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Redo restores it
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
// Clear for next test
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
// Insert "ab" then newline then "cd" → expect two lines
frontend.Input().QueueText("ab");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("cd");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Split into two lines\n";
// Undo once should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Current design batches typing on the second line; after undo, the second line should exist but be empty
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "");
// Undo the newline should rejoin to a single line "ab"
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "ab");
// Redo twice to get back to ["ab","cd"]
frontend.Input().QueueCommand(CommandId::Redo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Newline undo/redo round-trip\n";
// Now join via Backspace at beginning of second line
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Backspace at BOL joins lines\n";
// Undo/Redo the join
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Join undo/redo round-trip\n\n";
// Test 7: Typing batching a contiguous word undone in one step
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
// Clear current line first
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
// Type a word and verify one undo clears it
frontend.Input().QueueText("hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
// Test 8: Forward delete batching at a fixed anchor column
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
// Prepare line content
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
frontend.Input().QueueText("abcdef");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Ensure cursor at anchor column 0
frontend.Input().QueueCommand(CommandId::MoveHome);
// Delete three chars at cursor; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
// Single undo should restore the entire deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo should remove the same run again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
// Test 9: Backspace batching with prepend rule (cursor moves left)
std::cout << "Test 9: Backspace batching with prepend rule\n";
// Restore to full string then backspace a run
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Move to end and backspace three characters; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::MoveEnd);
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Single undo restores the deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo removes it again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
frontend.Shutdown();
std::cout << "====================================\n";
std::cout << "All tests passed!\n";
return 0;
}

63
tests/Test.h Normal file
View File

@@ -0,0 +1,63 @@
// Minimal header-only unit test framework for kte
#pragma once
#include <functional>
#include <iostream>
#include <string>
#include <vector>
#include <chrono>
#include <sstream>
namespace ktet {
struct TestCase {
std::string name;
std::function<void()> fn;
};
inline std::vector<TestCase>& registry() {
static std::vector<TestCase> r;
return r;
}
struct Registrar {
Registrar(const char* name, std::function<void()> fn) {
registry().push_back(TestCase{std::string(name), std::move(fn)});
}
};
// Assertions
struct AssertionFailure {
std::string msg;
};
inline void expect(bool cond, const char* expr, const char* file, int line) {
if (!cond) {
std::cerr << file << ":" << line << ": EXPECT failed: " << expr << "\n";
}
}
inline void assert_true(bool cond, const char* expr, const char* file, int line) {
if (!cond) {
throw AssertionFailure{std::string(file) + ":" + std::to_string(line) + ": ASSERT failed: " + expr};
}
}
template<typename A, typename B>
inline void assert_eq_impl(const A& a, const B& b, const char* ea, const char* eb, const char* file, int line) {
if (!(a == b)) {
std::ostringstream oss;
oss << file << ":" << line << ": ASSERT_EQ failed: " << ea << " == " << eb;
throw AssertionFailure{oss.str()};
}
}
} // namespace ktet
#define TEST(name) \
static void name(); \
static ::ktet::Registrar _reg_##name(#name, &name); \
static void name()
#define EXPECT_TRUE(x) ::ktet::expect((x), #x, __FILE__, __LINE__)
#define ASSERT_TRUE(x) ::ktet::assert_true((x), #x, __FILE__, __LINE__)
#define ASSERT_EQ(a,b) ::ktet::assert_eq_impl((a),(b), #a, #b, __FILE__, __LINE__)

33
tests/TestRunner.cc Normal file
View File

@@ -0,0 +1,33 @@
#include "Test.h"
#include <iostream>
#include <chrono>
int main() {
using namespace std::chrono;
auto &reg = ktet::registry();
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
int failed = 0;
auto t0 = steady_clock::now();
for (const auto &tc : reg) {
auto ts = steady_clock::now();
try {
tc.fn();
auto te = steady_clock::now();
auto ms = duration_cast<milliseconds>(te - ts).count();
std::cout << "[ OK ] " << tc.name << " (" << ms << " ms)\n";
} catch (const ktet::AssertionFailure &e) {
++failed;
std::cerr << "[FAIL] " << tc.name << " -> " << e.msg << "\n";
} catch (const std::exception &e) {
++failed;
std::cerr << "[EXCP] " << tc.name << " -> " << e.what() << "\n";
} catch (...) {
++failed;
std::cerr << "[EXCP] " << tc.name << " -> unknown exception\n";
}
}
auto t1 = steady_clock::now();
auto total_ms = duration_cast<milliseconds>(t1 - t0).count();
std::cout << "Done in " << total_ms << " ms. Failures: " << failed << "\n";
return failed == 0 ? 0 : 1;
}

79
tests/test_buffer_io.cc Normal file
View File

@@ -0,0 +1,79 @@
#include "Test.h"
#include <fstream>
#include <cstdio>
#include <string>
#include "Buffer.h"
static std::string read_all(const std::string &path) {
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
TEST(Buffer_SaveAs_and_Save_new_file) {
const std::string path = "./.kte_ut_buffer_io_1.tmp";
std::remove(path.c_str());
Buffer b;
// insert two lines
b.insert_text(0, 0, std::string("Hello, world!\n"));
b.insert_text(1, 0, std::string("Second line\n"));
std::string err;
ASSERT_TRUE(b.SaveAs(path, err));
ASSERT_EQ(err.empty(), true);
// append another line then Save()
b.insert_text(2, 0, std::string("Third\n"));
b.SetDirty(true);
ASSERT_TRUE(b.Save(err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("Hello, world!\nSecond line\nThird\n"));
std::remove(path.c_str());
}
TEST(Buffer_Save_after_Open_existing) {
const std::string path = "./.kte_ut_buffer_io_2.tmp";
std::remove(path.c_str());
{
std::ofstream out(path, std::ios::binary);
out << "abc\n123\n";
}
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(err.empty(), true);
b.insert_text(2, 0, std::string("tail\n"));
b.SetDirty(true);
ASSERT_TRUE(b.Save(err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("abc\n123\ntail\n"));
std::remove(path.c_str());
}
TEST(Buffer_Open_nonexistent_then_SaveAs) {
const std::string path = "./.kte_ut_buffer_io_3.tmp";
std::remove(path.c_str());
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(err.empty(), true);
ASSERT_EQ(b.IsFileBacked(), false);
b.insert_text(0, 0, std::string("hello, world"));
b.insert_text(0, 12, std::string("\n"));
b.SetDirty(true);
ASSERT_TRUE(b.SaveAs(path, err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("hello, world\n"));
std::remove(path.c_str());
}

49
tests/test_piece_table.cc Normal file
View File

@@ -0,0 +1,49 @@
#include "Test.h"
#include "PieceTable.h"
#include <string>
TEST(PieceTable_Insert_Delete_LineCount) {
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t)0);
ASSERT_EQ(pt.LineCount(), (std::size_t)1); // empty buffer has 1 logical line
// Insert some text with newlines
const char *t = "abc\n123\nxyz"; // last line without trailing NL
pt.Insert(0, t, 11);
ASSERT_EQ(pt.Size(), (std::size_t)11);
ASSERT_EQ(pt.LineCount(), (std::size_t)3);
// Check get line
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("123"));
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t)2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
}
TEST(PieceTable_LineCol_Conversions) {
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts
auto off0 = pt.LineColToByteOffset(0, 0);
auto off1 = pt.LineColToByteOffset(1, 0);
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
ASSERT_EQ(off0, (std::size_t)0);
ASSERT_EQ(off1, (std::size_t)6); // "hello\n"
ASSERT_EQ(off2, pt.Size());
auto lc0 = pt.ByteOffsetToLineCol(0);
auto lc1 = pt.ByteOffsetToLineCol(6);
ASSERT_EQ(lc0.first, (std::size_t)0);
ASSERT_EQ(lc0.second, (std::size_t)0);
ASSERT_EQ(lc1.first, (std::size_t)1);
ASSERT_EQ(lc1.second, (std::size_t)0);
}

View File

@@ -0,0 +1,102 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <iostream>
#include <string>
static std::string
to_string_rows(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
TEST (ReflowParagraph_NumberedList_HangingIndent)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
// Two list items in one paragraph (no blank lines).
// Second line of each item already uses a hanging indent.
const std::string initial =
"1. one two three four five six seven eight nine ten eleven\n"
" twelve thirteen fourteen\n"
"10. alpha beta gamma delta epsilon zeta eta theta iota kappa lambda\n"
" mu nu xi omicron\n";
b.insert_text(0, 0, initial);
// Put cursor on first item
b.SetCursor(0, 0);
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
const int width = 25;
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
const auto &rows = buf->Rows();
ASSERT_TRUE(!rows.empty());
const std::string dump = to_string_rows(*buf);
// Find the start of the second item.
bool any_too_long = false;
std::size_t idx_10 = rows.size();
for (std::size_t i = 0; i < rows.size(); ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (static_cast<int>(line.size()) > width)
any_too_long = true;
if (line.rfind("10. ", 0) == 0) {
idx_10 = i;
break;
}
}
ASSERT_TRUE(idx_10 < rows.size());
if (any_too_long) {
std::cerr << "Reflow produced a line longer than width=" << width << "\n";
std::cerr << to_string_rows(*buf) << "\n";
}
EXPECT_TRUE(!any_too_long);
// Item 1: first line has "1. ", continuation lines have 3 spaces.
for (std::size_t i = 0; i < idx_10; ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (i == 0) {
ASSERT_TRUE(line.rfind("1. ", 0) == 0);
} else {
ASSERT_TRUE(line.rfind(" ", 0) == 0);
ASSERT_TRUE(line.rfind("1. ", 0) != 0);
}
}
// Item 10: first line has "10. ", continuation lines have 4 spaces.
ASSERT_TRUE(static_cast<std::string>(rows[idx_10]).rfind("10. ", 0) == 0);
bool bad_10 = false;
for (std::size_t i = idx_10 + 1; i < rows.size(); ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (line.empty())
break; // paragraph terminator / trailing empty line
if (line.rfind(" ", 0) != 0)
bad_10 = true;
if (line.rfind("10. ", 0) == 0)
bad_10 = true;
}
if (bad_10) {
std::cerr << "Unexpected prefix in reflow output:\n" << dump << "\n";
}
ASSERT_TRUE(!bad_10);
// Debug helper if something goes wrong (kept as a string for easy inspection).
EXPECT_TRUE(!to_string_rows(*buf).empty());
}

36
tests/test_search.cc Normal file
View File

@@ -0,0 +1,36 @@
#include "Test.h"
#include "OptimizedSearch.h"
#include <string>
#include <vector>
static std::vector<std::size_t> ref_find_all(const std::string &text, const std::string &pat) {
std::vector<std::size_t> res;
if (pat.empty()) return res;
std::size_t from = 0;
while (true) {
auto p = text.find(pat, from);
if (p == std::string::npos) break;
res.push_back(p);
from = p + pat.size();
}
return res;
}
TEST(OptimizedSearch_basic_cases) {
OptimizedSearch os;
struct Case { std::string text; std::string pat; } cases[] = {
{"", ""},
{"", "a"},
{"a", ""},
{"a", "a"},
{"aaaaa", "aa"},
{"hello world", "world"},
{"abcabcabc", "abc"},
{"the quick brown fox", "fox"},
};
for (auto &c : cases) {
auto got = os.find_all(c.text, c.pat, 0);
auto ref = ref_find_all(c.text, c.pat);
ASSERT_EQ(got, ref);
}
}

940
tests/test_undo.cc Normal file
View File

@@ -0,0 +1,940 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <cstddef>
#include <random>
#if defined(KTE_TESTS)
#include <unordered_set>
static void
validate_undo_subtree(const UndoNode *node, const UndoNode *expected_parent,
std::unordered_set<const UndoNode *> &seen)
{
ASSERT_TRUE(node != nullptr);
ASSERT_TRUE(seen.find(node) == seen.end());
seen.insert(node);
ASSERT_TRUE(node->parent == expected_parent);
// Validate each redo branch under this node.
for (const UndoNode *ch = node->child; ch != nullptr; ch = ch->next) {
validate_undo_subtree(ch, node, seen);
}
}
static void
validate_undo_tree(const UndoSystem &u)
{
const UndoTree &t = u.TreeForTests();
std::unordered_set<const UndoNode *> seen;
for (const UndoNode *root = t.root; root != nullptr; root = root->next) {
validate_undo_subtree(root, nullptr, seen);
}
// current/saved must either be null or be reachable from some root.
if (t.current)
ASSERT_TRUE(seen.find(t.current) != seen.end());
if (t.saved)
ASSERT_TRUE(seen.find(t.saved) != seen.end());
// pending is detached (not part of the committed tree).
if (t.pending) {
ASSERT_TRUE(seen.find(t.pending) == seen.end());
ASSERT_TRUE(t.pending->parent == nullptr);
ASSERT_TRUE(t.pending->child == nullptr);
ASSERT_TRUE(t.pending->next == nullptr);
}
}
#endif
TEST (Undo_InsertRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Simulate two separate "typed" insert commands without committing in between.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("h"));
u->Append('h');
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("i"));
u->Append('i');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST (Undo_BackspaceRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed content.
b.insert_text(0, 0, std::string_view("abc"));
b.SetCursor(3, 0);
u->mark_saved();
// Simulate two backspaces: delete 'c' then 'b'.
{
const auto &rows = b.Rows();
char deleted = rows[0][2];
b.delete_text(0, 2, 1);
b.SetCursor(2, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
b.delete_text(0, 1, 1);
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// One undo should restore both characters.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
}
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// New edit after undo creates a new branch; the old redo should remain as an alternate branch.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// No further redo from the tip.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// Undo back to the branch point and redo the original branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
}
TEST (Undo_DirtyFlag_MarkSavedAndUndoRedo)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_TRUE(b.Dirty());
u->undo();
ASSERT_TRUE(!b.Dirty());
u->redo();
ASSERT_TRUE(b.Dirty());
}
TEST (Undo_Newline_UndoRedo_SplitJoin)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed a single line and split it.
b.insert_text(0, 0, std::string_view("hello"));
b.SetCursor(2, 0); // split after "he"
u->Begin(UndoType::Newline);
b.split_line(0, 2);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
// Undo should join the lines back.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hello"));
// Redo should split again at the same point.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
}
TEST (Undo_DeleteKeyRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed content: delete-key semantics keep cursor at the same column.
b.insert_text(0, 0, std::string_view("abcd"));
b.SetCursor(1, 0); // on 'b'
// Delete 'b'
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 1);
u->Append(deleted);
b.SetCursor(1, 0);
}
// Delete next char (was 'c', now at same col=1)
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 1);
u->Append(deleted);
b.SetCursor(1, 0);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
// One undo should restore both deleted characters.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
}
TEST (Undo_UndoPastFirstEdit_RedoFromPreFirstEdit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Commit two separate insert edits.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo twice: we should reach the pre-first-edit state.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
// Redo twice should restore both edits.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
}
TEST (Undo_NewEditFromPreFirstEdit_PreservesOldHistoryAsAlternateRootBranch)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build up two edits.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo past first edit so current becomes null.
u->undo();
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
// Commit a new edit from the pre-first-edit state.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// From the tip, no further redo.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// Undo back to pre-first-edit and select the older root branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
}
TEST (Undo_MultiLineDelete_ConsumesNewline_UndoRestores)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Create two lines. PieceTable treats '\n' between logical lines.
b.insert_text(0, 0, std::string_view("ab\ncd"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
// Delete spanning the newline: delete "b\n" starting at (0,1).
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 2);
u->Append(std::string_view("b\n"));
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("acd"));
// Undo should restore exact original text/line structure.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
}
TEST (Undo_DeleteIndent_UndoRestoresCursorAtText)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed 3-line content with indentation on the middle line.
b.insert_text(0, 0,
std::string_view("I did a thing\n and then I edited a thing\nbut there were gaps"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Cursor at start of the line (before spaces), then C-d C-d deletes two spaces.
b.SetCursor(0, 1);
for (int i = 0; i < 2; ++i) {
const auto &rows = b.Rows();
char deleted = rows[1][0];
ASSERT_EQ(deleted, ' ');
u->Begin(UndoType::Delete);
b.delete_text(1, 0, 1);
u->Append(deleted);
b.SetCursor(0, 1); // delete-key keeps col the same
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[1]), std::string("and then I edited a thing"));
ASSERT_EQ(b.Cury(), (std::size_t) 1);
ASSERT_EQ(b.Curx(), (std::size_t) 0);
// Undo should restore indentation, and keep cursor on the text (at 'a'), not at EOL.
u->undo();
ASSERT_EQ(std::string(b.Rows()[1]), std::string(" and then I edited a thing"));
ASSERT_EQ(b.Cury(), (std::size_t) 1);
ASSERT_EQ(b.Curx(), (std::size_t) 2);
}
TEST (Undo_StructuralInvariants_BranchingAndRoots)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build history: a -> b
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo past first edit; now create a new root-level branch x.
u->undo();
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// Return to the older root branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Create a normal branch under 'a'.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
validate_undo_tree(*u);
}
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Root: a
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 1: a->b
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Back to branch point.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 2: a->c
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 3: a->d
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("d"));
u->Append('d');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Under 'a', the sibling list should now contain 3 branches.
validate_undo_tree(*u);
// Select the 3rd sibling (branch_index=2) which should be the oldest ("b"), and make it active.
u->redo(2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Since we selected "b", redo with default should now follow "b" again.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Select another branch by index and ensure it becomes the new default.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
// Out-of-range selection should be a no-op.
u->redo(99);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
validate_undo_tree(*u);
}
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build A->B.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Curx(), (std::size_t) 1);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Undo to A.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Curx(), (std::size_t) 1);
// Create sibling branch A->C.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Back to A.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Curx(), (std::size_t) 1);
// Redo into B as the alternate branch (older sibling), and confirm cursor is consistent.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Both branches remain reachable: undo to A, redo defaults to B (head reordered).
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// And the other branch C should still be selectable.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// After selecting C, default redo from A should now follow C.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
validate_undo_tree(*u);
}
TEST (Undo_Randomized_Deterministic_EditUndoRedoBranchSelect)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
std::mt19937 rng(0xC0FFEEu);
std::uniform_int_distribution<int> op(0, 99);
std::uniform_int_distribution<int> ch(0, 25);
const int steps = 300;
const int max_len = 40;
const int max_branch = 4;
for (int i = 0; i < steps; ++i) {
ASSERT_TRUE(!b.Rows().empty());
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_TRUE(b.Curx() <= b.Rows()[0].size());
validate_undo_tree(*u);
int r = op(rng);
std::string cur = std::string(b.Rows()[0]);
int len = static_cast<int>(cur.size());
if (r < 40 && len < max_len) {
// Insert one char at end as a standalone committed node.
char c = static_cast<char>('a' + ch(rng));
b.SetCursor(static_cast<std::size_t>(len), 0);
u->Begin(UndoType::Insert);
b.insert_text(0, len, std::string_view(&c, 1));
u->Append(c);
b.SetCursor(static_cast<std::size_t>(len + 1), 0);
u->commit();
} else if (r < 60 && len > 0) {
// Backspace at end as a standalone committed node.
char deleted = cur[static_cast<std::size_t>(len - 1)];
b.delete_text(0, len - 1, 1);
b.SetCursor(static_cast<std::size_t>(len - 1), 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
u->commit();
} else if (r < 80) {
// Undo then redo should round-trip to the exact same node/text/cursor when possible.
const UndoNode *before_node = u->TreeForTests().current;
const std::string before_text(std::string(b.Rows()[0]));
const std::size_t before_x = b.Curx();
if (before_node) {
u->undo();
u->redo();
ASSERT_TRUE(u->TreeForTests().current == before_node);
ASSERT_EQ(std::string(b.Rows()[0]), before_text);
ASSERT_EQ(b.Curx(), before_x);
} else {
// Nothing to undo; just exercise redo/branch-select paths.
u->redo();
}
} else if (r < 90) {
u->undo();
} else {
int idx = static_cast<int>(rng() % static_cast<std::uint32_t>(max_branch));
if ((rng() % 8u) == 0u)
idx = 99; // intentionally out of range sometimes
u->redo(idx);
}
}
validate_undo_tree(*u);
}
TEST (Undo_PendingCoalescedRun_UndoCommitsThenUndoes)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Create a coalesced insert run without an explicit commit.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// undo() should implicitly commit pending and then undo it as one step.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}
TEST (Undo_PendingRunAtBranchPoint_UndoThenBranchSelectionStillWorks)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo to the branch point.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Start a pending insert "c" at the branch point, but don't commit.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// Undo should seal the pending "c" as a new branch, then undo it, leaving us at "a".
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// The active redo should now be "c".
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Select the older "b" branch.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}
TEST (Undo_SavedNodeOnOtherBranch_DirtyClearsWhenReturning)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b and mark saved at the tip.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
// Move to a different branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_TRUE(b.Dirty());
// Return to the saved node by selecting the older branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_TRUE(!b.Dirty());
validate_undo_tree(*u);
}
TEST (Undo_Clear_AfterSaved_ResetsStateSafely)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("y"));
u->Append('y');
b.SetCursor(2, 0);
u->commit();
ASSERT_TRUE(b.Dirty());
u->clear();
ASSERT_TRUE(!b.Dirty());
// clear() resets undo history, but does not mutate buffer contents.
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
validate_undo_tree(*u);
}
TEST (Undo_Command_UndoHonorsRepeatCount)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
UndoSystem *u = buf->Undo();
ASSERT_TRUE(u != nullptr);
// Create two committed steps using the undo system directly.
buf->SetCursor(0, 0);
u->Begin(UndoType::Insert);
buf->insert_text(0, 0, std::string_view("a"));
u->Append('a');
buf->SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("b"));
u->Append('b');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// Undo twice via command repeat count.
ed.SetUniversalArg(1, 2);
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string(""));
validate_undo_tree(*u);
}
TEST (Undo_Command_RedoCountSelectsBranch)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
UndoSystem *u = buf->Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b.
buf->SetCursor(0, 0);
u->Begin(UndoType::Insert);
buf->insert_text(0, 0, std::string_view("a"));
u->Append('a');
buf->SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("b"));
u->Append('b');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// Undo to the branch point and create a sibling branch "c".
u->undo();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("c"));
u->Append('c');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ac"));
// Back to branch point.
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
// Command redo with count=2 should select branch_index=1 (the older "b" branch).
ed.SetUniversalArg(1, 2);
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// After selection, "b" should be the default redo from the branch point.
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}

View File

@@ -0,0 +1,158 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <string>
static std::string
dump_buf(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
static std::string
dump_bytes(const std::string &s)
{
static const char *hex = "0123456789abcdef";
std::string out;
for (unsigned char c: s) {
out.push_back(hex[(c >> 4) & 0xF]);
out.push_back(hex[c & 0xF]);
out.push_back(' ');
}
return out;
}
TEST (VisualLineMode_BroadcastInsert)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0); // fo|o
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
// Enter visual-line mode and extend selection to 3 lines
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// Broadcast insert to all selected lines
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (VisualLineMode_BroadcastBackspace)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
b.SetCursor(2, 0); // ab|cd
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(Execute(ed, std::string("backspace")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "acd\nacd\nacd\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (VisualLineMode_CancelWithCtrlG)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0);
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// C-g is mapped to "refresh" and should cancel visual-line mode.
ASSERT_TRUE(Execute(ed, std::string("refresh")));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_TRUE(!ed.CurrentBuffer()->VisualLineActive());
// After cancel, edits should only affect the primary cursor line.
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Cursor is still on the last line we moved to (down, down).
const std::string exp = "foo\nfoo\nfXoo\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (Yank_ClearsMarkAndVisualLine)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nbar\n");
b.SetCursor(1, 0);
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
Buffer *buf = ed.CurrentBuffer();
// Seed mark + visual-line highlighting.
buf->SetMark(buf->Curx(), buf->Cury());
ASSERT_TRUE(buf->MarkSet());
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 1));
ASSERT_TRUE(buf->VisualLineActive());
// Yank should clear mark and any highlighting.
ed.KillRingClear();
ed.KillRingPush("X");
ASSERT_TRUE(Execute(ed, std::string("yank")));
ASSERT_TRUE(!buf->MarkSet());
ASSERT_TRUE(!buf->VisualLineActive());
}