Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b255901e | ||
|
|
b60a8dc491 | ||
|
|
056c9af38e | ||
|
|
6413e14455 |
@@ -231,10 +231,12 @@ Buffer::Buffer(const Buffer &other)
|
|||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
// Copy syntax/highlighting flags
|
// Copy edit mode + syntax/highlighting flags
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = other.filetype_;
|
version_ = other.version_;
|
||||||
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
filetype_ = other.filetype_;
|
||||||
// Fresh undo system for the copy
|
// Fresh undo system for the copy
|
||||||
undo_tree_ = std::make_unique<UndoTree>();
|
undo_tree_ = std::make_unique<UndoTree>();
|
||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
@@ -279,11 +281,13 @@ Buffer::operator=(const Buffer &other)
|
|||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
read_only_ = other.read_only_;
|
read_only_ = other.read_only_;
|
||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = other.filetype_;
|
version_ = other.version_;
|
||||||
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
filetype_ = other.filetype_;
|
||||||
// Recreate undo system for this instance
|
// Recreate undo system for this instance
|
||||||
undo_tree_ = std::make_unique<UndoTree>();
|
undo_tree_ = std::make_unique<UndoTree>();
|
||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
@@ -326,13 +330,15 @@ Buffer::Buffer(Buffer &&other) noexcept
|
|||||||
undo_tree_(std::move(other.undo_tree_)),
|
undo_tree_(std::move(other.undo_tree_)),
|
||||||
undo_sys_(std::move(other.undo_sys_))
|
undo_sys_(std::move(other.undo_sys_))
|
||||||
{
|
{
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = std::move(other.filetype_);
|
version_ = other.version_;
|
||||||
highlighter_ = std::move(other.highlighter_);
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
content_ = std::move(other.content_);
|
filetype_ = std::move(other.filetype_);
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
highlighter_ = std::move(other.highlighter_);
|
||||||
|
content_ = std::move(other.content_);
|
||||||
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||||
// Update UndoSystem's buffer reference to point to this object
|
// Update UndoSystem's buffer reference to point to this object
|
||||||
if (undo_sys_) {
|
if (undo_sys_) {
|
||||||
undo_sys_->UpdateBufferReference(*this);
|
undo_sys_->UpdateBufferReference(*this);
|
||||||
@@ -364,13 +370,15 @@ Buffer::operator=(Buffer &&other) noexcept
|
|||||||
undo_tree_ = std::move(other.undo_tree_);
|
undo_tree_ = std::move(other.undo_tree_);
|
||||||
undo_sys_ = std::move(other.undo_sys_);
|
undo_sys_ = std::move(other.undo_sys_);
|
||||||
|
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = std::move(other.filetype_);
|
version_ = other.version_;
|
||||||
highlighter_ = std::move(other.highlighter_);
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
content_ = std::move(other.content_);
|
filetype_ = std::move(other.filetype_);
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
highlighter_ = std::move(other.highlighter_);
|
||||||
|
content_ = std::move(other.content_);
|
||||||
|
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||||
// Update UndoSystem's buffer reference to point to this object
|
// Update UndoSystem's buffer reference to point to this object
|
||||||
if (undo_sys_) {
|
if (undo_sys_) {
|
||||||
undo_sys_->UpdateBufferReference(*this);
|
undo_sys_->UpdateBufferReference(*this);
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ public:
|
|||||||
void SetEditMode(EditMode m)
|
void SetEditMode(EditMode m)
|
||||||
{
|
{
|
||||||
edit_mode_ = m;
|
edit_mode_ = m;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -525,6 +526,13 @@ public:
|
|||||||
edit_mode_ = (edit_mode_ == EditMode::Code)
|
edit_mode_ = (edit_mode_ == EditMode::Code)
|
||||||
? EditMode::Writing
|
? EditMode::Writing
|
||||||
: EditMode::Code;
|
: EditMode::Code;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool EditModeDetected() const
|
||||||
|
{
|
||||||
|
return edit_mode_detected_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -660,6 +668,7 @@ private:
|
|||||||
|
|
||||||
// Edit mode (code vs writing)
|
// Edit mode (code vs writing)
|
||||||
EditMode edit_mode_ = EditMode::Code;
|
EditMode edit_mode_ = EditMode::Code;
|
||||||
|
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||||
|
|
||||||
// Syntax/highlighting state
|
// Syntax/highlighting state
|
||||||
std::uint64_t version_ = 0; // increment on edits
|
std::uint64_t version_ = 0; // increment on edits
|
||||||
|
|||||||
@@ -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)
|
||||||
+2
-1
@@ -4,7 +4,7 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.11.1")
|
set(KTE_VERSION "1.11.3")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
@@ -344,6 +344,7 @@ if (BUILD_TESTS)
|
|||||||
tests/test_migration_coverage.cc
|
tests/test_migration_coverage.cc
|
||||||
tests/test_smart_newline.cc
|
tests/test_smart_newline.cc
|
||||||
tests/test_reflow_undo.cc
|
tests/test_reflow_undo.cc
|
||||||
|
tests/test_paste_split.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
|
|||||||
@@ -1363,6 +1363,9 @@ cmd_toggle_edit_mode(const CommandContext &ctx)
|
|||||||
b->ToggleEditMode();
|
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";
|
const char *mode_str = (b->GetEditMode() == EditMode::Writing) ? "writing" : "code";
|
||||||
ctx.editor.SetStatus(std::string("Mode: ") + mode_str);
|
ctx.editor.SetStatus(std::string("Mode: ") + mode_str);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+5
-3
@@ -39,11 +39,13 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
|||||||
if (!b)
|
if (!b)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Auto-detect edit mode from file extension
|
// Auto-detect edit mode from file extension once per buffer so that
|
||||||
if (!b->Filename().empty())
|
// manual toggles (C-k m / : mode) are not overridden every frame.
|
||||||
|
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||||
b->SetEditMode(DetectEditMode(b->Filename()));
|
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||||
|
|
||||||
if (cfg.syntax) {
|
// Writing mode disables syntax; otherwise follow the global config.
|
||||||
|
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||||
b->SetSyntaxEnabled(true);
|
b->SetSyntaxEnabled(true);
|
||||||
b->EnsureHighlighter();
|
b->EnsureHighlighter();
|
||||||
if (auto *eng = b->Highlighter()) {
|
if (auto *eng = b->Highlighter()) {
|
||||||
|
|||||||
+6
-25
@@ -8,6 +8,7 @@
|
|||||||
#include "ImGuiInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
|
|
||||||
static bool
|
static bool
|
||||||
@@ -376,32 +377,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (clip) {
|
if (clip) {
|
||||||
std::string text(clip);
|
std::string text(clip);
|
||||||
SDL_free(clip);
|
SDL_free(clip);
|
||||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||||
|
// commands and the rest into InsertText; InsertText itself
|
||||||
|
// rejects any embedded '\r'/'\n'.
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
std::size_t start = 0;
|
for (const auto &cmd : SplitPasteIntoCommands(text))
|
||||||
while (start <= text.size()) {
|
q_.push(cmd);
|
||||||
std::size_t pos = text.find('\n', start);
|
|
||||||
std::string_view segment;
|
|
||||||
bool has_nl = (pos != std::string::npos);
|
|
||||||
if (has_nl) {
|
|
||||||
segment = std::string_view(text).substr(start, pos - start);
|
|
||||||
} else {
|
|
||||||
segment = std::string_view(text).substr(start);
|
|
||||||
}
|
|
||||||
if (!segment.empty()) {
|
|
||||||
MappedInput ins{
|
|
||||||
true, CommandId::InsertText, std::string(segment), 0
|
|
||||||
};
|
|
||||||
q_.push(ins);
|
|
||||||
}
|
|
||||||
if (has_nl) {
|
|
||||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
|
||||||
q_.push(nl);
|
|
||||||
start = pos + 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress the corresponding TEXTINPUT that may follow
|
// Suppress the corresponding TEXTINPUT that may follow
|
||||||
suppress_text_input_once_ = true;
|
suppress_text_input_once_ = true;
|
||||||
return true; // consumed
|
return true; // consumed
|
||||||
|
|||||||
+97
-26
@@ -106,7 +106,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||||
float scroll_y = ImGui::GetScrollY();
|
float scroll_y = ImGui::GetScrollY();
|
||||||
float scroll_x = ImGui::GetScrollX();
|
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
|
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||||
bool forced_scroll = false;
|
bool forced_scroll = false;
|
||||||
@@ -134,8 +133,65 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
prev_buf_rowoffs_ = buf_rowoffs;
|
prev_buf_rowoffs_ = buf_rowoffs;
|
||||||
prev_buf_coloffs_ = buf_coloffs;
|
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
|
// Mark selection state (mark -> cursor), in source coordinates
|
||||||
bool sel_active = false;
|
bool sel_active = false;
|
||||||
@@ -257,7 +313,13 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||||
mouse_selecting_ = false;
|
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
|
// Capture the screen position before drawing the line
|
||||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||||
std::string line = static_cast<std::string>(lines[i]);
|
std::string line = static_cast<std::string>(lines[i]);
|
||||||
@@ -291,24 +353,22 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Compute search highlight ranges for this line in source indices
|
// 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;
|
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||||
if (search_mode) {
|
if (search_mode) {
|
||||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
// In regex mode, reuse the compiled regex hoisted above the loop.
|
||||||
if (ed.PromptActive() && (
|
if (regex_mode) {
|
||||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
if (search_rx_valid) {
|
||||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
try {
|
||||||
try {
|
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
|
||||||
std::regex rx(ed.SearchQuery());
|
it != std::sregex_iterator(); ++it) {
|
||||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
const auto &m = *it;
|
||||||
it != std::sregex_iterator(); ++it) {
|
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||||
const auto &m = *it;
|
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
||||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
hl_src_ranges.emplace_back(sx, ex);
|
||||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
}
|
||||||
hl_src_ranges.emplace_back(sx, ex);
|
} catch (const std::regex_error &) {
|
||||||
|
// ignore invalid patterns here; status line already shows the error
|
||||||
}
|
}
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// ignore invalid patterns here; status line already shows the error
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const std::string &q = ed.SearchQuery();
|
const std::string &q = ed.SearchQuery();
|
||||||
@@ -483,18 +543,29 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
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()) {
|
if (!expanded.empty()) {
|
||||||
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
|
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
|
||||||
if (line_w > max_line_width_px)
|
if (line_w > max_width_px_)
|
||||||
max_line_width_px = line_w;
|
max_width_px_ = line_w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Report content width to ImGui so horizontal scrollbar works correctly.
|
// After the visible-range loop, advance the layout cursor to the end of
|
||||||
if (max_line_width_px > 0.0f) {
|
// the (virtual) content so ImGui sees the correct total size. Vertical
|
||||||
ImGui::SetCursorPosX(max_line_width_px);
|
// height comes from total_rows; horizontal width comes from the cached
|
||||||
ImGui::Dummy(ImVec2(0, 0));
|
// 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.
|
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||||
{
|
{
|
||||||
float child_h_actual = ImGui::GetWindowHeight();
|
float child_h_actual = ImGui::GetWindowHeight();
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
struct ImFont;
|
||||||
|
class Buffer;
|
||||||
|
|
||||||
class ImGuiRenderer final : public Renderer {
|
class ImGuiRenderer final : public Renderer {
|
||||||
public:
|
public:
|
||||||
ImGuiRenderer() = default;
|
ImGuiRenderer() = default;
|
||||||
@@ -20,4 +25,13 @@ private:
|
|||||||
float prev_scroll_y_ = -1.0f;
|
float prev_scroll_y_ = -1.0f;
|
||||||
float prev_scroll_x_ = -1.0f;
|
float prev_scroll_x_ = -1.0f;
|
||||||
bool mouse_selecting_ = false;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <random>
|
#include <random>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
@@ -255,7 +256,18 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// 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);
|
editor.RequestOpenFile(path, pending_line);
|
||||||
pending_line = 0; // consumed (if set)
|
pending_line = 0; // consumed (if set)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Tests for SplitPasteIntoCommands: pasted text must turn embedded line
|
||||||
|
// breaks into Newline commands so InsertText never receives a '\r' or '\n'.
|
||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Assert that no InsertText command carries an embedded newline/carriage
|
||||||
|
// return, mirroring the rejection in Command.cc's InsertText handler.
|
||||||
|
void
|
||||||
|
assert_no_newlines_in_inserts(const std::vector<MappedInput> &cmds)
|
||||||
|
{
|
||||||
|
for (const auto &c : cmds) {
|
||||||
|
if (c.id == CommandId::InsertText) {
|
||||||
|
ASSERT_TRUE(c.arg.find('\n') == std::string::npos);
|
||||||
|
ASSERT_TRUE(c.arg.find('\r') == std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_PlainTextNoNewline)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("hello world");
|
||||||
|
ASSERT_EQ(cmds.size(), 1u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
|
||||||
|
ASSERT_TRUE(cmds[0].arg == "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_UnixNewlines)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Regression: CRLF clipboards left a '\r' in the InsertText segment, which
|
||||||
|
// InsertText rejected with "InsertText arg must not contain newlines".
|
||||||
|
TEST(PasteSplit_WindowsCRLF)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\r\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Regression: a bare '\r' (classic Mac / some macOS apps) must also break lines.
|
||||||
|
TEST(PasteSplit_BareCR)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\rb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_BlankLinesPreserved)
|
||||||
|
{
|
||||||
|
// Two line breaks between "a" and "b" => one empty line.
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\n\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 4u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[3].id == CommandId::InsertText && cmds[3].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_TrailingNewline)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("abc\n");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 2u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "abc");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_MultilineCRLF)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("line1\r\nline2\r\nline3");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 5u);
|
||||||
|
ASSERT_TRUE(cmds[0].arg == "line1");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].arg == "line2");
|
||||||
|
ASSERT_TRUE(cmds[3].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[4].arg == "line3");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user