Compare commits

...

9 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
81a5c25071 Fix proportional font rendering with pixel-based horizontal scroll
The GUI renderer had two competing horizontal scroll systems: a
character-based one (coloffs × space_w) and a pixel-based one. For
proportional fonts the character-based system used "M" width to
calculate viewport columns, triggering premature scrolling at ~50%
of the actual display width.

Switch the GUI renderer to purely pixel-based horizontal scrolling:
- Remove coloffs↔ImGui scroll_x bidirectional sync
- Measure rx_to_px from column 0 (absolute) instead of from coloffs
- Draw full expanded lines; let ImGui clip via its scroll viewport
- Report content width via SetCursorPosX+Dummy for the scrollbar
- Use average character width for cols estimate (not "M" width)

The terminal renderer continues using coloffs correctly—no changes
needed there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:53 -07:00
5667a6d7bd Fix Linux build: default static linking off, add Clang link flag
KTE_STATIC_LINK defaulted to ON, which fails on systems where ncurses
is only available as a shared library. The Nix build already passed
-DKTE_STATIC_LINK=OFF explicitly; this makes the default match.

Also add add_link_options("-stdlib=libc++") for Clang builds — without
it, compilation uses libc++ but the linker defaults to libstdc++,
causing undefined symbol errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
99c4bb2066 Fix font atlas crash and make Nix build first-class
Defer edit-mode font switch to next frame via RequestLoadFont() to
avoid modifying the locked ImFontAtlas between NewFrame() and Render().

Rework Nix packaging: split nativeBuildInputs/buildInputs correctly,
add devShells for nix develop, desktop file for kge, per-variant pname
and meta.mainProgram, and an overlay for NixOS configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
953fee97d7 Bump patch. 2026-03-25 07:43:49 -07:00
d7e35727f1 Fix segfault from mid-frame font atlas rebuild
The edit-mode font switcher called LoadFont() directly between
NewFrame() and Render(), invalidating the font atlas ImGui was
actively using. Use RequestLoadFont() to defer the change to
the safe inter-frame point, matching the existing zoom pattern.

Also default code_font/writing_font to the main font when not
explicitly configured, preventing a mismatch that triggered the
switch on every first frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 02:07:31 -07:00
12 changed files with 425 additions and 125 deletions

View File

@@ -231,7 +231,9 @@ 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
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
@@ -281,6 +283,8 @@ Buffer::operator=(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_;
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
@@ -326,7 +330,9 @@ 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
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);
@@ -364,7 +370,9 @@ 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
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);

View File

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

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) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.10.0") set(KTE_VERSION "1.11.2")
# 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.
@@ -14,7 +14,7 @@ set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
option(KTE_STATIC_LINK "Enable static linking on Linux" ON) option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
# Optionally enable AddressSanitizer (ASan) # Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF) option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -51,6 +51,7 @@ else ()
) )
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++") add_compile_options("-stdlib=libc++")
add_link_options("-stdlib=libc++")
else () else ()
# nothing special for gcc at the moment # nothing special for gcc at the moment
endif () endif ()

View File

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

View File

