Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b60a8dc491 | |||
| 056c9af38e | |||
| 6413e14455 | |||
| d1a45581bf | |||
| 81a5c25071 | |||
| 5667a6d7bd | |||
| 99c4bb2066 | |||
| 953fee97d7 | |||
| d7e35727f1 |
14
Buffer.cc
14
Buffer.cc
@@ -231,7 +231,9 @@ Buffer::Buffer(const Buffer &other)
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
// Copy syntax/highlighting flags
|
||||
// Copy edit mode + syntax/highlighting flags
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = other.filetype_;
|
||||
@@ -281,6 +283,8 @@ Buffer::operator=(const Buffer &other)
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = other.filetype_;
|
||||
@@ -326,7 +330,9 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
undo_tree_(std::move(other.undo_tree_)),
|
||||
undo_sys_(std::move(other.undo_sys_))
|
||||
{
|
||||
// Move syntax/highlighting state
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
@@ -364,7 +370,9 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
undo_tree_ = std::move(other.undo_tree_);
|
||||
undo_sys_ = std::move(other.undo_sys_);
|
||||
|
||||
// Move syntax/highlighting state
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
|
||||
9
Buffer.h
9
Buffer.h
@@ -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
115
CLAUDE.md
Normal 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)
|
||||
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
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.
|
||||
# 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")
|
||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" 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)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
||||
@@ -51,6 +51,7 @@ else ()
|
||||
)
|
||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||
add_compile_options("-stdlib=libc++")
|
||||
add_link_options("-stdlib=libc++")
|
||||
else ()
|
||||
# nothing special for gcc at the moment
|
||||
endif ()
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
GUIConfig.cc
29
GUIConfig.cc
@@ -99,16 +99,22 @@ GUIConfig::LoadFromTOML(const std::string &path)
|
||||
}
|
||||
|
||||
// [font]
|
||||
bool explicit_code_font = false;
|
||||
bool explicit_writing_font = false;
|
||||
if (auto sec = tbl["font"].as_table()) {
|
||||
if (auto v = (*sec)["name"].value<std::string>())
|
||||
font = *v;
|
||||
if (auto v = (*sec)["size"].value<double>()) {
|
||||
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;
|
||||
if (auto v = (*sec)["writing"].value<std::string>())
|
||||
explicit_code_font = true;
|
||||
}
|
||||
if (auto v = (*sec)["writing"].value<std::string>()) {
|
||||
writing_font = *v;
|
||||
explicit_writing_font = true;
|
||||
}
|
||||
}
|
||||
|
||||
// [appearance]
|
||||
@@ -131,6 +137,12 @@ GUIConfig::LoadFromTOML(const std::string &path)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -142,6 +154,9 @@ GUIConfig::LoadFromINI(const std::string &path)
|
||||
if (!in.good())
|
||||
return false;
|
||||
|
||||
bool explicit_code_font = false;
|
||||
bool explicit_writing_font = false;
|
||||
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
// Remove comments starting with '#' or ';'
|
||||
@@ -198,8 +213,10 @@ GUIConfig::LoadFromINI(const std::string &path)
|
||||
font = val;
|
||||
} else if (key == "code_font") {
|
||||
code_font = val;
|
||||
explicit_code_font = true;
|
||||
} else if (key == "writing_font") {
|
||||
writing_font = val;
|
||||
explicit_writing_font = true;
|
||||
} else if (key == "theme") {
|
||||
theme = val;
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
@@ -76,7 +78,9 @@ static void
|
||||
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
|
||||
{
|
||||
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)
|
||||
row_h = 16.0f;
|
||||
if (ch_w <= 0.0f)
|
||||
@@ -565,7 +569,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
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();
|
||||
if (cur) {
|
||||
@@ -577,9 +581,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
|
||||
float sz = fr.CurrentFontSize();
|
||||
if (sz <= 0.0f) sz = config_.font_size;
|
||||
fr.LoadFont(expected, sz);
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
fr.RequestLoadFont(expected, sz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
ImGuiRenderer.cc
163
ImGuiRenderer.cc
@@ -85,11 +85,9 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
||||
}
|
||||
if (prev_buf_coloffs_ >= 0 && buf_coloffs != prev_buf_coloffs_) {
|
||||
float target_x = static_cast<float>(buf_coloffs) * space_w;
|
||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
||||
}
|
||||
// Horizontal scroll is handled purely in pixel space (see
|
||||
// cursor-visibility block after the line loop) so we don't
|
||||
// convert the character-based coloffs to an ImGui scroll here.
|
||||
|
||||
// Reserve space for status bar at bottom.
|
||||
// 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();
|
||||
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;
|
||||
{
|
||||
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
|
||||
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||
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 (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
||||
mbuf->Coloffs());
|
||||
}
|
||||
}
|
||||
if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
||||
}
|
||||
}
|
||||
// Horizontal scroll is pixel-based and managed by the cursor
|
||||
// visibility block below; we don't sync it back to coloffs.
|
||||
|
||||
// Update trackers for next frame
|
||||
prev_scroll_y_ = scroll_y;
|
||||
@@ -141,8 +133,65 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
}
|
||||
prev_buf_rowoffs_ = buf_rowoffs;
|
||||
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
|
||||
bool sel_active = false;
|
||||
@@ -264,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]);
|
||||
@@ -286,31 +341,25 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: convert a rendered column position to pixel x offset
|
||||
// relative to the visible line start, using actual text measurement
|
||||
// so proportional fonts render correctly.
|
||||
// Helper: convert a rendered column position to an absolute
|
||||
// pixel x offset from the start of the line. ImGui's scroll
|
||||
// handles viewport clipping so we measure from column 0.
|
||||
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);
|
||||
if (start >= expanded.size() || end <= start)
|
||||
if (end == 0)
|
||||
return 0.0f;
|
||||
return ImGui::CalcTextSize(expanded.c_str() + start,
|
||||
return ImGui::CalcTextSize(expanded.c_str(),
|
||||
expanded.c_str() + end).x;
|
||||
};
|
||||
|
||||
// Compute search highlight ranges for this line in source indices
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
if (search_mode) {
|
||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
||||
if (ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
||||
// In regex mode, reuse the compiled regex hoisted above the loop.
|
||||
if (regex_mode) {
|
||||
if (search_rx_valid) {
|
||||
try {
|
||||
std::regex rx(ed.SearchQuery());
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
@@ -320,6 +369,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
@@ -351,9 +401,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
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 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
line_pos.y + line_h);
|
||||
@@ -392,7 +439,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
if (line_has) {
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
std::size_t rx_end = src_to_rx(ex);
|
||||
if (rx_end > coloffs_now) {
|
||||
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),
|
||||
@@ -401,7 +447,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||
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.
|
||||
rx_end = rx_start + 1;
|
||||
}
|
||||
if (rx_end > coloffs_now) {
|
||||
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),
|
||||
@@ -421,7 +465,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
// Draw syntax-colored runs (text above background highlights)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
||||
@@ -464,16 +507,12 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
for (const auto &sp: spans) {
|
||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
||||
if (rx_e <= coloffs_now)
|
||||
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;
|
||||
std::size_t draw_start = rx_s;
|
||||
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());
|
||||
if (draw_end <= draw_start)
|
||||
continue;
|
||||
// Screen position via actual text measurement
|
||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
|
||||
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
|
||||
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.
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
} else {
|
||||
// No syntax: draw as one run, accounting for horizontal scroll offset
|
||||
if (coloffs_now < expanded.size()) {
|
||||
// No syntax: draw the full line; ImGui scroll handles clipping.
|
||||
if (!expanded.empty()) {
|
||||
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(
|
||||
p, ImGui::GetColorU32(ImGuiCol_Text),
|
||||
expanded.c_str() + coloffs_now);
|
||||
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));
|
||||
expanded.c_str());
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
}
|
||||
|
||||
// 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
|
||||
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.
|
||||
{
|
||||
float child_h_actual = ImGui::GetWindowHeight();
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include "Renderer.h"
|
||||
|
||||
struct ImFont;
|
||||
class Buffer;
|
||||
|
||||
class ImGuiRenderer final : public Renderer {
|
||||
public:
|
||||
ImGuiRenderer() = default;
|
||||
@@ -20,4 +25,13 @@ private:
|
||||
float prev_scroll_y_ = -1.0f;
|
||||
float prev_scroll_x_ = -1.0f;
|
||||
bool mouse_selecting_ = false;
|
||||
|
||||
// Max-line-width cache for the horizontal scrollbar. Measuring every line
|
||||
// every frame is prohibitively expensive on large files; we only update the
|
||||
// running max from visible lines and reset when buffer/version/font changes.
|
||||
const Buffer *max_width_buf_ = nullptr;
|
||||
std::uint64_t max_width_version_ = 0;
|
||||
ImFont *max_width_font_ = nullptr;
|
||||
float max_width_font_size_ = 0.0f;
|
||||
float max_width_px_ = 0.0f;
|
||||
};
|
||||
|
||||
57
default.nix
57
default.nix
@@ -1,6 +1,5 @@
|
||||
{
|
||||
pkgs ? import <nixpkgs> {},
|
||||
lib ? pkgs.lib,
|
||||
lib,
|
||||
stdenv,
|
||||
cmake,
|
||||
ncurses,
|
||||
@@ -10,9 +9,10 @@
|
||||
kdePackages,
|
||||
qt6Packages ? kdePackages.qt6Packages,
|
||||
installShellFiles,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
graphical ? false,
|
||||
graphical-qt ? false,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cmakeContent = builtins.readFile ./CMakeLists.txt;
|
||||
@@ -23,25 +23,29 @@ let
|
||||
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "kte";
|
||||
pname = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
|
||||
inherit version;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
cmake
|
||||
ncurses
|
||||
installShellFiles
|
||||
]
|
||||
++ lib.optionals graphical [
|
||||
] ++ lib.optionals graphical [
|
||||
copyDesktopItems
|
||||
] ++ lib.optionals graphical-qt [
|
||||
qt6Packages.wrapQtAppsHook
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
ncurses
|
||||
] ++ lib.optionals graphical [
|
||||
SDL2
|
||||
libGL
|
||||
xorg.libX11
|
||||
]
|
||||
++ lib.optionals graphical-qt [
|
||||
] ++ lib.optionals graphical-qt [
|
||||
kdePackages.qt6ct
|
||||
qt6Packages.qtbase
|
||||
qt6Packages.wrapQtAppsHook
|
||||
];
|
||||
|
||||
cmakeFlags = [
|
||||
@@ -51,6 +55,30 @@ stdenv.mkDerivation {
|
||||
"-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 = ''
|
||||
runHook preInstall
|
||||
|
||||
@@ -59,14 +87,11 @@ stdenv.mkDerivation {
|
||||
installManPage ../docs/kte.1
|
||||
|
||||
${lib.optionalString graphical ''
|
||||
mkdir -p $out/bin
|
||||
|
||||
${if graphical-qt then ''
|
||||
cp kge $out/bin/kge-qt
|
||||
'' else ''
|
||||
cp kge $out/bin/kge
|
||||
''}
|
||||
|
||||
installManPage ../docs/kge.1
|
||||
|
||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||
@@ -75,4 +100,10 @@ stdenv.mkDerivation {
|
||||
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
25
flake.nix
25
flake.nix
@@ -3,8 +3,7 @@
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
inputs@{ self, nixpkgs, ... }:
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
let
|
||||
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
@@ -17,5 +16,27 @@
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
|
||||
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
14
main.cc
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user