Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ce005ec0 | ||
|
|
22f7573361 | ||
|
|
96e82cc6dd | ||
|
|
480f1390c7 | ||
|
|
3126a5e523 | ||
|
|
b60a8dc491 | ||
|
|
056c9af38e | ||
|
|
6413e14455 |
@@ -231,9 +231,12 @@ Buffer::Buffer(const Buffer &other)
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
// Copy syntax/highlighting flags
|
||||
// Copy edit mode + syntax/highlighting flags
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Fresh undo system for the copy
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
@@ -281,8 +284,11 @@ Buffer::operator=(const Buffer &other)
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Recreate undo system for this instance
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
@@ -323,16 +329,28 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
mark_set_(other.mark_set_),
|
||||
mark_curx_(other.mark_curx_),
|
||||
mark_cury_(other.mark_cury_),
|
||||
visual_line_active_(other.visual_line_active_),
|
||||
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
||||
visual_line_active_y_(other.visual_line_active_y_),
|
||||
undo_tree_(std::move(other.undo_tree_)),
|
||||
undo_sys_(std::move(other.undo_sys_))
|
||||
{
|
||||
// Move syntax/highlighting state
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -361,16 +379,28 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
visual_line_active_ = other.visual_line_active_;
|
||||
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
||||
visual_line_active_y_ = other.visual_line_active_y_;
|
||||
undo_tree_ = std::move(other.undo_tree_);
|
||||
undo_sys_ = std::move(other.undo_sys_);
|
||||
|
||||
// Move syntax/highlighting state
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
|
||||
@@ -517,6 +517,7 @@ public:
|
||||
void SetEditMode(EditMode m)
|
||||
{
|
||||
edit_mode_ = m;
|
||||
edit_mode_detected_ = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -525,6 +526,13 @@ public:
|
||||
edit_mode_ = (edit_mode_ == EditMode::Code)
|
||||
? EditMode::Writing
|
||||
: EditMode::Code;
|
||||
edit_mode_detected_ = true;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool EditModeDetected() const
|
||||
{
|
||||
return edit_mode_detected_;
|
||||
}
|
||||
|
||||
|
||||
@@ -540,6 +548,25 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Marks that the user explicitly set syntax state via a command (:syntax
|
||||
// on/off, :set filetype=...), as opposed to it being auto-applied from
|
||||
// GUIConfig. Frontends that re-apply the config-driven default every
|
||||
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
|
||||
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
|
||||
// pattern - otherwise a manual :syntax off is silently undone on the very
|
||||
// next frame.
|
||||
void SetSyntaxUserOverride(bool on)
|
||||
{
|
||||
syntax_user_override_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool SyntaxUserOverride() const
|
||||
{
|
||||
return syntax_user_override_;
|
||||
}
|
||||
|
||||
|
||||
void SetFiletype(const std::string &ft)
|
||||
{
|
||||
filetype_ = ft;
|
||||
@@ -660,10 +687,12 @@ private:
|
||||
|
||||
// Edit mode (code vs writing)
|
||||
EditMode edit_mode_ = EditMode::Code;
|
||||
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||
|
||||
// Syntax/highlighting state
|
||||
std::uint64_t version_ = 0; // increment on edits
|
||||
bool syntax_enabled_ = true;
|
||||
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**kte** (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and optional GUI frontends (ImGui via SDL2/OpenGL/Freetype, or Qt6). It uses a WordStar/VDE-style command model. The terminal editor is `kte`; the GUI editor is `kge`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Configure (from project root, build dir is "build")
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
|
||||
|
||||
# Build everything
|
||||
cmake --build build
|
||||
|
||||
# Build specific targets
|
||||
cmake --build build --target kte # terminal editor
|
||||
cmake --build build --target kge # GUI editor (requires -DBUILD_GUI=ON)
|
||||
cmake --build build --target kte_tests # test suite
|
||||
|
||||
# Run all tests
|
||||
cmake --build build --target kte_tests && ./build/kte_tests
|
||||
```
|
||||
|
||||
There is no single-test runner; the test binary runs all tests. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros.
|
||||
|
||||
### Key CMake Options
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
|
||||
| `KTE_USE_QT` | OFF | Use Qt6 instead of ImGui for GUI |
|
||||
| `BUILD_TESTS` | ON | Build test suite |
|
||||
| `ENABLE_ASAN` | OFF | AddressSanitizer |
|
||||
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
|
||||
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
|
||||
|
||||
### Nix
|
||||
|
||||
`flake.nix` provides devshells: `default` (ImGui+debug tools), `terminal`, `qt`.
|
||||
|
||||
### Docker (cross-platform Linux testing)
|
||||
|
||||
```bash
|
||||
docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Three-layer design with strict frontend independence:
|
||||
|
||||
```
|
||||
Frontend Layer (Terminal / ImGui / Qt / Test)
|
||||
InputHandler.h, Renderer.h, Frontend.h interfaces
|
||||
↓
|
||||
Command Layer
|
||||
CommandId enum → CommandRegistry → handler functions in Command.cc
|
||||
↓
|
||||
Core Model Layer
|
||||
Editor → Buffer → PieceTable
|
||||
UndoSystem (tree-based, records at PieceTable level)
|
||||
SwapManager (crash recovery journal per buffer)
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
|
||||
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable. Prefer `GetLineView(row)` (zero-copy) or `GetLineString(row)` over `Rows()` (legacy, materializes all lines). All text mutations must go through PieceTable API (`insert_text`, `delete_text`) to ensure undo and swap recording work.
|
||||
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
|
||||
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
|
||||
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
|
||||
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
|
||||
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
|
||||
|
||||
### Frontend Implementations
|
||||
|
||||
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
|
||||
- **Terminal**: ncurses-based (always built)
|
||||
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
|
||||
- **Qt**: Qt6 (built with `-DBUILD_GUI=ON -DKTE_USE_QT=ON`)
|
||||
- **Test**: Programmatic frontend for testing (always built, no UI deps)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **C++20**, compiled with `-Wall -Wextra -Werror -pedantic`
|
||||
- **Clang** uses `-stdlib=libc++`
|
||||
- **Naming**: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g., `pieces_`)
|
||||
- **Indentation**: Tabs
|
||||
- **Error handling**: Fallible ops use `bool func(args..., std::string &err)` pattern. Always clear `err` at start, capture `errno` immediately after syscall failure. Use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
|
||||
- **ErrorHandler**: Centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/test_*.cc`. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
|
||||
|
||||
Key test files by area:
|
||||
- PieceTable: `test_piece_table.cc`
|
||||
- Buffer I/O: `test_buffer_io.cc`
|
||||
- Commands: `test_command_semantics.cc`
|
||||
- Search/replace: `test_search.cc`, `test_search_replace_flow.cc`
|
||||
- Undo: `test_undo.cc`
|
||||
- Swap (crash recovery): `test_swap_*.cc` (7 files)
|
||||
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
|
||||
- Integration: `test_daily_workflows.cc`
|
||||
|
||||
## Important Caveats
|
||||
|
||||
- `Buffer::Rows()` is legacy; use `GetLineView()` / `GetLineString()` in new code
|
||||
- `GetLineView()` returns a `string_view` valid only until next buffer modification
|
||||
- After editing ops, call `ensure_cursor_visible()` to update viewport
|
||||
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`
|
||||
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser)
|
||||
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI)
|
||||
+30
-2
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.11.1")
|
||||
set(KTE_VERSION "1.12.0")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -71,8 +71,34 @@ endif ()
|
||||
# NCurses for terminal mode
|
||||
set(CURSES_NEED_NCURSES TRUE)
|
||||
set(CURSES_NEED_WIDE TRUE)
|
||||
|
||||
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
|
||||
# build is keg-only, so FindCurses can't see it without a hint.
|
||||
if (APPLE)
|
||||
find_program(BREW_EXECUTABLE brew)
|
||||
if (BREW_EXECUTABLE)
|
||||
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
|
||||
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
ERROR_QUIET)
|
||||
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
|
||||
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
|
||||
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
|
||||
endif ()
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
find_package(Curses REQUIRED)
|
||||
include_directories(${CURSES_INCLUDE_DIR})
|
||||
|
||||
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||
# never set, so this silently expanded to nothing and the compiler fell back
|
||||
# to whatever curses header happened to be on the default include path.
|
||||
include_directories(${CURSES_INCLUDE_DIRS})
|
||||
|
||||
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||
# macro says otherwise. We always link the wide library, so ask for them.
|
||||
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||
|
||||
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||
# Manually find the correct ncurses library
|
||||
@@ -344,6 +370,8 @@ if (BUILD_TESTS)
|
||||
tests/test_migration_coverage.cc
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
tests/test_syntax_highlighting.cc
|
||||
tests/test_paste_split.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
|
||||
+168
-39
@@ -159,6 +159,27 @@ ensure_at_least_one_line(Buffer &buf)
|
||||
}
|
||||
|
||||
|
||||
// RAII helper: brackets a multi-edit command so its individual undo nodes
|
||||
// undo/redo as a single atomic step, per the project's undo-grouping convention.
|
||||
struct UndoGroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit UndoGroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~UndoGroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Determine if a command mutates the buffer contents (text edits)
|
||||
static bool
|
||||
is_mutating_command(CommandId id)
|
||||
@@ -264,8 +285,12 @@ extract_region_text(const Buffer &buf, std::size_t sx, std::size_t sy, std::size
|
||||
|
||||
|
||||
// Helper: delete region and leave cursor at start (sx,sy). Adjust lines appropriately.
|
||||
// If `u` is non-null, each underlying mutation is recorded as its own undo node;
|
||||
// callers that want the whole region-delete to undo/redo as one step should
|
||||
// wrap the call in an UndoGroupGuard.
|
||||
static void
|
||||
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey)
|
||||
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey,
|
||||
UndoSystem *u = nullptr)
|
||||
{
|
||||
std::size_t nrows = buf.Nrows();
|
||||
if (nrows == 0)
|
||||
@@ -282,7 +307,14 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
std::size_t xe = std::min(ex, line.size());
|
||||
if (xe < xs)
|
||||
std::swap(xs, xe);
|
||||
std::string deleted = std::string(line.substr(xs, xe - xs));
|
||||
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), xe - xs);
|
||||
if (u && !deleted.empty()) {
|
||||
buf.SetCursor(xs, sy);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(deleted));
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
// Multi-line: delete from (sx,sy) to (ex,ey)
|
||||
// Strategy:
|
||||
@@ -303,11 +335,25 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
|
||||
// Delete tail of first line (from xs to end)
|
||||
if (xs < first_line_len) {
|
||||
std::string tail = std::string(rows[sy].substr(xs));
|
||||
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), first_line_len - xs);
|
||||
if (u && !tail.empty()) {
|
||||
buf.SetCursor(xs, sy);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(tail));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete lines from ey down to sy+1 (reverse order to preserve indices)
|
||||
for (std::size_t i = ey; i > sy; --i) {
|
||||
if (u) {
|
||||
std::string row_text = static_cast<std::string>(buf.Rows()[i]);
|
||||
buf.SetCursor(0, i);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(std::string_view(row_text));
|
||||
u->commit();
|
||||
}
|
||||
buf.delete_row(static_cast<int>(i));
|
||||
}
|
||||
|
||||
@@ -317,6 +363,12 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
const auto &rows_after = buf.Rows();
|
||||
std::size_t line_len = rows_after[sy].size();
|
||||
buf.insert_text(static_cast<int>(sy), static_cast<int>(line_len), suffix);
|
||||
if (u) {
|
||||
buf.SetCursor(line_len, sy);
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append(std::string_view(suffix));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.SetCursor(sx, sy);
|
||||
@@ -908,6 +960,10 @@ cmd_unknown_esc_command(CommandContext &ctx)
|
||||
static void
|
||||
apply_filetype(Buffer &buf, const std::string &ft)
|
||||
{
|
||||
// Only reachable from explicit user commands (:syntax on, :set
|
||||
// filetype=...) - mark so frontends that re-apply config-driven syntax
|
||||
// defaults every frame (e.g. ImGuiFrontend) don't stomp this choice.
|
||||
buf.SetSyntaxUserOverride(true);
|
||||
buf.EnsureHighlighter();
|
||||
auto *eng = buf.Highlighter();
|
||||
if (!eng)
|
||||
@@ -977,6 +1033,7 @@ cmd_syntax(CommandContext &ctx)
|
||||
};
|
||||
trim(arg);
|
||||
if (arg == "on") {
|
||||
b->SetSyntaxUserOverride(true);
|
||||
b->SetSyntaxEnabled(true);
|
||||
// If no highlighter but filetype is cpp by extension, set it
|
||||
if (!b->Highlighter() || !b->Highlighter()->HasHighlighter()) {
|
||||
@@ -984,6 +1041,7 @@ cmd_syntax(CommandContext &ctx)
|
||||
}
|
||||
ctx.editor.SetStatus("syntax: on");
|
||||
} else if (arg == "off") {
|
||||
b->SetSyntaxUserOverride(true);
|
||||
b->SetSyntaxEnabled(false);
|
||||
ctx.editor.SetStatus("syntax: off");
|
||||
} else if (arg == "reload") {
|
||||
@@ -1363,6 +1421,9 @@ cmd_toggle_edit_mode(const CommandContext &ctx)
|
||||
b->ToggleEditMode();
|
||||
}
|
||||
|
||||
// Writing mode disables syntax highlighting; code mode re-enables it.
|
||||
b->SetSyntaxEnabled(b->GetEditMode() == EditMode::Code);
|
||||
|
||||
const char *mode_str = (b->GetEditMode() == EditMode::Writing) ? "writing" : "code";
|
||||
ctx.editor.SetStatus(std::string("Mode: ") + mode_str);
|
||||
return true;
|
||||
@@ -2506,12 +2567,10 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
pos = p + with.size();
|
||||
} else {
|
||||
// When replacing with empty, continue after the deletion point to avoid re-matching
|
||||
// Replacing with empty leaves nothing inserted at the deletion
|
||||
// point, so resume scanning from `p` itself (not p+1) to catch
|
||||
// adjacent/overlapping matches, e.g. "aaaa" -> "" replacing "aa".
|
||||
pos = p;
|
||||
if (pos < static_cast<std::size_t>(buf->Rows()[y].size()))
|
||||
++pos;
|
||||
else
|
||||
break;
|
||||
}
|
||||
++total;
|
||||
}
|
||||
@@ -2920,14 +2979,28 @@ cmd_newline(CommandContext &ctx)
|
||||
return true;
|
||||
}
|
||||
std::size_t changed = 0;
|
||||
UndoSystem *ru = buf->Undo();
|
||||
UndoGroupGuard rguard(ru);
|
||||
// Iterate by index to allow modifications via PieceTable helpers
|
||||
for (std::size_t y = 0; y < buf->Rows().size(); ++y) {
|
||||
std::string before = static_cast<std::string>(buf->Rows()[y]);
|
||||
std::string after = std::regex_replace(before, rx, repl);
|
||||
if (after != before) {
|
||||
// Replace entire line y with 'after' using PieceTable ops
|
||||
if (ru) {
|
||||
buf->SetCursor(0, y);
|
||||
ru->Begin(UndoType::DeleteRow);
|
||||
ru->Append(std::string_view(before));
|
||||
ru->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(y));
|
||||
buf->insert_row(static_cast<int>(y), std::string_view(after));
|
||||
if (ru) {
|
||||
buf->SetCursor(0, y);
|
||||
ru->Begin(UndoType::InsertRow);
|
||||
ru->Append(std::string_view(after));
|
||||
ru->commit();
|
||||
}
|
||||
++changed;
|
||||
}
|
||||
}
|
||||
@@ -3000,18 +3073,28 @@ cmd_newline(CommandContext &ctx)
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
if (u && repeat > 1)
|
||||
u->BeginGroup();
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
// Sync the buffer's cursor to the split point before Begin(), which
|
||||
// records its node's row/col from the buffer's *current* cursor. Undo
|
||||
// must reverse this exact split, not wherever the cursor ends up after
|
||||
// the whole loop finishes.
|
||||
buf->SetCursor(x, y);
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
u->commit();
|
||||
}
|
||||
buf->split_line(static_cast<int>(y), static_cast<int>(x));
|
||||
// Move to start of next line
|
||||
y += 1;
|
||||
x = 0;
|
||||
}
|
||||
if (u && repeat > 1)
|
||||
u->EndGroup();
|
||||
buf->SetCursor(x, y);
|
||||
buf->SetDirty(true);
|
||||
if (auto *u = buf->Undo()) {
|
||||
u->Begin(UndoType::Newline);
|
||||
u->commit();
|
||||
}
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
@@ -3195,7 +3278,9 @@ cmd_backspace(CommandContext &ctx)
|
||||
x = prev_len;
|
||||
buf->SetCursor(x, y);
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
// Forward action here is a join, not a split: JoinLines has the
|
||||
// correct (inverted) apply() semantics, unlike Newline.
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
@@ -3274,7 +3359,9 @@ cmd_delete_char(CommandContext &ctx)
|
||||
} else if (y + 1 < rows_view.size()) {
|
||||
buf->join_lines(static_cast<int>(y));
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
// Forward action here is a join, not a split: JoinLines has the
|
||||
// correct (inverted) apply() semantics, unlike Newline.
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
@@ -3347,18 +3434,32 @@ cmd_kill_to_eol(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (y >= rows_view.size())
|
||||
break;
|
||||
if (x < rows_view[y].size()) {
|
||||
// delete from cursor to end of line
|
||||
killed_total += rows_view[y].substr(x);
|
||||
std::string seg = static_cast<std::string>(rows_view[y].substr(x));
|
||||
killed_total += seg;
|
||||
std::size_t len = rows_view[y].size() - x;
|
||||
buf->delete_text(static_cast<int>(y), static_cast<int>(x), len);
|
||||
if (u) {
|
||||
buf->SetCursor(x, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(seg));
|
||||
u->commit();
|
||||
}
|
||||
} else if (y + 1 < rows_view.size()) {
|
||||
// at EOL: delete the newline (join with next line)
|
||||
killed_total += "\n";
|
||||
if (u) {
|
||||
buf->SetCursor(x, y);
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
buf->join_lines(static_cast<int>(y));
|
||||
} else {
|
||||
// nothing to delete
|
||||
@@ -3392,20 +3493,37 @@ cmd_kill_line(CommandContext &ctx)
|
||||
(void) x; // cursor x will be reset to 0
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (rows_view.empty())
|
||||
break;
|
||||
if (rows_view.size() == 1) {
|
||||
// last remaining line: clear its contents
|
||||
killed_total += static_cast<std::string>(rows_view[0]);
|
||||
if (!rows_view[0].empty())
|
||||
buf->delete_text(0, 0, rows_view[0].size());
|
||||
std::string content = static_cast<std::string>(rows_view[0]);
|
||||
killed_total += content;
|
||||
if (!content.empty()) {
|
||||
buf->delete_text(0, 0, content.size());
|
||||
if (u) {
|
||||
buf->SetCursor(0, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(content));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
y = 0;
|
||||
} else if (y < rows_view.size()) {
|
||||
// erase current line; keep y pointing at the next line
|
||||
killed_total += static_cast<std::string>(rows_view[y]);
|
||||
std::string content = static_cast<std::string>(rows_view[y]);
|
||||
killed_total += content;
|
||||
killed_total += "\n";
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(std::string_view(content));
|
||||
u->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(y));
|
||||
const auto &rows_after = buf->Rows();
|
||||
if (y >= rows_after.size()) {
|
||||
@@ -3619,7 +3737,10 @@ cmd_kill_region(CommandContext &ctx)
|
||||
return false;
|
||||
}
|
||||
std::string text = extract_region_text(*buf, sx, sy, ex, ey);
|
||||
delete_region(*buf, sx, sy, ex, ey);
|
||||
{
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
delete_region(*buf, sx, sy, ex, ey, buf->Undo());
|
||||
}
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
if (!text.empty()) {
|
||||
if (ctx.editor.KillChain())
|
||||
@@ -4231,6 +4352,7 @@ cmd_delete_word_prev(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
if (y >= rows.size()) {
|
||||
y = rows.empty() ? 0 : rows.size() - 1;
|
||||
@@ -4272,7 +4394,7 @@ cmd_delete_word_prev(CommandContext &ctx)
|
||||
}
|
||||
// Now delete from (x, y) to (start_x, start_y) using PieceTable
|
||||
std::string deleted = extract_region_text(*buf, x, y, start_x, start_y);
|
||||
delete_region(*buf, x, y, start_x, start_y);
|
||||
delete_region(*buf, x, y, start_x, start_y, buf->Undo());
|
||||
// Prepend to killed_total (since we're deleting backwards)
|
||||
killed_total = deleted + killed_total;
|
||||
}
|
||||
@@ -4304,6 +4426,7 @@ cmd_delete_word_next(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
if (y >= rows.size())
|
||||
break;
|
||||
@@ -4343,7 +4466,7 @@ cmd_delete_word_next(CommandContext &ctx)
|
||||
}
|
||||
// Now delete from (start_x, start_y) to (x, y) using PieceTable
|
||||
std::string deleted = extract_region_text(*buf, start_x, start_y, x, y);
|
||||
delete_region(*buf, start_x, start_y, x, y);
|
||||
delete_region(*buf, start_x, start_y, x, y, buf->Undo());
|
||||
y = start_y;
|
||||
x = start_x;
|
||||
killed_total += deleted;
|
||||
@@ -4377,8 +4500,16 @@ cmd_indent_region(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No region to indent");
|
||||
return false;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
|
||||
buf->insert_text(static_cast<int>(y), 0, std::string_view("\t"));
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append('\t');
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
buf->SetDirty(true);
|
||||
buf->ClearMark();
|
||||
@@ -4402,6 +4533,8 @@ cmd_unindent_region(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No region to unindent");
|
||||
return false;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (y >= rows_view.size())
|
||||
@@ -4410,13 +4543,26 @@ cmd_unindent_region(CommandContext &ctx)
|
||||
if (!line.empty()) {
|
||||
if (line[0] == '\t') {
|
||||
buf->delete_text(static_cast<int>(y), 0, 1);
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append('\t');
|
||||
u->commit();
|
||||
}
|
||||
} else if (line[0] == ' ') {
|
||||
std::size_t spaces = 0;
|
||||
while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') {
|
||||
++spaces;
|
||||
}
|
||||
if (spaces > 0)
|
||||
if (spaces > 0) {
|
||||
buf->delete_text(static_cast<int>(y), 0, spaces);
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(line.substr(0, spaces));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4433,25 +4579,8 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
struct GroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit GroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~GroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
|
||||
GroupGuard guard(buf->Undo());
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
|
||||
@@ -165,9 +165,23 @@ std::size_t
|
||||
Editor::AddBuffer(const Buffer &buf)
|
||||
{
|
||||
auto &bufs = Buffers();
|
||||
// push_back may reallocate the vector's storage, moving every existing
|
||||
// Buffer to a new address. Drain any in-flight swap records first so the
|
||||
// writer thread never dereferences an address that's about to move, then
|
||||
// rehome each attached buffer that actually moved.
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(buf);
|
||||
// Attach swap recorder
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
@@ -182,8 +196,19 @@ std::size_t
|
||||
Editor::AddBuffer(Buffer &&buf)
|
||||
{
|
||||
auto &bufs = Buffers();
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(std::move(buf));
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
@@ -495,8 +520,22 @@ Editor::CloseBuffer(std::size_t index)
|
||||
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||
swap_->Detach(&bufs[index], true);
|
||||
bufs[index].SetSwapRecorder(nullptr);
|
||||
// Drain in-flight records before the erase-shift below moves other
|
||||
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||
// move-assign each trailing buffer into the previous slot).
|
||||
swap_->Flush();
|
||||
}
|
||||
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (swap_) {
|
||||
// erase() shifts every buffer after `index` down by one slot in-place
|
||||
// (no reallocation), so the buffer now at slot i used to live at slot
|
||||
// i+1 (same underlying storage, since data() doesn't move on erase).
|
||||
for (std::size_t i = index; i < bufs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
Buffer *old_addr = new_addr + 1;
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addr, new_addr));
|
||||
}
|
||||
}
|
||||
if (bufs.empty()) {
|
||||
curbuf_ = 0;
|
||||
} else if (curbuf_ >= bufs.size()) {
|
||||
|
||||
+23
-3
@@ -39,11 +39,19 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
||||
if (!b)
|
||||
return;
|
||||
|
||||
// Auto-detect edit mode from file extension
|
||||
if (!b->Filename().empty())
|
||||
// Auto-detect edit mode from file extension once per buffer so that
|
||||
// manual toggles (C-k m / : mode) are not overridden every frame.
|
||||
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||
|
||||
if (cfg.syntax) {
|
||||
// If the user explicitly set syntax state via a command (:syntax on/off,
|
||||
// :set filetype=...), leave it alone - otherwise this runs every frame
|
||||
// and silently undoes a manual ":syntax off" on the very next frame.
|
||||
if (b->SyntaxUserOverride())
|
||||
return;
|
||||
|
||||
// Writing mode disables syntax; otherwise follow the global config.
|
||||
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||
b->SetSyntaxEnabled(true);
|
||||
b->EnsureHighlighter();
|
||||
if (auto *eng = b->Highlighter()) {
|
||||
@@ -467,9 +475,21 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
|
||||
// Route input events to the correct window's input handler
|
||||
if (target) {
|
||||
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
|
||||
if (tgt_ed.FilePickerVisible()) {
|
||||
// Modal: don't let keystrokes fall through as edit commands
|
||||
// to the buffer underneath. Escape closes the picker;
|
||||
// everything else (navigation, filtering, double-click) is
|
||||
// handled by the picker's own ImGui widgets via
|
||||
// ImGui_ImplSDL2_ProcessEvent above.
|
||||
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
|
||||
tgt_ed.SetFilePickerVisible(false);
|
||||
}
|
||||
} else {
|
||||
target->input.ProcessSDLEvent(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!running)
|
||||
return;
|
||||
|
||||
+27
-30
@@ -8,6 +8,20 @@
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Editor.h"
|
||||
#include "PasteSplit.h"
|
||||
|
||||
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||
// time. Mirrors QtInputHandler.cc's QT_IH_DEBUG gate.
|
||||
#ifndef IMGUI_IH_DEBUG
|
||||
#define IMGUI_IH_DEBUG 0
|
||||
#endif
|
||||
|
||||
#if IMGUI_IH_DEBUG
|
||||
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
|
||||
#else
|
||||
#define IH_LOGF(...) ((void) 0)
|
||||
#endif
|
||||
|
||||
|
||||
static bool
|
||||
@@ -182,18 +196,19 @@ map_key(const SDL_Keycode key,
|
||||
k_ctrl_pending = false;
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics for u/U
|
||||
if (lower == 'u') {
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
IH_LOGF(
|
||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
@@ -376,32 +391,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
if (clip) {
|
||||
std::string text(clip);
|
||||
SDL_free(clip);
|
||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
||||
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||
// commands and the rest into InsertText; InsertText itself
|
||||
// rejects any embedded '\r'/'\n'.
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
std::size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
std::size_t pos = text.find('\n', start);
|
||||
std::string_view segment;
|
||||
bool has_nl = (pos != std::string::npos);
|
||||
if (has_nl) {
|
||||
segment = std::string_view(text).substr(start, pos - start);
|
||||
} else {
|
||||
segment = std::string_view(text).substr(start);
|
||||
}
|
||||
if (!segment.empty()) {
|
||||
MappedInput ins{
|
||||
true, CommandId::InsertText, std::string(segment), 0
|
||||
};
|
||||
q_.push(ins);
|
||||
}
|
||||
if (has_nl) {
|
||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
||||
q_.push(nl);
|
||||
start = pos + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const auto &cmd : SplitPasteIntoCommands(text))
|
||||
q_.push(cmd);
|
||||
// Suppress the corresponding TEXTINPUT that may follow
|
||||
suppress_text_input_once_ = true;
|
||||
return true; // consumed
|
||||
@@ -524,15 +519,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
bool pass_ctrl = k_ctrl_pending_;
|
||||
k_ctrl_pending_ = false;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||
{
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
mi = {true, id, "", 0};
|
||||
if (ed_)
|
||||
|
||||
+89
-18
@@ -106,7 +106,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||
float scroll_y = ImGui::GetScrollY();
|
||||
float scroll_x = ImGui::GetScrollX();
|
||||
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
|
||||
|
||||
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||
bool forced_scroll = false;
|
||||
@@ -134,8 +133,65 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
}
|
||||
prev_buf_rowoffs_ = buf_rowoffs;
|
||||
prev_buf_coloffs_ = buf_coloffs;
|
||||
// Track the widest line in pixels for ImGui content width
|
||||
float max_line_width_px = 0.0f;
|
||||
|
||||
// Max-line-width cache: reset when the buffer or font changes. We only
|
||||
// measure visible lines per frame and maintain a running max, so the
|
||||
// scrollbar may be slightly conservative on first view of a file until
|
||||
// the user scrolls, but idle CPU drops dramatically on large files.
|
||||
// Edits bump the buffer version but we intentionally do NOT reset the
|
||||
// max on version changes — keeping a conservative (possibly too-wide)
|
||||
// scrollbar is preferable to per-keystroke jitter.
|
||||
{
|
||||
ImFont *cur_font = ImGui::GetFont();
|
||||
float cur_fsize = ImGui::GetFontSize();
|
||||
if (buf != max_width_buf_
|
||||
|| cur_font != max_width_font_
|
||||
|| cur_fsize != max_width_font_size_) {
|
||||
max_width_buf_ = buf;
|
||||
max_width_font_ = cur_font;
|
||||
max_width_font_size_ = cur_fsize;
|
||||
max_width_px_ = 0.0f;
|
||||
}
|
||||
max_width_version_ = buf->Version();
|
||||
}
|
||||
|
||||
// Hoist the search-regex compilation out of the per-line loop. Compiling
|
||||
// std::regex per line per frame was a large source of idle CPU on macOS.
|
||||
const bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
const bool regex_mode = search_mode && ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
|
||||
std::regex search_rx;
|
||||
bool search_rx_valid = false;
|
||||
if (regex_mode) {
|
||||
try {
|
||||
search_rx = std::regex(ed.SearchQuery());
|
||||
search_rx_valid = true;
|
||||
} catch (const std::regex_error &) {
|
||||
search_rx_valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the visible row range and skip rendering work for off-screen
|
||||
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||
// syntax-highlight lookups, and width measurement are not free.
|
||||
const std::size_t total_rows = lines.size();
|
||||
std::size_t first_vis = 0;
|
||||
std::size_t last_vis = total_rows; // exclusive
|
||||
if (row_h > 0.0f && total_rows > 0) {
|
||||
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||
long lv = static_cast<long>(
|
||||
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||
if (fv < 0) fv = 0;
|
||||
if (lv < 0) lv = 0;
|
||||
if (static_cast<std::size_t>(fv) > total_rows)
|
||||
fv = static_cast<long>(total_rows);
|
||||
if (static_cast<std::size_t>(lv) > total_rows)
|
||||
lv = static_cast<long>(total_rows);
|
||||
first_vis = static_cast<std::size_t>(fv);
|
||||
last_vis = static_cast<std::size_t>(lv);
|
||||
}
|
||||
|
||||
// Mark selection state (mark -> cursor), in source coordinates
|
||||
bool sel_active = false;
|
||||
@@ -257,7 +313,13 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||
mouse_selecting_ = false;
|
||||
}
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||
// Advance the cursor Y so the first visible line draws at its correct
|
||||
// scroll position. Skipped rows simply leave the layout cursor untouched.
|
||||
if (first_vis > 0) {
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||
static_cast<float>(first_vis) * row_h);
|
||||
}
|
||||
for (std::size_t i = first_vis; i < last_vis; ++i) {
|
||||
// Capture the screen position before drawing the line
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = static_cast<std::string>(lines[i]);
|
||||
@@ -291,16 +353,13 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
};
|
||||
|
||||
// Compute search highlight ranges for this line in source indices
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
if (search_mode) {
|
||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
||||
if (ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
||||
// In regex mode, reuse the compiled regex hoisted above the loop.
|
||||
if (regex_mode) {
|
||||
if (search_rx_valid) {
|
||||
try {
|
||||
std::regex rx(ed.SearchQuery());
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
@@ -310,6 +369,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
@@ -483,18 +543,29 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
|
||||
// Track widest line for content width reporting
|
||||
// Track widest line for content width reporting. We only measure
|
||||
// visible lines and fold the result into a monotonic max cached on
|
||||
// the renderer (see max_width_* members). Off-screen lines are not
|
||||
// measured per frame; if the user scrolls or edits, the cache is
|
||||
// refreshed accordingly.
|
||||
if (!expanded.empty()) {
|
||||
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
|
||||
if (line_w > max_line_width_px)
|
||||
max_line_width_px = line_w;
|
||||
if (line_w > max_width_px_)
|
||||
max_width_px_ = line_w;
|
||||
}
|
||||
}
|
||||
// Report content width to ImGui so horizontal scrollbar works correctly.
|
||||
if (max_line_width_px > 0.0f) {
|
||||
ImGui::SetCursorPosX(max_line_width_px);
|
||||
// After the visible-range loop, advance the layout cursor to the end of
|
||||
// the (virtual) content so ImGui sees the correct total size. Vertical
|
||||
// height comes from total_rows; horizontal width comes from the cached
|
||||
// max line width. A Dummy at the final position records both.
|
||||
if (total_rows > last_vis) {
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||
static_cast<float>(total_rows - last_vis) * row_h);
|
||||
}
|
||||
if (max_width_px_ > 0.0f) {
|
||||
ImGui::SetCursorPosX(max_width_px_);
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, 0));
|
||||
}
|
||||
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||
{
|
||||
float child_h_actual = ImGui::GetWindowHeight();
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include "Renderer.h"
|
||||
|
||||
struct ImFont;
|
||||
class Buffer;
|
||||
|
||||
class ImGuiRenderer final : public Renderer {
|
||||
public:
|
||||
ImGuiRenderer() = default;
|
||||
@@ -20,4 +25,13 @@ private:
|
||||
float prev_scroll_y_ = -1.0f;
|
||||
float prev_scroll_x_ = -1.0f;
|
||||
bool mouse_selecting_ = false;
|
||||
|
||||
// Max-line-width cache for the horizontal scrollbar. Measuring every line
|
||||
// every frame is prohibitively expensive on large files; we only update the
|
||||
// running max from visible lines and reset when buffer/version/font changes.
|
||||
const Buffer *max_width_buf_ = nullptr;
|
||||
std::uint64_t max_width_version_ = 0;
|
||||
ImFont *max_width_font_ = nullptr;
|
||||
float max_width_font_size_ = 0.0f;
|
||||
float max_width_px_ = 0.0f;
|
||||
};
|
||||
|
||||
+9
-5
@@ -1,6 +1,4 @@
|
||||
#include <iostream>
|
||||
#include <ncurses.h>
|
||||
#include <ostream>
|
||||
|
||||
#include "KKeymap.h"
|
||||
|
||||
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
return true;
|
||||
}
|
||||
if (ascii_key == 'E') {
|
||||
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||
// switch below operates on the lowercased key, so it can't otherwise
|
||||
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||
// normal "unknown k-command" status-line message - curses already
|
||||
// owns the terminal here, so writing to stderr would corrupt the
|
||||
// screen.
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (k_lower) {
|
||||
case 'a':
|
||||
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'e':
|
||||
out = CommandId::OpenFileStart;
|
||||
return true;
|
||||
case 'E':
|
||||
std::cerr << "E is not a valid command" << std::endl;
|
||||
return false;
|
||||
case 'f':
|
||||
out = CommandId::FlushKillRing;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* PasteSplit.h - split pasted/clipboard text into editor commands
|
||||
*
|
||||
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
|
||||
* across frontends. Line breaks in the pasted text must become Newline
|
||||
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
|
||||
*/
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "InputHandler.h"
|
||||
|
||||
|
||||
// Translate a block of pasted text into a sequence of editor commands.
|
||||
//
|
||||
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
|
||||
// emitted as a Newline command; the text between breaks becomes InsertText
|
||||
// commands. Empty text segments are skipped, but line breaks are always
|
||||
// emitted so blank lines round-trip correctly.
|
||||
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
|
||||
{
|
||||
std::vector<MappedInput> out;
|
||||
std::string segment;
|
||||
|
||||
auto flush_segment = [&]() {
|
||||
if (!segment.empty()) {
|
||||
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
|
||||
segment.clear();
|
||||
}
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||
const char c = text[i];
|
||||
if (c == '\n' || c == '\r') {
|
||||
flush_segment();
|
||||
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
|
||||
// Collapse a "\r\n" pair into one line break.
|
||||
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
|
||||
++i;
|
||||
} else {
|
||||
segment.push_back(c);
|
||||
}
|
||||
}
|
||||
flush_segment();
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -268,6 +268,43 @@ SwapManager::RecorderFor(Buffer *buf)
|
||||
}
|
||||
|
||||
|
||||
SwapRecorder *
|
||||
SwapManager::Rehome(Buffer *old_addr, Buffer *new_addr)
|
||||
{
|
||||
if (!old_addr || !new_addr || old_addr == new_addr)
|
||||
return nullptr;
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
SwapRecorder *result = nullptr;
|
||||
|
||||
auto jit = journals_.find(old_addr);
|
||||
if (jit != journals_.end()) {
|
||||
JournalCtx ctx = std::move(jit->second);
|
||||
journals_.erase(jit);
|
||||
journals_[new_addr] = std::move(ctx);
|
||||
}
|
||||
|
||||
auto rit = recorders_.find(old_addr);
|
||||
if (rit != recorders_.end()) {
|
||||
recorders_.erase(rit);
|
||||
// BufferRecorder binds a Buffer& at construction, so it can't be
|
||||
// repointed in place; rebuild it against the buffer's new address.
|
||||
auto rec = std::make_unique<BufferRecorder>(*this, *new_addr);
|
||||
result = rec.get();
|
||||
recorders_[new_addr] = std::move(rec);
|
||||
}
|
||||
|
||||
// Defensive: any record still queued (not yet drained by the writer thread)
|
||||
// for the old address must follow the buffer to its new location. Callers
|
||||
// are expected to Flush() before rehoming so this should normally be a no-op.
|
||||
for (auto &p: queue_) {
|
||||
if (p.buf == old_addr)
|
||||
p.buf = new_addr;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Attach(Buffer *buf)
|
||||
{
|
||||
|
||||
@@ -94,6 +94,15 @@ public:
|
||||
// Detach(buf) or SwapManager destruction.
|
||||
SwapRecorder *RecorderFor(Buffer *buf);
|
||||
|
||||
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||
// before calling this, and must not call it with an address that isn't
|
||||
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||
// wasn't attached); the caller is responsible for calling
|
||||
// new_buf->SetSwapRecorder() with the result.
|
||||
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||
|
||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||
void NotifyFilenameChanged(Buffer &buf);
|
||||
|
||||
|
||||
+45
-10
@@ -1,4 +1,6 @@
|
||||
#include <cstdio>
|
||||
#include <climits>
|
||||
#include <cwchar>
|
||||
#include <ncurses.h>
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
@@ -12,6 +14,22 @@ CTRL(char c)
|
||||
{
|
||||
return c & 0x1F;
|
||||
}
|
||||
|
||||
|
||||
// Encode a single wide character in the process locale's multibyte encoding
|
||||
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||
// codepoint can't be represented, leaving `out` untouched.
|
||||
bool
|
||||
wchar_to_mb(wchar_t wc, std::string &out)
|
||||
{
|
||||
std::mbstate_t state{};
|
||||
char buf[MB_LEN_MAX];
|
||||
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||
if (n == static_cast<std::size_t>(-1))
|
||||
return false;
|
||||
out.assign(buf, n);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
TerminalInputHandler::TerminalInputHandler() = default;
|
||||
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
||||
|
||||
static bool
|
||||
map_key_to_command(const int ch,
|
||||
const bool is_keycode,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
bool &k_ctrl_pending,
|
||||
@@ -28,9 +47,13 @@ map_key_to_command(const int ch,
|
||||
Editor *ed,
|
||||
MappedInput &out)
|
||||
{
|
||||
// Handle special keys from ncurses
|
||||
// Handle special keys from ncurses. These are only meaningful when
|
||||
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||
// character can numerically collide with a KEY_* constant otherwise
|
||||
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||
// swallow it as a special key instead of inserting it.
|
||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||
switch (ch) {
|
||||
switch (is_keycode ? ch : -1) {
|
||||
case KEY_ENTER:
|
||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||
k_prefix = false;
|
||||
@@ -259,7 +282,7 @@ map_key_to_command(const int ch,
|
||||
esc_meta = false;
|
||||
int ascii_key = ch;
|
||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||
} else if (ch == ',') {
|
||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||
@@ -281,7 +304,7 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
|
||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
@@ -297,11 +320,20 @@ map_key_to_command(const int ch,
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable ASCII
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||
// Latin, Cyrillic, CJK, etc.
|
||||
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||
std::string mb;
|
||||
if (ch <= 0x7E) {
|
||||
mb.assign(1, static_cast<char>(ch));
|
||||
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
out.hasCommand = true;
|
||||
out.id = CommandId::InsertText;
|
||||
out.arg.assign(1, static_cast<char>(ch));
|
||||
out.arg = mb;
|
||||
out.count = 0;
|
||||
return true;
|
||||
}
|
||||
@@ -314,12 +346,15 @@ map_key_to_command(const int ch,
|
||||
bool
|
||||
TerminalInputHandler::decode_(MappedInput &out)
|
||||
{
|
||||
int ch = getch();
|
||||
if (ch == ERR) {
|
||||
wint_t wch;
|
||||
int ret = get_wch(&wch);
|
||||
if (ret == ERR) {
|
||||
return false; // no input
|
||||
}
|
||||
const bool is_keycode = (ret == KEY_CODE_YES);
|
||||
const int ch = static_cast<int>(wch);
|
||||
bool consumed = map_key_to_command(
|
||||
ch,
|
||||
ch, is_keycode,
|
||||
k_prefix_, esc_meta_,
|
||||
k_ctrl_pending_,
|
||||
mouse_selecting_,
|
||||
|
||||
@@ -10,6 +10,12 @@ enum class UndoType : std::uint8_t {
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||
// action is a join, not a split — Newline's apply() semantics are the
|
||||
// wrong direction for those.
|
||||
JoinLines,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
|
||||
+14
-1
@@ -37,7 +37,7 @@ UndoSystem::Begin(UndoType type)
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow);
|
||||
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
@@ -77,6 +77,7 @@ UndoSystem::Begin(UndoType type)
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
case UndoType::JoinLines:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -325,6 +326,16 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::JoinLines:
|
||||
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||
if (direction > 0) {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +435,8 @@ UndoSystem::type_str(UndoType t)
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
case UndoType::JoinLines:
|
||||
return "JoinLines";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
+23
-2
@@ -1,3 +1,24 @@
|
||||
// Placeholder translation unit for UndoTree struct definition.
|
||||
// Undo logic is implemented in UndoSystem.
|
||||
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||
#include "UndoTree.h"
|
||||
|
||||
namespace {
|
||||
void
|
||||
free_node_graph(UndoNode *node)
|
||||
{
|
||||
// Walk the sibling (redo-branch) list; for each node, recursively free its
|
||||
// child subtree first, then the node itself.
|
||||
while (node) {
|
||||
UndoNode *next = node->next;
|
||||
free_node_graph(node->child);
|
||||
delete node;
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
UndoTree::~UndoTree()
|
||||
{
|
||||
free_node_graph(root);
|
||||
delete pending;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,11 @@ struct UndoTree {
|
||||
UndoNode *current = nullptr; // current state of buffer
|
||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||
|
||||
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||
// created for it.
|
||||
~UndoTree();
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <random>
|
||||
#include <thread>
|
||||
#include <signal.h>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -255,7 +256,18 @@ main(int argc, char *argv[])
|
||||
// Fall through: not a +number, treat as filename starting with '+'
|
||||
}
|
||||
|
||||
const std::string path = arg;
|
||||
// Resolve to absolute path now, before any
|
||||
// chdir (macOS GUI changes CWD to HOME before
|
||||
// deferred opens are processed).
|
||||
std::string path = arg;
|
||||
try {
|
||||
std::filesystem::path p(path);
|
||||
if (p.is_relative()) {
|
||||
path = std::filesystem::absolute(p).string();
|
||||
}
|
||||
} catch (...) {
|
||||
// Fall through with original path
|
||||
}
|
||||
editor.RequestOpenFile(path, pending_line);
|
||||
pending_line = 0; // consumed (if set)
|
||||
}
|
||||
@@ -280,6 +292,20 @@ main(int argc, char *argv[])
|
||||
fe = std::make_unique<TerminalFrontend>();
|
||||
}
|
||||
|
||||
// Guarantee Shutdown() runs on every exit path from here on (normal
|
||||
// return, early return, or exception unwinding e.g. from Init/Execute/
|
||||
// Step) so a crash never leaves the terminal in raw/cbreak mode.
|
||||
struct FrontendShutdownGuard {
|
||||
Frontend *fe;
|
||||
|
||||
|
||||
~FrontendShutdownGuard()
|
||||
{
|
||||
if (fe)
|
||||
fe->Shutdown();
|
||||
}
|
||||
} shutdown_guard{fe.get()};
|
||||
|
||||
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
||||
if (use_gui) {
|
||||
/* likely using the .app, so need to cd */
|
||||
@@ -308,8 +334,6 @@ main(int argc, char *argv[])
|
||||
fe->Step(editor, running);
|
||||
}
|
||||
|
||||
fe->Shutdown();
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||
|
||||
+35
-2
@@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter()
|
||||
void
|
||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
GoHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol = 0;
|
||||
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
||||
++bol;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// line comment
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
@@ -82,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -152,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class GoHighlighter final : public LanguageHighlighter {
|
||||
class GoHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
GoHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -74,7 +74,22 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
||||
|
||||
StatefulHighlighter::LineState prev_state;
|
||||
int start_row = -1;
|
||||
if (!state_cache_.empty()) {
|
||||
|
||||
// Fast path: state_last_contig_ tracks the highest row we've cached state
|
||||
// for, per version. If that row is already below our target, it's
|
||||
// necessarily the best anchor the O(n) scan below would have found, so we
|
||||
// can skip the scan entirely. Re-validated against state_cache_ before
|
||||
// use since InvalidateFrom()/SetHighlighter() may have raced/cleared it.
|
||||
auto contig_it = state_last_contig_.find(buf_version);
|
||||
if (contig_it != state_last_contig_.end() && contig_it->second < row) {
|
||||
auto sc_it = state_cache_.find(contig_it->second);
|
||||
if (sc_it != state_cache_.end() && sc_it->second.version == buf_version) {
|
||||
start_row = contig_it->second;
|
||||
prev_state = sc_it->second.state;
|
||||
}
|
||||
}
|
||||
|
||||
if (start_row < 0 && !state_cache_.empty()) {
|
||||
// linear search over map (unordered), track best candidate
|
||||
int best = -1;
|
||||
for (const auto &kv: state_cache_) {
|
||||
@@ -109,6 +124,9 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
||||
se.state = next_state;
|
||||
state_cache_[r] = se;
|
||||
cur_state = next_state;
|
||||
int &contig = state_last_contig_[buf_version];
|
||||
if (r > contig)
|
||||
contig = r;
|
||||
}
|
||||
|
||||
// Store in cache and return by value
|
||||
@@ -139,6 +157,14 @@ HighlighterEngine::InvalidateFrom(int row)
|
||||
++it;
|
||||
}
|
||||
}
|
||||
// A version's tracked contiguous-state row is no longer valid once any
|
||||
// row at or above it has been evicted from state_cache_ above.
|
||||
for (auto it = state_last_contig_.begin(); it != state_last_contig_.end();) {
|
||||
if (it->second >= row)
|
||||
it = state_last_contig_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,31 +55,26 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
int i = 0;
|
||||
|
||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
||||
auto pos = s.find(state.raw_delim);
|
||||
if (pos == std::string::npos) {
|
||||
push(out, 0, n, TokenKind::String);
|
||||
return state; // still inside
|
||||
} else {
|
||||
}
|
||||
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
||||
push(out, 0, end, TokenKind::String);
|
||||
// remainder processed normally
|
||||
s = s.substr(end);
|
||||
n = static_cast<int>(s.size());
|
||||
state.in_raw_string = false;
|
||||
state.raw_delim.clear();
|
||||
// Continue parsing remainder as a separate small loop
|
||||
int base = end;
|
||||
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
||||
// For simplicity, mark rest as Default
|
||||
if (n > 0)
|
||||
push(out, base, base + n, TokenKind::Default);
|
||||
return state;
|
||||
}
|
||||
// Resume the normal tokenizer at the closing delimiter's end, on the
|
||||
// same (unmodified) `s`/`n`, so anything after it - including a new
|
||||
// triple-quoted string opening on this same line - is re-scanned
|
||||
// instead of being dumped into a single opaque Default span.
|
||||
i = end;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
// Detect comment start '#', ignoring inside strings
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
|
||||
@@ -47,11 +47,42 @@ RustHighlighter::RustHighlighter()
|
||||
void
|
||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
RustHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') {
|
||||
@@ -79,7 +110,8 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -140,5 +172,6 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class RustHighlighter final : public LanguageHighlighter {
|
||||
class RustHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
RustHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -47,12 +47,42 @@ SqlHighlighter::SqlHighlighter()
|
||||
void
|
||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
SqlHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') {
|
||||
@@ -68,7 +98,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
}
|
||||
// simple block comment on same line: /* ... */
|
||||
// block comment: /* ... */ (may span multiple lines)
|
||||
if (c == '/' && i + 1 < n && s[i + 1] == '*') {
|
||||
int j = i + 2;
|
||||
bool closed = false;
|
||||
@@ -82,7 +112,8 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -151,5 +182,6 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class SqlHighlighter final : public LanguageHighlighter {
|
||||
class SqlHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
SqlHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -108,3 +108,26 @@ TEST(CommandSemantics_CopyRegion_And_KillRegion)
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||
}
|
||||
|
||||
|
||||
TEST(CommandSemantics_Syntax_OnOff_SetsUserOverride)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
// Before any explicit :syntax command, nothing has overridden the
|
||||
// frontend's config-driven default.
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), false);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "off"));
|
||||
ASSERT_EQ(b.SyntaxEnabled(), false);
|
||||
// This flag is what a frontend's per-frame "apply config default" pass
|
||||
// must check before re-enabling syntax, or a manual :syntax off gets
|
||||
// silently undone on the next frame.
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "on"));
|
||||
ASSERT_EQ(b.SyntaxEnabled(), true);
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,21 @@ TEST(KKeymap_KPrefix_CanonicalChords)
|
||||
}
|
||||
|
||||
|
||||
TEST(KKeymap_KPrefix_UppercaseE_RejectedAndDistinctFromLowercaseE)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
// 'e' is a real binding (OpenFileStart).
|
||||
ASSERT_TRUE(KLookupKCommand('e', false, id));
|
||||
ASSERT_EQ(id, CommandId::OpenFileStart);
|
||||
|
||||
// 'E' must be rejected, not silently aliased to 'e' via case-insensitive
|
||||
// lookup (the switch normalizes to lowercase, so this has to be special-
|
||||
// cased before it).
|
||||
ASSERT_EQ(KLookupKCommand('E', false, id), false);
|
||||
}
|
||||
|
||||
|
||||
TEST(KKeymap_CtrlChords_CanonicalChords)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
@@ -414,17 +414,21 @@ TEST (Migration_EmptyBufferCheck_Pattern)
|
||||
|
||||
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||
{
|
||||
// Test the pattern used in syntax highlighters
|
||||
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each
|
||||
// line via GetLineString(row). Assert the pattern actually returns the
|
||||
// right content, not just that it doesn't crash - see syntax highlighter
|
||||
// correctness tests (test_syntax_highlighting.cc) for tokenization coverage.
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), static_cast<std::size_t>(3));
|
||||
const std::vector<std::string> expected = {"int main() {", " return 0;", "}"};
|
||||
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
||||
// This is the pattern used in all migrated highlighters
|
||||
if (row >= buf.Nrows()) {
|
||||
break; // Should never happen
|
||||
}
|
||||
std::string line = buf.GetLineString(row);
|
||||
// Successfully accessed line - size() is always valid for std::string
|
||||
ASSERT_EQ(line, expected[row]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Tests for SplitPasteIntoCommands: pasted text must turn embedded line
|
||||
// breaks into Newline commands so InsertText never receives a '\r' or '\n'.
|
||||
#include "Test.h"
|
||||
|
||||
#include "PasteSplit.h"
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
// Assert that no InsertText command carries an embedded newline/carriage
|
||||
// return, mirroring the rejection in Command.cc's InsertText handler.
|
||||
void
|
||||
assert_no_newlines_in_inserts(const std::vector<MappedInput> &cmds)
|
||||
{
|
||||
for (const auto &c : cmds) {
|
||||
if (c.id == CommandId::InsertText) {
|
||||
ASSERT_TRUE(c.arg.find('\n') == std::string::npos);
|
||||
ASSERT_TRUE(c.arg.find('\r') == std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST(PasteSplit_PlainTextNoNewline)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("hello world");
|
||||
ASSERT_EQ(cmds.size(), 1u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
|
||||
ASSERT_TRUE(cmds[0].arg == "hello world");
|
||||
}
|
||||
|
||||
|
||||
TEST(PasteSplit_UnixNewlines)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("a\nb");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 3u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||
}
|
||||
|
||||
|
||||
// Regression: CRLF clipboards left a '\r' in the InsertText segment, which
|
||||
// InsertText rejected with "InsertText arg must not contain newlines".
|
||||
TEST(PasteSplit_WindowsCRLF)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("a\r\nb");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 3u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||
}
|
||||
|
||||
|
||||
// Regression: a bare '\r' (classic Mac / some macOS apps) must also break lines.
|
||||
TEST(PasteSplit_BareCR)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("a\rb");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 3u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||
}
|
||||
|
||||
|
||||
TEST(PasteSplit_BlankLinesPreserved)
|
||||
{
|
||||
// Two line breaks between "a" and "b" => one empty line.
|
||||
auto cmds = SplitPasteIntoCommands("a\n\nb");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 4u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[2].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[3].id == CommandId::InsertText && cmds[3].arg == "b");
|
||||
}
|
||||
|
||||
|
||||
TEST(PasteSplit_TrailingNewline)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("abc\n");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 2u);
|
||||
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "abc");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
}
|
||||
|
||||
|
||||
TEST(PasteSplit_MultilineCRLF)
|
||||
{
|
||||
auto cmds = SplitPasteIntoCommands("line1\r\nline2\r\nline3");
|
||||
assert_no_newlines_in_inserts(cmds);
|
||||
ASSERT_EQ(cmds.size(), 5u);
|
||||
ASSERT_TRUE(cmds[0].arg == "line1");
|
||||
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[2].arg == "line2");
|
||||
ASSERT_TRUE(cmds[3].id == CommandId::Newline);
|
||||
ASSERT_TRUE(cmds[4].arg == "line3");
|
||||
}
|
||||
@@ -101,6 +101,36 @@ TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
|
||||
}
|
||||
|
||||
|
||||
TEST(SearchFlow_SearchReplace_EmptyWith_ReplacesAdjacentOverlappingMatches)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
// "aaaa" with "aa" -> "" must remove both non-overlapping occurrences, not
|
||||
// just the first: after deleting the match at column 0, the next "aa" now
|
||||
// sits at column 0 too (not column 1), so the scan must resume at the
|
||||
// deletion point rather than one character past it.
|
||||
b.insert_text(0, 0, "aaaa\n");
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "aa"));
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
|
||||
|
||||
// Leave the replacement empty and accept.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
|
||||
{
|
||||
TestHarness h;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// test_syntax_highlighting.cc - tokenization correctness for LanguageHighlighter
|
||||
// implementations, focused on multi-line state propagation (block comments,
|
||||
// triple-quoted strings) that stateless-looking per-line scans easily get wrong.
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include "Highlight.h"
|
||||
#include "syntax/LanguageHighlighter.h"
|
||||
#include "syntax/GoHighlighter.h"
|
||||
#include "syntax/RustHighlighter.h"
|
||||
#include "syntax/SqlHighlighter.h"
|
||||
#include "syntax/PythonHighlighter.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using kte::HighlightSpan;
|
||||
using kte::StatefulHighlighter;
|
||||
using kte::TokenKind;
|
||||
|
||||
namespace {
|
||||
bool
|
||||
has_kind(const std::vector<HighlightSpan> &spans, TokenKind k)
|
||||
{
|
||||
for (const auto &sp: spans) {
|
||||
if (sp.kind == k)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST (Syntax_Go_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nfunc foo() {}\n");
|
||||
|
||||
kte::GoHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
// Line 0 opens an unclosed block comment; state must carry forward.
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
// Line 1 is entirely inside the comment; the whole line must be Comment,
|
||||
// not re-tokenized as code (this is the bug: without state propagation
|
||||
// "middle" and "line" would come back as identifiers).
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
ASSERT_TRUE(!spans.empty());
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
// Line 2 closes the comment.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Comment));
|
||||
|
||||
// Line 3 is ordinary code again: "func" must be a Keyword, not a Comment.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Rust_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nfn foo() {}\n");
|
||||
|
||||
kte::RustHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Sql_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nSELECT 1;\n");
|
||||
|
||||
kte::SqlHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Python_TripleQuote_ClosesAndReopensOnSameLine)
|
||||
{
|
||||
Buffer b;
|
||||
// Line 0 opens a triple-quoted string that stays open.
|
||||
// Line 1 closes the first string ("end'''") and, on the SAME line, opens a
|
||||
// second triple-quoted string ("'''start of") that stays open into line 2.
|
||||
b.replace_all_bytes("x = '''abc\nend''' + '''start of\nnext string'''\n");
|
||||
|
||||
kte::PythonHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_raw_string);
|
||||
ASSERT_EQ(state.raw_delim, std::string("'''"));
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
// The bug: without re-scanning the remainder after the closing ''' the
|
||||
// second opening ''' on this line is never noticed, so state.in_raw_string
|
||||
// would incorrectly come back false here.
|
||||
ASSERT_TRUE(state.in_raw_string);
|
||||
ASSERT_EQ(state.raw_delim, std::string("'''"));
|
||||
|
||||
// Line 2 is inside the second string; it must be highlighted as String,
|
||||
// not as ordinary code.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!spans.empty());
|
||||
bool all_string = true;
|
||||
for (const auto &sp: spans) {
|
||||
if (sp.kind != TokenKind::String)
|
||||
all_string = false;
|
||||
}
|
||||
ASSERT_TRUE(all_string);
|
||||
}
|
||||
@@ -1198,6 +1198,312 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_Newline_UndoRejoinsCorrectLines)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abcdef\nghijkl");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghijkl"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_Backspace_JoinUndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef");
|
||||
buf->SetCursor(0, 1);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Backspace));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_DeleteChar_JoinUndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteChar));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_RegexReplaceAll_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("foo one\nfoo two\nbar three");
|
||||
buf->SetCursor(0, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::RegexpReplace));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ed.SetPromptText("foo");
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ed.SetPromptText("baz");
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("foo one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("foo two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_IndentUnindentRegion_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("one\ntwo\nthree");
|
||||
buf->SetMark(0, 0);
|
||||
buf->SetCursor(0, 2);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::IndentRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
buf->SetMark(0, 0);
|
||||
buf->SetCursor(0, 2);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::UnindentRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillToEol_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abcdef\nghijkl");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillToEOL));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillLine_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef\nghi");
|
||||
buf->SetCursor(0, 1);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillLine));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillRegion_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc def ghi");
|
||||
buf->SetMark(4, 0);
|
||||
buf->SetCursor(8, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_DeleteWordPrevNext_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc def ghi");
|
||||
buf->SetCursor(8, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordPrev));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
buf->SetCursor(4, 0);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordNext));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_UndoDeletesRow)
|
||||
{
|
||||
Buffer b;
|
||||
|
||||
Reference in New Issue
Block a user