@@ -99,16 +99,22 @@ GUIConfig::LoadFromTOML(const std::string &path)
} }
// [font] // [font]
bool explicit_code_font = false;
bool explicit_writing_font = false;
if (auto sec = tbl["font"].as_table()) { if (auto sec = tbl["font"].as_table()) {
if (auto v = (*sec)["name"].value<std::string>()) if (auto v = (*sec)["name"].value<std::string>())
font = *v; font = *v;
if (auto v = (*sec)["size"].value<double>()) { if (auto v = (*sec)["size"].value<double>()) {
if (*v > 0.0) font_size = static_cast<float>(*v); if (*v > 0.0) font_size = static_cast<float>(*v);
} }
if (auto v = (*sec)["code"].value<std::string>()) if (auto v = (*sec)["code"].value<std::string>()) {
code_font = *v; code_font = *v;
if (auto v = (*sec)["writing"].value<std::string>()) explicit_code_font = true;
}
if (auto v = (*sec)["writing"].value<std::string>()) {
writing_font = *v; writing_font = *v;
explicit_writing_font = true;
}
} }
// [appearance] // [appearance]
@@ -131,6 +137,12 @@ GUIConfig::LoadFromTOML(const std::string &path)
syntax = *v; syntax = *v;
} }
// Default code_font to the main font if not explicitly set
if (!explicit_code_font)
code_font = font;
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
writing_font = font;
return true; return true;
} }
@@ -142,6 +154,9 @@ GUIConfig::LoadFromINI(const std::string &path)
if (!in.good()) if (!in.good())
return false; return false;
bool explicit_code_font = false;
bool explicit_writing_font = false;
std::string line; std::string line;
while (std::getline(in, line)) { while (std::getline(in, line)) {
// Remove comments starting with '#' or ';' // Remove comments starting with '#' or ';'
@@ -198,8 +213,10 @@ GUIConfig::LoadFromINI(const std::string &path)
font = val; font = val;
} else if (key == "code_font") { } else if (key == "code_font") {
code_font = val; code_font = val;
explicit_code_font = true;
} else if (key == "writing_font") { } else if (key == "writing_font") {
writing_font = val; writing_font = val;
explicit_writing_font = true;
} else if (key == "theme") { } else if (key == "theme") {
theme = val; theme = val;
} else if (key == "background" || key == "bg") { } else if (key == "background" || key == "bg") {
@@ -222,5 +239,13 @@ GUIConfig::LoadFromINI(const std::string &path)
} }
} }
// If code_font was not explicitly set, default it to the main font
// so that the edit-mode font switcher doesn't immediately switch away
// from the font loaded during Init.
if (!explicit_code_font)
code_font = font;
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
writing_font = font;
return true; return true;
} }

View File

@@ -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()) {
@@ -76,7 +78,9 @@ static void
update_editor_dimensions(Editor &ed, float disp_w, float disp_h) update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
{ {
float row_h = ImGui::GetTextLineHeightWithSpacing(); float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x; // Use average character width rather than "M" (the widest character)
// so that column count is reasonable for proportional fonts too.
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
if (row_h <= 0.0f) if (row_h <= 0.0f)
row_h = 16.0f; row_h = 16.0f;
if (ch_w <= 0.0f) if (ch_w <= 0.0f)
@@ -565,7 +569,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
running = false; running = false;
} }
// Switch font based on current buffer's edit mode // Switch font based on current buffer's edit mode (deferred to next frame)
{ {
Buffer *cur = wed.CurrentBuffer(); Buffer *cur = wed.CurrentBuffer();
if (cur) { if (cur) {
@@ -577,9 +581,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) { if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
float sz = fr.CurrentFontSize(); float sz = fr.CurrentFontSize();
if (sz <= 0.0f) sz = config_.font_size; if (sz <= 0.0f) sz = config_.font_size;
fr.LoadFont(expected, sz); fr.RequestLoadFont(expected, sz);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
} }
} }
} }

View File

