Compare commits

...

4 Commits

Author SHA1 Message Date
b60a8dc491 Optimize ImGui renderer: viewport culling and width caching
Large files made idle CPU spike because every frame re-measured every
line, recompiled the search regex per line, and ran full per-line
rendering work even for off-screen rows.

- Render only visible rows (with a small margin for smooth scrolling)
  and advance the layout cursor to preserve total content height.
- Hoist the search std::regex compilation out of the per-line loop.
- Cache the max line width on the renderer; reset only when the buffer
  or font changes, not on every edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:12:44 -07:00
056c9af38e Fix macOS GUI file loading: resolve CLI paths before chdir
On macOS GUI builds, chdir(HOME) runs before deferred file opens are
processed, breaking relative paths passed on the command line. Resolve
each argv path to absolute immediately during argument parsing.

Bump version to 1.11.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:20:36 -07:00
6413e14455 Fix writing mode: prevent per-frame override and disable syntax highlighting
apply_syntax_to_buffer() was called every frame and unconditionally reset
edit mode from the file extension, making it impossible to toggle out of
writing mode for .txt/.md files. Add edit_mode_detected_ flag to Buffer so
auto-detection runs once per buffer. Writing mode now also disables syntax
highlighting as intended. Propagate edit_mode_ through Buffer copy/move ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:03:25 -07:00
d1a45581bf Bump patch version. 2026-03-31 23:48:01 -07:00
9 changed files with 288 additions and 54 deletions

View File

@@ -231,10 +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
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// 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_;
filetype_ = other.filetype_;
// Fresh undo system for the copy
undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
@@ -279,11 +281,13 @@ Buffer::operator=(const Buffer &other)
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
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_;
filetype_ = other.filetype_;
// Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>();
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_sys_(std::move(other.undo_sys_))
{
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// 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_;
filetype_ = std::move(other.filetype_);
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
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
@@ -364,13 +370,15 @@ Buffer::operator=(Buffer &&other) noexcept
undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_);
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// 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_;
filetype_ = std::move(other.filetype_);
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
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);

View File

@@ -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_;
}
@@ -660,6 +668,7 @@ 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

115
CLAUDE.md Normal file
View File

@@ -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)

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.11.0")
set(KTE_VERSION "1.11.2")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.

View File

@@ -1363,6 +1363,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;

View File

@@ -39,11 +39,13 @@ 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) {
// 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()) {

View File

@@ -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,24 +353,22 @@ 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)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
// In regex mode, reuse the compiled regex hoisted above the loop.
if (regex_mode) {
if (search_rx_valid) {
try {
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());
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 {
const std::string &q = ed.SearchQuery();
@@ -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);
ImGui::Dummy(ImVec2(0, 0));
// 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();

View File

@@ -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;
};

14
main.cc
View File

@@ -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)
}