@@ -85,11 +85,9 @@ ImGuiRenderer::Draw(Editor &ed)
float target_y = static_cast<float>(buf_rowoffs) * row_h; float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y)); ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
} }
if (prev_buf_coloffs_ >= 0 && buf_coloffs != prev_buf_coloffs_) { // Horizontal scroll is handled purely in pixel space (see
float target_x = static_cast<float>(buf_coloffs) * space_w; // cursor-visibility block after the line loop) so we don't
float target_y = static_cast<float>(buf_rowoffs) * row_h; // convert the character-based coloffs to an ImGui scroll here.
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
}
// Reserve space for status bar at bottom. // Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height // We calculate a height that is an exact multiple of the line height
@@ -108,32 +106,26 @@ 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;
{ {
const long scroll_top = static_cast<long>(scroll_y / row_h); const long scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame // Check if rowoffs was programmatically changed this frame
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) { if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
forced_scroll = true; forced_scroll = true;
} }
// If user scrolled (not programmatic), update buffer offsets accordingly // If user scrolled vertically (not programmatic), update buffer row offset
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) { if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)), mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs()); mbuf->Coloffs());
} }
} }
if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) { // Horizontal scroll is pixel-based and managed by the cursor
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { // visibility block below; we don't sync it back to coloffs.
mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left)));
}
}
// Update trackers for next frame // Update trackers for next frame
prev_scroll_y_ = scroll_y; prev_scroll_y_ = scroll_y;
@@ -141,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;
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs(); // 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;
@@ -264,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]);
@@ -286,31 +341,25 @@ ImGuiRenderer::Draw(Editor &ed)
} }
} }
// Helper: convert a rendered column position to pixel x offset // Helper: convert a rendered column position to an absolute
// relative to the visible line start, using actual text measurement // pixel x offset from the start of the line. ImGui's scroll
// so proportional fonts render correctly. // handles viewport clipping so we measure from column 0.
auto rx_to_px = [&](std::size_t rx_col) -> float { auto rx_to_px = [&](std::size_t rx_col) -> float {
if (rx_col <= coloffs_now)
return 0.0f;
std::size_t start = coloffs_now;
std::size_t end = std::min(expanded.size(), rx_col); std::size_t end = std::min(expanded.size(), rx_col);
if (start >= expanded.size() || end <= start) if (end == 0)
return 0.0f; return 0.0f;
return ImGui::CalcTextSize(expanded.c_str() + start, return ImGui::CalcTextSize(expanded.c_str(),
expanded.c_str() + end).x; expanded.c_str() + end).x;
}; };
// 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 {
std::regex rx(ed.SearchQuery()); for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) { it != std::sregex_iterator(); ++it) {
const auto &m = *it; const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position()); std::size_t sx = static_cast<std::size_t>(m.position());
@@ -320,6 +369,7 @@ ImGuiRenderer::Draw(Editor &ed)
} catch (const std::regex_error &) { } catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the 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();
std::size_t pos = 0; std::size_t pos = 0;
@@ -351,9 +401,6 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t sx = rg.first, ex = rg.second; std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now)
continue; // fully left of view
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y); ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
line_pos.y + line_h); line_pos.y + line_h);
@@ -392,7 +439,6 @@ ImGuiRenderer::Draw(Editor &ed)
if (line_has) { if (line_has) {
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) {
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
@@ -401,7 +447,6 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
}
if (vsel_active && i >= vsel_sy && i <= vsel_ey) { if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot. // Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
const std::size_t spot_sx = std::min(buf->Curx(), line.size()); const std::size_t spot_sx = std::min(buf->Curx(), line.size());
@@ -413,7 +458,6 @@ ImGuiRenderer::Draw(Editor &ed)
// EOL spot: draw a 1-cell highlight just past the last character. // EOL spot: draw a 1-cell highlight just past the last character.
rx_end = rx_start + 1; rx_end = rx_start + 1;
} }
if (rx_end > coloffs_now) {
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end), ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
@@ -421,7 +465,6 @@ ImGuiRenderer::Draw(Editor &ed)
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
}
// Draw syntax-colored runs (text above background highlights) // Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine( kte::LineHighlight lh = buf->Highlighter()->GetLine(
@@ -464,16 +507,12 @@ ImGuiRenderer::Draw(Editor &ed)
for (const auto &sp: spans) { for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s); std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e); std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs_now) std::size_t draw_start = rx_s;
continue; // fully left of viewport
// Clamp to visible portion and expanded length
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
if (draw_start >= expanded.size()) if (draw_start >= expanded.size())
continue; // fully right of expanded text continue;
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size()); std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start) if (draw_end <= draw_start)
continue; continue;
// Screen position via actual text measurement
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k)); ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start), ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
line_pos.y); line_pos.y);
@@ -484,17 +523,14 @@ ImGuiRenderer::Draw(Editor &ed)
// Use row_h (with spacing) to match click calculation and ensure consistent line positions. // Use row_h (with spacing) to match click calculation and ensure consistent line positions.
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h)); ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else { } else {
// No syntax: draw as one run, accounting for horizontal scroll offset // No syntax: draw the full line; ImGui scroll handles clipping.
if (coloffs_now < expanded.size()) { if (!expanded.empty()) {
ImVec2 p = ImVec2(line_pos.x, line_pos.y); ImVec2 p = ImVec2(line_pos.x, line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
p, ImGui::GetColorU32(ImGuiCol_Text), p, ImGui::GetColorU32(ImGuiCol_Text),
expanded.c_str() + coloffs_now); expanded.c_str());
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else {
// Line is fully scrolled out of view horizontally
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
// Draw a visible cursor indicator on the current line // Draw a visible cursor indicator on the current line
@@ -506,7 +542,30 @@ ImGuiRenderer::Draw(Editor &ed)
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
// 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_width_px_)
max_width_px_ = line_w;
} }
}
// 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. // 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();

View File

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

View File

@@ -1,6 +1,5 @@
{ {
pkgs ? import <nixpkgs> {}, lib,
lib ? pkgs.lib,
stdenv, stdenv,
cmake, cmake,
ncurses, ncurses,
@@ -10,9 +9,10 @@
kdePackages, kdePackages,
qt6Packages ? kdePackages.qt6Packages, qt6Packages ? kdePackages.qt6Packages,
installShellFiles, installShellFiles,
copyDesktopItems,
makeDesktopItem,
graphical ? false, graphical ? false,
graphical-qt ? false, graphical-qt ? false,
...
}: }:
let let
cmakeContent = builtins.readFile ./CMakeLists.txt; cmakeContent = builtins.readFile ./CMakeLists.txt;
@@ -23,25 +23,29 @@ let
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine); version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in in
stdenv.mkDerivation { stdenv.mkDerivation {
pname = "kte"; pname = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
inherit version; inherit version;
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
nativeBuildInputs = [ nativeBuildInputs = [
cmake cmake
ncurses
installShellFiles installShellFiles
] ] ++ lib.optionals graphical [
++ lib.optionals graphical [ copyDesktopItems
] ++ lib.optionals graphical-qt [
qt6Packages.wrapQtAppsHook
];
buildInputs = [
ncurses
] ++ lib.optionals graphical [
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
] ] ++ lib.optionals graphical-qt [
++ lib.optionals graphical-qt [
kdePackages.qt6ct kdePackages.qt6ct
qt6Packages.qtbase qt6Packages.qtbase
qt6Packages.wrapQtAppsHook
]; ];
cmakeFlags = [ cmakeFlags = [
@@ -51,6 +55,30 @@ stdenv.mkDerivation {
"-DKTE_STATIC_LINK=OFF" "-DKTE_STATIC_LINK=OFF"
]; ];
desktopItems = lib.optionals graphical [
(makeDesktopItem {
name = "kge";
desktopName = "kge";
genericName = "Text Editor";
comment = "kyle's graphical text editor";
exec = if graphical-qt then "kge-qt %F" else "kge %F";
icon = "kge";
terminal = false;
categories = [ "Utility" "TextEditor" "Development" ];
mimeTypes = [
"text/plain"
"text/x-c"
"text/x-c++"
"text/x-python"
"text/x-go"
"text/x-rust"
"application/json"
"text/markdown"
"text/x-shellscript"
];
})
];
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
@@ -59,14 +87,11 @@ stdenv.mkDerivation {
installManPage ../docs/kte.1 installManPage ../docs/kte.1
${lib.optionalString graphical '' ${lib.optionalString graphical ''
mkdir -p $out/bin
${if graphical-qt then '' ${if graphical-qt then ''
cp kge $out/bin/kge-qt cp kge $out/bin/kge-qt
'' else '' '' else ''
cp kge $out/bin/kge cp kge $out/bin/kge
''} ''}
installManPage ../docs/kge.1 installManPage ../docs/kge.1
mkdir -p $out/share/icons/hicolor/256x256/apps mkdir -p $out/share/icons/hicolor/256x256/apps
@@ -75,4 +100,10 @@ stdenv.mkDerivation {
runHook postInstall runHook postInstall
''; '';
meta = {
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
platforms = lib.platforms.unix;
mainProgram = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
};
} }

View File

@@ -3,8 +3,7 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = outputs = { self, nixpkgs, ... }:
inputs@{ self, nixpkgs, ... }:
let let
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed; eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
pkgsFor = system: import nixpkgs { inherit system; }; pkgsFor = system: import nixpkgs { inherit system; };
@@ -17,5 +16,27 @@
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; }; kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; }; qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
}); });
devShells = eachSystem (system:
let pkgs = pkgsFor system;
in {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kge ];
packages = with pkgs; [ gdb valgrind ];
};
terminal = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kte ];
};
qt = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.qt ];
packages = with pkgs; [ gdb valgrind ];
};
}
);
overlays.default = final: prev: {
kte = self.packages.${final.system}.kte;
kge = self.packages.${final.system}.kge;
};
}; };
} }

14
main.cc
View File

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