Compare commits

...

106 Commits

Author SHA1 Message Date
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
23f04e4357 Add proportional fonts, edit modes, and TOML config
- Add three proportional serif fonts: Crimson Pro, ET Book, Spectral
- Fix text rendering for variable-width fonts: selection, cursor,
  mouse click mapping, search highlights, and syntax-colored text
  now use pixel-accurate measurement via ImGui::CalcTextSize()
- Add per-buffer edit mode (code/writing) with auto-detection from
  file extension (.txt, .md, .rst, .org, .tex default to writing)
- Add C-k m keybinding and :mode command to toggle edit modes
- Switch config format from INI to TOML (kge.toml), with legacy
  INI fallback; vendor toml++ v3.4.0
- New config keys: font.code and font.writing for per-mode defaults
- Add font tab completion for ImGui builds
- Add tab completion for :mode command
- Update help text, themes.md, and add CONFIG.md
- Bump version to 1.10.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:05:56 -07:00
0585edad9e Disable Qt build in make-app-release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:22:11 -07:00
8712ea673d Add leuchtturm theme, font zoom, syntax palette fixes
Themes:
- Add leuchtturm theme (fountain pen ink on cream paper, brass/leather dark)
- Add per-theme syntax palettes for leuchtturm, tufte, and everforest
- Fix static inline globals giving each TU its own copy of gCurrentTheme
  and gBackgroundMode (changed to inline for proper C++17 linkage)
- :background with no args now shows current mode

Font zoom:
- CMD-=/CMD--/CMD-0 to increase/decrease/reset font size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:17:55 -07:00
3148e16cf8 Fix multi-window architecture and swap file cleanup
Multi-window:
- Per-window ImGui contexts (fixes input, scroll, and rendering isolation)
- Per-instance scroll and mouse state in ImGuiRenderer (no more statics)
- Proper GL context activation during window destruction
- ValidateBufferIndex guards against stale curbuf_ across shared buffers
- Editor methods (CurrentBuffer, SwitchTo, CloseBuffer, etc.) use Buffers()
  accessor to respect shared buffer lists
- New windows open with an untitled buffer
- Scratch buffer reuse works in secondary windows
- CMD-w on macOS closes only the focused window
- Deferred new-window creation to avoid mid-frame ImGui context corruption

Swap file cleanup:
- SaveAs prompt handler now calls ResetJournal
- cmd_save_and_quit now calls ResetJournal
- Editor::Reset detaches all buffers before clearing
- Tests for save-and-quit and editor-reset swap cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:48:34 -07:00
34eaa72033 Bump patch version to 1.8.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:36:21 -07:00
f49f1698f4 Add Tufte theme with light and dark variants
Warm cream paper, near-black ink, zero rounding, minimal chrome,
restrained dark red and navy accents following Tufte's design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:34:23 -07:00
f4b3188069 Forgot to bump patch version. 2026-03-17 17:28:57 -07:00
2571ab79c1 build now works on nix
1. Static linking - Added KTE_STATIC_LINK CMake option and
   disabled it in default.nix to avoid the "attempted
   static link of dynamic object" error

2. Missing include - Added <cstring> to
   test_swap_edge_cases.cc for std::memset/std::memcpy (GCC
   14 is stricter about transitive includes)
2026-03-17 17:15:16 -07:00
d768e56727 Add multi-window support to GUI with shared buffer list and improved input handling
- Introduced support for multiple windows, sharing the primary editor's buffer list.
- Added `GUIFrontend::OpenNewWindow_` for creating secondary windows with independent dimensions and input handlers.
- Redesigned `WindowState` to encapsulate per-window attributes (dimensions, renderer, input, etc.).
- Updated input processing and command execution to route events based on active window, preserving window-level states.
- Enhanced SDL2 and ImGui integration for proper context management across multiple windows.
- Increased robustness by handling window closing, resizing, and cleanup of secondary windows without affecting the primary editor.
- Updated documentation and key bindings for multi-window operations (e.g., Cmd+N / Ctrl+Shift+N).
- Version updated to 1.8.0 to reflect the major GUI enhancement.
2026-03-15 13:19:04 -07:00
11c523ad52 Bump patch version. 2026-02-26 13:27:13 -08:00
c261261e26 Initialize ErrorHandler early and ensure immediate log file creation
- Added early initialization of `ErrorHandler` in `main.cc` for robust error handling.
- Modified `ErrorHandler` to create the log file immediately, ensuring its presence in the state directory.
- Simplified conditional checks for log file operations and updated timestamp handling to use `system_clock`.
2026-02-26 13:25:57 -08:00
27dcb41857 Add ReflowUndo tests and integrate InsertRow undo support
- Added `test_reflow_undo.cc` to validate undo/redo workflows for reflow operations.
- Introduced `UndoType::InsertRow` in `UndoSystem` for tracking row insertion changes in undo history.
- Updated `UndoNode.h` and `UndoSystem.cc` to support row insertion as a standalone undo step.
- Enhanced reflow paragraph functionality to properly record undo/redo actions for both row deletion and insertion.
- Enabled legacy/extended undo tests in `test_undo.cc` for comprehensive validation.
- Updated `CMakeLists.txt` to include new test file in the build target.
2026-02-26 13:21:07 -08:00
bc3433e988 Add SmartNewline command with tests and editor integration
- Introduced `CommandId::SmartNewline` for auto-indented newlines, enhancing text editing workflows.
- Added `cmd_smart_newline` to implement indentation-aware newline logic.
- Integrated SmartNewline with keymaps, mouse/keyboard input handlers, and terminal/editor commands.
- Wrote comprehensive tests in `test_smart_newline.cc` to validate behavior for spaces, tabs, and no-indentation cases.
- Updated `Command.h` and `CMakeLists.txt` to register and build the new command.
2026-02-26 13:08:56 -08:00
690c51b0f3 MacOS: remove static linking. Bump minor version. 2026-02-19 21:00:29 -08:00
0d87bc0b25 Introduce error recovery mechanisms with retry logic and circuit breaker integration.
- Added `ErrorRecovery.cc` and `ErrorRecovery.h` for retry and circuit breaker implementations.
- Enhanced swap file handling with transient error retries and exponential backoff (e.g., ENOSPC, EDQUOT).
- Integrated circuit breaker into SwapManager to gracefully handle repeated failures, prevent system overload, and enable automatic recovery.
- Updated `DEVELOPER_GUIDE.md` with comprehensive documentation on error recovery patterns and graceful degradation strategies.
- Refined fsync, temp file creation, and swap file logic with retry-on-failure mechanisms for improved resilience.
2026-02-17 21:38:40 -08:00
daeeecb342 Standardize error handling patterns and improve ErrorHandler integration.
- Added a comprehensive error propagation standardization report detailing dominant patterns, inconsistencies, and recommended remediations (`docs/audits/error-propagation-standardization.md`).
- Integrated `ErrorHandler` into key components, including `main.cc` for robust exception reporting, and added centralized logging to a user state path.
- Introduced EINTR-safe syscall wrappers (`SyscallWrappers.h`, `.cc`) to improve resilience of file and metadata operations.
- Enhanced `DEVELOPER_GUIDE.md` with an error handling conventions section, covering pattern guidelines and best practices.
- Identified gaps in `PieceTable` and internal helpers; deferred fixes with detailed recommendations for improved memory allocation error reporting.
2026-02-17 21:25:19 -08:00
a428b204a0 Improve exception robustness.
- Introduced `test_swap_edge_cases.cc` with extensive tests for minimum payload sizes, truncated payloads, data overflows, unsupported encoding versions, CRC mismatches, and mixed valid/invalid records to ensure reliability under complex scenarios.
- Enhanced `main.cc` with a top-level exception handler to prevent data loss and ensure cleanup during unexpected failures.
2026-02-17 20:12:09 -08:00
a21409e689 Remove PID from unnamed buffer swap names. 2026-02-17 17:17:55 -08:00
b0b5b55dce Switch Docker to Alpine and build kge.
Update build environment to Alpine, enable GUI support, and refine developer guide

- Migrated Dockerfile base image from Ubuntu 22.04 to Alpine 3.19 for a smaller and faster container.
- Added dependencies for GUI support (SDL2, OpenGL/Mesa, Freetype, etc.) and updated CMake options.
- Enhanced `DEVELOPER_GUIDE.md` with new instructions for GUI builds, updated dependencies, and simplified custom build workflows.
- Addressed Alpine-specific ncurses library path issues in CMake configuration.
2026-02-17 16:53:12 -08:00
422b27b1ba Add Docker support for Linux build testing
- Introduced a `Dockerfile` for setting up a minimal Ubuntu-based build environment with required dependencies.
- Added `docker-build.sh` script to simplify Linux build and test execution using Docker or Podman.
- Updated `DEVELOPER_GUIDE.md` with instructions for using Docker/Podman for Linux builds, including CI/CD integration examples.
2026-02-17 16:35:52 -08:00
9485d2aa24 Linux fixup. 2026-02-17 16:13:28 -08:00
8a6b7851d5 Bump patch version. 2026-02-17 16:08:53 -08:00
8ec0d6ac41 Add benchmarks, migration tests, and dev guide
Add benchmarks for core operations, migration edge case tests, improved
buffer I/O tests, and developer guide

- Introduced `test_benchmarks.cc` for performance benchmarking of key
  operations in `PieceTable` and `Buffer`, including syntax highlighting
  and iteration patterns.
- Added `test_migration_coverage.cc` to provide comprehensive tests for
  migration of `Buffer::Rows()` to `PieceTable` APIs, with edge cases,
  boundary handling, and consistency checks.
- Enhanced `test_buffer_io.cc` with additional cases for save/load
  workflows, file handling, and better integration with the core API.
- Documented architectural details and core concepts in a new
  `DEVELOPER_GUIDE.md`. Highlighted design principles, code
  organization, and contribution workflows.
2026-02-17 16:08:23 -08:00
337b585ba0 Reformat code. 2026-02-17 13:44:36 -08:00
95a588b0df Add test for Git editor swap cleanup and improve swap file handling
- Added `test_swap_git_editor.cc` to verify proper swap file cleanup during Git editor workflows. Ensures no stale swap files are left after editor closure.
- Updated swap handling logic in `Editor.cc` to always remove swap files on buffer closure during normal exit, preventing accumulation of leftover files.
- Bumped version to 1.6.5 in `CMakeLists.txt`.
2026-02-17 13:10:01 -08:00
199d7a20f7 Add indented bullet reflow test, improve undo edge cases, and bump version
- Added `test_reflow_indented_bullets.cc` to verify correct reflow handling for indented bullet points.
- Enhanced undo system with additional tests for cursor adjacency, explicit grouping, branching, newline independence, and dirty-state tracking.
- Introduced external modification detection for files and required confirmation before overwrites.
- Refactored buffer save logic to use atomic writes and track on-disk identity.
- Updated CMake to include new test files and bumped version to 1.6.4.
2026-02-16 12:44:08 -08:00
44827fe53f Add mark-clearing behavior to refresh command and related test.
- Updated `Refresh` command to clear the mark when no active prompt, search, or visual-line mode is present.
- Added a new unit test verifying mark-clearing behavior for `Ctrl-G` (mapped to `Refresh`).
- Bumped version to 1.6.3 in `CMakeLists.txt`.
2026-02-14 23:05:44 -08:00
2a6ff2a862 Introduce swap journaling crash recovery system with tests.
- Added detailed journaling system (`SwapManager`) for crash recovery, including edit recording and replay.
- Integrated recovery prompts for handling swap files during file open flows.
- Implemented swap file cleanup, checkpointing, and compaction mechanisms.
- Added extensive unit tests for swap-related behaviors such as recovery prompts, file pruning, and corruption handling.
- Updated CMake to include new test files.
2026-02-13 08:45:27 -08:00
895e4ccb1e Add swap journaling and group undo/redo with extensive tests.
- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
2026-02-11 20:47:18 -08:00
15b350bfaa Add TestHarness infrastructure and initial smoke test
- Implemented `TestHarness` class for headless editor testing.
- Added utility methods for text insertion, editing, and querying.
- Introduced `test_daily_driver_harness` for verifying basic text buffer operations.
- Updated CMake to include the new test files.
2026-02-10 23:34:01 -08:00
cc8df36bdf Implement branching undo system with tests and updates.
- Added branching model for undo/redo, enabling multiple redo paths and branch selection.
- Updated `UndoNode` to include `parent` and refined hierarchical navigation.
- Extended `UndoSystem` with branching logic for redo operations, supporting sibling branch selection.
- Overhauled tests to validate branching behavior and tree invariants.
- Refined editor command logic for undo/redo with repeat counts and branch selection.
- Enabled test-only introspection hooks for undo tree validation.
- Updated CMake to include test definitions (`KTE_TESTS` flag).
2026-02-10 23:13:00 -08:00
1c0f04f076 Bump version to 1.6.0.
- Linear undo
- Multicursor support
- Reflow numbered lists
2026-02-10 22:41:20 -08:00
ac0eadc345 Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete).
- Added `UndoSystem` integration into the editor/commands pipeline.
- Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management.
- Refactored to ensure consistent cursor behavior during undo/redo actions.
- Updated CMake to include new tests.
2026-02-10 22:39:55 -08:00
f3bdced3d4 Add visual-line mode support with tests and UI integration.
- Introduced visual-line mode for multi-line selection and edits.
- Implemented commands, rendering, and keyboard shortcuts.
- Added tests for broadcast operations in visual-line mode.
2026-02-10 22:07:13 -08:00
2551388420 Support numbered lists in reflow-paragraph.
Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic.
2026-02-10 21:23:20 -08:00
d2d155f211 Fix data race.
+ Add thread-safety with mutexes in `PieceTable` and `Buffer`
+ Bump version to 1.5.9
2026-01-28 01:03:58 -08:00
8634eb78f0 Refactor Init method across all frontends to include argc and argv for improved argument handling consistency. 2026-01-12 10:35:24 -08:00
6eb240a0c4 Refactor ImGui editor layout and scrolling logic for improved precision and consistency. 2026-01-11 15:34:56 -08:00
4c402f5ef3 Replace Greek and Mathematical Operators font fallback with Iosevka Extended for improved font handling. 2026-01-11 12:07:24 -08:00
a8abda4b87 Unicode improvements and version bump.
- Added full UTF-8 support for terminal rendering, including multi-width character handling.
- Improved font handling in ImGui with expanded glyph support (Greek, Mathematical Operators).
- Updated locale initialization to enable proper character rendering.
- Bumped version to 1.5.8.
2026-01-11 11:39:08 -08:00
7347556aa2 Add missing cmake for macos. 2026-01-02 10:39:33 -08:00
289e155c98 bump version 2026-01-02 09:50:08 -08:00
147a52f3d4 center cursor 2026-01-01 21:59:20 -08:00
dda7541e2f adding berkeley mono as the default. 2026-01-01 20:10:22 -08:00
2408f5494c bump version 2026-01-01 19:13:07 -08:00
2542690eca updating jump to line 2026-01-01 19:12:46 -08:00
cc0c187481 Improve macOS app build process and bundle handling.
- Updated `make-app-release` script to use `macdeployqt` with proper verbosity and bundle fixup.
- Introduced post-build fixup using CMake's `BundleUtilities` to internalize non-Qt dylibs.
- Enhanced macOS bundle RPATH settings for accurate Framework resolution.
- Added optional `kge_fixup_bundle` CMake target for post-build handling.
- Refined `default.nix` to load Nixpkgs in a default argument.
2025-12-09 18:49:16 -08:00
a8dcfbec58 Fix C-k c handling. 2025-12-08 15:28:45 -08:00
65705e3354 bump version 2025-12-07 15:25:50 -08:00
e1f9a9eb6a Preserve cursor position on buffer reload.
- Remember and restore the cursor's position after reloading a buffer, clamping if necessary.
- Improve user experience by maintaining editing context.
2025-12-07 15:25:40 -08:00
c9f34003f2 Add unit testing plan documentation.
- Introduced comprehensive test plan to guide development and ensure coverage.
- Documented test principles, execution harness, build steps, and test catalog.
- Categorized test cases by functionality (e.g., filesystem I/O, PieceTable semantics, buffer editing, undo system, etc.).
- Outlined regression tests and performance/stress scenarios.
- Provided a phased roadmap for implementing planned test cases.
2025-12-07 12:34:47 -08:00
f450ef825c Replace individual test binaries with unified test runner.
- Removed standalone test executables (`test_undo`, `test_buffer_save`, `test_buffer_open_nonexistent_save`, etc.).
- Introduced `kte_tests` as a unified test runner.
- Migrated existing tests to a new minimal, reusable framework in `tests/Test.h`.
- Updated `CMakeLists.txt` to build a single `kte_tests` executable.
- Simplified dependencies, reducing the need for ncurses/GUI in test builds.
2025-12-07 00:37:16 -08:00
f6f0c11be4 Add PieceTable-based buffer tests and improvements for file I/O and editing.
- Introduced comprehensive tests:
  - `test_buffer_open_nonexistent_save.cc`: Save after opening a non-existent file.
  - `test_buffer_save.cc`: Save buffer contents to disk.
  - `test_buffer_save_existing.cc`: Save after opening existing files.
- Implemented `PieceTable::WriteToStream()` to directly stream content without full materialization.
- Updated `Buffer::Save` and `Buffer::SaveAs` to use efficient streaming via `PieceTable`.
- Enhanced editing commands (`Insert`, `Delete`, `Replace`, etc.) to use PieceTable APIs, ensuring proper undo and save functionality.
2025-12-07 00:30:11 -08:00
657c9bbc19 bump version 2025-12-06 11:40:27 -08:00
3493695165 Add support for creating a new empty buffer (C-k i).
- Introduced `BufferNew` command to create and switch to a new unnamed buffer.
- Registered `BufferNew` in the command registry and updated keymap and help text.
- Implemented `cmd_buffer_new()` to handle buffer creation and switching logic.
2025-12-06 11:40:00 -08:00
5f57cf23dc bump version 2025-12-05 21:31:46 -08:00
9312550be4 Fix scrolling issue in TUI. 2025-12-05 21:31:33 -08:00
f734f98891 update mac app release 2025-12-05 20:53:04 -08:00
1191e14ce9 Bump version. 2025-12-05 20:53:04 -08:00
12cc04d7e0 Improve input handling and scrolling behavior for high-resolution trackpads.
- Added precise fractional mouse wheel delta handling with per-step command emission.
- Introduced scroll accumulators (`wheel_accum_y_`, `wheel_accum_x_`) for high-resolution trackpad input.
- Replaced hardcoded ESC delay with configurable `kEscDelayMs` constant in `TerminalFrontend`.
- Enabled mouse position reporting and reduced CPU usage during idle with optimized `timeout()` setting.
2025-12-05 20:53:04 -08:00
3f4c60d311 Add detailed migration plan for PieceTable-based buffer architecture.
- Created `piece-table-migration.md` outlining the steps to transition from GapBuffer to a unified PieceTable architecture.
- Included phased approach: extending PieceTable, Buffer adapter layer, command updates, and renderer changes.
- Detailed API changes, file updates, testing strategy, risk assessment, and timeline for each migration phase.
- Document serves as a reference for architecture goals and implementation details.
2025-12-05 20:53:04 -08:00
71c1c9e50b Remove GapBuffer and associated legacy implementation.
- Deleted `GapBuffer` class and its API implementations.
- Removed `AppendBuffer` selector and conditional `KTE_USE_PIECE_TABLE` macros.
- Eliminated legacy support in buffer APIs, file I/O, benchmarks, and correctness tests.
- Updated guidelines and comments to reflect PieceTable as the default and only buffer backend.
2025-12-05 20:53:04 -08:00
afb6888c31 Introduce PieceTable-based buffer backend (Phase 1)
- Added `PieceTable` class for efficient text manipulation and implemented core editing APIs (`Insert`, `Delete`, `Find`, etc.).
- Integrated `PieceTable` into `Buffer` class with an adapter for rows caching.
- Enabled seamless switching between legacy row-based and new PieceTable-backed editing via `KTE_USE_BUFFER_PIECE_TABLE`.
- Updated file I/O, line-based queries, and cursor operations to support PieceTable-based storage.
- Lazy rebuilding of line index and improved management of edit state for performance.
2025-12-05 20:53:04 -08:00
222f73252b nixos: rename kge->kge-qt 2025-12-05 10:37:16 -08:00
51ea473a91 nixos and qt fixup 2025-12-05 09:25:48 -08:00
fd517b5d57 fix nixos build 2025-12-05 08:21:38 -08:00
952e1ed3f2 Add 'CenterOnCursor' command and improve cursor scrolling logic
- Introduced `CommandId::CenterOnCursor` to center viewport on the cursor line.
- Improved scrolling behavior in `ImGuiRenderer` to avoid aggressive centering and keep visible lines stable.
- Updated `make-app-release` to rename the output app to `kge-qt.app`.
- Adjusted padding in `ImGuiFrontend` to align with `ImGuiRenderer` settings for consistent scrolling.
- Bumped version to 1.4.1.
2025-12-05 08:15:23 -08:00
7069943df5 bump version 2025-12-04 23:08:11 -08:00
ee2c9939d7 Introduce QtFrontend with renderer, input handler, and theming support.
- Added `QtFrontend`, `QtRenderer`, and `QtInputHandler` for Qt-based UI rendering and input handling.
- Implemented support for theming, font customization, and palette overrides in GUITheme.
- Renamed and refactored ImGui-specific components (e.g., `GUIRenderer` -> `ImGuiRenderer`).
- Added cross-frontend integration for commands and visual font picker.
2025-12-04 21:33:55 -08:00
f5a4625652 Add QtFrontend plans. 2025-12-04 15:51:06 -08:00
37472c71ec Fix UI cursor positioning issues.
Accurately recompute cursor position to prevent drift in terminal and GUI renderers.
2025-12-04 15:18:02 -08:00
5ff4b2ed3e Reflow-paragraph is fixed.
- Forgot to check whether the universal argument value (1 by default), so it was trying to reflow to column 1.
- Minor formatting fixups.
2025-12-04 15:14:30 -08:00
ab2f9918f3 fix build on nixos 2025-12-04 13:11:43 -08:00
d2b53601e2 bump version 2025-12-04 08:49:36 -08:00
78b9345799 Add swap file journaling for crash recovery.
- Introduced `SwapManager` for buffering and writing incremental edits to sidecar `.kte.swp` files.
- Implemented basic operations: insertion, deletion, split, join, and checkpointing.
- Added recovery design doc (`docs/plans/swap-files.md`).
- Updated editor initialization to integrate `SwapManager` instance for crash recovery across buffers.
2025-12-04 08:48:32 -08:00
495183ebd2 Various cleanups.
- Update input handling to retain SDL_TEXTINPUT after Tab insertion for better platform consistency.
- Allow multiple app instances in macOS by modifying `Info.plist`.
- Bump version to 1.3.8-alpha.
2025-12-04 00:05:13 -08:00
998b1b9817 disable ASAN actually
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-03 17:09:00 -08:00
dc2cf4c0a6 Update highlighter logic, add release scripts, and bump version to 1.3.6.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Refined cached state validation in `HighlighterEngine` to ensure row validity and buffer consistency.
- Added `make-release` and `make-app-release` scripts for streamlined release builds.
- Disabled AddressSanitizer (ASAN) by default.
- Version bump to 1.3.6.
2025-12-03 16:20:04 -08:00
f6c4a5ab34 Add theme, font, and font-size introspection; bump version.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-03 15:53:55 -08:00
35ef74910d bump version
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-03 15:27:54 -08:00
b17672d440 Fix iconset and bump version. 2025-12-03 15:27:42 -08:00
cb1d16ebbc Enable ASAN support and bump version
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-03 15:14:02 -08:00
cbbde43dc2 Stub out previous undo implementation; update docs.
- Remove outdated `undo-state.md`
- Add two code quality/optimization reports that were used to guide previous work:
  - `code-report.md` (optimization)
  - `code-report-quality.md` (stability and code health)
- Add `themes.md`.
- Update undo system docs and roadmap.
2025-12-03 15:12:28 -08:00
45b2b88623 Code quality, safety, stability, and cleanups.
- Replace header include guards with `#pragma once` and perform minor optimizations.
- Replaced traditional include guards with `#pragma once` for simplicity and to reduce boilerplate in all headers.
- Improved CLI line number handling with clamping and error messaging.
- Enhanced `chdir` error handling for macOS GUI builds.
- Removed redundant logic for GUI builds.
- Adjusted font constructor and registry to handle `const` data pointers consistently.
2025-12-03 14:02:54 -08:00
389dcf9cc7 Add new benchmarks, optimized search, UndoNode pool, and fix horizontal scrolling.
- Added benchmarking for GapBuffer and PieceTable (BufferBench, PerformanceSuite).
- Implemented `OptimizedSearch` using Boyer-Moore (bad character heuristic).
- Introduced `UndoNodePool` for efficient memory management.
- Fixed horizontal scrolling and cursor placement in GUI: ensured cursor visibility and improved accuracy for rendered columns.
2025-12-03 13:53:24 -08:00
c98d9e717a Code cleanups.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Implement font management improvements
- Add commands for font selection and size
- Handle pending font loading consistently
- Update related documentation.
- Version bump to 1.3.1.
2025-12-03 08:28:44 -08:00
c864af7daa Add new GUI themes and update documentation.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Amber
- LCARS
- Orbital
- Weyland-Yutani
- Kanagawa Paper (dark mode extension)

Update help text to reflect additions.
2025-12-03 00:36:01 -08:00
64022766c5 Add new GUI themes and prep version bump.
- Everforest
- Kanagawa Paper
- Old Book
- Zenburn

Update help/docs accordingly.
2025-12-03 00:05:49 -08:00
d706b6db44 split fonts out to separate repo 2025-12-03 00:03:09 -08:00
bce9b3b33e Ensure k-prefix mode exits after special key handling 2025-12-02 23:59:15 -08:00
56dc904432 Fix horizontal scrolling and cursor placement.
Ensure horizontal scrolling for cursor visibility, add accurate click handling for rendered columns, and improve syntax highlighting bounds adjustment.
2025-12-02 23:46:35 -08:00
a8197939f8 Add thread-safe font loading requests and integrate FiraCode-Regular font 2025-12-02 23:41:52 -08:00
9f722ec2bb fix naming issue 2025-12-02 19:26:23 -08:00
094020dab5 Adding additional fonts. 2025-12-02 19:18:34 -08:00
09e4cd7ec6 Stashing fonts, start font registry. 2025-12-02 19:05:08 -08:00
49fa7ff8a7 refactoring some font support 2025-12-02 19:05:08 -08:00
38915484ac bump version
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-02 18:44:17 -08:00
87b1e6f502 Prevent kge from inhibiting sleep. 2025-12-02 18:43:45 -08:00
ae822083c2 Bump version.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-02 11:22:59 -08:00
0c93d619c8 Set custom ImGui ini file path
Path is set to `~/.config/kte/imgui.ini`, enable keyboard and gamepad navigation, and ensure configuration directory creation.
2025-12-02 11:22:38 -08:00
483ff18b0d Add ScrollUp and ScrollDown commands for viewport scrolling, refine mouse wheel handling in GUI and terminal, and bump version to 1.2.2.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-02 02:53:02 -08:00
cd33e8feb1 Refactor scrolling logic for GUIRenderer and terminal to improve synchronization and cursor visibility. 2025-12-02 02:43:05 -08:00
0bfe75fbf0 Refactor indentation for consistent style across codebase.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-02 01:37:44 -08:00
d15b241140 Refactor syntax highlighting infrastructure and related classes.
- Moved all language highlighter implementations (`CppHighlighter`, `GoHighlighter`, `JsonHighlighter`, etc.), the engine, and registry to `syntax/`.
2025-12-02 01:36:26 -08:00
238 changed files with 286239 additions and 16266 deletions

View File

@@ -141,6 +141,9 @@
<pair source="c++m" header="" fileNamingConvention="NONE" /> <pair source="c++m" header="" fileNamingConvention="NONE" />
</extensions> </extensions>
</files> </files>
<codeStyleSettings language="Markdown">
<option name="RIGHT_MARGIN" value="72" />
</codeStyleSettings>
<codeStyleSettings language="ObjectiveC"> <codeStyleSettings language="ObjectiveC">
<indentOptions> <indentOptions>
<option name="INDENT_SIZE" value="8" /> <option name="INDENT_SIZE" value="8" />

View File

@@ -1,5 +1,6 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<state> <state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" /> <option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
</state> </state>
</component> </component>

3
.idea/editor.xml generated
View File

@@ -19,7 +19,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
@@ -58,6 +58,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" /> <option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />

2
.idea/kte.iml generated
View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4"> <module classpath="CIDR" type="CPP_MODULE" version="4">
<component name="FacetManager"> <component name="FacetManager">
<facet type="Python" name="Python facet"> <facet type="Python" name="Python facet">
<configuration sdkName="" /> <configuration sdkName="" />

View File

@@ -1,28 +1,35 @@
# Project Guidelines # Project Guidelines
kte is Kyle's Text Editor — a simple, fast text editor written in C++17. It kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
replaces the earlier C implementation, ke (see the ke manual in `docs/ke.md`). The It
design draws inspiration from Antirez' kilo, with keybindings rooted in the replaces the earlier C implementation, ke (see the ke manual in
`docs/ke.md`). The
design draws inspiration from Antirez' kilo, with keybindings rooted in
the
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`. WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
These guidelines summarize the goals, interfaces, key operations, and current These guidelines summarize the goals, interfaces, key operations, and
current
development practices for kte. development practices for kte.
## Goals ## Goals
- Keep the core small, fast, and understandable. - Keep the core small, fast, and understandable.
- Provide an ncurses-based terminal-first editing experience, with an additional ImGui GUI. - Provide an ncurses-based terminal-first editing experience, with an
additional ImGui GUI.
- Preserve familiar keybindings from ke while modernizing the internals. - Preserve familiar keybindings from ke while modernizing the internals.
- Favor simple data structures (e.g., piece table) and incremental evolution. - Favor simple data structures (e.g., piece table) and incremental
evolution.
Project entry point: `main.cpp` Project entry point: `main.cpp`
## Core Components (current codebase) ## Core Components (current codebase)
- Buffer: editing model and file I/O (`Buffer.h/.cpp`). - Buffer: editing model and file I/O (`Buffer.h/.cpp`).
- GapBuffer: editable in-memory text representation (`GapBuffer.h/.cpp`). - PieceTable: editable in-memory text representation (
- PieceTable: experimental/alternative representation (`PieceTable.h/.cpp`). `PieceTable.h/.cpp`).
- InputHandler: interface for handling text input (`InputHandler.h/`), along - InputHandler: interface for handling text input (`InputHandler.h/`),
along
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`. with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
- Renderer: interface for rendering text (`Renderer.h`), along with - Renderer: interface for rendering text (`Renderer.h`), along with
`TerminalRenderer` (ncurses-based) and `GUIRenderer`. `TerminalRenderer` (ncurses-based) and `GUIRenderer`.
@@ -36,13 +43,16 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
## Contributing/Development Notes ## Contributing/Development Notes
- C++ standard: C++17. - C++ standard: C++20.
- Keep dependencies minimal. - Keep dependencies minimal.
- Prefer small, focused changes that preserve kes UX unless explicitly changing - Prefer small, focused changes that preserve kes UX unless explicitly
changing
behavior. behavior.
## References ## References
- Previous editor manual: `ke.md` (canonical keybinding/spec reference for now). - Previous editor manual: `ke.md` (canonical keybinding/spec reference
for now).
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`. - Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.

View File

@@ -1,15 +0,0 @@
/*
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
*/
#ifndef KTE_APPENDBUFFER_H
#define KTE_APPENDBUFFER_H
#ifdef KTE_USE_PIECE_TABLE
#include "PieceTable.h"
using AppendBuffer = PieceTable;
#else
#include "GapBuffer.h"
using AppendBuffer = GapBuffer;
#endif
#endif // KTE_APPENDBUFFER_H

652
Buffer.cc
View File

@@ -2,13 +2,28 @@
#include <sstream> #include <sstream>
#include <filesystem> #include <filesystem>
#include <cstdlib> #include <cstdlib>
#include <limits>
#include <cerrno>
#include <cstring>
#include <string_view>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "Buffer.h" #include "Buffer.h"
#include "SwapRecorder.h"
#include "UndoSystem.h" #include "UndoSystem.h"
#include "UndoTree.h" #include "UndoTree.h"
#include "ErrorHandler.h"
#include "SyscallWrappers.h"
#include "ErrorRecovery.h"
// For reconstructing highlighter state on copies // For reconstructing highlighter state on copies
#include "HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "NullHighlighter.h" #include "syntax/NullHighlighter.h"
Buffer::Buffer() Buffer::Buffer()
@@ -19,6 +34,177 @@ Buffer::Buffer()
} }
bool
Buffer::stat_identity(const std::string &path, FileIdentity &out)
{
struct stat st{};
if (::stat(path.c_str(), &st) != 0) {
out.valid = false;
return false;
}
out.valid = true;
// Use nanosecond timestamp when available.
std::uint64_t ns = 0;
#if defined(__APPLE__)
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
#else
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
#endif
out.mtime_ns = ns;
out.size = static_cast<std::uint64_t>(st.st_size);
out.dev = static_cast<std::uint64_t>(st.st_dev);
out.ino = static_cast<std::uint64_t>(st.st_ino);
return true;
}
bool
Buffer::current_disk_identity(FileIdentity &out) const
{
if (!is_file_backed_ || filename_.empty()) {
out.valid = false;
return false;
}
return stat_identity(filename_, out);
}
bool
Buffer::ExternallyModifiedOnDisk() const
{
if (!is_file_backed_ || filename_.empty())
return false;
FileIdentity now{};
if (!current_disk_identity(now)) {
// If the file vanished, treat as modified when we previously had an identity.
return on_disk_identity_.valid;
}
if (!on_disk_identity_.valid)
return false;
return now.mtime_ns != on_disk_identity_.mtime_ns
|| now.size != on_disk_identity_.size
|| now.dev != on_disk_identity_.dev
|| now.ino != on_disk_identity_.ino;
}
void
Buffer::RefreshOnDiskIdentity()
{
FileIdentity id{};
if (current_disk_identity(id))
on_disk_identity_ = id;
}
static bool
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
{
std::size_t off = 0;
while (off < len) {
ssize_t n = ::write(fd, data + off, len - off);
if (n < 0) {
if (errno == EINTR)
continue;
err = std::string("Write failed: ") + std::strerror(errno);
return false;
}
off += static_cast<std::size_t>(n);
}
return true;
}
static void
best_effort_fsync_dir(const std::string &path)
{
try {
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
if (dir.empty())
return;
int dfd = kte::syscall::Open(dir.c_str(), O_RDONLY);
if (dfd < 0)
return;
(void) kte::syscall::Fsync(dfd);
(void) kte::syscall::Close(dfd);
} catch (...) {
// best-effort
}
}
static bool
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
{
// Create a temp file in the same directory so rename() is atomic.
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
std::string base = p.filename().string();
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
std::string tmpl_s = tmpl.string();
// mkstemp requires a mutable buffer.
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
// Retry on transient errors for temp file creation
int fd = -1;
auto mkstemp_fn = [&]() -> bool {
// Reset buffer for each retry attempt
buf.assign(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
fd = kte::syscall::Mkstemp(buf.data());
return fd >= 0;
};
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
if (fd < 0) {
err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
}
return false;
}
std::string tmp_path(buf.data());
// If the destination exists, carry over its permissions.
struct stat dst_st{};
if (::stat(path.c_str(), &dst_st) == 0) {
(void) kte::syscall::Fchmod(fd, dst_st.st_mode);
}
bool ok = write_all_fd(fd, data, len, err);
if (ok) {
// Retry fsync on transient errors
auto fsync_fn = [&]() -> bool {
return kte::syscall::Fsync(fd) == 0;
};
std::string fsync_err;
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
ok = false;
}
}
(void) kte::syscall::Close(fd);
if (ok) {
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
err = std::string("rename failed: ") + std::strerror(errno);
ok = false;
}
}
if (!ok) {
(void) ::unlink(tmp_path.c_str());
return false;
}
best_effort_fsync_dir(path);
return true;
}
Buffer::Buffer(const std::string &path) Buffer::Buffer(const std::string &path)
{ {
std::string err; std::string err;
@@ -29,20 +215,22 @@ Buffer::Buffer(const std::string &path)
// Copy constructor/assignment: perform a deep copy of core fields; reinitialize undo for the new buffer. // Copy constructor/assignment: perform a deep copy of core fields; reinitialize undo for the new buffer.
Buffer::Buffer(const Buffer &other) Buffer::Buffer(const Buffer &other)
{ {
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_; rx_ = other.rx_;
nrows_ = other.nrows_; nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = other.rows_; rows_ = other.rows_;
filename_ = other.filename_; content_ = other.content_;
is_file_backed_ = other.is_file_backed_; rows_cache_dirty_ = other.rows_cache_dirty_;
dirty_ = other.dirty_; filename_ = other.filename_;
read_only_ = other.read_only_; is_file_backed_ = other.is_file_backed_;
mark_set_ = other.mark_set_; dirty_ = other.dirty_;
mark_curx_ = other.mark_curx_; read_only_ = other.read_only_;
mark_cury_ = other.mark_cury_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
// Copy syntax/highlighting flags // Copy syntax/highlighting flags
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
@@ -77,23 +265,25 @@ Buffer::operator=(const Buffer &other)
{ {
if (this == &other) if (this == &other)
return *this; return *this;
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_; rx_ = other.rx_;
nrows_ = other.nrows_; nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = other.rows_; rows_ = other.rows_;
filename_ = other.filename_; content_ = other.content_;
is_file_backed_ = other.is_file_backed_; rows_cache_dirty_ = other.rows_cache_dirty_;
dirty_ = other.dirty_; filename_ = other.filename_;
read_only_ = other.read_only_; is_file_backed_ = other.is_file_backed_;
mark_set_ = other.mark_set_; dirty_ = other.dirty_;
mark_curx_ = other.mark_curx_; read_only_ = other.read_only_;
mark_cury_ = other.mark_cury_; mark_set_ = other.mark_set_;
version_ = other.version_; mark_curx_ = other.mark_curx_;
syntax_enabled_ = other.syntax_enabled_; mark_cury_ = other.mark_cury_;
filetype_ = other.filetype_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// Recreate undo system for this instance // Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>(); undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_); undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
@@ -137,10 +327,12 @@ Buffer::Buffer(Buffer &&other) noexcept
undo_sys_(std::move(other.undo_sys_)) undo_sys_(std::move(other.undo_sys_))
{ {
// Move syntax/highlighting state // Move syntax/highlighting state
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_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
@@ -173,11 +365,12 @@ Buffer::operator=(Buffer &&other) noexcept
undo_sys_ = std::move(other.undo_sys_); undo_sys_ = std::move(other.undo_sys_);
// Move syntax/highlighting state // Move syntax/highlighting state
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_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
@@ -229,59 +422,66 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
mark_set_ = false; mark_set_ = false;
mark_curx_ = mark_cury_ = 0; mark_curx_ = mark_cury_ = 0;
// Empty PieceTable
content_.Clear();
rows_cache_dirty_ = true;
return true; return true;
} }
std::ifstream in(norm, std::ios::in | std::ios::binary); std::ifstream in(norm, std::ios::in | std::ios::binary);
if (!in) { if (!in) {
err = "Failed to open file: " + norm; err = "Failed to open file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false; return false;
} }
// Detect if file ends with a newline so we can preserve a final empty line // Read entire file into PieceTable as-is
// in our in-memory representation (mg-style semantics). std::string data;
bool ends_with_nl = false; in.seekg(0, std::ios::end);
{ if (!in) {
in.seekg(0, std::ios::end); err = "Failed to seek to end of file: " + norm;
std::streamoff sz = in.tellg(); kte::ErrorHandler::Instance().Error("Buffer", err, norm);
if (sz > 0) { return false;
in.seekg(-1, std::ios::end); }
char last = 0; auto sz = in.tellg();
in.read(&last, 1); if (sz < 0) {
ends_with_nl = (last == '\n'); err = "Failed to get file size: " + norm;
} else { kte::ErrorHandler::Instance().Error("Buffer", err, norm);
in.clear(); return false;
} }
// Rewind to start for line-by-line read if (sz > 0) {
in.clear(); data.resize(static_cast<std::size_t>(sz));
in.seekg(0, std::ios::beg); in.seekg(0, std::ios::beg);
} if (!in) {
err = "Failed to seek to beginning of file: " + norm;
rows_.clear(); kte::ErrorHandler::Instance().Error("Buffer", err, norm);
std::string line; return false;
while (std::getline(in, line)) {
// std::getline strips the '\n', keep raw line content only
// Handle potential Windows CRLF: strip trailing '\r'
if (!line.empty() && line.back() == '\r') {
line.pop_back();
} }
rows_.emplace_back(line); in.read(data.data(), static_cast<std::streamsize>(data.size()));
} if (!in && !in.eof()) {
err = "Failed to read file: " + norm;
// If the file ended with a newline and we didn't already get an kte::ErrorHandler::Instance().Error("Buffer", err, norm);
// empty final row from getline (e.g., when the last textual line return false;
// had content followed by '\n'), append an empty row to represent }
// the cursor position past the last newline. // Validate we read the expected number of bytes
if (ends_with_nl) { const std::streamsize bytes_read = in.gcount();
if (rows_.empty() || !rows_.back().empty()) { if (bytes_read != static_cast<std::streamsize>(data.size())) {
rows_.emplace_back(std::string()); err = "Partial read of file (expected " + std::to_string(data.size()) +
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
} }
} }
content_.Clear();
nrows_ = rows_.size(); if (!data.empty())
filename_ = norm; content_.Append(data.data(), data.size());
is_file_backed_ = true; rows_cache_dirty_ = true;
dirty_ = false; nrows_ = 0; // not used under PieceTable
filename_ = norm;
is_file_backed_ = true;
dirty_ = false;
RefreshOnDiskIdentity();
// Reset/initialize undo system for this loaded file // Reset/initialize undo system for this loaded file
if (!undo_tree_) if (!undo_tree_)
@@ -308,24 +508,18 @@ Buffer::Save(std::string &err) const
err = "Buffer is not file-backed; use SaveAs()"; err = "Buffer is not file-backed; use SaveAs()";
return false; return false;
} }
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc); const std::size_t sz = content_.Size();
if (!out) { const char *data = sz ? content_.Data() : nullptr;
err = "Failed to open for write: " + filename_; if (sz && !data) {
err = "Internal error: buffer materialization failed";
return false; return false;
} }
for (std::size_t i = 0; i < rows_.size(); ++i) { if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
const char *d = rows_[i].Data(); kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
std::size_t n = rows_[i].Size();
if (d && n)
out.write(d, static_cast<std::streamsize>(n));
if (i + 1 < rows_.size()) {
out.put('\n');
}
}
if (!out.good()) {
err = "Write error";
return false; return false;
} }
// Update observed on-disk identity after a successful save.
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
// Note: const method cannot change dirty_. Intentionally const to allow UI code // Note: const method cannot change dirty_. Intentionally const to allow UI code
// to decide when to flip dirty flag after successful save. // to decide when to flip dirty flag after successful save.
return true; return true;
@@ -354,29 +548,21 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path; out_path = path;
} }
// Write to the given path const std::size_t sz = content_.Size();
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc); const char *data = sz ? content_.Data() : nullptr;
if (!out) { if (sz && !data) {
err = "Failed to open for write: " + out_path; err = "Internal error: buffer materialization failed";
return false; return false;
} }
for (std::size_t i = 0; i < rows_.size(); ++i) { if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
const char *d = rows_[i].Data(); kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
std::size_t n = rows_[i].Size();
if (d && n)
out.write(d, static_cast<std::streamsize>(n));
if (i + 1 < rows_.size()) {
out.put('\n');
}
}
if (!out.good()) {
err = "Write error";
return false; return false;
} }
filename_ = out_path; filename_ = out_path;
is_file_backed_ = true; is_file_backed_ = true;
dirty_ = false; dirty_ = false;
RefreshOnDiskIdentity();
return true; return true;
} }
@@ -389,7 +575,7 @@ Buffer::AsString() const
if (this->Dirty()) { if (this->Dirty()) {
ss << "*"; ss << "*";
} }
ss << ">: " << rows_.size() << " lines"; ss << ">: " << content_.LineCount() << " lines";
return ss.str(); return ss.str();
} }
@@ -400,111 +586,166 @@ Buffer::insert_text(int row, int col, std::string_view text)
{ {
if (row < 0) if (row < 0)
row = 0; row = 0;
if (static_cast<std::size_t>(row) > rows_.size()) if (col < 0)
row = static_cast<int>(rows_.size()); col = 0;
if (rows_.empty()) const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
rows_.emplace_back(""); static_cast<std::size_t>(col));
if (static_cast<std::size_t>(row) >= rows_.size()) if (!text.empty()) {
rows_.emplace_back(""); content_.Insert(off, text.data(), text.size());
rows_cache_dirty_ = true;
auto y = static_cast<std::size_t>(row); if (swap_rec_)
auto x = static_cast<std::size_t>(col); swap_rec_->OnInsert(row, col, text);
if (x > rows_[y].size())
x = rows_[y].size();
std::string remain(text);
while (true) {
auto pos = remain.find('\n');
if (pos == std::string::npos) {
rows_[y].insert(x, remain);
break;
}
// Insert up to newline
std::string seg = remain.substr(0, pos);
rows_[y].insert(x, seg);
x += seg.size();
// Split line at x
std::string tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
y += 1;
x = 0;
remain.erase(0, pos + 1);
} }
// Do not set dirty here; UndoSystem will manage state/dirty externally
} }
// ===== Adapter helpers for PieceTable-backed Buffer =====
std::string_view
Buffer::GetLineView(std::size_t row) const
{
// Get byte range for the logical line and return a view into materialized data
auto range = content_.GetLineRange(row); // [start,end) in bytes
const char *base = content_.Data(); // materializes if needed
if (!base)
return std::string_view();
const std::size_t start = range.first;
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
return std::string_view(base + start, len);
}
void
Buffer::ensure_rows_cache() const
{
std::lock_guard<std::mutex> lock(buffer_mutex_);
if (!rows_cache_dirty_)
return;
rows_.clear();
const std::size_t lc = content_.LineCount();
rows_.reserve(lc);
for (std::size_t i = 0; i < lc; ++i) {
rows_.emplace_back(content_.GetLine(i));
}
// Keep nrows_ in sync for any legacy code that still reads it
const_cast<Buffer *>(this)->nrows_ = rows_.size();
rows_cache_dirty_ = false;
}
std::size_t
Buffer::content_LineCount_() const
{
return content_.LineCount();
}
#if defined(KTE_TESTS)
std::string
Buffer::BytesForTests() const
{
const std::size_t sz = content_.Size();
if (sz == 0)
return std::string();
const char *data = content_.Data();
if (!data)
return std::string();
return std::string(data, data + sz);
}
#endif
void void
Buffer::delete_text(int row, int col, std::size_t len) Buffer::delete_text(int row, int col, std::size_t len)
{ {
if (rows_.empty() || len == 0) if (len == 0)
return; return;
if (row < 0) if (row < 0)
row = 0; row = 0;
if (static_cast<std::size_t>(row) >= rows_.size()) if (col < 0)
return; col = 0;
const auto y = static_cast<std::size_t>(row);
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col));
std::size_t r = static_cast<std::size_t>(row);
std::size_t c = static_cast<std::size_t>(col);
std::size_t remaining = len; std::size_t remaining = len;
while (remaining > 0 && y < rows_.size()) { const std::size_t lc = content_.LineCount();
auto &line = rows_[y];
const std::size_t in_line = std::min<std::size_t>(remaining, line.size() - std::min(x, line.size())); while (remaining > 0 && r < lc) {
if (x < line.size() && in_line > 0) { const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
line.erase(x, in_line); const std::size_t L = line.size();
remaining -= in_line; if (c < L) {
const std::size_t take = std::min(remaining, L - c);
c += take;
remaining -= take;
} }
if (remaining == 0) if (remaining == 0)
break; break;
// If at or beyond end of line and there is a next line, join it (deleting the implied '\n') // Consume newline between lines as one char, if there is a next line
if (y + 1 < rows_.size()) { if (r + 1 < lc) {
line += rows_[y + 1]; remaining -= 1; // the newline
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1)); r += 1;
// deleting the newline consumes one virtual character c = 0;
if (remaining > 0) {
// Treat the newline as one deletion unit if len spans it
// We already joined, so nothing else to do here.
}
} else { } else {
break; // At last line and still remaining: delete to EOF
const std::size_t total = content_.Size();
const std::size_t actual = (total > start) ? (total - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
return;
} }
} }
// Compute end offset at (r,c)
std::size_t end = content_.LineColToByteOffset(r, c);
if (end > start) {
const std::size_t actual = end - start;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
}
} }
void void
Buffer::split_line(int row, const int col) Buffer::split_line(int row, const int col)
{ {
if (row < 0) { int c = col;
if (row < 0)
row = 0; row = 0;
} if (c < 0)
c = 0;
if (static_cast<std::size_t>(row) >= rows_.size()) { const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
rows_.resize(static_cast<std::size_t>(row) + 1); static_cast<std::size_t>(c));
} const char nl = '\n';
const auto y = static_cast<std::size_t>(row); content_.Insert(off, &nl, 1);
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size()); rows_cache_dirty_ = true;
const auto tail = rows_[y].substr(x); if (swap_rec_)
rows_[y].erase(x); swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
} }
void void
Buffer::join_lines(int row) Buffer::join_lines(int row)
{ {
if (row < 0) { if (row < 0)
row = 0; row = 0;
} std::size_t r = static_cast<std::size_t>(row);
if (r + 1 >= content_.LineCount())
const auto y = static_cast<std::size_t>(row);
if (y + 1 >= rows_.size()) {
return; return;
} const int col = static_cast<int>(content_.GetLine(r).size());
// Delete the newline between line r and r+1
rows_[y] += rows_[y + 1]; std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1)); // end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
content_.Delete(end_of_line, 1);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, 1);
} }
@@ -513,9 +754,18 @@ Buffer::insert_row(int row, const std::string_view text)
{ {
if (row < 0) if (row < 0)
row = 0; row = 0;
if (static_cast<std::size_t>(row) > rows_.size()) std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row), 0);
row = static_cast<int>(rows_.size()); if (!text.empty())
rows_.insert(rows_.begin() + row, Line(std::string(text))); content_.Insert(off, text.data(), text.size());
const char nl = '\n';
content_.Insert(off + text.size(), &nl, 1);
rows_cache_dirty_ = true;
if (swap_rec_) {
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
if (!text.empty())
swap_rec_->OnInsert(row, 0, text);
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
}
} }
@@ -524,9 +774,31 @@ Buffer::delete_row(int row)
{ {
if (row < 0) if (row < 0)
row = 0; row = 0;
if (static_cast<std::size_t>(row) >= rows_.size()) std::size_t r = static_cast<std::size_t>(row);
if (r >= content_.LineCount())
return; return;
rows_.erase(rows_.begin() + row); auto range = content_.GetLineRange(r); // [start,end)
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
const std::size_t start = range.first;
const std::size_t end = range.second;
const std::size_t actual = (end > start) ? (end - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, 0, actual);
}
void
Buffer::replace_all_bytes(const std::string_view bytes)
{
content_.Clear();
if (!bytes.empty())
content_.Append(bytes.data(), bytes.size());
rows_cache_dirty_ = true;
} }
@@ -542,4 +814,4 @@ const UndoSystem *
Buffer::Undo() const Buffer::Undo() const
{ {
return undo_sys_.get(); return undo_sys_.get();
} }

383
Buffer.h
View File

@@ -1,21 +1,80 @@
/* /*
* Buffer.h - editor buffer representing an open document * Buffer.h - editor buffer representing an open document
*
* Buffer is the central document model in kte. Each Buffer represents one open file
* or scratch document and manages:
*
* - Content storage: Uses PieceTable for efficient text operations
* - Cursor state: Current position (curx_, cury_), rendered column (rx_)
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
* - File backing: Optional association with a file on disk
* - Undo/Redo: Integrated UndoSystem for operation history
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
* - Swap/crash recovery: Integration with SwapRecorder for journaling
* - Dirty tracking: Modification state for save prompts
*
* Key concepts:
*
* 1. Cursor coordinates:
* - (curx_, cury_): Logical character position in the document
* - rx_: Rendered column accounting for tab expansion
*
* 2. File backing:
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
* - File identity tracking detects external modifications
*
* 3. Legacy Line wrapper:
* - Buffer::Line provides a string-like interface for legacy command code
* - New code should prefer direct PieceTable operations
* - See DEVELOPER_GUIDE.md for migration guidance
*
* 4. Content access:
* - Rows(): Materialized line cache (legacy, being phased out)
* - GetLineView(): Zero-copy line access via string_view (preferred)
* - Direct PieceTable access for new editing operations
*/ */
#ifndef KTE_BUFFER_H #pragma once
#define KTE_BUFFER_H
#include <algorithm>
#include <cstddef> #include <cstddef>
#include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
#include <unordered_set>
#include <vector> #include <vector>
#include <string_view> #include <string_view>
#include "AppendBuffer.h" #include "PieceTable.h"
#include "UndoSystem.h" #include "UndoSystem.h"
#include <cstdint> #include <cstdint>
#include <memory> #include "syntax/HighlighterEngine.h"
#include "HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
#include <mutex>
// Edit mode determines which font class is used for a buffer.
enum class EditMode { Code, Writing };
// Detect edit mode from a filename's extension.
inline EditMode
DetectEditMode(const std::string &filename)
{
std::string ext = std::filesystem::path(filename).extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
static const std::unordered_set<std::string> writing_exts = {
".txt", ".md", ".markdown", ".rst", ".org",
".tex", ".adoc", ".asciidoc",
};
if (writing_exts.count(ext))
return EditMode::Writing;
return EditMode::Code;
}
// Forward declaration for swap journal integration
namespace kte {
class SwapRecorder;
}
class Buffer { class Buffer {
@@ -38,6 +97,14 @@ public:
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
// External modification detection.
// Returns true if the file on disk differs from the last observed identity recorded
// on open/save.
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
// Refresh the stored on-disk identity to match current stat (used after open/save).
void RefreshOnDiskIdentity();
// Accessors // Accessors
[[nodiscard]] std::size_t Curx() const [[nodiscard]] std::size_t Curx() const
{ {
@@ -59,7 +126,7 @@ public:
[[nodiscard]] std::size_t Nrows() const [[nodiscard]] std::size_t Nrows() const
{ {
return nrows_; return content_LineCount_();
} }
@@ -75,7 +142,8 @@ public:
} }
// Line wrapper backed by AppendBuffer (GapBuffer/PieceTable) // Line wrapper used by legacy command paths.
// Keep this lightweight: store materialized bytes only for that line.
class Line { class Line {
public: public:
Line() = default; Line() = default;
@@ -104,119 +172,102 @@ public:
// capacity helpers // capacity helpers
void Clear() void Clear()
{ {
buf_.Clear(); s_.clear();
} }
// size/access // size/access
[[nodiscard]] std::size_t size() const [[nodiscard]] std::size_t size() const
{ {
return buf_.Size(); return s_.size();
} }
[[nodiscard]] bool empty() const [[nodiscard]] bool empty() const
{ {
return size() == 0; return s_.empty();
} }
// read-only raw view // read-only raw view
[[nodiscard]] const char *Data() const [[nodiscard]] const char *Data() const
{ {
return buf_.Data(); return s_.data();
} }
[[nodiscard]] std::size_t Size() const [[nodiscard]] std::size_t Size() const
{ {
return buf_.Size(); return s_.size();
} }
// element access (read-only) // element access (read-only)
[[nodiscard]] char operator[](std::size_t i) const [[nodiscard]] char operator[](std::size_t i) const
{ {
const char *d = buf_.Data(); return (i < s_.size()) ? s_[i] : '\0';
return (i < buf_.Size() && d) ? d[i] : '\0';
} }
// conversions // conversions
explicit operator std::string() const explicit operator std::string() const
{ {
return {buf_.Data() ? buf_.Data() : "", buf_.Size()}; return s_;
} }
// string-like API used by command/renderer layers (implemented via materialization for now) // string-like API used by command/renderer layers (implemented via materialization for now)
[[nodiscard]] std::string substr(std::size_t pos) const [[nodiscard]] std::string substr(std::size_t pos) const
{ {
const std::size_t n = buf_.Size(); return pos < s_.size() ? s_.substr(pos) : std::string();
if (pos >= n)
return {};
return {buf_.Data() + pos, n - pos};
} }
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const [[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
{ {
const std::size_t n = buf_.Size(); return pos < s_.size() ? s_.substr(pos, len) : std::string();
if (pos >= n)
return {};
const std::size_t take = (pos + len > n) ? (n - pos) : len;
return {buf_.Data() + pos, take};
} }
// minimal find() to support search within a line // minimal find() to support search within a line
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const [[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
{ {
// Materialize to std::string for now; Line is backed by AppendBuffer return s_.find(needle, pos);
const auto s = static_cast<std::string>(*this);
return s.find(needle, pos);
} }
void erase(std::size_t pos) void erase(std::size_t pos)
{ {
// erase to end if (pos < s_.size())
material_edit([&](std::string &s) { s_.erase(pos);
if (pos < s.size())
s.erase(pos);
});
} }
void erase(std::size_t pos, std::size_t len) void erase(std::size_t pos, std::size_t len)
{ {
material_edit([&](std::string &s) { if (pos < s_.size())
if (pos < s.size()) s_.erase(pos, len);
s.erase(pos, len);
});
} }
void insert(std::size_t pos, const std::string &seg) void insert(std::size_t pos, const std::string &seg)
{ {
material_edit([&](std::string &s) { if (pos > s_.size())
if (pos > s.size()) pos = s_.size();
pos = s.size(); s_.insert(pos, seg);
s.insert(pos, seg);
});
} }
Line &operator+=(const Line &other) Line &operator+=(const Line &other)
{ {
buf_.Append(other.buf_.Data(), other.buf_.Size()); s_ += other.s_;
return *this; return *this;
} }
Line &operator+=(const std::string &s) Line &operator+=(const std::string &s)
{ {
buf_.Append(s.data(), s.size()); s_ += s;
return *this; return *this;
} }
@@ -230,37 +281,47 @@ public:
private: private:
void assign_from(const std::string &s) void assign_from(const std::string &s)
{ {
buf_.Clear(); s_ = s;
if (!s.empty())
buf_.Append(s.data(), s.size());
} }
template<typename F> std::string s_;
void material_edit(F fn)
{
std::string tmp = static_cast<std::string>(*this);
fn(tmp);
assign_from(tmp);
}
AppendBuffer buf_;
}; };
[[nodiscard]] const std::vector<Line> &Rows() const [[nodiscard]] const std::vector<Line> &Rows() const
{ {
ensure_rows_cache();
return rows_; return rows_;
} }
[[nodiscard]] std::vector<Line> &Rows() [[nodiscard]] std::vector<Line> &Rows()
{ {
ensure_rows_cache();
return rows_; return rows_;
} }
// Lightweight, lazy per-line accessors that avoid materializing all rows.
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
[[nodiscard]] std::string GetLineString(std::size_t row) const
{
return content_.GetLine(row);
}
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
{
return content_.GetLineRange(row);
}
// Zero-copy view of a line. Points into the materialized backing store; becomes
// invalid after subsequent edits. Use immediately.
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
[[nodiscard]] const std::string &Filename() const [[nodiscard]] const std::string &Filename() const
{ {
return filename_; return filename_;
@@ -372,25 +433,157 @@ public:
} }
// Visual-line selection support (multicursor/visual mode)
void VisualLineClear()
{
visual_line_active_ = false;
}
void VisualLineStart()
{
visual_line_active_ = true;
visual_line_anchor_y_ = cury_;
visual_line_active_y_ = cury_;
}
void VisualLineToggle()
{
if (visual_line_active_)
VisualLineClear();
else
VisualLineStart();
}
[[nodiscard]] bool VisualLineActive() const
{
return visual_line_active_;
}
void VisualLineSetActiveY(std::size_t y)
{
visual_line_active_y_ = y;
}
[[nodiscard]] std::size_t VisualLineStartY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
}
[[nodiscard]] std::size_t VisualLineEndY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
}
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
// cursor "spot" (Curx clamped to each line length), not the entire line.
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
{
if (!visual_line_active_)
return false;
if (y < VisualLineStartY() || y > VisualLineEndY())
return false;
std::string_view ln = GetLineView(y);
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
if (!ln.empty() && ln.back() == '\n')
ln.remove_suffix(1);
const std::size_t spot = std::min(Curx(), ln.size());
return sx == spot;
}
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)
[[nodiscard]] std::uint64_t Version() const { return version_; } [[nodiscard]] std::uint64_t Version() const
{
return version_;
}
void SetSyntaxEnabled(bool on) { syntax_enabled_ = on; }
[[nodiscard]] bool SyntaxEnabled() const { return syntax_enabled_; }
void SetFiletype(const std::string &ft) { filetype_ = ft; } // Edit mode (code vs writing)
[[nodiscard]] const std::string &Filetype() const { return filetype_; } [[nodiscard]] EditMode GetEditMode() const
{
return edit_mode_;
}
void SetEditMode(EditMode m)
{
edit_mode_ = m;
}
void ToggleEditMode()
{
edit_mode_ = (edit_mode_ == EditMode::Code)
? EditMode::Writing
: EditMode::Code;
}
void SetSyntaxEnabled(bool on)
{
syntax_enabled_ = on;
}
[[nodiscard]] bool SyntaxEnabled() const
{
return syntax_enabled_;
}
void SetFiletype(const std::string &ft)
{
filetype_ = ft;
}
[[nodiscard]] const std::string &Filetype() const
{
return filetype_;
}
[[nodiscard]] kte::HighlighterEngine *Highlighter()
{
return highlighter_.get();
}
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
{
return highlighter_.get();
}
kte::HighlighterEngine *Highlighter() { return highlighter_.get(); }
const kte::HighlighterEngine *Highlighter() const { return highlighter_.get(); }
void EnsureHighlighter() void EnsureHighlighter()
{ {
if (!highlighter_) highlighter_ = std::make_unique<kte::HighlighterEngine>(); if (!highlighter_)
highlighter_ = std::make_unique<kte::HighlighterEngine>();
} }
// Swap journal integration (set by Editor)
void SetSwapRecorder(kte::SwapRecorder *rec)
{
swap_rec_ = rec;
}
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
{
return swap_rec_;
}
// Raw, low-level editing APIs used by UndoSystem apply(). // Raw, low-level editing APIs used by UndoSystem apply().
// These must NOT trigger undo recording. They also do not move the cursor. // These must NOT trigger undo recording. They also do not move the cursor.
void insert_text(int row, int col, std::string_view text); void insert_text(int row, int col, std::string_view text);
@@ -405,34 +598,76 @@ public:
void delete_row(int row); void delete_row(int row);
// Replace the entire buffer content with raw bytes.
// Intended for crash recovery (swap replay) and test harnesses.
// This does not trigger swap or undo recording.
void replace_all_bytes(std::string_view bytes);
// Undo system accessors (created per-buffer) // Undo system accessors (created per-buffer)
UndoSystem *Undo(); [[nodiscard]] UndoSystem *Undo();
[[nodiscard]] const UndoSystem *Undo() const; [[nodiscard]] const UndoSystem *Undo() const;
#if defined(KTE_TESTS)
// Test-only: return the raw buffer bytes (including newlines) as a string.
[[nodiscard]] std::string BytesForTests() const;
#endif
private: private:
struct FileIdentity {
bool valid = false;
std::uint64_t mtime_ns = 0;
std::uint64_t size = 0;
std::uint64_t dev = 0;
std::uint64_t ino = 0;
};
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
mutable FileIdentity on_disk_identity_{};
// State mirroring original C struct (without undo_tree) // State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded) std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
std::vector<Line> rows_; // buffer rows (without trailing newlines) mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
// PieceTable is the source of truth.
PieceTable content_{};
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
// Helper to rebuild rows_ from content_
void ensure_rows_cache() const;
// Helper to query content_.LineCount() while keeping header minimal
std::size_t content_LineCount_() const;
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool dirty_ = false; bool dirty_ = false;
bool read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0; std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state // Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_; std::unique_ptr<struct UndoTree> undo_tree_;
std::unique_ptr<UndoSystem> undo_sys_; std::unique_ptr<UndoSystem> undo_sys_;
// Edit mode (code vs writing)
EditMode edit_mode_ = EditMode::Code;
// Syntax/highlighting state // Syntax/highlighting state
std::uint64_t version_ = 0; // increment on edits std::uint64_t version_ = 0; // increment on edits
bool syntax_enabled_ = true; bool syntax_enabled_ = true;
std::string filetype_; std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
}; // Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr;
#endif // KTE_BUFFER_H mutable std::mutex buffer_mutex_;
};

View File

@@ -3,17 +3,28 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.2.0") set(KTE_VERSION "1.10.1")
# 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.
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.") set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.") set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON) 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)
# Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
if (ENABLE_ASAN)
message(STATUS "ASan enabled")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
# Ensure the sanitizer is linked too (especially important on some platforms)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
endif ()
if (CMAKE_HOST_UNIX) if (CMAKE_HOST_UNIX)
message(STATUS "Build system is POSIX.") message(STATUS "Build system is POSIX.")
@@ -21,6 +32,10 @@ else ()
message(STATUS "Build system is NOT POSIX.") message(STATUS "Build system is NOT POSIX.")
endif () endif ()
add_compile_options(
)
if (MSVC) if (MSVC)
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>") add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
else () else ()
@@ -28,8 +43,12 @@ else ()
"-Wall" "-Wall"
"-Wextra" "-Wextra"
"-Werror" "-Werror"
"-pedantic"
"-Wno-unused-function"
"-Wno-unused-parameter"
"$<$<CONFIG:RELEASE>:-O2>"
"$<$<CONFIG:DEBUG>:-g>" "$<$<CONFIG:DEBUG>:-g>"
"$<$<CONFIG:RELEASE>:-O2>") )
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++") add_compile_options("-stdlib=libc++")
else () else ()
@@ -44,24 +63,88 @@ endif ()
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}") message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
if (${BUILD_GUI}) if (BUILD_GUI)
include(cmake/imgui.cmake) include(cmake/imgui.cmake)
endif () endif ()
# NCurses for terminal mode # NCurses for terminal mode
set(CURSES_NEED_NCURSES) set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE) set(CURSES_NEED_WIDE TRUE)
find_package(Curses REQUIRED) find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR}) include_directories(${CURSES_INCLUDE_DIR})
# On Alpine Linux, CMake's FindCurses looks in wrong paths
# Manually find the correct ncurses library
if (EXISTS "/etc/alpine-release")
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
set(CURSES_LIBRARIES ${NCURSESW_LIB})
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
endif ()
set(SYNTAX_SOURCES
syntax/GoHighlighter.cc
syntax/CppHighlighter.cc
syntax/JsonHighlighter.cc
syntax/ErlangHighlighter.cc
syntax/MarkdownHighlighter.cc
syntax/TreeSitterHighlighter.cc
syntax/LispHighlighter.cc
syntax/HighlighterEngine.cc
syntax/RustHighlighter.cc
syntax/HighlighterRegistry.cc
syntax/SqlHighlighter.cc
syntax/NullHighlighter.cc
syntax/ForthHighlighter.cc
syntax/PythonHighlighter.cc
syntax/ShellHighlighter.cc
)
if (KTE_ENABLE_TREESITTER)
list(APPEND SYNTAX_SOURCES
TreeSitterHighlighter.cc)
endif ()
set(FONT_SOURCES
fonts/Font.cc
fonts/FontRegistry.cc
)
if (BUILD_GUI)
set(GUI_SOURCES
GUIConfig.cc
)
if (KTE_USE_QT)
find_package(Qt6 COMPONENTS Widgets REQUIRED)
set(GUI_SOURCES
${GUI_SOURCES}
QtFrontend.cc
QtInputHandler.cc
QtRenderer.cc
)
# Expose preprocessor switch so sources can exclude ImGui-specific code
add_compile_definitions(KTE_USE_QT)
else ()
set(GUI_SOURCES
${GUI_SOURCES}
${FONT_SOURCES}
ImGuiFrontend.cc
ImGuiInputHandler.cc
ImGuiRenderer.cc
)
endif ()
endif ()
set(COMMON_SOURCES set(COMMON_SOURCES
GapBuffer.cc
PieceTable.cc PieceTable.cc
Buffer.cc Buffer.cc
Editor.cc Editor.cc
Command.cc Command.cc
HelpText.cc HelpText.cc
KKeymap.cc KKeymap.cc
Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
TerminalInputHandler.cc TerminalInputHandler.cc
TerminalRenderer.cc TerminalRenderer.cc
TerminalFrontend.cc TerminalFrontend.cc
@@ -71,33 +154,85 @@ set(COMMON_SOURCES
UndoNode.cc UndoNode.cc
UndoTree.cc UndoTree.cc
UndoSystem.cc UndoSystem.cc
HighlighterEngine.cc
CppHighlighter.cc ${SYNTAX_SOURCES}
HighlighterRegistry.cc )
NullHighlighter.cc
JsonHighlighter.cc set(SYNTAX_HEADERS
MarkdownHighlighter.cc syntax/GoHighlighter.h
ShellHighlighter.cc syntax/HighlighterEngine.h
GoHighlighter.cc syntax/ShellHighlighter.h
PythonHighlighter.cc syntax/MarkdownHighlighter.h
RustHighlighter.cc syntax/LispHighlighter.h
LispHighlighter.cc syntax/SqlHighlighter.h
syntax/ForthHighlighter.h
syntax/JsonHighlighter.h
syntax/TreeSitterHighlighter.h
syntax/NullHighlighter.h
syntax/CppHighlighter.h
syntax/ErlangHighlighter.h
syntax/LanguageHighlighter.h
syntax/RustHighlighter.h
syntax/PythonHighlighter.h
) )
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
list(APPEND COMMON_SOURCES list(APPEND THEME_HEADERS
TreeSitterHighlighter.cc) TreeSitterHighlighter.h)
endif () endif ()
set(THEME_HEADERS
themes/ThemeHelpers.h
themes/EInk.h
themes/Gruvbox.h
themes/Solarized.h
themes/Plan9.h
themes/Nord.h
themes/Everforest.h
themes/KanagawaPaper.h
themes/LCARS.h
themes/OldBook.h
themes/Amber.h
themes/Orbital.h
themes/WeylandYutani.h
themes/Zenburn.h
)
set(FONT_HEADERS
fonts/Font.h
fonts/FontRegistry.h
fonts/FontRegistry.h
fonts/FontList.h
fonts/B612Mono.h
fonts/BrassMono.h
fonts/CrimsonPro.h
fonts/ETBook.h
fonts/BrassMonoCode.h
fonts/FiraCode.h
fonts/Go.h
fonts/IBMPlexMono.h
fonts/Idealist.h
fonts/Inconsolata.h
fonts/InconsolataExpanded.h
fonts/Iosevka.h
fonts/IosevkaExtended.h
fonts/ShareTech.h
fonts/SpaceMono.h
fonts/Spectral.h
fonts/Syne.h
fonts/Triplicate.h
fonts/Unispace.h
fonts/BerkeleyMono.h
)
set(COMMON_HEADERS set(COMMON_HEADERS
GapBuffer.h
PieceTable.h PieceTable.h
Buffer.h Buffer.h
Editor.h Editor.h
AppendBuffer.h
Command.h Command.h
HelpText.h HelpText.h
KKeymap.h KKeymap.h
Swap.h
InputHandler.h InputHandler.h
TerminalInputHandler.h TerminalInputHandler.h
Renderer.h Renderer.h
@@ -111,23 +246,33 @@ set(COMMON_HEADERS
UndoTree.h UndoTree.h
UndoSystem.h UndoSystem.h
Highlight.h Highlight.h
LanguageHighlighter.h
HighlighterEngine.h ${SYNTAX_HEADERS}
CppHighlighter.h
HighlighterRegistry.h
NullHighlighter.h
JsonHighlighter.h
MarkdownHighlighter.h
ShellHighlighter.h
GoHighlighter.h
PythonHighlighter.h
RustHighlighter.h
LispHighlighter.h
) )
if (KTE_ENABLE_TREESITTER) if (BUILD_GUI)
list(APPEND COMMON_HEADERS set(GUI_HEADERS
TreeSitterHighlighter.h) GUIConfig.h
)
if (KTE_USE_QT)
set(GUI_HEADERS
${GUI_HEADERS}
QtFrontend.h
QtInputHandler.h
QtRenderer.h
)
else ()
set(GUI_HEADERS
${GUI_HEADERS}
${THEME_HEADERS}
${FONT_HEADERS}
ImGuiFrontend.h
ImGuiInputHandler.h
ImGuiRenderer.h
fonts/BerkeleyMono.h
)
endif ()
endif () endif ()
# kte (terminal-first) executable # kte (terminal-first) executable
@@ -137,15 +282,17 @@ add_executable(kte
${COMMON_HEADERS} ${COMMON_HEADERS}
) )
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
if (KTE_UNDO_DEBUG) if (KTE_UNDO_DEBUG)
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
endif () endif ()
target_link_libraries(kte ${CURSES_LIBRARIES}) target_link_libraries(kte ${CURSES_LIBRARIES})
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte PRIVATE -static)
endif ()
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
# Users can provide their own tree-sitter include/lib via cache variables # Users can provide their own tree-sitter include/lib via cache variables
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory") set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
@@ -166,65 +313,119 @@ install(TARGETS kte
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
if (BUILD_TESTS) if (BUILD_TESTS)
# test_undo executable for testing undo/redo system # Unified unit test runner
add_executable(test_undo add_executable(kte_tests
test_undo.cc tests/TestRunner.cc
${COMMON_SOURCES} tests/Test.h
${COMMON_HEADERS} tests/TestHarness.h
tests/test_daily_driver_harness.cc
tests/test_daily_workflows.cc
tests/test_buffer_io.cc
tests/test_buffer_rows.cc
tests/test_command_semantics.cc
tests/test_kkeymap.cc
tests/test_swap_recorder.cc
tests/test_swap_writer.cc
tests/test_swap_replay.cc
tests/test_swap_edge_cases.cc
tests/test_swap_recovery_prompt.cc
tests/test_swap_cleanup.cc
tests/test_swap_cleanup2.cc
tests/test_swap_git_editor.cc
tests/test_piece_table.cc
tests/test_search.cc
tests/test_search_replace_flow.cc
tests/test_reflow_paragraph.cc
tests/test_reflow_indented_bullets.cc
tests/test_undo.cc
tests/test_visual_line_mode.cc
tests/test_benchmarks.cc
tests/test_migration_coverage.cc
tests/test_smart_newline.cc
tests/test_reflow_undo.cc
# minimal engine sources required by Buffer
PieceTable.cc
Buffer.cc
Editor.cc
Command.cc
HelpText.cc
Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
KKeymap.cc
SwapRecorder.h
OptimizedSearch.cc
UndoNode.cc
UndoTree.cc
UndoSystem.cc
${SYNTAX_SOURCES}
) )
if (KTE_USE_PIECE_TABLE) # Allow test-only introspection hooks (guarded in headers) without affecting production builds.
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1) target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
endif ()
if (KTE_UNDO_DEBUG) # Allow tests to include project headers like "Buffer.h"
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1) target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
endif ()
# Keep tests free of ncurses/GUI deps
target_link_libraries(test_undo ${CURSES_LIBRARIES})
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR) if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR}) target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
endif () endif ()
if (TREESITTER_LIBRARY) if (TREESITTER_LIBRARY)
target_link_libraries(test_undo ${TREESITTER_LIBRARY}) target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
endif () endif ()
endif () endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte_tests PRIVATE -static)
endif ()
endif () endif ()
if (${BUILD_GUI}) if (BUILD_GUI)
target_sources(kte PRIVATE # ImGui::CreateContext();
Font.h # ImGuiIO& io = ImGui::GetIO();
GUIConfig.cc
GUIConfig.h # // Set custom ini filename path to ~/.config/kte/imgui.ini
GUIRenderer.cc # if (const char* home = std::getenv("HOME")) {
GUIRenderer.h # static std::string ini_path = std::string(home) + "/.config/kte/imgui.ini";
GUIInputHandler.cc # io.IniFilename = ini_path.c_str();
GUIInputHandler.h # }
GUIFrontend.cc
GUIFrontend.h) # io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1) # io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
target_link_libraries(kte imgui) # Do not enable GUI in the terminal-first 'kte' binary; GUI is built as separate 'kge'.
# This avoids referencing GUI classes from kte and keeps dependencies minimal.
# kge (GUI-first) executable # kge (GUI-first) executable
add_executable(kge add_executable(kge
main.cc main.cc
${COMMON_SOURCES} ${COMMON_SOURCES}
${GUI_SOURCES}
${COMMON_HEADERS} ${COMMON_HEADERS}
GUIConfig.cc ${GUI_HEADERS}
GUIConfig.h
GUIRenderer.cc )
GUIRenderer.h
GUIInputHandler.cc
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE}) target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_USE_QT)
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
endif ()
if (KTE_UNDO_DEBUG) if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif () endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui) if (KTE_USE_QT)
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
else ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kge PRIVATE -static)
endif ()
# On macOS, build kge as a proper .app bundle # On macOS, build kge as a proper .app bundle
if (APPLE) if (APPLE)
@@ -244,12 +445,18 @@ if (${BUILD_GUI})
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist ${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
@ONLY) @ONLY)
# Ensure proper macOS bundle properties and RPATH so our bundled
# frameworks are preferred over system/Homebrew ones.
set_target_properties(kge PROPERTIES set_target_properties(kge PROPERTIES
MACOSX_BUNDLE TRUE MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID} MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
MACOSX_BUNDLE_BUNDLE_NAME "kge" MACOSX_BUNDLE_BUNDLE_NAME "kge"
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE} MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist") MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
# Prefer the app's bundled frameworks at runtime
INSTALL_RPATH "@executable_path/../Frameworks"
BUILD_WITH_INSTALL_RPATH TRUE
)
add_dependencies(kge kte) add_dependencies(kge kte)
add_custom_command(TARGET kge POST_BUILD add_custom_command(TARGET kge POST_BUILD
@@ -273,4 +480,20 @@ if (${BUILD_GUI})
# Install kge man page only when GUI is built # Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons) install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
# Optional post-build bundle fixup (can also be run from scripts).
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
if (APPLE AND TARGET kge)
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
if (IS_BUNDLE)
add_custom_target(kge_fixup_bundle ALL
COMMAND ${CMAKE_COMMAND}
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
VERBATIM)
add_dependencies(kge_fixup_bundle kge)
endif ()
endif ()
endif () endif ()

116
CONFIG.md Normal file
View File

@@ -0,0 +1,116 @@
# kge Configuration
kge loads configuration from `~/.config/kte/kge.toml`. If no TOML file is
found, it falls back to the legacy `kge.ini` format.
## TOML Format
```toml
[window]
fullscreen = false
columns = 80
rows = 42
[font]
# Default font and size
name = "default"
size = 18.0
# Font used in code mode (monospace)
code = "default"
# Font used in writing mode (proportional)
writing = "crimsonpro"
[appearance]
theme = "nord"
# "dark" or "light" for themes with variants
background = "dark"
[editor]
syntax = true
```
## Sections
### `[window]`
| Key | Type | Default | Description |
|--------------|------|---------|---------------------------------|
| `fullscreen` | bool | false | Start in fullscreen mode |
| `columns` | int | 80 | Initial window width in columns |
| `rows` | int | 42 | Initial window height in rows |
### `[font]`
| Key | Type | Default | Description |
|-----------|--------|--------------|------------------------------------------|
| `name` | string | "default" | Default font loaded at startup |
| `size` | float | 18.0 | Font size in pixels |
| `code` | string | "default" | Font for code mode (monospace) |
| `writing` | string | "crimsonpro" | Font for writing mode (proportional) |
### `[appearance]`
| Key | Type | Default | Description |
|--------------|--------|---------|-----------------------------------------|
| `theme` | string | "nord" | Color theme |
| `background` | string | "dark" | Background mode: "dark" or "light" |
### `[editor]`
| Key | Type | Default | Description |
|----------|------|---------|------------------------------|
| `syntax` | bool | true | Enable syntax highlighting |
## Edit Modes
kge has two edit modes that control which font is used:
- **code** — Uses the monospace font (`font.code`). Default for source files.
- **writing** — Uses the proportional font (`font.writing`). Auto-detected
for `.txt`, `.md`, `.markdown`, `.rst`, `.org`, `.tex`, `.adoc`, and
`.asciidoc` files.
Toggle with `C-k m` or `: mode [code|writing]`.
## Available Fonts
### Monospace
b612, berkeley, berkeley-bold, brassmono, brassmono-bold, brassmonocode,
brassmonocode-bold, fira, go, ibm, idealist, inconsolata, inconsolataex,
iosevka, iosevkaex, sharetech, space, syne, triplicate, unispace
### Proportional (Serif)
crimsonpro, etbook, spectral
## Available Themes
amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord,
old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
Themes with light/dark variants: eink, gruvbox, leuchtturm, old-book,
solarized. Set `background = "light"` or use `: background light`.
## Migrating from kge.ini
If you have an existing `kge.ini`, kge will still read it but prints a
notice to stderr suggesting migration. To migrate, create `kge.toml` in the
same directory (`~/.config/kte/`) using the format above. The TOML file
takes priority when both exist.
The INI keys map to TOML as follows:
| INI key | TOML equivalent |
|---------------|--------------------------|
| `fullscreen` | `window.fullscreen` |
| `columns` | `window.columns` |
| `rows` | `window.rows` |
| `font` | `font.name` |
| `font_size` | `font.size` |
| `theme` | `appearance.theme` |
| `background` | `appearance.background` |
| `syntax` | `editor.syntax` |
New keys `font.code` and `font.writing` have no INI equivalent (the INI
parser accepts `code_font` and `writing_font` if needed).

2262
Command.cc

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
/* /*
* Command.h - command model and registry for editor actions * Command.h - command model and registry for editor actions
*/ */
#ifndef KTE_COMMAND_H #pragma once
#define KTE_COMMAND_H
#include <functional> #include <functional>
#include <string> #include <string>
@@ -28,14 +27,18 @@ enum class CommandId {
SearchReplace, // begin search & replace (two-step prompt) SearchReplace, // begin search & replace (two-step prompt)
OpenFileStart, // begin open-file prompt OpenFileStart, // begin open-file prompt
VisualFilePickerToggle, VisualFilePickerToggle,
// GUI-only: toggle/show a visual font selector dialog
VisualFontPickerToggle,
// Buffers // Buffers
BufferSwitchStart, // begin buffer switch prompt BufferSwitchStart, // begin buffer switch prompt
BufferNew, // create a new empty, unnamed buffer (C-k i)
BufferClose, BufferClose,
BufferNext, BufferNext,
BufferPrev, BufferPrev,
// Editing // Editing
InsertText, // arg: text to insert at cursor (UTF-8, no newlines) InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
Newline, // insert a newline at cursor Newline, // insert a newline at cursor
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
Backspace, // delete char before cursor (may join lines) Backspace, // delete char before cursor (may join lines)
DeleteChar, // delete char at cursor (may join lines) DeleteChar, // delete char at cursor (may join lines)
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
@@ -45,6 +48,7 @@ enum class CommandId {
MoveFileStart, // move to beginning of file MoveFileStart, // move to beginning of file
MoveFileEnd, // move to end of file MoveFileEnd, // move to end of file
ToggleMark, // toggle mark at cursor ToggleMark, // toggle mark at cursor
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
JumpToMark, // jump to mark, set mark to previous cursor JumpToMark, // jump to mark, set mark to previous cursor
KillRegion, // kill region between mark and cursor (to kill ring) KillRegion, // kill region between mark and cursor (to kill ring)
CopyRegion, // copy region to kill ring (Alt-w) CopyRegion, // copy region to kill ring (Alt-w)
@@ -58,6 +62,8 @@ enum class CommandId {
MoveEnd, MoveEnd,
PageUp, PageUp,
PageDown, PageDown,
ScrollUp, // scroll viewport up (towards beginning) without moving cursor
ScrollDown, // scroll viewport down (towards end) without moving cursor
WordPrev, WordPrev,
WordNext, WordNext,
DeleteWordPrev, // delete previous word (ESC BACKSPACE) DeleteWordPrev, // delete previous word (ESC BACKSPACE)
@@ -89,15 +95,30 @@ enum class CommandId {
ShowHelp, // open +HELP+ buffer with manual text (C-k h) ShowHelp, // open +HELP+ buffer with manual text (C-k h)
// Meta // Meta
UnknownKCommand, // arg: single character that was not recognized after C-k UnknownKCommand, // arg: single character that was not recognized after C-k
UnknownEscCommand, // invalid ESC (meta) command; show status and exit escape mode
// Generic command prompt // Generic command prompt
CommandPromptStart, // begin generic command prompt (C-k ;) CommandPromptStart, // begin generic command prompt (C-k ;)
// Theme by name // Theme by name
ThemeSetByName, ThemeSetByName,
// Font by name (GUI)
FontSetByName,
// Font size (GUI)
FontSetSize,
// Background mode (GUI) // Background mode (GUI)
BackgroundSet, BackgroundSet,
// Syntax highlighting // Syntax highlighting
Syntax, // ":syntax on|off|reload" Syntax, // ":syntax on|off|reload"
SetOption, // generic ":set key=value" (v1: filetype=<lang>) SetOption, // generic ":set key=value" (v1: filetype=<lang>)
// Viewport control
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
// GUI: open a new editor window sharing the same buffer list
NewWindow,
// GUI: font size controls
FontZoomIn,
FontZoomOut,
FontZoomReset,
// Edit mode (code/writing)
ToggleEditMode,
}; };
@@ -123,6 +144,9 @@ struct Command {
CommandHandler handler; CommandHandler handler;
// Public commands are exposed in the ": " prompt (C-k ;) // Public commands are exposed in the ": " prompt (C-k ;)
bool isPublic = false; bool isPublic = false;
// Whether this command should consume and honor a universal argument repeat count.
// Default true per issue request; authors can turn off per-command.
bool repeatable = true;
}; };
@@ -149,6 +173,4 @@ void InstallDefaultCommands();
// Returns true if the command executed successfully. // Returns true if the command executed successfully.
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0); bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0); bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
#endif // KTE_COMMAND_H

View File

@@ -1,170 +0,0 @@
#include "CppHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
CppHighlighter::CppHighlighter()
{
const char *kw[] = {
"if","else","for","while","do","switch","case","default","break","continue",
"return","goto","struct","class","namespace","using","template","typename",
"public","private","protected","virtual","override","const","constexpr","auto",
"static","inline","operator","new","delete","try","catch","throw","friend",
"enum","union","extern","volatile","mutable","noexcept","sizeof","this"
};
for (auto s: kw) keywords_.insert(s);
const char *types[] = {
"int","long","short","char","signed","unsigned","float","double","void",
"bool","wchar_t","size_t","ptrdiff_t","uint8_t","uint16_t","uint32_t","uint64_t",
"int8_t","int16_t","int32_t","int64_t"
};
for (auto s: types) types_.insert(s);
}
bool CppHighlighter::is_ident_start(char c) { return std::isalpha(static_cast<unsigned char>(c)) || c == '_'; }
bool CppHighlighter::is_ident_char(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
void CppHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
// Stateless entry simply delegates to stateful with a clean previous state
StatefulHighlighter::LineState prev;
(void)HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState CppHighlighter::HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
if (s.empty()) return state;
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
int n = static_cast<int>(s.size());
int bol = 0; while (bol < n && (s[bol] == ' ' || s[bol] == '\t')) ++bol;
int i = 0;
// Continue multi-line raw string from previous line
if (state.in_raw_string) {
std::string needle = ")" + state.raw_delim + "\"";
auto pos = s.find(needle);
if (pos == std::string::npos) {
push(0, n, TokenKind::String);
state.in_raw_string = true;
return state;
} else {
int end = static_cast<int>(pos + needle.size());
push(0, end, TokenKind::String);
i = end;
state.in_raw_string = false;
state.raw_delim.clear();
}
}
// Continue multi-line block comment from previous line
if (state.in_block_comment) {
int j = i;
while (i + 1 < n) {
if (s[i] == '*' && s[i+1] == '/') { i += 2; push(j, i, TokenKind::Comment); state.in_block_comment = false; break; }
++i;
}
if (state.in_block_comment) { push(j, n, TokenKind::Comment); return state; }
}
while (i < n) {
char c = s[i];
// Preprocessor at beginning of line (after leading whitespace)
if (i == bol && c == '#') { push(0, n, TokenKind::Preproc); break; }
// Whitespace
if (c == ' ' || c == '\t') {
int j = i+1; while (j < n && (s[j] == ' ' || s[j] == '\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue;
}
// Line comment
if (c == '/' && i+1 < n && s[i+1] == '/') { push(i, n, TokenKind::Comment); break; }
// Block comment
if (c == '/' && i+1 < n && s[i+1] == '*') {
int j = i+2;
bool closed = false;
while (j + 1 <= n) {
if (j + 1 < n && s[j] == '*' && s[j+1] == '/') { j += 2; closed = true; break; }
++j;
}
if (closed) { push(i, j, TokenKind::Comment); i = j; continue; }
// Spill to next lines
push(i, n, TokenKind::Comment);
state.in_block_comment = true;
return state;
}
// Raw string start: very simple detection: R"delim(
if (c == 'R' && i+1 < n && s[i+1] == '"') {
int k = i + 2;
std::string delim;
while (k < n && s[k] != '(') { delim.push_back(s[k]); ++k; }
if (k < n && s[k] == '(') {
int body_start = k + 1;
std::string needle = ")" + delim + "\"";
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
if (pos == std::string::npos) {
push(i, n, TokenKind::String);
state.in_raw_string = true;
state.raw_delim = delim;
return state;
} else {
int end = static_cast<int>(pos + needle.size());
push(i, end, TokenKind::String);
i = end;
continue;
}
}
// If malformed, just treat 'R' as identifier fallback
}
// Regular string literal
if (c == '"') {
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
push(i, j, TokenKind::String); i = j; continue;
}
// Char literal
if (c == '\'') {
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '\'') break; }
push(i, j, TokenKind::Char); i = j; continue;
}
// Number literal (simple)
if (is_digit(c) || (c == '.' && i+1 < n && is_digit(s[i+1]))) {
int j = i+1; while (j < n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='.' || s[j]=='x' || s[j]=='X' || s[j]=='b' || s[j]=='B' || s[j]=='_')) ++j;
push(i, j, TokenKind::Number); i = j; continue;
}
// Identifier / keyword / type
if (is_ident_start(c)) {
int j = i+1; while (j < n && is_ident_char(s[j])) ++j; std::string id = s.substr(i, j-i);
TokenKind k = TokenKind::Identifier; if (keywords_.count(id)) k = TokenKind::Keyword; else if (types_.count(id)) k = TokenKind::Type; push(i, j, k); i = j; continue;
}
// Operators and punctuation (single char for now)
TokenKind kind = TokenKind::Operator;
if (std::ispunct(static_cast<unsigned char>(c)) && c != '_' && c != '#') {
if (c==';' || c==',' || c=='(' || c==')' || c=='{' || c=='}' || c=='[' || c==']') kind = TokenKind::Punctuation;
push(i, i+1, kind); ++i; continue;
}
// Fallback
push(i, i+1, TokenKind::Default); ++i;
}
return state;
}
} // namespace kte

View File

@@ -1,34 +0,0 @@
// CppHighlighter.h - minimal stateless C/C++ line highlighter
#pragma once
#include <regex>
#include <string>
#include <unordered_set>
#include <vector>
#include "LanguageHighlighter.h"
class Buffer;
namespace kte {
class CppHighlighter final : public StatefulHighlighter {
public:
CppHighlighter();
~CppHighlighter() override = default;
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const override;
private:
std::unordered_set<std::string> keywords_;
std::unordered_set<std::string> types_;
static bool is_ident_start(char c);
static bool is_ident_char(char c);
};
} // namespace kte

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Minimal Dockerfile for building and testing kte on Linux
# This container provides a build environment with all dependencies.
# Mount the source tree at /kte when running the container.
FROM alpine:3.19
# Install build dependencies
RUN apk add --no-cache \
g++ \
cmake \
make \
ncurses-dev \
sdl2-dev \
mesa-dev \
freetype-dev \
libx11-dev \
libxext-dev
# Set working directory where source will be mounted
WORKDIR /kte
# Default command: build and run tests
# Add DirectFB include path for SDL2 compatibility on Alpine
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]

541
Editor.cc
View File

@@ -1,16 +1,53 @@
#include <algorithm> #include <algorithm>
#include <utility> #include <cstdio>
#include <filesystem> #include <filesystem>
#include "HighlighterRegistry.h" #include <utility>
#include "NullHighlighter.h"
#include "Editor.h" #include "Editor.h"
#include "HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "CppHighlighter.h" #include "syntax/CppHighlighter.h"
#include "NullHighlighter.h" #include "syntax/NullHighlighter.h"
Editor::Editor() = default; namespace {
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const std::size_t nrows = b.Nrows();
std::string out;
for (std::size_t i = 0; i < nrows; i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
static void
apply_pending_line(Editor &ed, const std::size_t line1)
{
if (line1 == 0)
return;
Buffer *b = ed.CurrentBuffer();
if (!b)
return;
const std::size_t nrows = b->Nrows();
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
if (nrows > 0) {
if (line >= nrows)
line = nrows - 1;
} else {
line = 0;
}
b->SetCursor(0, line);
}
} // namespace
Editor::Editor()
{
swap_ = std::make_unique<kte::SwapManager>();
}
void void
@@ -32,20 +69,22 @@ Editor::SetStatus(const std::string &message)
Buffer * Buffer *
Editor::CurrentBuffer() Editor::CurrentBuffer()
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
const Buffer * const Buffer *
Editor::CurrentBuffer() const Editor::CurrentBuffer() const
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { const auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
@@ -80,8 +119,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
// Prepare list of other buffer paths // Prepare list of other buffer paths
std::vector<std::vector<std::filesystem::path> > others; std::vector<std::vector<std::filesystem::path> > others;
others.reserve(buffers_.size()); const auto &bufs = Buffers();
for (const auto &b: buffers_) { others.reserve(bufs.size());
for (const auto &b: bufs) {
if (&b == &buf) if (&b == &buf)
continue; continue;
if (b.Filename().empty()) if (b.Filename().empty())
@@ -124,142 +164,343 @@ Editor::DisplayNameFor(const Buffer &buf) const
std::size_t std::size_t
Editor::AddBuffer(const Buffer &buf) Editor::AddBuffer(const Buffer &buf)
{ {
buffers_.push_back(buf); auto &bufs = Buffers();
if (buffers_.size() == 1) { bufs.push_back(buf);
// Attach swap recorder
if (swap_) {
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
std::size_t std::size_t
Editor::AddBuffer(Buffer &&buf) Editor::AddBuffer(Buffer &&buf)
{ {
buffers_.push_back(std::move(buf)); auto &bufs = Buffers();
if (buffers_.size() == 1) { bufs.push_back(std::move(buf));
if (swap_) {
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
bool bool
Editor::OpenFile(const std::string &path, std::string &err) Editor::OpenFile(const std::string &path, std::string &err)
{ {
// If there is exactly one unnamed, empty, clean buffer, reuse it instead // If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// of creating a new one. // it instead of creating a new one.
if (buffers_.size() == 1) { auto &bufs_ref = Buffers();
Buffer &cur = buffers_[curbuf_]; if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked(); Buffer &cur = bufs_ref[curbuf_];
const bool clean = !cur.Dirty(); const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
const auto &rows = cur.Rows(); const bool clean = !cur.Dirty();
const bool rows_empty = rows.empty(); const std::size_t nrows = cur.Nrows();
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0); const bool rows_empty = (nrows == 0);
if (unnamed && clean && (rows_empty || single_empty_line)) { const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
bool ok = cur.OpenFromFile(path, err); if (unnamed && clean && (rows_empty || single_empty_line)) {
if (!ok) return false; bool ok = cur.OpenFromFile(path, err);
// Setup highlighting using registry (extension + shebang) if (!ok)
cur.EnsureHighlighter(); return false;
std::string first = ""; // Ensure swap recorder is attached for this buffer
const auto &rows = cur.Rows(); if (swap_) {
if (!rows.empty()) first = static_cast<std::string>(rows[0]); swap_->Attach(&cur);
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first); cur.SetSwapRecorder(swap_->RecorderFor(&cur));
if (!ft.empty()) { swap_->NotifyFilenameChanged(cur);
cur.SetFiletype(ft); }
cur.SetSyntaxEnabled(true); // Setup highlighting using registry (extension + shebang)
if (auto *eng = cur.Highlighter()) { cur.EnsureHighlighter();
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft)); std::string first = "";
eng->InvalidateFrom(0); if (cur.Nrows() > 0)
} first = cur.GetLineString(0);
} else { std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
cur.SetFiletype(""); if (!ft.empty()) {
cur.SetSyntaxEnabled(true); cur.SetFiletype(ft);
if (auto *eng = cur.Highlighter()) { cur.SetSyntaxEnabled(true);
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>()); if (auto *eng = cur.Highlighter()) {
eng->InvalidateFrom(0); eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
} eng->InvalidateFrom(0);
} }
return true; } else {
} cur.SetFiletype("");
} cur.SetSyntaxEnabled(true);
if (auto *eng = cur.Highlighter()) {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0);
}
}
// Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
}
}
Buffer b; Buffer b;
if (!b.OpenFromFile(path, err)) { if (!b.OpenFromFile(path, err)) {
return false; return false;
} }
// Initialize syntax highlighting by extension + shebang via registry (v2) // NOTE: swap recorder/attach must happen after the buffer is stored in its
b.EnsureHighlighter(); // final location (vector) because swap manager keys off Buffer*.
std::string first = ""; // Initialize syntax highlighting by extension + shebang via registry (v2)
{ b.EnsureHighlighter();
const auto &rows = b.Rows(); std::string first = "";
if (!rows.empty()) first = static_cast<std::string>(rows[0]); if (b.Nrows() > 0)
} first = b.GetLineString(0);
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first); std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
if (!ft.empty()) { if (!ft.empty()) {
b.SetFiletype(ft); b.SetFiletype(ft);
b.SetSyntaxEnabled(true); b.SetSyntaxEnabled(true);
if (auto *eng = b.Highlighter()) { if (auto *eng = b.Highlighter()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft)); eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
eng->InvalidateFrom(0); eng->InvalidateFrom(0);
} }
} else { } else {
b.SetFiletype(""); b.SetFiletype("");
b.SetSyntaxEnabled(true); b.SetSyntaxEnabled(true);
if (auto *eng = b.Highlighter()) { if (auto *eng = b.Highlighter()) {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>()); eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0); eng->InvalidateFrom(0);
} }
} }
// Add as a new buffer and switch to it // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
SwitchTo(idx); if (swap_) {
return true; swap_->NotifyFilenameChanged(Buffers()[idx]);
}
SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
}
void
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
{
PendingOpen p;
p.path = path;
p.line1 = line1;
pending_open_.push_back(std::move(p));
}
bool
Editor::HasPendingOpens() const
{
return !pending_open_.empty();
}
Editor::RecoveryPromptKind
Editor::PendingRecoveryPrompt() const
{
return pending_recovery_prompt_;
}
void
Editor::CancelRecoveryPrompt()
{
pending_recovery_prompt_ = RecoveryPromptKind::None;
pending_recovery_open_ = PendingOpen{};
pending_recovery_swap_path_.clear();
pending_recovery_replay_err_.clear();
}
bool
Editor::ResolveRecoveryPrompt(const bool yes)
{
const RecoveryPromptKind kind = pending_recovery_prompt_;
if (kind == RecoveryPromptKind::None)
return false;
const PendingOpen req = pending_recovery_open_;
const std::string swp = pending_recovery_swap_path_;
const std::string rerr_s = pending_recovery_replay_err_;
CancelRecoveryPrompt();
std::string err;
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
if (yes) {
if (!OpenFile(req.path, err)) {
SetStatus(err);
return false;
}
Buffer *b = CurrentBuffer();
if (!b) {
SetStatus("Recovery failed: no buffer");
return false;
}
std::string rerr;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
SetStatus("Swap recovery failed: " + rerr);
return false;
}
b->SetDirty(true);
apply_pending_line(*this, req.line1);
SetStatus("Recovered " + req.path);
return true;
}
// Discard: best-effort delete swap, then open clean.
(void) std::remove(swp.c_str());
if (!OpenFile(req.path, err)) {
SetStatus(err);
return false;
}
apply_pending_line(*this, req.line1);
SetStatus("Opened " + req.path);
return true;
}
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
if (yes) {
(void) std::remove(swp.c_str());
}
if (!OpenFile(req.path, err)) {
SetStatus(err);
return false;
}
apply_pending_line(*this, req.line1);
// Include a short hint that the swap was corrupt.
if (!rerr_s.empty()) {
SetStatus("Opened " + req.path + " (swap unreadable)");
} else {
SetStatus("Opened " + req.path);
}
return true;
}
return false;
}
bool
Editor::ProcessPendingOpens()
{
if (PromptActive())
return false;
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
return false;
bool opened_any = false;
while (!pending_open_.empty()) {
PendingOpen req = std::move(pending_open_.front());
pending_open_.pop_front();
if (req.path.empty())
continue;
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
bool swp_exists = false;
try {
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
} catch (...) {
swp_exists = false;
}
if (swp_exists) {
Buffer tmp;
std::string oerr;
if (tmp.OpenFromFile(req.path, oerr)) {
const std::string orig = buffer_bytes_via_views(tmp);
std::string rerr;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
const std::string rec = buffer_bytes_via_views(tmp);
if (rec != orig) {
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;
StartPrompt(PromptKind::Confirm, "Recover", "");
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
return opened_any;
}
} else {
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;
pending_recovery_replay_err_ = rerr;
StartPrompt(PromptKind::Confirm, "Swap", "");
SetStatus(
"Swap file unreadable for " + req.path +
". Delete it? (y/N, C-g cancel)");
return opened_any;
}
}
}
std::string err;
if (!OpenFile(req.path, err)) {
SetStatus(err);
opened_any = false;
continue;
}
apply_pending_line(*this, req.line1);
SetStatus("Opened " + req.path);
opened_any = true;
// Open at most one per call; frontends can call us again next frame.
break;
}
return opened_any;
} }
bool bool
Editor::SwitchTo(std::size_t index) Editor::SwitchTo(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
return false; if (index >= bufs.size()) {
} return false;
curbuf_ = index; }
// Robustness: ensure a valid highlighter is installed when switching buffers curbuf_ = index;
Buffer &b = buffers_[curbuf_]; // Robustness: ensure a valid highlighter is installed when switching buffers
if (b.SyntaxEnabled()) { Buffer &b = bufs[curbuf_];
b.EnsureHighlighter(); if (b.SyntaxEnabled()) {
if (auto *eng = b.Highlighter()) { b.EnsureHighlighter();
if (!eng->HasHighlighter()) { if (auto *eng = b.Highlighter()) {
// Try to set based on existing filetype; fall back to NullHighlighter if (!eng->HasHighlighter()) {
if (!b.Filetype().empty()) { // Try to set based on existing filetype; fall back to NullHighlighter
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype()); if (!b.Filetype().empty()) {
if (hl) { auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
eng->SetHighlighter(std::move(hl)); if (hl) {
} else { eng->SetHighlighter(std::move(hl));
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>()); } else {
} eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
} else { }
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>()); } else {
} eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0); }
} eng->InvalidateFrom(0);
} }
} }
return true; }
return true;
} }
bool bool
Editor::CloseBuffer(std::size_t index) Editor::CloseBuffer(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
if (index >= bufs.size()) {
return false; return false;
} }
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index)); if (swap_) {
if (buffers_.empty()) { // Always remove swap file when closing a buffer on normal exit.
// Swap files are for crash recovery; on clean close, we don't need them.
// This prevents stale swap files from accumulating (e.g., when used as git editor).
swap_->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr);
}
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
if (bufs.empty()) {
curbuf_ = 0; curbuf_ = 0;
} else if (curbuf_ >= buffers_.size()) { } else if (curbuf_ >= bufs.size()) {
curbuf_ = buffers_.size() - 1; curbuf_ = bufs.size() - 1;
} }
return true; return true;
} }
@@ -277,8 +518,72 @@ Editor::Reset()
msgtm_ = 0; msgtm_ = 0;
uarg_ = 0; uarg_ = 0;
ucount_ = 0; ucount_ = 0;
repeatable_ = false;
quit_requested_ = false; quit_requested_ = false;
quit_confirm_pending_ = false; quit_confirm_pending_ = false;
buffers_.clear(); // Reset close-confirm/save state
close_confirm_pending_ = false;
close_after_save_ = false;
auto &bufs = Buffers();
if (swap_) {
for (auto &buf : bufs)
swap_->Detach(&buf, true);
}
bufs.clear();
curbuf_ = 0; curbuf_ = 0;
} }
// --- Universal argument helpers ---
void
Editor::UArgStart()
{
// If not active, start fresh; else multiply by 4 per ke semantics
if (uarg_ == 0) {
ucount_ = 0;
} else {
if (ucount_ == 0) {
ucount_ = 1;
}
ucount_ *= 4;
}
uarg_ = 1;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgDigit(int d)
{
if (d < 0)
d = 0;
if (d > 9)
d = 9;
if (uarg_ == 0) {
uarg_ = 1;
ucount_ = 0;
}
ucount_ = ucount_ * 10 + d;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgClear()
{
uarg_ = 0;
ucount_ = 0;
}
int
Editor::UArgGet()
{
int n = (ucount_ > 0) ? ucount_ : 1;
UArgClear();
return n;
}

206
Editor.h
View File

@@ -1,15 +1,52 @@
/* /*
* Editor.h - top-level editor state and buffer management * Editor.h - top-level editor state and buffer management
*
* Editor is the top-level coordinator in kte. It manages:
*
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
* - UI state: Dimensions, status messages, prompts, search state
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
* - Universal argument: Repeat count mechanism (C-u)
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
* - Swap/crash recovery: SwapManager integration for journaling
* - File operations: Opening files, managing pending opens, recovery prompts
*
* Key responsibilities:
*
* 1. Buffer lifecycle:
* - AddBuffer(): Add new buffers to the collection
* - OpenFile(): Load files into buffers
* - SwitchTo(): Change active buffer
* - CloseBuffer(): Remove buffers with dirty checks
*
* 2. UI coordination:
* - SetDimensions(): Terminal/window size for viewport calculations
* - SetStatus(): Status line messages with timestamps
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
* - Search state: Active search, query, match position, origin tracking
*
* 3. Shared editor state:
* - Kill ring: Circular buffer of killed text (max 60 entries)
* - Universal argument: C-u digit collection for command repetition
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
*
* 4. Integration points:
* - Commands operate on Editor and current Buffer
* - Frontend (Terminal/GUI) queries Editor for rendering
* - SwapManager journals all buffer modifications
*
* Design note: Editor owns the buffer collection but doesn't directly edit content.
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
*/ */
#ifndef KTE_EDITOR_H #pragma once
#define KTE_EDITOR_H
#include <cstddef> #include <cstddef>
#include <ctime> #include <ctime>
#include <deque>
#include <string> #include <string>
#include <vector> #include <vector>
#include "Buffer.h" #include "Buffer.h"
#include "Swap.h"
class Editor { class Editor {
@@ -32,6 +69,16 @@ public:
} }
[[nodiscard]] std::size_t ContentRows() const
{
// Always compute from current rows_ to avoid stale values.
// Reserve 1 row for status line.
if (rows_ == 0)
return 1;
return std::max<std::size_t>(1, rows_ - 1);
}
// Mode and flags (mirroring legacy fields) // Mode and flags (mirroring legacy fields)
void SetMode(int m) void SetMode(int m)
{ {
@@ -148,6 +195,33 @@ public:
} }
// --- Universal argument control (C-u) ---
// Begin or extend a universal argument (like ke's uarg_start)
void UArgStart();
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
void UArgDigit(int d);
// Clear universal-argument state (like ke's uarg_clear)
void UArgClear();
// Consume the current universal argument, returning count >= 1.
// If no universal argument active, returns 1.
int UArgGet();
// Repeatable command flag: input layer can mark the next command as repeatable
void SetRepeatable(bool on)
{
repeatable_ = on;
}
[[nodiscard]] bool Repeatable() const
{
return repeatable_;
}
// Status message storage. Rendering is renderer-dependent; the editor // Status message storage. Rendering is renderer-dependent; the editor
// merely stores the current message and its timestamp. // merely stores the current message and its timestamp.
void SetStatus(const std::string &message); void SetStatus(const std::string &message);
@@ -172,6 +246,18 @@ public:
} }
void SetNewWindowRequested(bool on)
{
new_window_requested_ = on;
}
[[nodiscard]] bool NewWindowRequested() const
{
return new_window_requested_;
}
void SetQuitConfirmPending(bool on) void SetQuitConfirmPending(bool on)
{ {
quit_confirm_pending_ = on; quit_confirm_pending_ = on;
@@ -184,6 +270,31 @@ public:
} }
// --- Buffer close/save confirmation state ---
void SetCloseConfirmPending(bool on)
{
close_confirm_pending_ = on;
}
[[nodiscard]] bool CloseConfirmPending() const
{
return close_confirm_pending_;
}
void SetCloseAfterSave(bool on)
{
close_after_save_ = on;
}
[[nodiscard]] bool CloseAfterSave() const
{
return close_after_save_;
}
[[nodiscard]] std::time_t StatusTime() const [[nodiscard]] std::time_t StatusTime() const
{ {
return msgtm_; return msgtm_;
@@ -410,7 +521,7 @@ public:
// Buffers // Buffers
[[nodiscard]] std::size_t BufferCount() const [[nodiscard]] std::size_t BufferCount() const
{ {
return buffers_.size(); return Buffers().size();
} }
@@ -420,6 +531,19 @@ public:
} }
// Clamp curbuf_ to valid range. Call when the shared buffer list may
// have been modified by another editor (e.g., buffer closed in another window).
void ValidateBufferIndex()
{
const auto &bufs = Buffers();
if (bufs.empty()) {
curbuf_ = 0;
} else if (curbuf_ >= bufs.size()) {
curbuf_ = bufs.size() - 1;
}
}
Buffer *CurrentBuffer(); Buffer *CurrentBuffer();
const Buffer *CurrentBuffer() const; const Buffer *CurrentBuffer() const;
@@ -436,6 +560,30 @@ public:
bool OpenFile(const std::string &path, std::string &err); bool OpenFile(const std::string &path, std::string &err);
// Request that a file be opened. The request is processed by calling
// ProcessPendingOpens() (typically once per frontend frame).
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
// If no modal prompt is active, process queued open requests.
// Returns true if a file was opened during this call.
bool ProcessPendingOpens();
[[nodiscard]] bool HasPendingOpens() const;
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
enum class RecoveryPromptKind {
None = 0,
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
DeleteCorruptSwap // y = delete corrupt swap, else keep it
};
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
bool ResolveRecoveryPrompt(bool yes);
void CancelRecoveryPrompt();
// Buffer switching/closing // Buffer switching/closing
bool SwitchTo(std::size_t index); bool SwitchTo(std::size_t index);
@@ -447,13 +595,29 @@ public:
// Direct access when needed (try to prefer methods above) // Direct access when needed (try to prefer methods above)
[[nodiscard]] const std::vector<Buffer> &Buffers() const [[nodiscard]] const std::vector<Buffer> &Buffers() const
{ {
return buffers_; return shared_buffers_ ? *shared_buffers_ : buffers_;
} }
std::vector<Buffer> &Buffers() std::vector<Buffer> &Buffers()
{ {
return buffers_; return shared_buffers_ ? *shared_buffers_ : buffers_;
}
// Share another editor's buffer list. When set, this editor operates on
// the provided vector instead of its own. Pass nullptr to detach.
void SetSharedBuffers(std::vector<Buffer> *shared)
{
shared_buffers_ = shared;
curbuf_ = 0;
}
// Swap manager access (for advanced integrations/tests)
[[nodiscard]] kte::SwapManager *Swap()
{
return swap_.get();
} }
@@ -482,6 +646,11 @@ public:
} }
private: private:
struct PendingOpen {
std::string path;
std::size_t line1{0}; // 1-based; 0 = none
};
std::size_t rows_ = 0, cols_ = 0; std::size_t rows_ = 0, cols_ = 0;
int mode_ = 0; int mode_ = 0;
int kill_ = 0; // KILL CHAIN int kill_ = 0; // KILL CHAIN
@@ -490,17 +659,25 @@ private:
std::string msg_; std::string msg_;
std::time_t msgtm_ = 0; std::time_t msgtm_ = 0;
int uarg_ = 0, ucount_ = 0; // C-u support int uarg_ = 0, ucount_ = 0; // C-u support
bool repeatable_ = false; // whether the next command is repeatable
std::vector<Buffer> buffers_; std::vector<Buffer> buffers_;
std::size_t curbuf_ = 0; // index into buffers_ std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
std::size_t curbuf_ = 0; // index into buffers_
// Swap journaling manager (lifetime = editor)
std::unique_ptr<kte::SwapManager> swap_;
// Kill ring (Emacs-like) // Kill ring (Emacs-like)
std::vector<std::string> kill_ring_; std::vector<std::string> kill_ring_;
std::size_t kill_ring_max_ = 60; std::size_t kill_ring_max_ = 60;
// Quit state // Quit state
bool quit_requested_ = false; bool quit_requested_ = false;
bool quit_confirm_pending_ = false; bool new_window_requested_ = false;
bool quit_confirm_pending_ = false;
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
// Search state // Search state
bool search_active_ = false; bool search_active_ = false;
@@ -519,6 +696,13 @@ private:
std::string prompt_text_; std::string prompt_text_;
std::string pending_overwrite_path_; std::string pending_overwrite_path_;
// Deferred open + swap recovery prompt state
std::deque<PendingOpen> pending_open_;
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
PendingOpen pending_recovery_open_{};
std::string pending_recovery_swap_path_;
std::string pending_recovery_replay_err_;
// GUI-only state (safe no-op in terminal builds) // GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false; bool file_picker_visible_ = false;
std::string file_picker_dir_; std::string file_picker_dir_;
@@ -551,6 +735,4 @@ public:
private: private:
std::string replace_find_tmp_; std::string replace_find_tmp_;
std::string replace_with_tmp_; std::string replace_with_tmp_;
}; };
#endif // KTE_EDITOR_H

313
ErrorHandler.cc Normal file
View File

@@ -0,0 +1,313 @@
#include "ErrorHandler.h"
#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <filesystem>
#include <cstdlib>
namespace fs = std::filesystem;
namespace kte {
ErrorHandler::ErrorHandler()
{
// Determine log file path: ~/.local/state/kte/error.log
const char *home = std::getenv("HOME");
if (home) {
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
try {
if (!fs::exists(log_dir)) {
fs::create_directories(log_dir);
}
log_file_path_ = (log_dir / "error.log").string();
// Create the log file immediately so it exists in the state directory
ensure_log_file();
} catch (...) {
// If we can't create the directory, disable file logging
file_logging_enabled_ = false;
}
} else {
// No HOME, disable file logging
file_logging_enabled_ = false;
}
}
ErrorHandler::~ErrorHandler()
{
std::lock_guard<std::mutex> lg(mtx_);
if (log_file_ && log_file_->is_open()) {
log_file_->flush();
log_file_->close();
}
}
ErrorHandler &
ErrorHandler::Instance()
{
static ErrorHandler instance;
return instance;
}
void
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
const std::string &message, const std::string &context)
{
ErrorRecord record;
record.timestamp_ns = now_ns();
record.severity = severity;
record.component = component;
record.message = message;
record.context = context;
{
std::lock_guard<std::mutex> lg(mtx_);
// Add to in-memory queue
errors_.push_back(record);
while (errors_.size() > 100) {
errors_.pop_front();
}
++total_error_count_;
if (severity == ErrorSeverity::Critical) {
++critical_error_count_;
}
// Write to log file if enabled
if (file_logging_enabled_) {
write_to_log(record);
}
}
}
void
ErrorHandler::Info(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Info, component, message, context);
}
void
ErrorHandler::Warning(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Warning, component, message, context);
}
void
ErrorHandler::Error(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Error, component, message, context);
}
void
ErrorHandler::Critical(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Critical, component, message, context);
}
bool
ErrorHandler::HasErrors() const
{
std::lock_guard<std::mutex> lg(mtx_);
return !errors_.empty();
}
bool
ErrorHandler::HasCriticalErrors() const
{
std::lock_guard<std::mutex> lg(mtx_);
return critical_error_count_ > 0;
}
std::string
ErrorHandler::GetLastError() const
{
std::lock_guard<std::mutex> lg(mtx_);
if (errors_.empty())
return "";
const ErrorRecord &e = errors_.back();
std::string result = "[" + severity_to_string(e.severity) + "] ";
result += e.component;
if (!e.context.empty()) {
result += " (" + e.context + ")";
}
result += ": " + e.message;
return result;
}
std::size_t
ErrorHandler::GetErrorCount() const
{
std::lock_guard<std::mutex> lg(mtx_);
return total_error_count_;
}
std::size_t
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
{
std::lock_guard<std::mutex> lg(mtx_);
std::size_t count = 0;
for (const auto &e: errors_) {
if (e.severity == severity) {
++count;
}
}
return count;
}
std::vector<ErrorHandler::ErrorRecord>
ErrorHandler::GetRecentErrors(std::size_t max_count) const
{
std::lock_guard<std::mutex> lg(mtx_);
std::vector<ErrorRecord> result;
result.reserve(std::min(max_count, errors_.size()));
// Return most recent first
auto it = errors_.rbegin();
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
result.push_back(*it);
}
return result;
}
void
ErrorHandler::ClearErrors()
{
std::lock_guard<std::mutex> lg(mtx_);
errors_.clear();
total_error_count_ = 0;
critical_error_count_ = 0;
}
void
ErrorHandler::SetFileLoggingEnabled(bool enabled)
{
std::lock_guard<std::mutex> lg(mtx_);
file_logging_enabled_ = enabled;
if (!enabled && log_file_ && log_file_->is_open()) {
log_file_->flush();
log_file_->close();
log_file_.reset();
}
}
std::string
ErrorHandler::GetLogFilePath() const
{
std::lock_guard<std::mutex> lg(mtx_);
return log_file_path_;
}
void
ErrorHandler::write_to_log(const ErrorRecord &record)
{
// Must be called with mtx_ held
if (log_file_path_.empty())
return;
ensure_log_file();
if (!log_file_ || !log_file_->is_open())
return;
// Format: [timestamp] [SEVERITY] component (context): message
std::string timestamp = format_timestamp(record.timestamp_ns);
std::string severity = severity_to_string(record.severity);
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
if (!record.context.empty()) {
*log_file_ << " (" << record.context << ")";
}
*log_file_ << ": " << record.message << "\n";
log_file_->flush();
}
void
ErrorHandler::ensure_log_file()
{
// Must be called with mtx_ held
if (log_file_ && log_file_->is_open())
return;
if (log_file_path_.empty())
return;
try {
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
std::ios::app | std::ios::out);
if (!log_file_->is_open()) {
log_file_.reset();
}
} catch (...) {
log_file_.reset();
}
}
std::string
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
{
// Convert nanoseconds to time_t (seconds)
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
std::tm tm_buf{};
#if defined(_WIN32)
localtime_s(&tm_buf, &seconds);
#else
localtime_r(&seconds, &tm_buf);
#endif
std::ostringstream oss;
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
return oss.str();
}
std::string
ErrorHandler::severity_to_string(ErrorSeverity severity) const
{
switch (severity) {
case ErrorSeverity::Info:
return "INFO";
case ErrorSeverity::Warning:
return "WARNING";
case ErrorSeverity::Error:
return "ERROR";
case ErrorSeverity::Critical:
return "CRITICAL";
default:
return "UNKNOWN";
}
}
std::uint64_t
ErrorHandler::now_ns()
{
using namespace std::chrono;
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
}
} // namespace kte

106
ErrorHandler.h Normal file
View File

@@ -0,0 +1,106 @@
// ErrorHandler.h - Centralized error handling and logging for kte
#pragma once
#include <string>
#include <vector>
#include <deque>
#include <mutex>
#include <cstdint>
#include <memory>
#include <fstream>
namespace kte {
enum class ErrorSeverity {
Info, // Informational messages
Warning, // Non-critical issues
Error, // Errors that affect functionality but allow continuation
Critical // Critical errors that may cause data loss or crashes
};
// Centralized error handler with logging and in-memory error tracking
class ErrorHandler {
public:
struct ErrorRecord {
std::uint64_t timestamp_ns{0};
ErrorSeverity severity{ErrorSeverity::Error};
std::string component; // e.g., "SwapManager", "Buffer", "main"
std::string message;
std::string context; // e.g., filename, buffer name, operation
};
// Get the global ErrorHandler instance
static ErrorHandler &Instance();
// Report an error with severity, component, message, and optional context
void Report(ErrorSeverity severity, const std::string &component,
const std::string &message, const std::string &context = "");
// Convenience methods for common severity levels
void Info(const std::string &component, const std::string &message,
const std::string &context = "");
void Warning(const std::string &component, const std::string &message,
const std::string &context = "");
void Error(const std::string &component, const std::string &message,
const std::string &context = "");
void Critical(const std::string &component, const std::string &message,
const std::string &context = "");
// Query error state (thread-safe)
bool HasErrors() const;
bool HasCriticalErrors() const;
std::string GetLastError() const;
std::size_t GetErrorCount() const;
std::size_t GetErrorCount(ErrorSeverity severity) const;
// Get recent errors (up to max_count, most recent first)
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
// Clear in-memory error history (does not affect log file)
void ClearErrors();
// Enable/disable file logging (enabled by default)
void SetFileLoggingEnabled(bool enabled);
// Get the path to the error log file
std::string GetLogFilePath() const;
private:
ErrorHandler();
~ErrorHandler();
// Non-copyable, non-movable
ErrorHandler(const ErrorHandler &) = delete;
ErrorHandler &operator=(const ErrorHandler &) = delete;
ErrorHandler(ErrorHandler &&) = delete;
ErrorHandler &operator=(ErrorHandler &&) = delete;
void write_to_log(const ErrorRecord &record);
void ensure_log_file();
std::string format_timestamp(std::uint64_t timestamp_ns) const;
std::string severity_to_string(ErrorSeverity severity) const;
static std::uint64_t now_ns();
mutable std::mutex mtx_;
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
std::size_t total_error_count_{0};
std::size_t critical_error_count_{0};
bool file_logging_enabled_{true};
std::string log_file_path_;
std::unique_ptr<std::ofstream> log_file_;
};
} // namespace kte

157
ErrorRecovery.cc Normal file
View File

@@ -0,0 +1,157 @@
// ErrorRecovery.cc - Error recovery mechanisms implementation
#include "ErrorRecovery.h"
#include <mutex>
namespace kte {
CircuitBreaker::CircuitBreaker(const Config &cfg)
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
last_failure_time_(std::chrono::steady_clock::time_point::min()),
state_change_time_(std::chrono::steady_clock::now()) {}
bool
CircuitBreaker::AllowRequest()
{
std::lock_guard<std::mutex> lg(mtx_);
const auto now = std::chrono::steady_clock::now();
switch (state_) {
case State::Closed:
// Normal operation, allow all requests
return true;
case State::Open: {
// Check if timeout has elapsed to transition to HalfOpen
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - state_change_time_
);
if (elapsed >= config_.open_timeout) {
TransitionTo(State::HalfOpen);
return true; // Allow one request to test recovery
}
return false; // Circuit is open, reject request
}
case State::HalfOpen:
// Allow limited requests to test recovery
return true;
}
return false;
}
void
CircuitBreaker::RecordSuccess()
{
std::lock_guard<std::mutex> lg(mtx_);
switch (state_) {
case State::Closed:
// Reset failure count on success in normal operation
failure_count_ = 0;
break;
case State::HalfOpen:
++success_count_;
if (success_count_ >= config_.success_threshold) {
// Enough successes, close the circuit
TransitionTo(State::Closed);
}
break;
case State::Open:
// Shouldn't happen (requests rejected), but handle gracefully
break;
}
}
void
CircuitBreaker::RecordFailure()
{
std::lock_guard<std::mutex> lg(mtx_);
const auto now = std::chrono::steady_clock::now();
last_failure_time_ = now;
switch (state_) {
case State::Closed:
// Check if we need to reset the failure count (window expired)
if (IsWindowExpired()) {
failure_count_ = 0;
}
++failure_count_;
if (failure_count_ >= config_.failure_threshold) {
// Too many failures, open the circuit
TransitionTo(State::Open);
}
break;
case State::HalfOpen:
// Failure during recovery test, reopen the circuit
TransitionTo(State::Open);
break;
case State::Open:
// Already open, just track the failure
++failure_count_;
break;
}
}
void
CircuitBreaker::Reset()
{
std::lock_guard<std::mutex> lg(mtx_);
TransitionTo(State::Closed);
}
void
CircuitBreaker::TransitionTo(State new_state)
{
if (state_ == new_state) {
return;
}
state_ = new_state;
state_change_time_ = std::chrono::steady_clock::now();
switch (new_state) {
case State::Closed:
failure_count_ = 0;
success_count_ = 0;
break;
case State::Open:
success_count_ = 0;
// Keep failure_count_ for diagnostics
break;
case State::HalfOpen:
success_count_ = 0;
// Keep failure_count_ for diagnostics
break;
}
}
bool
CircuitBreaker::IsWindowExpired() const
{
if (failure_count_ == 0) {
return false;
}
const auto now = std::chrono::steady_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - last_failure_time_
);
return elapsed >= config_.window;
}
} // namespace kte

170
ErrorRecovery.h Normal file
View File

@@ -0,0 +1,170 @@
// ErrorRecovery.h - Error recovery mechanisms for kte
#pragma once
#include <chrono>
#include <cstddef>
#include <functional>
#include <string>
#include <thread>
#include <mutex>
#include <cerrno>
namespace kte {
// Classify errno values as transient (retryable) or permanent
inline bool
IsTransientError(int err)
{
switch (err) {
case EAGAIN:
#if EAGAIN != EWOULDBLOCK
case EWOULDBLOCK:
#endif
case EBUSY:
case EIO: // I/O error (may be transient on network filesystems)
case ETIMEDOUT:
case ENOSPC: // Disk full (may become available)
case EDQUOT: // Quota exceeded (may become available)
return true;
default:
return false;
}
}
// RetryPolicy defines retry behavior for transient failures
struct RetryPolicy {
std::size_t max_attempts{3}; // Maximum retry attempts
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
double backoff_multiplier{2.0}; // Exponential backoff multiplier
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
static RetryPolicy Default()
{
return RetryPolicy{};
}
// Aggressive policy for critical operations: more attempts, faster retries
static RetryPolicy Aggressive()
{
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
}
// Conservative policy for non-critical operations: fewer attempts, slower retries
static RetryPolicy Conservative()
{
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
}
};
// Retry a function with exponential backoff for transient errors
// Returns true on success, false on permanent failure or exhausted retries
// The function `fn` should return true on success, false on failure, and set errno on failure
template<typename Func>
bool
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
{
std::size_t attempt = 0;
std::chrono::milliseconds delay = policy.initial_delay;
while (attempt < policy.max_attempts) {
++attempt;
errno = 0;
if (fn()) {
return true; // Success
}
int saved_errno = errno;
if (!IsTransientError(saved_errno)) {
// Permanent error, don't retry
return false;
}
if (attempt >= policy.max_attempts) {
// Exhausted retries
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
return false;
}
// Sleep before retry
std::this_thread::sleep_for(delay);
// Exponential backoff
delay = std::chrono::milliseconds(
static_cast<long long>(delay.count() * policy.backoff_multiplier)
);
if (delay > policy.max_delay) {
delay = policy.max_delay;
}
}
return false;
}
// CircuitBreaker prevents repeated attempts to failing operations
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
class CircuitBreaker {
public:
enum class State {
Closed, // Normal operation, allow all requests
Open, // Failing, reject requests immediately
HalfOpen // Testing recovery, allow limited requests
};
struct Config {
std::size_t failure_threshold; // Failures before opening circuit
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
std::size_t success_threshold; // Successes in HalfOpen before closing
std::chrono::seconds window; // Time window for counting failures
Config()
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
};
explicit CircuitBreaker(const Config &cfg = Config());
// Check if operation is allowed (returns false if circuit is Open)
bool AllowRequest();
// Record successful operation
void RecordSuccess();
// Record failed operation
void RecordFailure();
// Get current state
State GetState() const
{
return state_;
}
// Get failure count in current window
std::size_t GetFailureCount() const
{
return failure_count_;
}
// Reset circuit to Closed state (for testing or manual intervention)
void Reset();
private:
void TransitionTo(State new_state);
bool IsWindowExpired() const;
Config config_;
State state_;
std::size_t failure_count_;
std::size_t success_count_;
std::chrono::steady_clock::time_point last_failure_time_;
std::chrono::steady_clock::time_point state_change_time_;
mutable std::mutex mtx_;
};
} // namespace kte

4892
Font.h

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
/* /*
* Frontend.h - top-level container that couples Input + Renderer and runs the loop * Frontend.h - top-level container that couples Input + Renderer and runs the loop
*/ */
#ifndef KTE_FRONTEND_H #pragma once
#define KTE_FRONTEND_H
class Editor; class Editor;
class InputHandler; class InputHandler;
@@ -14,7 +12,7 @@ public:
virtual ~Frontend() = default; virtual ~Frontend() = default;
// Initialize the frontend (create window/terminal, etc.) // Initialize the frontend (create window/terminal, etc.)
virtual bool Init(Editor &ed) = 0; virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit. // Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
virtual void Step(Editor &ed, bool &running) = 0; virtual void Step(Editor &ed, bool &running) = 0;
@@ -22,5 +20,3 @@ public:
// Shutdown/cleanup // Shutdown/cleanup
virtual void Shutdown() = 0; virtual void Shutdown() = 0;
}; };
#endif // KTE_FRONTEND_H

View File

@@ -3,9 +3,29 @@
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <algorithm> #include <algorithm>
#include <filesystem>
#include <iostream>
#include "GUIConfig.h" #include "GUIConfig.h"
// toml++ for TOML config parsing
#if defined(__clang__)
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Weverything"
#elif defined(__GNUC__)
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wall"
# pragma GCC diagnostic ignored "-Wextra"
#endif
#include "ext/tomlplusplus/toml.hpp"
#if defined(__clang__)
# pragma clang diagnostic pop
#elif defined(__GNUC__)
# pragma GCC diagnostic pop
#endif
static void static void
trim(std::string &s) trim(std::string &s)
@@ -19,37 +39,124 @@ trim(std::string &s)
static std::string static std::string
default_config_path() config_dir()
{ {
const char *home = std::getenv("HOME"); const char *home = std::getenv("HOME");
if (!home || !*home) if (!home || !*home)
return {}; return {};
std::string path(home); return std::string(home) + "/.config/kte";
path += "/.config/kte/kge.ini";
return path;
} }
GUIConfig GUIConfig
GUIConfig::Load() GUIConfig::Load()
{ {
GUIConfig cfg; // defaults already set GUIConfig cfg;
const std::string path = default_config_path(); std::string dir = config_dir();
if (dir.empty())
return cfg;
if (!path.empty()) { // Try TOML first
cfg.LoadFromFile(path); std::string toml_path = dir + "/kge.toml";
if (cfg.LoadFromTOML(toml_path))
return cfg;
// Fall back to legacy INI
std::string ini_path = dir + "/kge.ini";
if (cfg.LoadFromINI(ini_path)) {
std::cerr << "kge: loaded legacy kge.ini; consider migrating to kge.toml\n";
return cfg;
} }
return cfg; return cfg;
} }
bool bool
GUIConfig::LoadFromFile(const std::string &path) GUIConfig::LoadFromTOML(const std::string &path)
{
if (!std::filesystem::exists(path))
return false;
toml::table tbl;
try {
tbl = toml::parse_file(path);
} catch (const toml::parse_error &err) {
std::cerr << "kge: TOML parse error in " << path << ": " << err.what() << "\n";
return false;
}
// [window]
if (auto win = tbl["window"].as_table()) {
if (auto v = (*win)["fullscreen"].value<bool>())
fullscreen = *v;
if (auto v = (*win)["columns"].value<int64_t>()) {
if (*v > 0) columns = static_cast<int>(*v);
}
if (auto v = (*win)["rows"].value<int64_t>()) {
if (*v > 0) rows = static_cast<int>(*v);
}
}
// [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>()) {
code_font = *v;
explicit_code_font = true;
}
if (auto v = (*sec)["writing"].value<std::string>()) {
writing_font = *v;
explicit_writing_font = true;
}
}
// [appearance]
if (auto sec = tbl["appearance"].as_table()) {
if (auto v = (*sec)["theme"].value<std::string>())
theme = *v;
if (auto v = (*sec)["background"].value<std::string>()) {
std::string bg = *v;
std::transform(bg.begin(), bg.end(), bg.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (bg == "light" || bg == "dark")
background = bg;
}
}
// [editor]
if (auto sec = tbl["editor"].as_table()) {
if (auto v = (*sec)["syntax"].value<bool>())
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;
}
bool
GUIConfig::LoadFromINI(const std::string &path)
{ {
std::ifstream in(path); std::ifstream in(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 ';'
@@ -102,27 +209,43 @@ GUIConfig::LoadFromFile(const std::string &path)
if (v > 0.0f) { if (v > 0.0f) {
font_size = v; font_size = v;
} }
} else if (key == "theme") { } else if (key == "font") {
theme = val; font = val;
} else if (key == "background" || key == "bg") { } else if (key == "code_font") {
std::string v = val; code_font = val;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { explicit_code_font = true;
return (char) std::tolower(c); } else if (key == "writing_font") {
}); writing_font = val;
if (v == "light" || v == "dark") explicit_writing_font = true;
background = v; } else if (key == "theme") {
} else if (key == "syntax") { theme = val;
std::string v = val; } else if (key == "background" || key == "bg") {
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { std::string v = val;
return (char) std::tolower(c); std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
}); return (char) std::tolower(c);
if (v == "1" || v == "on" || v == "true" || v == "yes") { });
syntax = true; if (v == "light" || v == "dark")
} else if (v == "0" || v == "off" || v == "false" || v == "no") { background = v;
syntax = false; } else if (key == "syntax") {
} std::string v = val;
} std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
} return (char) std::tolower(c);
});
if (v == "1" || v == "on" || v == "true" || v == "yes") {
syntax = true;
} else if (v == "0" || v == "off" || v == "false" || v == "no") {
syntax = false;
}
}
}
return true; // 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;
} }

View File

@@ -1,8 +1,9 @@
/* /*
* GUIConfig - loads simple GUI configuration from $HOME/.config/kte/kge.ini * GUIConfig - loads GUI configuration from $HOME/.config/kte/kge.toml
*
* Falls back to legacy kge.ini if no TOML config is found.
*/ */
#ifndef KTE_GUI_CONFIG_H #pragma once
#define KTE_GUI_CONFIG_H
#include <string> #include <string>
@@ -12,24 +13,29 @@
class GUIConfig { class GUIConfig {
public: public:
bool fullscreen = false; bool fullscreen = false;
int columns = 80; int columns = 80;
int rows = 42; int rows = 42;
float font_size = (float) KTE_FONT_SIZE; float font_size = (float) KTE_FONT_SIZE;
std::string theme = "nord"; std::string font = "default";
// Background mode for themes that support light/dark variants std::string theme = "nord";
// Values: "dark" (default), "light" // Background mode for themes that support light/dark variants
std::string background = "dark"; // Values: "dark" (default), "light"
std::string background = "dark";
// Default syntax highlighting state for GUI (kge): on/off // Default syntax highlighting state for GUI (kge): on/off
// Accepts: on/off/true/false/yes/no/1/0 in the ini file. bool syntax = true;
bool syntax = true; // default: enabled
// Load from default path: $HOME/.config/kte/kge.ini // Per-mode font defaults
std::string code_font = "default";
std::string writing_font = "crimsonpro";
// Load from default paths: try kge.toml first, fall back to kge.ini
static GUIConfig Load(); static GUIConfig Load();
// Load from explicit path. Returns true if file existed and was parsed. // Load from explicit TOML path. Returns true if file existed and was parsed.
bool LoadFromFile(const std::string &path); bool LoadFromTOML(const std::string &path);
};
#endif // KTE_GUI_CONFIG_H // Load from explicit INI path (legacy). Returns true if file existed and was parsed.
bool LoadFromINI(const std::string &path);
};

View File

@@ -1,319 +0,0 @@
#include <cstdio>
#include <string>
#include <cstring>
#include <cstdlib>
#include <algorithm>
#include <SDL.h>
#include <SDL_opengl.h>
#include <imgui.h>
#include <backends/imgui_impl_sdl2.h>
#include <backends/imgui_impl_opengl3.h>
#include "Editor.h"
#include "Command.h"
#include "GUIFrontend.h"
#include "Font.h" // embedded default font (DefaultFontRegular)
#include "GUIConfig.h"
#include "GUITheme.h"
#include "HighlighterRegistry.h"
#include "NullHighlighter.h"
#ifndef KTE_FONT_SIZE
#define KTE_FONT_SIZE 16.0f
#endif
static const char *kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool
GUIFrontend::Init(Editor &ed)
{
(void) ed; // editor dimensions will be initialized during the first Step() frame
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false;
}
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
GUIConfig cfg = GUIConfig::Load();
// GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
// Compute desired window size from config
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
if (cfg.fullscreen) {
// "Fullscreen": fill the usable bounds of the primary display.
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
width_ = usable.w;
height_ = usable.h;
}
#if !defined(__APPLE__)
// Non-macOS: desktop fullscreen uses the current display resolution.
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
#endif
} else {
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
int w = cfg.columns * static_cast<int>(cfg.font_size);
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
// As a safety, clamp to display usable bounds if retrievable
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
w = std::min(w, usable.w);
h = std::min(h, usable.h);
}
width_ = std::max(320, w);
height_ = std::max(200, h);
}
window_ = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width_, height_,
win_flags);
if (!window_)
return false;
#if defined(__APPLE__)
// macOS: when "fullscreen" is requested, position the window at the
// top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible.
if (cfg.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y);
}
}
#endif
gl_ctx_ = SDL_GL_CreateContext(window_);
if (!gl_ctx_)
return false;
SDL_GL_MakeCurrent(window_, gl_ctx_);
SDL_GL_SetSwapInterval(1); // vsync
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
(void) io;
ImGui::StyleColorsDark();
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
if (cfg.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(cfg.theme);
// Apply default syntax highlighting preference from GUI config to the current buffer
if (Buffer *b = ed.CurrentBuffer()) {
if (cfg.syntax) {
b->SetSyntaxEnabled(true);
// Ensure a highlighter is available if possible
b->EnsureHighlighter();
if (auto *eng = b->Highlighter()) {
if (!eng->HasHighlighter()) {
// Try detect from filename and first line; fall back to cpp or existing filetype
std::string first_line;
const auto &rows = b->Rows();
if (!rows.empty()) first_line = static_cast<std::string>(rows[0]);
std::string ft = kte::HighlighterRegistry::DetectForPath(b->Filename(), first_line);
if (!ft.empty()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
b->SetFiletype(ft);
eng->InvalidateFrom(0);
} else {
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
b->SetFiletype("");
eng->InvalidateFrom(0);
}
}
}
} else {
b->SetSyntaxEnabled(false);
}
}
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
return false;
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
return false;
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists
int w, h;
SDL_GetWindowSize(window_, &w, &h);
width_ = w;
height_ = h;
#if defined(__APPLE__)
// Workaround: On macOS Retina when starting maximized, we sometimes get a
// subtle input vs draw alignment mismatch until the first manual resize.
// Nudge the window size by 1px and back to trigger a proper internal
// recomputation, without visible impact.
if (w > 1 && h > 1) {
SDL_SetWindowSize(window_, w - 1, h - 1);
SDL_SetWindowSize(window_, w, h);
// Update cached size in case backend reports immediately
SDL_GetWindowSize(window_, &w, &h);
width_ = w;
height_ = h;
}
#endif
// Initialize GUI font from embedded default (use configured size or compiled default)
LoadGuiFont_(nullptr, (float) cfg.font_size);
return true;
}
void
GUIFrontend::Step(Editor &ed, bool &running)
{
SDL_Event e;
while (SDL_PollEvent(&e)) {
ImGui_ImplSDL2_ProcessEvent(&e);
switch (e.type) {
case SDL_QUIT:
running = false;
break;
case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
width_ = e.window.data1;
height_ = e.window.data2;
}
break;
default:
break;
}
// Map input to commands
input_.ProcessSDLEvent(e);
}
// Execute pending mapped inputs (drain queue)
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
// Track kill ring before and after to sync GUI clipboard when it changes
const std::string before = ed.KillRingHead();
Execute(ed, mi.id, mi.arg, mi.count);
const std::string after = ed.KillRingHead();
if (after != before && !after.empty()) {
// Update the system clipboard to mirror the kill ring head in GUI
SDL_SetClipboardText(after.c_str());
}
}
}
if (ed.QuitRequested()) {
running = false;
}
// Start a new ImGui frame
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame(window_);
ImGui::NewFrame();
// Update editor logical rows/cols using current ImGui metrics and display size
{
ImGuiIO &io = ImGui::GetIO();
float line_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x;
if (line_h <= 0.0f)
line_h = 16.0f;
if (ch_w <= 0.0f)
ch_w = 8.0f;
// Prefer ImGui IO display size; fall back to cached SDL window size
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(width_);
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
// Account for the GUI window padding and the status bar height used in GUIRenderer
const ImGuiStyle &style = ImGui::GetStyle();
float pad_x = style.WindowPadding.x;
float pad_y = style.WindowPadding.y;
// Status bar reserves one frame height (with spacing) inside the window
float status_h = ImGui::GetFrameHeightWithSpacing();
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
// Visible content rows inside the scroll child
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
// Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
// Only update if changed to avoid churn
if (rows != ed.Rows() || cols != ed.Cols()) {
ed.SetDimensions(rows, cols);
}
}
// No runtime font UI; always use embedded font.
// Draw editor UI
renderer_.Draw(ed);
// Render
ImGui::Render();
int display_w, display_h;
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(window_);
}
void
GUIFrontend::Shutdown()
{
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext();
if (gl_ctx_) {
SDL_GL_DeleteContext(gl_ctx_);
gl_ctx_ = nullptr;
}
if (window_) {
SDL_DestroyWindow(window_);
window_ = nullptr;
}
SDL_Quit();
}
bool
GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
{
const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear();
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
DefaultFontBoldCompressedData,
DefaultFontBoldCompressedSize,
size_px);
if (!font) {
font = io.Fonts->AddFontDefault();
}
(void) font;
io.Fonts->Build();
return true;
}
// No runtime font reload or system font resolution in this simplified build.

View File

@@ -1,38 +0,0 @@
/*
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
*/
#ifndef KTE_GUI_FRONTEND_H
#define KTE_GUI_FRONTEND_H
#include "Frontend.h"
#include "GUIInputHandler.h"
#include "GUIRenderer.h"
struct SDL_Window;
typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend {
public:
GUIFrontend() = default;
~GUIFrontend() override = default;
bool Init(Editor &ed) override;
void Step(Editor &ed, bool &running) override;
void Shutdown() override;
private:
static bool LoadGuiFont_(const char *path, float size_px);
GUIInputHandler input_{};
GUIRenderer renderer_{};
SDL_Window *window_ = nullptr;
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;
int height_ = 800;
};
#endif // KTE_GUI_FRONTEND_H

View File

@@ -1,18 +0,0 @@
/*
* GUIRenderer - ImGui-based renderer for GUI mode
*/
#ifndef KTE_GUI_RENDERER_H
#define KTE_GUI_RENDERER_H
#include "Renderer.h"
class GUIRenderer final : public Renderer {
public:
GUIRenderer() = default;
~GUIRenderer() override = default;
void Draw(Editor &ed) override;
};
#endif // KTE_GUI_RENDERER_H

1705
GUITheme.h

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +0,0 @@
#include <algorithm>
#include <cassert>
#include <cstring>
#include "GapBuffer.h"
GapBuffer::GapBuffer() = default;
GapBuffer::GapBuffer(std::size_t initialCapacity)
: buffer_(nullptr), size_(0), capacity_(0)
{
if (initialCapacity > 0) {
Reserve(initialCapacity);
}
}
GapBuffer::GapBuffer(const GapBuffer &other)
: buffer_(nullptr), size_(0), capacity_(0)
{
if (other.capacity_ > 0) {
Reserve(other.capacity_);
if (other.size_ > 0) {
std::memcpy(buffer_, other.buffer_, other.size_);
size_ = other.size_;
}
setTerminator();
}
}
GapBuffer &
GapBuffer::operator=(const GapBuffer &other)
{
if (this == &other)
return *this;
if (other.capacity_ > capacity_) {
Reserve(other.capacity_);
}
if (other.size_ > 0) {
std::memcpy(buffer_, other.buffer_, other.size_);
}
size_ = other.size_;
setTerminator();
return *this;
}
GapBuffer::GapBuffer(GapBuffer &&other) noexcept
: buffer_(other.buffer_), size_(other.size_), capacity_(other.capacity_)
{
other.buffer_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
GapBuffer &
GapBuffer::operator=(GapBuffer &&other) noexcept
{
if (this == &other)
return *this;
delete[] buffer_;
buffer_ = other.buffer_;
size_ = other.size_;
capacity_ = other.capacity_;
other.buffer_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
return *this;
}
GapBuffer::~GapBuffer()
{
delete[] buffer_;
}
void
GapBuffer::Reserve(const std::size_t newCapacity)
{
if (newCapacity <= capacity_)
return;
// Allocate space for terminator as well
char *nb = new char[newCapacity + 1];
if (size_ > 0 && buffer_) {
std::memcpy(nb, buffer_, size_);
}
delete[] buffer_;
buffer_ = nb;
capacity_ = newCapacity;
setTerminator();
}
void
GapBuffer::AppendChar(const char c)
{
ensureCapacityFor(1);
buffer_[size_++] = c;
setTerminator();
}
void
GapBuffer::Append(const char *s, const std::size_t len)
{
if (!s || len == 0)
return;
ensureCapacityFor(len);
std::memcpy(buffer_ + size_, s, len);
size_ += len;
setTerminator();
}
void
GapBuffer::Append(const GapBuffer &other)
{
if (other.size_ == 0)
return;
Append(other.buffer_, other.size_);
}
void
GapBuffer::PrependChar(char c)
{
ensureCapacityFor(1);
// shift right by 1
if (size_ > 0) {
std::memmove(buffer_ + 1, buffer_, size_);
}
buffer_[0] = c;
++size_;
setTerminator();
}
void
GapBuffer::Prepend(const char *s, std::size_t len)
{
if (!s || len == 0)
return;
ensureCapacityFor(len);
if (size_ > 0) {
std::memmove(buffer_ + len, buffer_, size_);
}
std::memcpy(buffer_, s, len);
size_ += len;
setTerminator();
}
void
GapBuffer::Prepend(const GapBuffer &other)
{
if (other.size_ == 0)
return;
Prepend(other.buffer_, other.size_);
}
void
GapBuffer::Clear()
{
size_ = 0;
setTerminator();
}
void
GapBuffer::ensureCapacityFor(std::size_t delta)
{
if (capacity_ - size_ >= delta)
return;
auto required = size_ + delta;
Reserve(growCapacity(capacity_, required));
}
std::size_t
GapBuffer::growCapacity(std::size_t current, std::size_t required)
{
// geometric growth, at least required
std::size_t newCap = current ? current : 8;
while (newCap < required)
newCap = newCap + (newCap >> 1); // 1.5x growth
return newCap;
}
void
GapBuffer::setTerminator() const
{
if (!buffer_) {
return;
}
buffer_[size_] = '\0';
}

View File

@@ -1,80 +0,0 @@
/*
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
*/
#ifndef KTE_GAPBUFFER_H
#define KTE_GAPBUFFER_H
#include <cstddef>
class GapBuffer {
public:
GapBuffer();
explicit GapBuffer(std::size_t initialCapacity);
GapBuffer(const GapBuffer &other);
GapBuffer &operator=(const GapBuffer &other);
GapBuffer(GapBuffer &&other) noexcept;
GapBuffer &operator=(GapBuffer &&other) noexcept;
~GapBuffer();
void Reserve(std::size_t newCapacity);
void AppendChar(char c);
void Append(const char *s, std::size_t len);
void Append(const GapBuffer &other);
void PrependChar(char c);
void Prepend(const char *s, std::size_t len);
void Prepend(const GapBuffer &other);
// Content management
void Clear();
// Accessors
char *Data()
{
return buffer_;
}
[[nodiscard]] const char *Data() const
{
return buffer_;
}
[[nodiscard]] std::size_t Size() const
{
return size_;
}
[[nodiscard]] std::size_t Capacity() const
{
return capacity_;
}
private:
void ensureCapacityFor(std::size_t delta);
static std::size_t growCapacity(std::size_t current, std::size_t required);
void setTerminator() const;
char *buffer_ = nullptr;
std::size_t size_ = 0; // number of valid bytes (excluding terminator)
std::size_t capacity_ = 0; // capacity of buffer_ excluding space for terminator
};
#endif // KTE_GAPBUFFER_H

View File

@@ -1,48 +0,0 @@
#include "GoHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
GoHighlighter::GoHighlighter()
{
const char* kw[] = {"break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"};
for (auto s: kw) kws_.insert(s);
const char* tp[] = {"bool","byte","complex64","complex128","error","float32","float64","int","int8","int16","int32","int64","rune","string","uint","uint8","uint16","uint32","uint64","uintptr"};
for (auto s: tp) types_.insert(s);
}
void GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
int i = 0;
int bol=0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
// line comment
while (i < n) {
char c = s[i];
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
if (c=='/' && i+1<n && s[i+1]=='*') {
int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; }
if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; }
}
if (c=='"' || c=='`') {
char q=c; int j=i+1; bool esc=false; if (q=='`') { while (j<n && s[j] != '`') ++j; if (j<n) ++j; }
else { while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break;} }
push(out,i,j,TokenKind::String); i=j; continue;
}
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='x'||s[j]=='X'||s[j]=='_')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
push(out,i,i+1,TokenKind::Default); ++i;
}
}
} // namespace kte

View File

@@ -22,9 +22,12 @@ HelpText::Text()
" C-k ' Toggle read-only\n" " C-k ' Toggle read-only\n"
" C-k - Unindent region (mark required)\n" " C-k - Unindent region (mark required)\n"
" C-k = Indent region (mark required)\n" " C-k = Indent region (mark required)\n"
" C-k / Toggle visual line mode\n"
" C-k ; Command prompt (:\\ )\n" " C-k ; Command prompt (:\\ )\n"
" C-k SPACE Toggle mark\n"
" C-k C-d Kill entire line\n" " C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n" " C-k C-q Quit now (no confirm)\n"
" C-k C-s Save\n"
" C-k C-x Save and quit\n" " C-k C-x Save and quit\n"
" C-k a Mark start of file, jump to end\n" " C-k a Mark start of file, jump to end\n"
" C-k b Switch buffer\n" " C-k b Switch buffer\n"
@@ -34,8 +37,11 @@ HelpText::Text()
" C-k f Flush kill ring\n" " C-k f Flush kill ring\n"
" C-k g Jump to line\n" " C-k g Jump to line\n"
" C-k h Show this help\n" " C-k h Show this help\n"
" C-k i New empty buffer\n"
" C-k j Jump to mark\n" " C-k j Jump to mark\n"
" C-k k Center viewport on cursor\n"
" C-k l Reload buffer from disk\n" " C-k l Reload buffer from disk\n"
" C-k m Toggle edit mode (code/writing)\n"
" C-k n Previous buffer\n" " C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n" " C-k o Change working directory (prompt)\n"
" C-k p Next buffer\n" " C-k p Next buffer\n"
@@ -59,6 +65,10 @@ HelpText::Text()
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n" " ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
" ESC q Reflow paragraph\n" " ESC q Reflow paragraph\n"
"\n" "\n"
"Universal argument:\n"
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
"\n"
"Control keys:\n" "Control keys:\n"
" C-a C-e Line start / end\n" " C-a C-e Line start / end\n"
" C-b C-f Move left / right\n" " C-b C-f Move left / right\n"
@@ -70,12 +80,32 @@ HelpText::Text()
" C-t Regex search & replace\n" " C-t Regex search & replace\n"
" C-h Search & replace\n" " C-h Search & replace\n"
" C-l / C-g Refresh / Cancel\n" " C-l / C-g Refresh / Cancel\n"
" C-u [digits] Universal argument (repeat count)\n"
"\n" "\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n" "Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n" "\n"
"GUI appearance (command prompt):\n" "Edit modes:\n"
" : theme NAME Set GUI theme (eink, gruvbox, nord, plan9, solarized)\n" " code Monospace font (default for source files)\n"
" : background MODE Set background: light | dark (affects eink, gruvbox, solarized)\n" " writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
" C-k m or : mode [code|writing] to toggle\n"
"\n"
"GUI commands (command prompt):\n"
" : theme NAME Set theme (amber, eink, everforest, gruvbox,\n"
" kanagawa-paper, lcars, leuchtturm, nord, old-book,\n"
" orbital, plan9, solarized, tufte, weyland-yutani,\n"
" zenburn)\n"
" : background MODE Background: light | dark\n"
" : font NAME Set font (tab completes)\n"
" : font-size NUM Set font size in pixels\n"
" : mode [code|writing] Toggle or set edit mode\n"
"\n"
"Configuration:\n"
" Config file: ~/.config/kte/kge.toml (see CONFIG.md)\n"
" Legacy kge.ini is also supported.\n"
"\n"
"GUI window management:\n"
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
" Close window Secondary windows close independently; closing the\n"
" primary window quits the editor\n"
); );
} }

View File

@@ -1,9 +1,7 @@
/* /*
* HelpText.h - embedded/customizable help content * HelpText.h - embedded/customizable help content
*/ */
#ifndef KTE_HELPTEXT_H #pragma once
#define KTE_HELPTEXT_H
#include <string> #include <string>
class HelpText { class HelpText {
@@ -13,5 +11,3 @@ public:
// (in HelpText.cc) without touching the help command logic. // (in HelpText.cc) without touching the help command logic.
static std::string Text(); static std::string Text();
}; };
#endif // KTE_HELPTEXT_H

View File

@@ -5,35 +5,33 @@
#include <vector> #include <vector>
namespace kte { namespace kte {
// Token kinds shared between renderers and highlighters // Token kinds shared between renderers and highlighters
enum class TokenKind { enum class TokenKind {
Default, Default,
Keyword, Keyword,
Type, Type,
String, String,
Char, Char,
Comment, Comment,
Number, Number,
Preproc, Preproc,
Constant, Constant,
Function, Function,
Operator, Operator,
Punctuation, Punctuation,
Identifier, Identifier,
Whitespace, Whitespace,
Error Error
}; };
struct HighlightSpan { struct HighlightSpan {
int col_start{0}; // inclusive, 0-based columns in buffer indices int col_start{0}; // inclusive, 0-based columns in buffer indices
int col_end{0}; // exclusive int col_end{0}; // exclusive
TokenKind kind{TokenKind::Default}; TokenKind kind{TokenKind::Default};
}; };
struct LineHighlight { struct LineHighlight {
std::vector<HighlightSpan> spans; std::vector<HighlightSpan> spans;
std::uint64_t version{0}; // buffer version used for this line std::uint64_t version{0}; // buffer version used for this line
}; };
} // namespace kte } // namespace kte

View File

@@ -1,181 +0,0 @@
#include "HighlighterEngine.h"
#include "Buffer.h"
#include "LanguageHighlighter.h"
#include <thread>
namespace kte {
HighlighterEngine::HighlighterEngine() = default;
HighlighterEngine::~HighlighterEngine()
{
// stop background worker
if (worker_running_.load()) {
{
std::lock_guard<std::mutex> lock(mtx_);
worker_running_.store(false);
has_request_ = true; // wake it up to exit
}
cv_.notify_one();
if (worker_.joinable()) worker_.join();
}
}
void
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
{
std::lock_guard<std::mutex> lock(mtx_);
hl_ = std::move(hl);
cache_.clear();
state_cache_.clear();
state_last_contig_.clear();
}
const LineHighlight &
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
{
std::unique_lock<std::mutex> lock(mtx_);
auto it = cache_.find(row);
if (it != cache_.end() && it->second.version == buf_version) {
return it->second;
}
// Prepare destination slot to reuse its capacity and avoid allocations
LineHighlight &slot = cache_[row];
slot.version = buf_version;
slot.spans.clear();
if (!hl_) {
return slot;
}
// Copy shared_ptr-like raw pointer for use outside critical sections
LanguageHighlighter *hl_ptr = hl_.get();
bool is_stateful = dynamic_cast<StatefulHighlighter *>(hl_ptr) != nullptr;
if (!is_stateful) {
// Stateless fast path: we can release the lock while computing to reduce contention
auto &out = slot.spans;
lock.unlock();
hl_ptr->HighlightLine(buf, row, out);
return cache_.at(row);
}
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
// but release during heavy computation.
auto *stateful = static_cast<StatefulHighlighter *>(hl_ptr);
StatefulHighlighter::LineState prev_state;
int start_row = -1;
if (!state_cache_.empty()) {
// linear search over map (unordered), track best candidate
int best = -1;
for (const auto &kv : state_cache_) {
int r = kv.first;
if (r <= row - 1 && kv.second.version == buf_version) {
if (r > best) best = r;
}
}
if (best >= 0) {
start_row = best;
prev_state = state_cache_.at(best).state;
}
}
// We'll compute states and the target line's spans without holding the lock for most of the work.
// Create a local copy of prev_state and iterate rows; we will update caches under lock.
lock.unlock();
StatefulHighlighter::LineState cur_state = prev_state;
for (int r = start_row + 1; r <= row; ++r) {
std::vector<HighlightSpan> tmp;
std::vector<HighlightSpan> &out = (r == row) ? slot.spans : tmp;
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
// Update state cache for r
std::lock_guard<std::mutex> gl(mtx_);
StateEntry se;
se.version = buf_version;
se.state = next_state;
state_cache_[r] = se;
cur_state = next_state;
}
// Return reference under lock to ensure slot's address stability in map
lock.lock();
return cache_.at(row);
}
void
HighlighterEngine::InvalidateFrom(int row)
{
std::lock_guard<std::mutex> lock(mtx_);
if (cache_.empty()) return;
// Simple implementation: erase all rows >= row
for (auto it = cache_.begin(); it != cache_.end(); ) {
if (it->first >= row) it = cache_.erase(it); else ++it;
}
if (!state_cache_.empty()) {
for (auto it = state_cache_.begin(); it != state_cache_.end(); ) {
if (it->first >= row) it = state_cache_.erase(it); else ++it;
}
}
}
void HighlighterEngine::ensure_worker_started() const
{
if (worker_running_.load()) return;
worker_running_.store(true);
worker_ = std::thread([this]() { this->worker_loop(); });
}
void HighlighterEngine::worker_loop() const
{
std::unique_lock<std::mutex> lock(mtx_);
while (worker_running_.load()) {
cv_.wait(lock, [this]() { return has_request_ || !worker_running_.load(); });
if (!worker_running_.load()) break;
WarmRequest req = pending_;
has_request_ = false;
// Copy locals then release lock while computing
lock.unlock();
if (req.buf) {
int start = std::max(0, req.start_row);
int end = std::max(start, req.end_row);
for (int r = start; r <= end; ++r) {
// Re-check version staleness quickly by peeking cache version; not strictly necessary
// Compute line; GetLine is thread-safe
(void)this->GetLine(*req.buf, r, req.version);
}
}
lock.lock();
}
}
void HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version, int warm_margin) const
{
if (row_count <= 0) return;
// Synchronously compute visible rows to ensure cache hits during draw
int start = std::max(0, first_row);
int end = start + row_count - 1;
int max_rows = static_cast<int>(buf.Nrows());
if (start >= max_rows) return;
if (end >= max_rows) end = max_rows - 1;
for (int r = start; r <= end; ++r) {
(void)GetLine(buf, r, buf_version);
}
// Enqueue background warm-around
int warm_start = std::max(0, start - warm_margin);
int warm_end = std::min(max_rows - 1, end + warm_margin);
{
std::lock_guard<std::mutex> lock(mtx_);
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
has_request_ = true;
}
ensure_worker_started();
cv_.notify_one();
}
} // namespace kte

View File

@@ -1,76 +0,0 @@
// HighlighterEngine.h - caching layer for per-line highlights
#pragma once
#include <cstdint>
#include <memory>
#include <unordered_map>
#include <vector>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <thread>
#include "Highlight.h"
#include "LanguageHighlighter.h"
class Buffer;
namespace kte {
class HighlighterEngine {
public:
HighlighterEngine();
~HighlighterEngine();
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Retrieve highlights for a given line and buffer version.
// If cache is stale, recompute using the current highlighter.
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive)
void InvalidateFrom(int row);
bool HasHighlighter() const { return static_cast<bool>(hl_); }
// Phase 3: viewport-first prefetch and background warming
// Compute only the visible range now, and enqueue a background warm-around task.
// warm_margin: how many extra lines above/below to warm in the background.
void PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version, int warm_margin = 200) const;
private:
std::unique_ptr<LanguageHighlighter> hl_;
// Simple cache by row index (mutable to allow caching in const GetLine)
mutable std::unordered_map<int, LineHighlight> cache_;
// For stateful highlighters, remember per-line state (state after finishing that row)
struct StateEntry {
std::uint64_t version{0};
// Using the interface type; forward-declare via header
StatefulHighlighter::LineState state;
};
mutable std::unordered_map<int, StateEntry> state_cache_;
// Track best known contiguous state row for a given version to avoid O(n) scans
mutable std::unordered_map<std::uint64_t, int> state_last_contig_;
// Thread-safety for caches and background worker state
mutable std::mutex mtx_;
// Background warmer
struct WarmRequest {
const Buffer *buf{nullptr};
std::uint64_t version{0};
int start_row{0};
int end_row{0}; // inclusive
};
mutable std::condition_variable cv_;
mutable std::thread worker_;
mutable std::atomic<bool> worker_running_{false};
mutable bool has_request_{false};
mutable WarmRequest pending_{};
void ensure_worker_started() const;
void worker_loop() const;
};
} // namespace kte

View File

@@ -1,157 +0,0 @@
#include "HighlighterRegistry.h"
#include "CppHighlighter.h"
#include <algorithm>
#include <filesystem>
#include <vector>
#include <cctype>
// Forward declare simple highlighters implemented in this project
namespace kte {
// Registration storage
struct RegEntry {
std::string ft; // normalized
HighlighterRegistry::Factory factory;
};
static std::vector<RegEntry> &registry() {
static std::vector<RegEntry> reg;
return reg;
}
class JSONHighlighter; class MarkdownHighlighter; class ShellHighlighter;
class GoHighlighter; class PythonHighlighter; class RustHighlighter; class LispHighlighter;
}
// Headers for the above
#include "JsonHighlighter.h"
#include "MarkdownHighlighter.h"
#include "ShellHighlighter.h"
#include "GoHighlighter.h"
#include "PythonHighlighter.h"
#include "RustHighlighter.h"
#include "LispHighlighter.h"
namespace kte {
static std::string to_lower(std::string_view s) {
std::string r(s);
std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c){ return static_cast<char>(std::tolower(c)); });
return r;
}
std::string HighlighterRegistry::Normalize(std::string_view ft)
{
std::string f = to_lower(ft);
if (f == "c" || f == "c++" || f == "cc" || f == "hpp" || f == "hh" || f == "h" || f == "cxx") return "cpp";
if (f == "cpp") return "cpp";
if (f == "json") return "json";
if (f == "markdown" || f == "md" || f == "mkd" || f == "mdown") return "markdown";
if (f == "shell" || f == "sh" || f == "bash" || f == "zsh" || f == "ksh" || f == "fish") return "shell";
if (f == "go" || f == "golang") return "go";
if (f == "py" || f == "python") return "python";
if (f == "rs" || f == "rust") return "rust";
if (f == "lisp" || f == "scheme" || f == "scm" || f == "rkt" || f == "el" || f == "clj" || f == "cljc" || f == "cl") return "lisp";
return f;
}
std::unique_ptr<LanguageHighlighter> HighlighterRegistry::CreateFor(std::string_view filetype)
{
std::string ft = Normalize(filetype);
// Prefer externally registered factories
for (const auto &e : registry()) {
if (e.ft == ft && e.factory) return e.factory();
}
if (ft == "cpp") return std::make_unique<CppHighlighter>();
if (ft == "json") return std::make_unique<JSONHighlighter>();
if (ft == "markdown") return std::make_unique<MarkdownHighlighter>();
if (ft == "shell") return std::make_unique<ShellHighlighter>();
if (ft == "go") return std::make_unique<GoHighlighter>();
if (ft == "python") return std::make_unique<PythonHighlighter>();
if (ft == "rust") return std::make_unique<RustHighlighter>();
if (ft == "lisp") return std::make_unique<LispHighlighter>();
return nullptr;
}
static std::string shebang_to_ft(std::string_view first_line) {
if (first_line.size() < 2 || first_line.substr(0,2) != "#!") return "";
std::string low = to_lower(first_line);
if (low.find("python") != std::string::npos) return "python";
if (low.find("bash") != std::string::npos) return "shell";
if (low.find("sh") != std::string::npos) return "shell";
if (low.find("zsh") != std::string::npos) return "shell";
if (low.find("fish") != std::string::npos) return "shell";
if (low.find("scheme") != std::string::npos || low.find("racket") != std::string::npos || low.find("guile") != std::string::npos) return "lisp";
return "";
}
std::string HighlighterRegistry::DetectForPath(std::string_view path, std::string_view first_line)
{
// Extension
std::string p(path);
std::error_code ec;
std::string ext = std::filesystem::path(p).extension().string();
for (auto &ch: ext) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
if (!ext.empty()) {
if (ext == ".c" || ext == ".cc" || ext == ".cpp" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".hh") return "cpp";
if (ext == ".json") return "json";
if (ext == ".md" || ext == ".markdown" || ext == ".mkd") return "markdown";
if (ext == ".sh" || ext == ".bash" || ext == ".zsh" || ext == ".ksh" || ext == ".fish") return "shell";
if (ext == ".go") return "go";
if (ext == ".py") return "python";
if (ext == ".rs") return "rust";
if (ext == ".lisp" || ext == ".scm" || ext == ".rkt" || ext == ".el" || ext == ".clj" || ext == ".cljc" || ext == ".cl") return "lisp";
}
// Shebang
std::string ft = shebang_to_ft(first_line);
return ft;
}
} // namespace kte
// Extensibility API implementations
namespace kte {
void HighlighterRegistry::Register(std::string_view filetype, Factory factory, bool override_existing)
{
std::string ft = Normalize(filetype);
for (auto &e : registry()) {
if (e.ft == ft) {
if (override_existing) e.factory = std::move(factory);
return;
}
}
registry().push_back(RegEntry{ft, std::move(factory)});
}
bool HighlighterRegistry::IsRegistered(std::string_view filetype)
{
std::string ft = Normalize(filetype);
for (const auto &e : registry()) if (e.ft == ft) return true;
return false;
}
std::vector<std::string> HighlighterRegistry::RegisteredFiletypes()
{
std::vector<std::string> out;
out.reserve(registry().size());
for (const auto &e : registry()) out.push_back(e.ft);
return out;
}
#ifdef KTE_ENABLE_TREESITTER
// Forward declare adapter factory
std::unique_ptr<LanguageHighlighter> CreateTreeSitterHighlighter(const char* filetype,
const void* (*get_lang)());
void HighlighterRegistry::RegisterTreeSitter(std::string_view filetype,
const TSLanguage* (*get_language)())
{
std::string ft = Normalize(filetype);
Register(ft, [ft, get_language]() {
return CreateTreeSitterHighlighter(ft.c_str(), reinterpret_cast<const void* (*)()>(get_language));
}, /*override_existing=*/true);
}
#endif
} // namespace kte

View File

@@ -1,49 +0,0 @@
// HighlighterRegistry.h - create/detect language highlighters and allow external registration
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "LanguageHighlighter.h"
namespace kte {
class HighlighterRegistry {
public:
using Factory = std::function<std::unique_ptr<LanguageHighlighter>()>;
// Create a highlighter for normalized filetype id (e.g., "cpp", "json", "markdown", "shell", "go", "python", "rust", "lisp").
static std::unique_ptr<LanguageHighlighter> CreateFor(std::string_view filetype);
// Detect filetype by path extension and shebang (first line).
// Returns normalized id or empty string if unknown.
static std::string DetectForPath(std::string_view path, std::string_view first_line);
// Normalize various aliases/extensions to canonical ids.
static std::string Normalize(std::string_view ft);
// Extensibility: allow external code to register highlighters at runtime.
// The filetype key is normalized via Normalize(). If a factory is already registered for the
// normalized key and override=false, the existing factory is kept.
static void Register(std::string_view filetype, Factory factory, bool override_existing = true);
// Returns true if a factory is registered for the (normalized) filetype.
static bool IsRegistered(std::string_view filetype);
// Return a list of currently registered (normalized) filetypes. Primarily for diagnostics/tests.
static std::vector<std::string> RegisteredFiletypes();
#ifdef KTE_ENABLE_TREESITTER
// Forward declaration to avoid hard dependency when disabled.
struct TSLanguage;
// Convenience: register a Tree-sitter-backed highlighter for a filetype.
// The getter should return a non-null language pointer for the grammar.
static void RegisterTreeSitter(std::string_view filetype,
const TSLanguage* (*get_language)());
#endif
};
} // namespace kte

670
ImGuiFrontend.cc Normal file
View File

@@ -0,0 +1,670 @@
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
#include <imgui.h>
#include <SDL.h>
#include <SDL_opengl.h>
#include <backends/imgui_impl_opengl3.h>
#include <backends/imgui_impl_sdl2.h>
#include "ImGuiFrontend.h"
#include "Command.h"
#include "Editor.h"
#include "GUIConfig.h"
#include "GUITheme.h"
#include "fonts/Font.h" // embedded default font (DefaultFont)
#include "fonts/FontRegistry.h"
#include "fonts/IosevkaExtended.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h"
#ifndef KTE_FONT_SIZE
#define KTE_FONT_SIZE 16.0f
#endif
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static void
apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
{
if (!b)
return;
// Auto-detect edit mode from file extension
if (!b->Filename().empty())
b->SetEditMode(DetectEditMode(b->Filename()));
if (cfg.syntax) {
b->SetSyntaxEnabled(true);
b->EnsureHighlighter();
if (auto *eng = b->Highlighter()) {
if (!eng->HasHighlighter()) {
std::string first_line;
const auto &rows = b->Rows();
if (!rows.empty())
first_line = static_cast<std::string>(rows[0]);
std::string ft = kte::HighlighterRegistry::DetectForPath(
b->Filename(), first_line);
if (!ft.empty()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
b->SetFiletype(ft);
eng->InvalidateFrom(0);
} else {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
b->SetFiletype("");
eng->InvalidateFrom(0);
}
}
}
} else {
b->SetSyntaxEnabled(false);
}
}
// Update editor logical rows/cols from current ImGui metrics for a given display size.
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;
if (row_h <= 0.0f)
row_h = 16.0f;
if (ch_w <= 0.0f)
ch_w = 8.0f;
const float pad_x = 6.0f;
const float pad_y = 6.0f;
float wanted_bar_h = ImGui::GetFrameHeight();
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
std::size_t rows = content_rows + 1;
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
if (rows != ed.Rows() || cols != ed.Cols()) {
ed.SetDimensions(rows, cols);
}
}
// ---------------------------------------------------------------------------
// SetupImGuiStyle_ — apply theme, fonts, and flags to the current ImGui context
// ---------------------------------------------------------------------------
void
GUIFrontend::SetupImGuiStyle_()
{
ImGuiIO &io = ImGui::GetIO();
// Disable imgui.ini for secondary windows (primary sets its own path in Init)
io.IniFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark();
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(config_.theme);
// Load fonts into this context's font atlas.
// Font registry is global and already populated by Init; just load into this atlas.
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
LoadGuiFont_(nullptr, (float) config_.font_size);
}
}
// ---------------------------------------------------------------------------
// Destroy a single window's ImGui context + SDL/GL resources
// ---------------------------------------------------------------------------
void
GUIFrontend::DestroyWindowResources_(WindowState &ws)
{
if (ws.imgui_ctx) {
// Must activate this window's GL context before shutting down the
// OpenGL3 backend, otherwise it deletes another context's resources.
if (ws.window && ws.gl_ctx)
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
ImGui::SetCurrentContext(ws.imgui_ctx);
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext(ws.imgui_ctx);
ws.imgui_ctx = nullptr;
}
if (ws.gl_ctx) {
SDL_GL_DeleteContext(ws.gl_ctx);
ws.gl_ctx = nullptr;
}
if (ws.window) {
SDL_DestroyWindow(ws.window);
ws.window = nullptr;
}
}
bool
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{
(void) argc;
(void) argv;
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
config_ = GUIConfig::Load();
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false;
}
// GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
// Compute desired window size from config
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
int init_w = 1280, init_h = 800;
if (config_.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
init_w = usable.w;
init_h = usable.h;
}
#if !defined(__APPLE__)
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
#endif
} else {
int w = config_.columns * static_cast<int>(config_.font_size);
int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
w = std::min(w, usable.w);
h = std::min(h, usable.h);
}
init_w = std::max(320, w);
init_h = std::max(200, h);
}
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
init_w, init_h,
win_flags);
if (!win) {
return false;
}
SDL_EnableScreenSaver();
#if defined(__APPLE__)
if (config_.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(win, usable.x, usable.y);
}
}
#endif
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx) {
SDL_DestroyWindow(win);
return false;
}
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION();
ImGuiContext *imgui_ctx = ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
// Set custom ini filename path to ~/.config/kte/imgui.ini
if (const char *home = std::getenv("HOME")) {
namespace fs = std::filesystem;
fs::path config_dir = fs::path(home) / ".config" / "kte";
std::error_code ec;
if (!fs::exists(config_dir)) {
fs::create_directories(config_dir, ec);
}
if (fs::exists(config_dir)) {
static std::string ini_path = (config_dir / "imgui.ini").string();
io.IniFilename = ini_path.c_str();
}
}
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark();
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(config_.theme);
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
return false;
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
return false;
// Cache initial window size
int w, h;
SDL_GetWindowSize(win, &w, &h);
init_w = w;
init_h = h;
#if defined(__APPLE__)
if (w > 1 && h > 1) {
SDL_SetWindowSize(win, w - 1, h - 1);
SDL_SetWindowSize(win, w, h);
SDL_GetWindowSize(win, &w, &h);
init_w = w;
init_h = h;
}
#endif
// Install embedded fonts
kte::Fonts::InstallDefaultFonts();
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
LoadGuiFont_(nullptr, (float) config_.font_size);
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
std::string n;
float s = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
kte::Fonts::FontRegistry::Instance().LoadFont(n, s);
}
}
// Build primary WindowState
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->imgui_ctx = imgui_ctx;
ws->width = init_w;
ws->height = init_h;
// The primary window's editor IS the editor passed in from main; we don't
// use ws->editor for the primary — instead we keep a pointer to &ed.
// We store a sentinel: window index 0 uses the external editor reference.
// To keep things simple, attach input to the passed-in editor.
ws->input.Attach(&ed);
windows_.push_back(std::move(ws));
return true;
}
bool
GUIFrontend::OpenNewWindow_(Editor &primary)
{
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
int w = windows_[0]->width;
int h = windows_[0]->height;
SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
w, h,
win_flags);
if (!win)
return false;
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx) {
SDL_DestroyWindow(win);
return false;
}
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1);
// Each window gets its own ImGui context — ImGui requires exactly one
// NewFrame/Render cycle per context per frame.
ImGuiContext *imgui_ctx = ImGui::CreateContext();
ImGui::SetCurrentContext(imgui_ctx);
SetupImGuiStyle_();
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx)) {
ImGui::DestroyContext(imgui_ctx);
SDL_GL_DeleteContext(gl_ctx);
SDL_DestroyWindow(win);
return false;
}
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) {
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext(imgui_ctx);
SDL_GL_DeleteContext(gl_ctx);
SDL_DestroyWindow(win);
return false;
}
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->imgui_ctx = imgui_ctx;
ws->width = w;
ws->height = h;
// Secondary editor shares the primary's buffer list
ws->editor.SetSharedBuffers(&primary.Buffers());
ws->editor.SetDimensions(primary.Rows(), primary.Cols());
// Open a new untitled buffer and switch to it in the new window.
ws->editor.AddBuffer(Buffer());
ws->editor.SwitchTo(ws->editor.BufferCount() - 1);
ws->input.Attach(&ws->editor);
windows_.push_back(std::move(ws));
// Restore primary context
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
return true;
}
void
GUIFrontend::Step(Editor &ed, bool &running)
{
// --- Event processing ---
// SDL events carry a window ID. Route each event to the correct window's
// ImGui context (for ImGui_ImplSDL2_ProcessEvent) and input handler.
SDL_Event e;
while (SDL_PollEvent(&e)) {
// Determine which window this event belongs to
Uint32 event_win_id = 0;
switch (e.type) {
case SDL_WINDOWEVENT:
event_win_id = e.window.windowID;
break;
case SDL_KEYDOWN:
case SDL_KEYUP:
event_win_id = e.key.windowID;
break;
case SDL_TEXTINPUT:
event_win_id = e.text.windowID;
break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
event_win_id = e.button.windowID;
break;
case SDL_MOUSEWHEEL:
event_win_id = e.wheel.windowID;
break;
case SDL_MOUSEMOTION:
event_win_id = e.motion.windowID;
break;
default:
break;
}
if (e.type == SDL_QUIT) {
running = false;
break;
}
// Find the target window and route the event to its ImGui context
WindowState *target = nullptr;
std::size_t target_idx = 0;
if (event_win_id != 0) {
for (std::size_t i = 0; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
target = windows_[i].get();
target_idx = i;
break;
}
}
}
if (target && target->imgui_ctx) {
// Set this window's ImGui context so ImGui_ImplSDL2_ProcessEvent
// updates the correct IO state.
ImGui::SetCurrentContext(target->imgui_ctx);
ImGui_ImplSDL2_ProcessEvent(&e);
}
if (e.type == SDL_WINDOWEVENT) {
if (e.window.event == SDL_WINDOWEVENT_CLOSE) {
if (target) {
if (target_idx == 0) {
running = false;
} else {
target->alive = false;
}
}
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
if (target) {
target->width = e.window.data1;
target->height = e.window.data2;
}
}
}
// Route input events to the correct window's input handler
if (target) {
target->input.ProcessSDLEvent(e);
}
}
if (!running)
return;
// --- Apply pending font change (to all contexts) ---
{
std::string fname;
float fsize = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
if (!fname.empty() && fsize > 0.0f) {
for (auto &ws : windows_) {
if (!ws->alive || !ws->imgui_ctx)
continue;
ImGui::SetCurrentContext(ws->imgui_ctx);
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
}
}
}
}
// --- Step each window ---
// We iterate by index because OpenNewWindow_ may append to windows_.
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
WindowState &ws = *windows_[wi];
if (!ws.alive)
continue;
Editor &wed = (wi == 0) ? ed : ws.editor;
// Shared buffer list may have been modified by another window.
wed.ValidateBufferIndex();
// Activate this window's GL and ImGui contexts
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
ImGui::SetCurrentContext(ws.imgui_ctx);
// Start a new ImGui frame
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame(ws.window);
ImGui::NewFrame();
// Update editor dimensions
{
ImGuiIO &io = ImGui::GetIO();
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
update_editor_dimensions(wed, disp_w, disp_h);
}
// Allow deferred opens
wed.ProcessPendingOpens();
// Ensure newly opened buffers get syntax + edit mode detection
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
// Drain input queue
for (;;) {
MappedInput mi;
if (!ws.input.Poll(mi))
break;
if (mi.hasCommand) {
if (mi.id == CommandId::NewWindow) {
// Open a new window; handled after this loop
wed.SetNewWindowRequested(true);
} else if (mi.id == CommandId::FontZoomIn ||
mi.id == CommandId::FontZoomOut ||
mi.id == CommandId::FontZoomReset) {
auto &fr = kte::Fonts::FontRegistry::Instance();
float cur = fr.CurrentFontSize();
if (cur <= 0.0f) cur = config_.font_size;
float next = cur;
if (mi.id == CommandId::FontZoomIn)
next = std::min(cur + 2.0f, 72.0f);
else if (mi.id == CommandId::FontZoomOut)
next = std::max(cur - 2.0f, 8.0f);
else
next = config_.font_size; // reset to config default
if (next != cur)
fr.RequestLoadFont(fr.CurrentFontName(), next);
} else {
const std::string before = wed.KillRingHead();
Execute(wed, mi.id, mi.arg, mi.count);
const std::string after = wed.KillRingHead();
if (after != before && !after.empty()) {
SDL_SetClipboardText(after.c_str());
}
}
}
}
if (wi == 0 && wed.QuitRequested()) {
running = false;
}
// Switch font based on current buffer's edit mode
{
Buffer *cur = wed.CurrentBuffer();
if (cur) {
auto &fr = kte::Fonts::FontRegistry::Instance();
const std::string &expected =
(cur->GetEditMode() == EditMode::Writing)
? config_.writing_font
: config_.code_font;
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
float sz = fr.CurrentFontSize();
if (sz <= 0.0f) sz = config_.font_size;
fr.RequestLoadFont(expected, sz);
}
}
}
// Draw
ws.renderer.Draw(wed);
// Render
ImGui::Render();
int display_w, display_h;
SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(ws.window);
}
// Handle deferred new-window requests (must happen outside the render loop
// to avoid corrupting an in-progress ImGui frame).
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
Editor &wed = (wi == 0) ? ed : windows_[wi]->editor;
if (wed.NewWindowRequested()) {
wed.SetNewWindowRequested(false);
OpenNewWindow_(ed);
}
}
// Remove dead secondary windows
for (auto it = windows_.begin() + 1; it != windows_.end();) {
if (!(*it)->alive) {
DestroyWindowResources_(**it);
it = windows_.erase(it);
} else {
++it;
}
}
// Restore primary context
if (!windows_.empty()) {
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
}
}
void
GUIFrontend::Shutdown()
{
// Destroy all windows (secondary first, then primary)
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
DestroyWindowResources_(**it);
}
windows_.clear();
SDL_Quit();
}
bool
GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
{
const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear();
ImFontConfig config;
config.MergeMode = false;
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize,
size_px,
&config,
io.Fonts->GetGlyphRangesDefault());
config.MergeMode = true;
static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic
0x2200, 0x22FF, // Mathematical Operators
0,
};
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
size_px,
&config,
extended_ranges);
io.Fonts->Build();
return true;
}

59
ImGuiFrontend.h Normal file
View File

@@ -0,0 +1,59 @@
/*
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
*/
#pragma once
#include <memory>
#include <vector>
#include "Frontend.h"
#include "GUIConfig.h"
#include "ImGuiInputHandler.h"
#include "ImGuiRenderer.h"
#include "Editor.h"
struct SDL_Window;
struct ImGuiContext;
typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend {
public:
GUIFrontend() = default;
~GUIFrontend() override = default;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;
void Shutdown() override;
private:
// Per-window state — each window owns its own ImGui context so that
// NewFrame/Render cycles are fully independent (ImGui requires exactly
// one NewFrame per Render per context).
struct WindowState {
SDL_Window *window = nullptr;
SDL_GLContext gl_ctx = nullptr;
ImGuiContext *imgui_ctx = nullptr;
ImGuiInputHandler input{};
ImGuiRenderer renderer{};
Editor editor{};
int width = 1280;
int height = 800;
bool alive = true;
};
// Open a new secondary window sharing the primary editor's buffer list.
// Returns false if window creation fails.
bool OpenNewWindow_(Editor &primary);
// Initialize fonts and theme for a given ImGui context (must be current).
void SetupImGuiStyle_();
static void DestroyWindowResources_(WindowState &ws);
static bool LoadGuiFont_(const char *path, float size_px);
GUIConfig config_{};
// Primary window (index 0 in windows_); created during Init.
std::vector<std::unique_ptr<WindowState> > windows_;
};

View File

@@ -5,8 +5,9 @@
#include <SDL.h> #include <SDL.h>
#include <imgui.h> #include <imgui.h>
#include "GUIInputHandler.h" #include "ImGuiInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Editor.h"
static bool static bool
@@ -14,20 +15,17 @@ map_key(const SDL_Keycode key,
const SDL_Keymod mod, const SDL_Keymod mod,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
// universal-argument state (by ref) bool &k_ctrl_pending,
bool &uarg_active, Editor *ed,
bool &uarg_collecting, MappedInput &out,
bool &uarg_negative, bool &suppress_textinput_once)
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
MappedInput &out)
{ {
// Ctrl handling // Ctrl handling
const bool is_ctrl = (mod & KMOD_CTRL) != 0; const bool is_ctrl = (mod & KMOD_CTRL) != 0;
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0; const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
// If previous key was ESC, interpret this as Meta via ESC keymap // If previous key was ESC, interpret this as Meta via ESC keymap.
// Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT.
if (esc_meta) { if (esc_meta) {
int ascii_key = 0; int ascii_key = 0;
if (key == SDLK_BACKSPACE) { if (key == SDLK_BACKSPACE) {
@@ -45,52 +43,74 @@ map_key(const SDL_Keycode key,
ascii_key = '>'; ascii_key = '>';
} }
if (ascii_key != 0) { if (ascii_key != 0) {
esc_meta = false; // consume if we can decide on KEYDOWN
ascii_key = KLowerAscii(ascii_key); ascii_key = KLowerAscii(ascii_key);
CommandId id; CommandId id;
if (KLookupEscCommand(ascii_key, id)) { if (KLookupEscCommand(ascii_key, id)) {
// Only consume the ESC-meta prefix if we actually mapped a command out = {true, id, "", 0};
esc_meta = false;
out = {true, id, "", 0};
return true; return true;
} }
// Known printable but unmapped ESC sequence: report invalid
out = {true, CommandId::UnknownEscCommand, "", 0};
return true;
} }
// Unhandled meta chord at KEYDOWN: do not clear esc_meta here. // No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
out.hasCommand = false; out.hasCommand = false;
return true; return true;
} }
// Movement and basic keys // Movement and basic keys
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (key) { switch (key) {
case SDLK_LEFT: case SDLK_LEFT:
out = {true, CommandId::MoveLeft, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true; return true;
case SDLK_RIGHT: case SDLK_RIGHT:
out = {true, CommandId::MoveRight, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true; return true;
case SDLK_UP: case SDLK_UP:
out = {true, CommandId::MoveUp, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true; return true;
case SDLK_DOWN: case SDLK_DOWN:
out = {true, CommandId::MoveDown, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true; return true;
case SDLK_HOME: case SDLK_HOME:
out = {true, CommandId::MoveHome, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true; return true;
case SDLK_END: case SDLK_END:
out = {true, CommandId::MoveEnd, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true; return true;
case SDLK_PAGEUP: case SDLK_PAGEUP:
out = {true, CommandId::PageUp, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true; return true;
case SDLK_PAGEDOWN: case SDLK_PAGEDOWN:
out = {true, CommandId::PageDown, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true; return true;
case SDLK_DELETE: case SDLK_DELETE:
out = {true, CommandId::DeleteChar, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true; return true;
case SDLK_BACKSPACE: case SDLK_BACKSPACE:
out = {true, CommandId::Backspace, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true; return true;
case SDLK_TAB: case SDLK_TAB:
// Insert a literal tab character when not interpreting a k-prefix suffix. // Insert a literal tab character when not interpreting a k-prefix suffix.
@@ -103,10 +123,17 @@ map_key(const SDL_Keycode key,
break; // fall through so k-prefix handler can process break; // fall through so k-prefix handler can process
case SDLK_RETURN: case SDLK_RETURN:
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
out = {true, CommandId::Newline, "", 0}; k_prefix = false;
k_ctrl_pending = false;
if (mod & KMOD_SHIFT) {
out = {true, CommandId::SmartNewline, "", 0};
} else {
out = {true, CommandId::Newline, "", 0};
}
return true; return true;
case SDLK_ESCAPE: case SDLK_ESCAPE:
k_prefix = false; k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be treated as Meta esc_meta = true; // next key will be treated as Meta
out.hasCommand = false; // no immediate command for bare ESC in GUI out.hasCommand = false; // no immediate command for bare ESC in GUI
return true; return true;
@@ -116,7 +143,6 @@ map_key(const SDL_Keycode key,
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately. // If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
if (k_prefix) { if (k_prefix) {
k_prefix = false;
esc_meta = false; esc_meta = false;
// Normalize to ASCII; preserve case for letters using Shift // Normalize to ASCII; preserve case for letters using Shift
int ascii_key = 0; int ascii_key = 0;
@@ -136,10 +162,24 @@ map_key(const SDL_Keycode key,
ascii_key = static_cast<int>(key); ascii_key = static_cast<int>(key);
} }
bool ctrl2 = (mod & KMOD_CTRL) != 0; bool ctrl2 = (mod & KMOD_CTRL) != 0;
// If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
// Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending = true;
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
if (ed)
ed->SetStatus("C-k C _");
suppress_textinput_once = true;
out.hasCommand = false;
return true;
}
// Otherwise, consume the k-prefix now for the actual suffix
k_prefix = false;
if (ascii_key != 0) { if (ascii_key != 0) {
int lower = KLowerAscii(ascii_key); int lower = KLowerAscii(ascii_key);
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q'); bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = ctrl2 && ctrl_suffix_supported; bool pass_ctrl = (ctrl2 || k_ctrl_pending) && ctrl_suffix_supported;
k_ctrl_pending = false;
CommandId id; CommandId id;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics for u/U // Diagnostics for u/U
@@ -156,54 +196,40 @@ map_key(const SDL_Keycode key,
} }
if (mapped) { if (mapped) {
out = {true, id, "", 0}; out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
return true; return true;
} }
int shown = KLowerAscii(ascii_key); int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?'; char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c); std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0}; out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
return true; return true;
} }
out.hasCommand = false; // Non-printable/unmappable key as k-suffix (e.g., F-keys): report unknown and exit k-mode
out = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed)
ed->SetStatus("");
return true; return true;
} }
if (is_ctrl) { if (is_ctrl) {
// Universal argument: C-u // Universal argument: C-u
if (key == SDLK_u) { if (key == SDLK_u) {
if (!uarg_active) { if (ed)
uarg_active = true; ed->UArgStart();
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4; // repeated C-u multiplies by 4
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// End collection if already started with digits or '-'
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
out.hasCommand = false; out.hasCommand = false;
return true; return true;
} }
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map) // Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
if (key == SDLK_g) { if (key == SDLK_g) {
uarg_active = false; if (ed)
uarg_collecting = false; ed->UArgClear();
uarg_negative = false; // Also cancel any pending k-prefix qualifier
uarg_had_digits = false; k_ctrl_pending = false;
uarg_value = 0; k_prefix = false; // treat as cancel of prefix
uarg_text.clear();
} }
if (key == SDLK_k || key == SDLK_KP_EQUALS) { if (key == SDLK_k || key == SDLK_KP_EQUALS) {
k_prefix = true; k_prefix = true;
@@ -247,29 +273,17 @@ map_key(const SDL_Keycode key,
} }
} }
// If collecting universal argument, allow digits/minus on KEYDOWN path too // If collecting universal argument, allow digits on KEYDOWN path too
if (uarg_active && uarg_collecting) { if (ed && ed->UArg() != 0) {
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) { if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
int d = static_cast<int>(key - SDLK_0); int d = static_cast<int>(key - SDLK_0);
if (!uarg_had_digits) { ed->UArgDigit(d);
uarg_value = 0; out.hasCommand = false;
uarg_had_digits = true; // We consumed a digit on KEYDOWN; SDL will often also emit TEXTINPUT for it.
} // Request suppression of the very next TEXTINPUT to avoid double-counting.
if (uarg_value < 100000000) { suppress_textinput_once = true;
uarg_value = uarg_value * 10 + d;
}
uarg_text.push_back(static_cast<char>('0' + d));
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true; return true;
} }
if (key == SDLK_MINUS && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will end collection; process it normally
uarg_collecting = false;
} }
// k_prefix handled earlier // k_prefix handled earlier
@@ -279,34 +293,40 @@ map_key(const SDL_Keycode key,
bool bool
GUIInputHandler::ProcessSDLEvent(const SDL_Event &e) ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
{ {
MappedInput mi; MappedInput mi;
bool produced = false; bool produced = false;
switch (e.type) { switch (e.type) {
case SDL_MOUSEWHEEL: { case SDL_MOUSEWHEEL: {
// If ImGui wants to capture the mouse (e.g., hovering the File Picker list), // High-resolution trackpads can deliver fractional wheel deltas. Accumulate
// don't translate wheel events into editor scrolling. // precise values and emit one scroll step per whole unit.
// This prevents background buffer scroll while using GUI widgets. float dy = 0.0f;
ImGuiIO &io = ImGui::GetIO(); #if SDL_VERSION_ATLEAST(2, 0, 18)
if (io.WantCaptureMouse) { dy = e.wheel.preciseY;
return true; // consumed by GUI #else
} dy = static_cast<float>(e.wheel.y);
#endif
// Map vertical wheel to line-wise cursor movement (MoveUp/MoveDown)
int dy = e.wheel.y;
#ifdef SDL_MOUSEWHEEL_FLIPPED #ifdef SDL_MOUSEWHEEL_FLIPPED
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
dy = -dy; dy = -dy;
#endif #endif
if (dy != 0) { if (dy != 0.0f) {
int repeat = dy > 0 ? dy : -dy; wheel_accum_y_ += dy;
CommandId id = dy > 0 ? CommandId::MoveUp : CommandId::MoveDown; float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
std::lock_guard<std::mutex> lk(mu_); int steps = static_cast<int>(abs_accum);
for (int i = 0; i < repeat; ++i) { if (steps > 0) {
q_.push(MappedInput{true, id, std::string(), 0}); CommandId id = (wheel_accum_y_ > 0.0f) ? CommandId::ScrollUp : CommandId::ScrollDown;
std::lock_guard<std::mutex> lk(mu_);
for (int i = 0; i < steps; ++i) {
q_.push(MappedInput{true, id, std::string(), 0});
}
// remove the whole steps, keep fractional remainder
wheel_accum_y_ += (wheel_accum_y_ > 0.0f)
? -static_cast<float>(steps)
: static_cast<float>(steps);
return true; // consumed
} }
return true; // consumed
} }
return false; return false;
} }
@@ -317,6 +337,38 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod); SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
const SDL_Keycode key = e.key.keysym.sym; const SDL_Keycode key = e.key.keysym.sym;
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
{
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
if (gui_n || ctrl_sn) {
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
suppress_text_input_once_ = true;
return true;
}
}
// Font zoom: Cmd+=/Cmd+-/Cmd+0 (macOS) or Ctrl+=/Ctrl+-/Ctrl+0
if ((mods & (KMOD_CTRL | KMOD_GUI)) && !(mods & KMOD_SHIFT)) {
bool is_zoom = true;
CommandId zoom_cmd = CommandId::FontZoomIn;
if (key == SDLK_EQUALS || key == SDLK_PLUS)
zoom_cmd = CommandId::FontZoomIn;
else if (key == SDLK_MINUS)
zoom_cmd = CommandId::FontZoomOut;
else if (key == SDLK_0)
zoom_cmd = CommandId::FontZoomReset;
else
is_zoom = false;
if (is_zoom) {
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, zoom_cmd, std::string(), 0});
suppress_text_input_once_ = true;
return true;
}
}
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS) // Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode. // Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) { if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
@@ -337,7 +389,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
segment = std::string_view(text).substr(start); segment = std::string_view(text).substr(start);
} }
if (!segment.empty()) { if (!segment.empty()) {
MappedInput ins{true, CommandId::InsertText, std::string(segment), 0}; MappedInput ins{
true, CommandId::InsertText, std::string(segment), 0
};
q_.push(ins); q_.push(ins);
} }
if (has_nl) { if (has_nl) {
@@ -354,29 +408,28 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
} }
} }
produced = map_key(key, mods, {
k_prefix_, esc_meta_, bool suppress_req = false;
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, produced = map_key(key, mods,
uarg_text_, k_prefix_, esc_meta_,
mi); k_ctrl_pending_,
ed_,
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT mi,
// for this keystroke to avoid double insertion on platforms that emit it. suppress_req);
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") { if (suppress_req) {
suppress_text_input_once_ = true; // Prevent the corresponding TEXTINPUT from delivering the same digit again
}
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
// Digits without shift, or a plain '-'
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
const bool is_minus_key = (key == SDLK_MINUS);
if (uarg_active_ && uarg_collecting_ && (is_digit_key || is_minus_key)) {
suppress_text_input_once_ = true; suppress_text_input_once_ = true;
} }
} }
// Note: Do NOT suppress SDL_TEXTINPUT after inserting a TAB. Most platforms
// do not emit TEXTINPUT for Tab, and suppressing here would incorrectly
// eat the next character typed if no TEXTINPUT follows the Tab press.
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
// Additional suppression handled above when KEYDOWN consumed a uarg digit
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a // Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced. // k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
const bool is_alt = (mods & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0; const bool is_alt = (mods & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
@@ -396,7 +449,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
} }
// Alt/Meta + letter can also generate TEXTINPUT on some platforms // Alt/Meta + letter can also generate TEXTINPUT on some platforms
const bool is_meta_symbol = ( const bool is_meta_symbol = (
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key == SDLK_GREATER); key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key ==
SDLK_GREATER);
if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) { if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) {
should_suppress = true; should_suppress = true;
} }
@@ -420,35 +474,26 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
break; break;
} }
// If universal argument collection is active, consume digit/minus TEXTINPUT // If editor universal argument is active, consume digit TEXTINPUT
if (uarg_active_ && uarg_collecting_) { if (ed_ &&ed_
->
UArg() != 0
)
{
const char *txt = e.text.text; const char *txt = e.text.text;
if (txt && *txt) { if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]); unsigned char c0 = static_cast<unsigned char>(txt[0]);
if (c0 >= '0' && c0 <= '9') { if (c0 >= '0' && c0 <= '9') {
int d = c0 - '0'; int d = c0 - '0';
if (!uarg_had_digits_) { ed_->UArgDigit(d);
uarg_value_ = 0; produced = true; // consumed to update status
uarg_had_digits_ = true;
}
if (uarg_value_ < 100000000) {
uarg_value_ = uarg_value_ * 10 + d;
}
uarg_text_.push_back(static_cast<char>(c0));
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true; // consumed and enqueued status update
break;
}
if (c0 == '-' && !uarg_had_digits_ && !uarg_negative_) {
uarg_negative_ = true;
uarg_text_ = "-";
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true;
break; break;
} }
} }
// End collection and allow this TEXTINPUT to be processed normally below // Non-digit ends collection; allow processing normally below
uarg_collecting_ = false;
} }
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix, // If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
@@ -464,9 +509,21 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
ascii_key = static_cast<int>(c0); ascii_key = static_cast<int>(c0);
} }
if (ascii_key != 0) { if (ascii_key != 0) {
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
// Keep k-prefix active; do not emit a command
k_prefix_ = true;
produced = true;
break;
}
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case // Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
CommandId id; CommandId id;
bool mapped = KLookupKCommand(ascii_key, false, id); bool pass_ctrl = k_ctrl_pending_;
k_ctrl_pending_ = false;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping // Diagnostics: log any k-prefix TEXTINPUT suffix mapping
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key) ? static_cast<char>(ascii_key)
@@ -477,7 +534,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
mapped ? static_cast<int>(id) : -1); mapped ? static_cast<int>(id) : -1);
std::fflush(stderr); std::fflush(stderr);
if (mapped) { if (mapped) {
mi = {true, id, "", 0}; mi = {true, id, "", 0};
if (ed_)
ed_->SetStatus(""); // clear "C-k _" hint after suffix
produced = true; produced = true;
break; // handled; do not insert text break; // handled; do not insert text
} else { } else {
@@ -487,13 +546,18 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
? static_cast<char>(shown) ? static_cast<char>(shown)
: '?'; : '?';
std::string arg(1, c); std::string arg(1, c);
mi = {true, CommandId::UnknownKCommand, arg, 0}; mi = {true, CommandId::UnknownKCommand, arg, 0};
if (ed_)
ed_->SetStatus("");
produced = true; produced = true;
break; break;
} }
} }
} }
// Consume even if no usable ascii was found // If no usable ASCII was found, still report an unknown k-command and exit k-mode
mi = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed_)
ed_->SetStatus("");
produced = true; produced = true;
break; break;
} }
@@ -533,7 +597,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
} }
} }
} }
// If we get here, swallow the TEXTINPUT (do not insert stray char) // If we get here, unmapped ESC sequence via TEXTINPUT: report invalid
mi = {true, CommandId::UnknownEscCommand, "", 0};
produced = true; produced = true;
break; break;
} }
@@ -563,24 +628,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
} }
if (produced && mi.hasCommand) { if (produced && mi.hasCommand) {
// Attach universal-argument count if present, then clear the state
if (uarg_active_ && mi.id != CommandId::UArgStatus) {
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
mi.count = count;
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
uarg_text_.clear();
}
std::lock_guard<std::mutex> lk(mu_); std::lock_guard<std::mutex> lk(mu_);
q_.push(mi); q_.push(mi);
} }
@@ -589,7 +636,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool bool
GUIInputHandler::Poll(MappedInput &out) ImGuiInputHandler::Poll(MappedInput &out)
{ {
std::lock_guard<std::mutex> lk(mu_); std::lock_guard<std::mutex> lk(mu_);
if (q_.empty()) if (q_.empty())
@@ -597,4 +644,4 @@ GUIInputHandler::Poll(MappedInput &out)
out = q_.front(); out = q_.front();
q_.pop(); q_.pop();
return true; return true;
} }

View File

@@ -1,9 +1,7 @@
/* /*
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode * ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
*/ */
#ifndef KTE_GUI_INPUT_HANDLER_H #pragma once
#define KTE_GUI_INPUT_HANDLER_H
#include <mutex> #include <mutex>
#include <queue> #include <queue>
@@ -12,11 +10,18 @@
union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union) union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union)
class GUIInputHandler final : public InputHandler { class ImGuiInputHandler final : public InputHandler {
public: public:
GUIInputHandler() = default; ImGuiInputHandler() = default;
~ImGuiInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
~GUIInputHandler() override = default;
// Translate an SDL event to editor command and enqueue if applicable. // Translate an SDL event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input. // Returns true if it produced a mapped command or consumed input.
@@ -27,20 +32,18 @@ public:
private: private:
std::mutex mu_; std::mutex mu_;
std::queue<MappedInput> q_; std::queue<MappedInput> q_;
bool k_prefix_ = false; bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // if true, next k-suffix is treated as Ctrl- (qualifier via literal 'C' or '^')
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap // Treat ESC as a Meta prefix: next key is looked up via ESC keymap
bool esc_meta_ = false; bool esc_meta_ = false;
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT // When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
// event produced by SDL for the same keystroke to avoid inserting stray characters. // event produced by SDL for the same keystroke to avoid inserting stray characters.
bool suppress_text_input_once_ = false; bool suppress_text_input_once_ = false;
// Universal argument (C-u) state for GUI Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
bool uarg_active_ = false; // an argument is pending for the next command
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
};
#endif // KTE_GUI_INPUT_HANDLER_H // Accumulators for high-resolution (trackpad) scrolling. We emit one scroll
// command per whole step and keep the fractional remainder.
float wheel_accum_y_ = 0.0f;
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
};

View File

@@ -9,7 +9,7 @@
#include <imgui.h> #include <imgui.h>
#include <regex> #include <regex>
#include "GUIRenderer.h" #include "ImGuiRenderer.h"
#include "Highlight.h" #include "Highlight.h"
#include "GUITheme.h" #include "GUITheme.h"
#include "Buffer.h" #include "Buffer.h"
@@ -30,7 +30,7 @@
void void
GUIRenderer::Draw(Editor &ed) ImGuiRenderer::Draw(Editor &ed)
{ {
// Make the editor window occupy the entire GUI container/viewport // Make the editor window occupy the entire GUI container/viewport
ImGuiViewport *vp = ImGui::GetMainViewport(); ImGuiViewport *vp = ImGui::GetMainViewport();
@@ -66,55 +66,69 @@ GUIRenderer::Draw(Editor &ed)
if (!buf) { if (!buf) {
ImGui::TextUnformatted("[no buffer]"); ImGui::TextUnformatted("[no buffer]");
} else { } else {
const auto &lines = buf->Rows(); const auto &lines = buf->Rows();
// Reserve space for status bar at bottom
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Detect click-to-move inside this scroll region
ImVec2 list_origin = ImGui::GetCursorScreenPos();
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
std::size_t cy = buf->Cury(); std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
const float line_h = ImGui::GetTextLineHeight(); const float line_h = ImGui::GetTextLineHeight();
const float row_h = ImGui::GetTextLineHeightWithSpacing(); const float row_h = ImGui::GetTextLineHeightWithSpacing();
const float space_w = ImGui::CalcTextSize(" ").x; const float space_w = ImGui::CalcTextSize(" ").x;
// Two-way sync between Buffer::Rowoffs and ImGui scroll position: // Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it. // - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view. // - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
// This prevents clicks/wheel from being immediately overridden by stale offsets. const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs());
// Detect programmatic change (e.g., page_down command changed rowoffs)
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
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));
}
// Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height
// to avoid partial lines and "scroll past end" jitter.
float total_avail_h = ImGui::GetContentRegionAvail().y;
float wanted_bar_h = ImGui::GetFrameHeight();
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
float real_bar_h = total_avail_h - child_h_plan;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Get child window position and scroll for click handling
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; bool forced_scroll = false;
{ {
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs());
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); const long scroll_left = static_cast<long>(scroll_x / space_w);
// Detect programmatic change (e.g., keyboard navigation ensured visibility) // 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_) {
ImGui::SetScrollY(static_cast<float>(buf_rowoffs) * row_h);
scroll_y = ImGui::GetScrollY();
forced_scroll = true; forced_scroll = true;
} }
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
ImGui::SetScrollX(static_cast<float>(buf_coloffs) * space_w); // If user scrolled (not programmatic), update buffer offsets accordingly
scroll_x = ImGui::GetScrollX(); if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
forced_scroll = true;
}
// If user scrolled, update buffer offsets accordingly
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) {
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) { if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(mbuf->Rowoffs(), mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left))); static_cast<std::size_t>(std::max(0L, scroll_left)));
@@ -122,146 +136,170 @@ GUIRenderer::Draw(Editor &ed)
} }
// Update trackers for next frame // Update trackers for next frame
prev_buf_rowoffs = static_cast<long>(buf->Rowoffs()); prev_scroll_y_ = scroll_y;
prev_buf_coloffs = static_cast<long>(buf->Coloffs()); prev_scroll_x_ = scroll_x;
prev_scroll_y = ImGui::GetScrollY();
prev_scroll_x = ImGui::GetScrollX();
} }
// Synchronize cursor and scrolling. prev_buf_rowoffs_ = buf_rowoffs;
// Ensure the cursor is visible even on the first frame or when it didn't move, prev_buf_coloffs_ = buf_coloffs;
// unless we already forced scrolling from Buffer::Rowoffs this frame. // Cache current horizontal offset in rendered columns for click handling
{
// Compute visible row range using the child window height
float child_h = ImGui::GetWindowHeight();
long first_row = static_cast<long>(scroll_y / row_h);
long vis_rows = static_cast<long>(child_h / row_h);
if (vis_rows < 1)
vis_rows = 1;
long last_row = first_row + vis_rows - 1;
if (!forced_scroll) {
long cyr = static_cast<long>(cy);
if (cyr < first_row || cyr > last_row) {
float target = (static_cast<float>(cyr) - std::max(0L, vis_rows / 2)) * row_h;
float max_y = ImGui::GetScrollMaxY();
if (target < 0.f)
target = 0.f;
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
// refresh local variables
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
}
}
// Phase 3: prefetch visible viewport highlights and warm around in background
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
// Handle mouse click before rendering to avoid dependent on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImVec2 mp = ImGui::GetIO().MousePos;
// Compute viewport-relative row so (0) is top row of the visible area
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
long vy = static_cast<long>(vy_f);
if (vy < 0)
vy = 0;
// Clamp vy within visible content height to avoid huge jumps
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
float child_h = (cr_max.y - cr_min.y);
long vis_rows = static_cast<long>(child_h / row_h);
if (vis_rows < 1)
vis_rows = 1;
if (vy >= vis_rows)
vy = vis_rows - 1;
// Translate viewport row to buffer row using Buffer::Rowoffs
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
if (by >= lines.size()) {
if (!lines.empty())
by = lines.size() - 1;
else
by = 0;
}
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
float px = (mp.x - list_origin.x - scroll_x);
if (px < 0.0f)
px = 0.0f;
// Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else {
// Convert pixel X to a render-column target including horizontal col offset
// Use our own tab expansion of width 8 to match command layer logic.
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
if (!line_clicked.empty() && coloffs > 0) {
while (i < line_clicked.size() && rx_abs < coloffs) {
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
}
// Now search for closest source column to clicked px within/after viewport
std::size_t best_col = i; // default to first visible column
float best_dist = std::numeric_limits<float>::infinity();
while (true) {
// For i in [current..size], evaluate candidate including the implicit end position
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
float rx_px = static_cast<float>(rx_view) * space_w;
float dist = std::fabs(px - rx_px);
if (dist <= best_dist) {
best_dist = dist;
best_col = i;
}
if (i == line_clicked.size())
break;
// advance to next source column
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
// Dispatch absolute buffer coordinates (row:col)
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
}
// Cache current horizontal offset in rendered columns
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
// Mark selection state (mark -> cursor), in source coordinates
bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line highlight range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
// (mouse_selecting__ is a member variable)
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos;
// Convert mouse pos to buffer row
float content_y = (mp.y - child_window_pos.y) + scroll_y;
long by_l = static_cast<long>(content_y / row_h);
if (by_l < 0)
by_l = 0;
std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size())
by = lines.empty() ? 0 : (lines.size() - 1);
if (lines.empty())
return {0, 0};
// Expand tabs for the clicked line
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
std::string click_expanded;
click_expanded.reserve(line_clicked.size() + 16);
std::size_t click_rx = 0;
// Map: source column -> expanded column
std::vector<std::size_t> src_to_exp;
src_to_exp.reserve(line_clicked.size() + 1);
for (std::size_t ci = 0; ci < line_clicked.size(); ++ci) {
src_to_exp.push_back(click_rx);
if (line_clicked[ci] == '\t') {
std::size_t adv = (tabw - (click_rx % tabw));
click_expanded.append(adv, ' ');
click_rx += adv;
} else {
click_expanded.push_back(line_clicked[ci]);
click_rx += 1;
}
}
src_to_exp.push_back(click_rx); // past-end position
// Pixel x relative to the line start (accounting for scroll)
float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f)
visual_x = 0.0f;
// Add scroll offset in pixels
visual_x += scroll_x;
// Find the source column whose expanded position is closest
// to the click pixel, using actual text measurement.
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
std::size_t exp_col = src_to_exp[ci];
float px = 0.0f;
if (exp_col > 0 && !click_expanded.empty()) {
std::size_t end = std::min(click_expanded.size(), exp_col);
px = ImGui::CalcTextSize(click_expanded.c_str(),
click_expanded.c_str() + end).x;
}
float dist = std::fabs(visual_x - px);
if (dist < best_dist) {
best_dist = dist;
best_col = ci;
}
}
return {by, best_col};
};
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting_ = true;
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
// Only set mark on double click.
// Dragging will also set the mark if not already set (handled below).
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by);
}
}
}
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf();
// If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
if (!mbuf->MarkSet()) {
// We'd need to convert click_pos to buf coords, but it's complex here.
// Setting it to where the cursor was *before* we started moving it
// in this frame is a good approximation, or just using current.
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
}
}
}
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting_ = false;
}
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++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]);
// Expand tabs to spaces with width=8 and apply horizontal scroll offset // Expand tabs to spaces with width=8
const std::size_t tabw = 8; const std::size_t tabw = 8;
std::string expanded; std::string expanded;
expanded.reserve(line.size() + 16); expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing std::size_t rx_abs_draw = 0;
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
// 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.
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)
return 0.0f;
return ImGui::CalcTextSize(expanded.c_str() + start,
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(); 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;
@@ -316,10 +354,8 @@ GUIRenderer::Draw(Editor &ed)
// Apply horizontal scroll offset // Apply horizontal scroll offset
if (rx_end <= coloffs_now) if (rx_end <= coloffs_now)
continue; // fully left of view continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0; ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
std::size_t vx1 = rx_end - coloffs_now; ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h); line_pos.y + line_h);
// Choose color: current match stronger // Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end; bool is_current = has_current && sx == cur_x && ex == cur_end;
@@ -329,86 +365,251 @@ GUIRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
// Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
// Draw syntax-colored runs (text above background highlights) // Draw selection background (over search highlight; under text)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (sel_active) {
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(*buf, static_cast<int>(i), buf->Version()); bool line_has = false;
// Helper to convert a src column to expanded rx position std::size_t sx = 0, ex = 0;
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t { if (i < sel_sy || i > sel_ey) {
std::size_t rx = 0; line_has = false;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) { } else if (sel_sy == sel_ey) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1; sx = sel_sx;
} ex = sel_ex;
return rx; line_has = ex > sx;
}; } else if (i == sel_sy) {
for (const auto &sp: lh.spans) { sx = sel_sx;
std::size_t rx_s = src_to_rx_full(static_cast<std::size_t>(std::max(0, sp.col_start))); ex = line.size();
std::size_t rx_e = src_to_rx_full(static_cast<std::size_t>(std::max(sp.col_start, sp.col_end))); line_has = ex > sx;
if (rx_e <= coloffs_now) } else if (i == sel_ey) {
continue; sx = 0;
std::size_t vx0 = (rx_s > coloffs_now) ? (rx_s - coloffs_now) : 0; ex = std::min(sel_ex, line.size());
std::size_t vx1 = (rx_e > coloffs_now) ? (rx_e - coloffs_now) : 0; line_has = ex > sx;
if (vx0 >= expanded.size()) continue; } else {
vx1 = std::min<std::size_t>(vx1, expanded.size()); sx = 0;
if (vx1 <= vx0) continue; ex = line.size();
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind)); line_has = ex > sx;
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y); }
ImGui::GetWindowDrawList()->AddText(p, col, expanded.c_str() + vx0, expanded.c_str() + vx1); if (line_has) {
} std::size_t rx_start = src_to_rx(sx);
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line. std::size_t rx_end = src_to_rx(ex);
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + line_h)); if (rx_end > coloffs_now) {
} else { ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
// No syntax: draw as one run line_pos.y);
ImGui::TextUnformatted(expanded.c_str()); ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
} line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
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());
const std::size_t rx_start = src_to_rx(spot_sx);
std::size_t rx_end = rx_start;
if (spot_sx < line.size()) {
rx_end = src_to_rx(spot_sx + 1);
} else {
// 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),
line_pos.y + line_h);
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(
*buf, static_cast<int>(i), buf->Version());
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
spans.push_back(SSpan{s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
// Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
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;
if (draw_start >= expanded.size())
continue; // fully right of expanded text
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);
ImGui::GetWindowDrawList()->AddText(
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
}
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line.
// 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()) {
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));
}
}
// Draw a visible cursor indicator on the current line // Draw a visible cursor indicator on the current line
if (i == cy) { if (i == cy) {
// Compute rendered X (rx) from source column with tab expansion std::size_t rx_abs = src_to_rx(cx);
std::size_t rx_abs = 0; float cursor_px = rx_to_px(rx_abs);
for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) { ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
if (line[k] == '\t')
rx_abs += (tabw - (rx_abs % tabw));
else
rx_abs += 1;
}
// Convert to viewport x by subtracting horizontal col offset
std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(rx_viewport) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h); ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
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);
} }
} }
// Synchronize cursor and scrolling after rendering all lines so content size is known.
{
float child_h_actual = ImGui::GetWindowHeight();
float child_w_actual = ImGui::GetWindowWidth();
float scroll_y_now = ImGui::GetScrollY();
float scroll_x_now = ImGui::GetScrollX();
long first_row = static_cast<long>(scroll_y_now / row_h);
long vis_rows = static_cast<long>(std::round(child_h_actual / row_h));
if (vis_rows < 1)
vis_rows = 1;
long last_row = first_row + vis_rows - 1;
long cyr = static_cast<long>(cy);
if (cyr < first_row) {
float target = static_cast<float>(cyr) * row_h;
if (target < 0.f)
target = 0.f;
float max_y = ImGui::GetScrollMaxY();
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
first_row = static_cast<long>(target / row_h);
last_row = first_row + vis_rows - 1;
} else if (cyr > last_row) {
long new_first = cyr - vis_rows + 1;
if (new_first < 0)
new_first = 0;
float target = static_cast<float>(new_first) * row_h;
float max_y = ImGui::GetScrollMaxY();
if (target < 0.f)
target = 0.f;
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
first_row = static_cast<long>(target / row_h);
last_row = first_row + vis_rows - 1;
}
// Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
float cursor_px_abs = 0.0f;
if (cy < lines.size()) {
std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = 8;
// Expand tabs for cursor line to measure pixel position
std::string cur_expanded;
cur_expanded.reserve(cur_line.size() + 16);
std::size_t cur_rx = 0;
for (std::size_t ci = 0; ci < cur_line.size(); ++ci) {
if (cur_line[ci] == '\t') {
std::size_t adv = tabw - (cur_rx % tabw);
cur_expanded.append(adv, ' ');
cur_rx += adv;
} else {
cur_expanded.push_back(cur_line[ci]);
cur_rx += 1;
}
}
// Compute rendered column of cursor
std::size_t cursor_rx = 0;
for (std::size_t ci = 0; ci < cx && ci < cur_line.size(); ++ci) {
if (cur_line[ci] == '\t')
cursor_rx += tabw - (cursor_rx % tabw);
else
cursor_rx += 1;
}
std::size_t exp_end = std::min(cur_expanded.size(), cursor_rx);
if (exp_end > 0)
cursor_px_abs = ImGui::CalcTextSize(cur_expanded.c_str(),
cur_expanded.c_str() + exp_end).x;
}
if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
float target_x = cursor_px_abs - (child_w_actual / 2.0f);
if (target_x < 0.f)
target_x = 0.f;
float max_x = ImGui::GetScrollMaxX();
if (max_x >= 0.f && target_x > max_x)
target_x = max_x;
ImGui::SetScrollX(target_x);
}
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
// Status bar spanning full width // Status bar area starting right after the scroll child
ImGui::Separator();
// Compute full content width and draw a filled background rectangle
ImVec2 win_pos = ImGui::GetWindowPos(); ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin(); ImVec2 win_sz = ImGui::GetWindowSize();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax(); float x0 = win_pos.x;
float x0 = win_pos.x + cr_min.x; float x1 = win_pos.x + win_sz.x;
float x1 = win_pos.x + cr_max.x; float y0 = ImGui::GetCursorScreenPos().y;
ImVec2 cursor = ImGui::GetCursorScreenPos(); float bar_h = real_bar_h;
float bar_h = ImGui::GetFrameHeight();
ImVec2 p0(x0, cursor.y); ImVec2 p0(x0, y0);
ImVec2 p1(x1, cursor.y + bar_h); ImVec2 p1(x1, y0 + bar_h);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive); ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// If a prompt is active, replace the entire status bar with the prompt text // If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) { if (ed.PromptActive()) {
std::string label = ed.PromptLabel(); std::string label = ed.PromptLabel();
@@ -463,7 +664,7 @@ GUIRenderer::Draw(Editor &ed)
(size_t) std::max<size_t>( (size_t) std::max<size_t>(
1, (size_t) (tail.size() / 4))) 1, (size_t) (tail.size() / 4)))
: 1; : 1;
start += skip; start += skip;
std::string candidate = tail.substr(start); std::string candidate = tail.substr(start);
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str()); ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
if (cand_sz.x <= avail_px) { if (cand_sz.x <= avail_px) {
@@ -494,11 +695,9 @@ GUIRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str()); ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true); ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(final_msg.c_str()); ImGui::TextUnformatted(final_msg.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} else { } else {
// Build left text // Build left text
std::string left; std::string left;
@@ -521,11 +720,11 @@ GUIRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount(); std::size_t total = ed.BufferCount();
if (total > 0) { if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "["; left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1)); left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/"; left += "/";
left += std::to_string(static_cast<unsigned long long>(total)); left += std::to_string(static_cast<unsigned long long>(total));
left += "] "; left += "] ";
} }
} }
left += fname; left += fname;
@@ -534,9 +733,9 @@ GUIRenderer::Draw(Editor &ed)
// Append total line count as "<n>L" // Append total line count as "<n>L"
{ {
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size()); unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
} }
// Build right text (cursor/mark) // Build right text (cursor/mark)
@@ -574,20 +773,21 @@ GUIRenderer::Draw(Editor &ed)
float max_left = std::max(0.0f, right_x - left_x - pad); float max_left = std::max(0.0f, right_x - left_x - pad);
if (max_left < left_sz.x && max_left > 10.0f) { if (max_left < left_sz.x && max_left > 10.0f) {
// Render a clipped left using a child region // Render a clipped left using a child region
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true); ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
true);
ImGui::TextUnformatted(left.c_str()); ImGui::TextUnformatted(left.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
} }
} else { } else {
// Draw left normally // Draw left normally
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::TextUnformatted(left.c_str()); ImGui::TextUnformatted(left.c_str());
} }
// Draw right // Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x), ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
p0.y + (bar_h - right_sz.y) * 0.5f)); y0 + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str()); ImGui::TextUnformatted(right.c_str());
// Draw middle message centered in remaining space // Draw middle message centered in remaining space
@@ -599,14 +799,12 @@ GUIRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str()); ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f); float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region // Clip to middle region
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true); ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(msg.c_str()); ImGui::TextUnformatted(msg.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
} }
} }
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} }
} }
@@ -742,12 +940,8 @@ GUIRenderer::Draw(Editor &ed)
ed.SetFilePickerDir(e.path.string()); ed.SetFilePickerDir(e.path.string());
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { } else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
// Open file on single click // Open file on single click
std::string err; ed.RequestOpenFile(e.path.string());
if (!ed.OpenFile(e.path.string(), err)) { (void) ed.ProcessPendingOpens();
ed.SetStatus(std::string("open: ") + err);
} else {
ed.SetStatus(std::string("Opened: ") + e.name);
}
ed.SetFilePickerVisible(false); ed.SetFilePickerVisible(false);
} }
} }
@@ -761,4 +955,4 @@ GUIRenderer::Draw(Editor &ed)
ed.SetFilePickerVisible(false); ed.SetFilePickerVisible(false);
} }
} }
} }

23
ImGuiRenderer.h Normal file
View File

@@ -0,0 +1,23 @@
/*
* ImGuiRenderer - ImGui-based renderer for GUI mode
*/
#pragma once
#include "Renderer.h"
class ImGuiRenderer final : public Renderer {
public:
ImGuiRenderer() = default;
~ImGuiRenderer() override = default;
void Draw(Editor &ed) override;
private:
// Per-window scroll tracking for two-way sync between Buffer offsets and ImGui scroll.
// These must be per-instance (not static) so each window maintains independent state.
long prev_buf_rowoffs_ = -1;
long prev_buf_coloffs_ = -1;
float prev_scroll_y_ = -1.0f;
float prev_scroll_x_ = -1.0f;
bool mouse_selecting_ = false;
};

View File

@@ -1,13 +1,13 @@
/* /*
* InputHandler.h - input abstraction and mapping to commands * InputHandler.h - input abstraction and mapping to commands
*/ */
#ifndef KTE_INPUT_HANDLER_H #pragma once
#define KTE_INPUT_HANDLER_H
#include <string> #include <string>
#include "Command.h" #include "Command.h"
class Editor; // fwd decl
// Result of translating raw input into an editor command. // Result of translating raw input into an editor command.
struct MappedInput { struct MappedInput {
@@ -21,9 +21,11 @@ class InputHandler {
public: public:
virtual ~InputHandler() = default; virtual ~InputHandler() = default;
// Optional: attach current Editor so handlers can consult editor state (e.g., universal argument)
// Default implementation does nothing.
virtual void Attach(Editor *) {}
// Poll for input and translate it to a command. Non-blocking. // Poll for input and translate it to a command. Non-blocking.
// Returns true if a command is available in 'out'. Returns false if no input. // Returns true if a command is available in 'out'. Returns false if no input.
virtual bool Poll(MappedInput &out) = 0; virtual bool Poll(MappedInput &out) = 0;
}; };
#endif // KTE_INPUT_HANDLER_H

View File

@@ -1,42 +0,0 @@
#include "JsonHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
void JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
int i = 0;
while (i < n) {
char c = s[i];
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue; }
if (c == '"') {
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
push(i, j, TokenKind::String); i = j; continue;
}
if (is_digit(c) || (c=='-' && i+1<n && is_digit(s[i+1]))) {
int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='e'||s[j]=='E'||s[j]=='+'||s[j]=='-'||s[j]=='_')) ++j; push(i,j,TokenKind::Number); i=j; continue;
}
// booleans/null
if (std::isalpha(static_cast<unsigned char>(c))) {
int j=i+1; while (j<n && std::isalpha(static_cast<unsigned char>(s[j]))) ++j;
std::string id = s.substr(i, j-i);
if (id == "true" || id == "false" || id == "null") push(i,j,TokenKind::Constant); else push(i,j,TokenKind::Identifier);
i=j; continue;
}
// punctuation
if (c=='{'||c=='}'||c=='['||c==']'||c==','||c==':' ) { push(i,i+1,TokenKind::Punctuation); ++i; continue; }
// fallback
push(i,i+1,TokenKind::Default); ++i;
}
}
} // namespace kte

View File

@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'd': case 'd':
out = CommandId::KillLine; out = CommandId::KillLine;
return true; return true;
case 's':
out = CommandId::Save;
return true;
case 'q': case 'q':
out = CommandId::QuitNow; out = CommandId::QuitNow;
return true; return true;
@@ -42,6 +45,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'a': case 'a':
out = CommandId::MarkAllAndJumpEnd; out = CommandId::MarkAllAndJumpEnd;
return true; return true;
case ' ': // C-k SPACE
out = CommandId::ToggleMark;
return true;
case 'i':
out = CommandId::BufferNew; // C-k i new empty buffer
return true;
case 'k':
out = CommandId::CenterOnCursor; // C-k k center current line
return true;
case 'b': case 'b':
out = CommandId::BufferSwitchStart; out = CommandId::BufferSwitchStart;
return true; return true;
@@ -72,6 +84,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'l': case 'l':
out = CommandId::ReloadBuffer; out = CommandId::ReloadBuffer;
return true; return true;
case 'm':
out = CommandId::ToggleEditMode;
return true;
case 'n': case 'n':
out = CommandId::BufferPrev; out = CommandId::BufferPrev;
return true; return true;
@@ -108,6 +123,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=': case '=':
out = CommandId::IndentRegion; out = CommandId::IndentRegion;
return true; return true;
case '/':
out = CommandId::VisualLineModeToggle;
return true;
case ';': case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true; return true;
@@ -211,8 +229,12 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
case 'q': case 'q':
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph) out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
return true; return true;
case '\n':
case '\r':
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
return true;
default: default:
break; break;
} }
return false; return false;
} }

View File

@@ -1,9 +1,7 @@
/* /*
* KKeymap.h - mapping for k-command (C-k prefix) keys to CommandId * KKeymap.h - mapping for k-command (C-k prefix) keys to CommandId
*/ */
#ifndef KTE_KKEYMAP_H #pragma once
#define KTE_KKEYMAP_H
#include "Command.h" #include "Command.h"
@@ -30,5 +28,3 @@ KLowerAscii(const int key)
return key + ('a' - 'A'); return key + ('a' - 'A');
return key; return key;
} }
#endif // KTE_KKEYMAP_H

View File

@@ -1,43 +0,0 @@
// LanguageHighlighter.h - interface for line-based highlighters
#pragma once
#include <memory>
#include <vector>
#include <string>
#include "Highlight.h"
class Buffer;
namespace kte {
class LanguageHighlighter {
public:
virtual ~LanguageHighlighter() = default;
// Produce highlight spans for a given buffer row. Implementations should append to out.
virtual void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const = 0;
virtual bool Stateful() const { return false; }
};
// Optional extension for stateful highlighters (e.g., multi-line comments/strings).
// Engines may detect and use this via dynamic_cast without breaking stateless impls.
class StatefulHighlighter : public LanguageHighlighter {
public:
struct LineState {
bool in_block_comment{false};
bool in_raw_string{false};
// For raw strings, remember the delimiter between the opening R"delim( and closing )delim"
std::string raw_delim;
};
// Highlight one line given the previous line state; return the resulting state after this line.
// Implementations should append spans for this line to out and compute the next state.
virtual LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const = 0;
bool Stateful() const override { return true; }
};
} // namespace kte

View File

@@ -1,41 +0,0 @@
#include "LispHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
LispHighlighter::LispHighlighter()
{
const char* kw[] = {"defun","lambda","let","let*","define","set!","if","cond","begin","quote","quasiquote","unquote","unquote-splicing","loop","do","and","or","not"};
for (auto s: kw) kws_.insert(s);
}
void LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
int i = 0;
int bol = 0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
if (bol < n && s[bol] == ';') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
while (i < n) {
char c = s[i];
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
if (c==';') { push(out,i,n,TokenKind::Comment); break; }
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
if (std::isalpha(static_cast<unsigned char>(c)) || c=='*' || c=='-' || c=='+' || c=='/' || c=='_' ) {
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='*' || s[j]=='-' || s[j]=='+' || s[j]=='/' || s[j]=='_' || s[j]=='!')) ++j;
std::string id=s.substr(i,j-i);
TokenKind k = kws_.count(id) ? TokenKind::Keyword : TokenKind::Identifier;
push(out,i,j,k); i=j; continue;
}
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
push(out,i,i+1,TokenKind::Default); ++i;
}
}
} // namespace kte

View File

@@ -1,88 +0,0 @@
#include "MarkdownHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push_span(std::vector<HighlightSpan> &out, int a, int b, TokenKind k) {
if (b > a) out.push_back({a,b,k});
}
void MarkdownHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
LineState st; // not used in stateless entry
(void)HighlightLineStateful(buf, row, st, out);
}
StatefulHighlighter::LineState MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
// Reuse in_block_comment flag as "in fenced code" state.
if (state.in_block_comment) {
// If line contains closing fence ``` then close after it
auto pos = s.find("```");
if (pos == std::string::npos) {
push_span(out, 0, n, TokenKind::String);
state.in_block_comment = true;
return state;
} else {
int end = static_cast<int>(pos + 3);
push_span(out, 0, end, TokenKind::String);
// rest of line processed normally after fence
int i = end;
// whitespace
if (i < n) push_span(out, i, n, TokenKind::Default);
state.in_block_comment = false;
return state;
}
}
// Detect fenced code block start at beginning (allow leading spaces)
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
if (bol + 3 <= n && s.compare(bol, 3, "```") == 0) {
push_span(out, bol, n, TokenKind::String);
state.in_block_comment = true; // enter fenced mode
return state;
}
// Headings: lines starting with 1-6 '#'
if (bol < n && s[bol] == '#') {
int j = bol; while (j < n && s[j] == '#') ++j; // hashes
// include following space and text as Keyword to stand out
push_span(out, bol, n, TokenKind::Keyword);
return state;
}
// Process inline: emphasis and code spans
int i = 0;
while (i < n) {
char c = s[i];
if (c == '`') {
int j = i + 1; while (j < n && s[j] != '`') ++j; if (j < n) ++j;
push_span(out, i, j, TokenKind::String); i = j; continue;
}
if (c == '*' || c == '_') {
// bold/italic markers: treat the marker and until next same marker as Type to highlight
char m = c; int j = i + 1; while (j < n && s[j] != m) ++j; if (j < n) ++j;
push_span(out, i, j, TokenKind::Type); i = j; continue;
}
// links []() minimal: treat [text](url) as Function
if (c == '[') {
int j = i + 1; while (j < n && s[j] != ']') ++j; if (j < n) ++j; // include ]
if (j < n && s[j] == '(') { while (j < n && s[j] != ')') ++j; if (j < n) ++j; }
push_span(out, i, j, TokenKind::Function); i = j; continue;
}
// whitespace
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push_span(out, i, j, TokenKind::Whitespace); i=j; continue; }
// fallback: default single char
push_span(out, i, i+1, TokenKind::Default); ++i;
}
return state;
}
} // namespace kte

View File

@@ -1,14 +0,0 @@
// MarkdownHighlighter.h - simple Markdown highlighter
#pragma once
#include "LanguageHighlighter.h"
namespace kte {
class MarkdownHighlighter final : public StatefulHighlighter {
public:
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
};
} // namespace kte

View File

@@ -1,16 +0,0 @@
#include "NullHighlighter.h"
#include "Buffer.h"
namespace kte {
void NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
if (n <= 0) return;
out.push_back({0, n, TokenKind::Default});
}
} // namespace kte

79
OptimizedSearch.cc Normal file
View File

@@ -0,0 +1,79 @@
#include "OptimizedSearch.h"
#include <algorithm>
void
OptimizedSearch::build_bad_char(const std::string &pattern)
{
if (pattern == last_pat_)
return;
last_pat_ = pattern;
std::fill(bad_char_.begin(), bad_char_.end(), -1);
for (std::size_t i = 0; i < pattern.size(); ++i) {
bad_char_[static_cast<unsigned char>(pattern[i])] = static_cast<int>(i);
}
}
std::size_t
OptimizedSearch::find_first(const std::string &text, const std::string &pattern, std::size_t start)
{
const std::size_t n = text.size();
const std::size_t m = pattern.size();
if (m == 0)
return start <= n ? start : std::string::npos;
if (m > n || start >= n)
return std::string::npos;
build_bad_char(pattern);
std::size_t s = start;
while (s <= n - m) {
std::size_t j = m;
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
--j;
}
if (j == 0) {
return s; // match found
}
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
int bcidx = bad_char_[badc];
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
? (j - 1 - static_cast<std::size_t>(bcidx))
: 1;
s += shift;
}
return std::string::npos;
}
std::vector<std::size_t>
OptimizedSearch::find_all(const std::string &text, const std::string &pattern, std::size_t start)
{
std::vector<std::size_t> res;
const std::size_t n = text.size();
const std::size_t m = pattern.size();
if (m == 0)
return res;
if (m > n || start >= n)
return res;
build_bad_char(pattern);
std::size_t s = start;
while (s <= n - m) {
std::size_t j = m;
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
--j;
}
if (j == 0) {
res.push_back(s);
s += m; // non-overlapping
continue;
}
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
int bcidx = bad_char_[badc];
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
? (j - 1 - static_cast<std::size_t>(bcidx))
: 1;
s += shift;
}
return res;
}

23
OptimizedSearch.h Normal file
View File

@@ -0,0 +1,23 @@
// OptimizedSearch.h - BoyerMoore (bad character) based substring search
#pragma once
#include <array>
#include <cstddef>
#include <string>
#include <vector>
class OptimizedSearch {
public:
OptimizedSearch() = default;
// Find first occurrence at or after start. Returns npos if not found.
std::size_t find_first(const std::string &text, const std::string &pattern, std::size_t start = 0);
// Find all non-overlapping matches at or after start. Returns starting indices.
std::vector<std::size_t> find_all(const std::string &text, const std::string &pattern, std::size_t start = 0);
private:
std::array<int, 256> bad_char_{};
std::string last_pat_;
void build_bad_char(const std::string &pattern);
};

View File

@@ -1,5 +1,7 @@
#include <algorithm> #include <algorithm>
#include <utility> #include <utility>
#include <limits>
#include <ostream>
#include "PieceTable.h" #include "PieceTable.h"
@@ -14,13 +16,32 @@ PieceTable::PieceTable(const std::size_t initialCapacity)
} }
PieceTable::PieceTable(const std::size_t initialCapacity,
const std::size_t piece_limit,
const std::size_t small_piece_threshold,
const std::size_t max_consolidation_bytes)
{
add_.reserve(initialCapacity);
materialized_.reserve(initialCapacity);
piece_limit_ = piece_limit;
small_piece_threshold_ = small_piece_threshold;
max_consolidation_bytes_ = max_consolidation_bytes;
}
PieceTable::PieceTable(const PieceTable &other) PieceTable::PieceTable(const PieceTable &other)
: original_(other.original_), : original_(other.original_),
add_(other.add_), add_(other.add_),
pieces_(other.pieces_), pieces_(other.pieces_),
materialized_(other.materialized_), materialized_(other.materialized_),
dirty_(other.dirty_), dirty_(other.dirty_),
total_size_(other.total_size_) {} total_size_(other.total_size_)
{
version_ = other.version_;
// caches are per-instance, mark invalid
range_cache_ = {};
find_cache_ = {};
}
PieceTable & PieceTable &
@@ -34,6 +55,9 @@ PieceTable::operator=(const PieceTable &other)
materialized_ = other.materialized_; materialized_ = other.materialized_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
total_size_ = other.total_size_; total_size_ = other.total_size_;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
return *this; return *this;
} }
@@ -48,6 +72,9 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
{ {
other.dirty_ = true; other.dirty_ = true;
other.total_size_ = 0; other.total_size_ = 0;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
} }
@@ -64,6 +91,9 @@ PieceTable::operator=(PieceTable &&other) noexcept
total_size_ = other.total_size_; total_size_ = other.total_size_;
other.dirty_ = true; other.dirty_ = true;
other.total_size_ = 0; other.total_size_ = 0;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
return *this; return *this;
} }
@@ -79,6 +109,21 @@ PieceTable::Reserve(const std::size_t newCapacity)
} }
// Setter to allow tuning consolidation heuristics
void
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
const std::size_t small_piece_threshold,
const std::size_t max_consolidation_bytes)
{
piece_limit_ = piece_limit;
small_piece_threshold_ = small_piece_threshold;
max_consolidation_bytes_ = max_consolidation_bytes;
}
// (removed helper) — we'll invalidate caches inline inside mutating methods
void void
PieceTable::AppendChar(char c) PieceTable::AppendChar(char c)
{ {
@@ -151,6 +196,11 @@ PieceTable::Clear()
materialized_.clear(); materialized_.clear();
total_size_ = 0; total_size_ = 0;
dirty_ = true; dirty_ = true;
line_index_.clear();
line_index_dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
} }
@@ -168,9 +218,12 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
std::size_t expectStart = last.start + last.len; std::size_t expectStart = last.start + last.len;
if (expectStart == start) { if (expectStart == start) {
last.len += len; last.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
return; return;
} }
} }
@@ -178,7 +231,11 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
pieces_.push_back(Piece{src, start, len}); pieces_.push_back(Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex();
version_++;
range_cache_ = {};
find_cache_ = {};
} }
@@ -194,21 +251,29 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
Piece &first = pieces_.front(); Piece &first = pieces_.front();
if (first.src == src && start + len == first.start) { if (first.src == src && start + len == first.start) {
first.start = start; first.start = start;
first.len += len; first.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
return; return;
} }
} }
pieces_.insert(pieces_.begin(), Piece{src, start, len}); pieces_.insert(pieces_.begin(), Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex();
version_++;
range_cache_ = {};
find_cache_ = {};
} }
void void
PieceTable::materialize() const PieceTable::materialize() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) { if (!dirty_) {
return; return;
} }
@@ -225,3 +290,511 @@ PieceTable::materialize() const
// Ensure there is a null terminator present via std::string invariants // Ensure there is a null terminator present via std::string invariants
dirty_ = false; dirty_ = false;
} }
// ===== New Phase 1 implementation =====
std::pair<std::size_t, std::size_t>
PieceTable::locate(const std::size_t byte_offset) const
{
if (byte_offset >= total_size_) {
return {pieces_.size(), 0};
}
std::size_t off = byte_offset;
for (std::size_t i = 0; i < pieces_.size(); ++i) {
const auto &p = pieces_[i];
if (off < p.len) {
return {i, off};
}
off -= p.len;
}
// Should not reach here unless inconsistency; return end
return {pieces_.size(), 0};
}
void
PieceTable::coalesceNeighbors(std::size_t index)
{
if (pieces_.empty())
return;
if (index >= pieces_.size())
index = pieces_.size() - 1;
// Merge repeatedly with previous while contiguous and same source
while (index > 0) {
auto &prev = pieces_[index - 1];
auto &curr = pieces_[index];
if (prev.src == curr.src && prev.start + prev.len == curr.start) {
prev.len += curr.len;
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index));
index -= 1;
} else {
break;
}
}
// Merge repeatedly with next while contiguous and same source
while (index + 1 < pieces_.size()) {
auto &curr = pieces_[index];
auto &next = pieces_[index + 1];
if (curr.src == next.src && curr.start + curr.len == next.start) {
curr.len += next.len;
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index + 1));
} else {
break;
}
}
}
void
PieceTable::InvalidateLineIndex() const
{
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true;
}
void
PieceTable::RebuildLineIndex() const
{
std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) {
return;
}
line_index_.clear();
line_index_.push_back(0);
std::size_t pos = 0;
for (const auto &pc: pieces_) {
const std::string &src = pc.src == Source::Original ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
for (std::size_t j = 0; j < pc.len; ++j) {
if (base[j] == '\n') {
// next line starts after the newline
line_index_.push_back(pos + j + 1);
}
}
pos += pc.len;
}
line_index_dirty_ = false;
}
void
PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
{
if (len == 0) {
return;
}
if (byte_offset > total_size_) {
byte_offset = total_size_;
}
const std::size_t add_start = add_.size();
add_.append(text, len);
if (pieces_.empty()) {
pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len;
dirty_ = true;
InvalidateLineIndex();
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
return;
}
auto [idx, inner] = locate(byte_offset);
if (idx == pieces_.size()) {
// insert at end
pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len;
dirty_ = true;
InvalidateLineIndex();
coalesceNeighbors(pieces_.size() - 1);
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
return;
}
Piece target = pieces_[idx];
// Build replacement sequence: left, inserted, right
std::vector<Piece> repl;
repl.reserve(3);
if (inner > 0) {
repl.push_back(Piece{target.src, target.start, inner});
}
repl.push_back(Piece{Source::Add, add_start, len});
const std::size_t right_len = target.len - inner;
if (right_len > 0) {
repl.push_back(Piece{target.src, target.start + inner, right_len});
}
// Replace target with repl
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end());
total_size_ += len;
dirty_ = true;
InvalidateLineIndex();
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
coalesceNeighbors(ins_index);
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
}
void
PieceTable::Delete(std::size_t byte_offset, std::size_t len)
{
if (len == 0) {
return;
}
if (byte_offset >= total_size_) {
return;
}
if (byte_offset + len > total_size_) {
len = total_size_ - byte_offset;
}
auto [idx, inner] = locate(byte_offset);
std::size_t remaining = len;
while (remaining > 0 && idx < pieces_.size()) {
Piece &pc = pieces_[idx];
std::size_t available = pc.len - inner; // bytes we can remove from this piece starting at inner
std::size_t take = std::min(available, remaining);
// Compute lengths for left and right remnants
std::size_t left_len = inner;
std::size_t right_len = pc.len - inner - take;
Source src = pc.src;
std::size_t start = pc.start;
// Replace current piece with up to two remnants
if (left_len > 0 && right_len > 0) {
pc.len = left_len; // keep left in place
Piece right{src, start + inner + take, right_len};
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx + 1), right);
idx += 1; // move to right for next iteration decision
} else if (left_len > 0) {
pc.len = left_len;
// no insertion; idx now points to left; move to next piece
} else if (right_len > 0) {
pc.start = start + inner + take;
pc.len = right_len;
} else {
// entire piece removed
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
// stay at same idx for next piece
inner = 0;
remaining -= take;
continue;
}
// After modifying current idx, next deletion continues at beginning of the next logical region
inner = 0;
remaining -= take;
if (remaining == 0)
break;
// Move to next piece
idx += 1;
}
total_size_ -= len;
dirty_ = true;
InvalidateLineIndex();
if (idx < pieces_.size())
coalesceNeighbors(idx);
if (idx > 0)
coalesceNeighbors(idx - 1);
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
}
// ===== Consolidation implementation =====
void
PieceTable::appendPieceDataTo(std::string &out, const Piece &p) const
{
if (p.len == 0)
return;
const std::string &src = p.src == Source::Original ? original_ : add_;
out.append(src.data() + static_cast<std::ptrdiff_t>(p.start), p.len);
}
void
PieceTable::consolidateRange(std::size_t start_idx, std::size_t end_idx)
{
if (start_idx >= end_idx || start_idx >= pieces_.size())
return;
end_idx = std::min(end_idx, pieces_.size());
std::size_t total = 0;
for (std::size_t i = start_idx; i < end_idx; ++i)
total += pieces_[i].len;
if (total == 0)
return;
const std::size_t add_start = add_.size();
std::string tmp;
tmp.reserve(std::min<std::size_t>(total, max_consolidation_bytes_));
for (std::size_t i = start_idx; i < end_idx; ++i)
appendPieceDataTo(tmp, pieces_[i]);
add_.append(tmp);
// Replace [start_idx, end_idx) with single Add piece
Piece consolidated{Source::Add, add_start, tmp.size()};
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx),
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
// total_size_ unchanged
dirty_ = true;
InvalidateLineIndex();
coalesceNeighbors(start_idx);
// Layout changed; invalidate caches/version
version_++;
range_cache_ = {};
find_cache_ = {};
}
void
PieceTable::maybeConsolidate()
{
if (pieces_.size() <= piece_limit_)
return;
// Find the first run of small pieces to consolidate
std::size_t n = pieces_.size();
std::size_t best_start = n, best_end = n;
std::size_t i = 0;
while (i < n) {
// Skip large pieces quickly
if (pieces_[i].len > small_piece_threshold_) {
i++;
continue;
}
std::size_t j = i;
std::size_t bytes = 0;
while (j < n) {
const auto &p = pieces_[j];
if (p.len > small_piece_threshold_)
break;
if (bytes + p.len > max_consolidation_bytes_)
break;
bytes += p.len;
j++;
}
if (j - i >= 2 && bytes > 0) {
// consolidate runs of at least 2 pieces
best_start = i;
best_end = j;
break; // do one run per call; subsequent ops can repeat if still over limit
}
i = j + 1;
}
if (best_start < best_end) {
consolidateRange(best_start, best_end);
}
}
std::size_t
PieceTable::LineCount() const
{
RebuildLineIndex();
return line_index_.empty() ? 0 : line_index_.size();
}
std::pair<std::size_t, std::size_t>
PieceTable::GetLineRange(std::size_t line_num) const
{
RebuildLineIndex();
if (line_index_.empty())
return {0, 0};
if (line_num >= line_index_.size())
return {0, 0};
std::size_t start = line_index_[line_num];
std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_;
return {start, end};
}
std::string
PieceTable::GetLine(std::size_t line_num) const
{
auto [start, end] = GetLineRange(line_num);
if (end < start)
return std::string();
// Trim trailing '\n'
if (end > start) {
// To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole
std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
end -= 1;
}
}
return GetRange(start, end - start);
}
std::pair<std::size_t, std::size_t>
PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
{
if (byte_offset > total_size_)
byte_offset = total_size_;
RebuildLineIndex();
if (line_index_.empty())
return {0, 0};
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
std::size_t col = byte_offset - line_index_[row];
return {row, col};
}
std::size_t
PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
{
RebuildLineIndex();
if (line_index_.empty())
return 0;
if (row >= line_index_.size())
return total_size_;
std::size_t start = line_index_[row];
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
// Clamp col to line length excluding trailing newline
if (end > start) {
std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
end -= 1;
}
}
std::size_t target = start + std::min(col, end - start);
return target;
}
std::string
PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
{
if (byte_offset >= total_size_ || len == 0)
return std::string();
if (byte_offset + len > total_size_)
len = total_size_ - byte_offset;
// Fast path: return cached value if version/offset/len match
{
std::lock_guard<std::mutex> lock(mutex_);
if (range_cache_.valid && range_cache_.version == version_ &&
range_cache_.off == byte_offset && range_cache_.len == len) {
return range_cache_.data;
}
}
std::string out;
out.reserve(len);
if (!dirty_) {
std::lock_guard<std::mutex> lock(mutex_);
// Already materialized; slice directly
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
} else {
// Assemble substring directly from pieces without full materialization
auto [idx, inner] = locate(byte_offset);
std::size_t remaining = len;
while (remaining > 0 && idx < pieces_.size()) {
const auto &p = pieces_[idx];
const std::string &src = (p.src == Source::Original) ? original_ : add_;
std::size_t take = std::min<std::size_t>(p.len - inner, remaining);
if (take > 0) {
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner);
out.append(base, take);
remaining -= take;
inner = 0;
idx += 1;
} else {
break;
}
}
}
// Update cache
{
std::lock_guard<std::mutex> lock(mutex_);
range_cache_.valid = true;
range_cache_.version = version_;
range_cache_.off = byte_offset;
range_cache_.len = len;
range_cache_.data = out;
}
return out;
}
std::size_t
PieceTable::Find(const std::string &needle, std::size_t start) const
{
if (needle.empty())
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
if (start > total_size_)
return std::numeric_limits<std::size_t>::max();
{
std::lock_guard<std::mutex> lock(mutex_);
if (find_cache_.valid &&
find_cache_.version == version_ &&
find_cache_.needle == needle &&
find_cache_.start == start) {
return find_cache_.result;
}
}
materialize();
std::size_t pos;
{
std::lock_guard<std::mutex> lock(mutex_);
pos = materialized_.find(needle, start);
if (pos == std::string::npos)
pos = std::numeric_limits<std::size_t>::max();
// Update cache
find_cache_.valid = true;
find_cache_.version = version_;
find_cache_.needle = needle;
find_cache_.start = start;
find_cache_.result = pos;
}
return pos;
}
void
PieceTable::WriteToStream(std::ostream &out) const
{
// Stream the content piece-by-piece without forcing full materialization
// No lock needed for original_ and add_ if they are not being modified.
// Since this is a const method and kte's piece table isn't modified by multiple threads
// (only queried), we just iterate pieces_.
for (const auto &p: pieces_) {
if (p.len == 0)
continue;
const std::string &src = (p.src == Source::Original) ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
out.write(base, static_cast<std::streamsize>(p.len));
}
}

View File

@@ -1,12 +1,48 @@
/* /*
* PieceTable.h - Alternative to GapBuffer using a piece table representation * PieceTable.h - Alternative to GapBuffer using a piece table representation
*
* PieceTable is kte's core text storage data structure. It provides efficient
* insert/delete operations without copying the entire buffer by maintaining a
* sequence of "pieces" that reference ranges in two underlying buffers:
* - original_: Initial file content (currently unused, reserved for future)
* - add_: All text added during editing
*
* Key advantages:
* - O(1) append/prepend operations (common case)
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
* - Efficient undo: just restore the piece list
* - Memory efficient: no gap buffer waste
*
* Performance characteristics:
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
* - Materialization (Data() call) is O(total_size) but cached until next edit
* - Line index is lazily rebuilt on first line-based query after edits
* - Range and Find operations use lightweight caches for repeated queries
*
* API evolution:
* 1. Legacy API (GapBuffer compatibility):
* - Append/Prepend: Build content sequentially
* - Data(): Materialize entire buffer
*
* 2. New buffer-wide API (Phase 1):
* - Insert/Delete: Edit at arbitrary byte offsets
* - Line-based queries: LineCount, GetLine, GetLineRange
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
* - Efficient extraction: GetRange, Find, WriteToStream
*
* Implementation notes:
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
* - Version tracking invalidates caches on mutations
*/ */
#ifndef KTE_PIECETABLE_H #pragma once
#define KTE_PIECETABLE_H
#include <cstddef> #include <cstddef>
#include <cstdint>
#include <string> #include <string>
#include <ostream>
#include <vector> #include <vector>
#include <limits>
#include <mutex>
class PieceTable { class PieceTable {
@@ -15,6 +51,12 @@ public:
explicit PieceTable(std::size_t initialCapacity); explicit PieceTable(std::size_t initialCapacity);
// Advanced constructor allowing configuration of consolidation heuristics
PieceTable(std::size_t initialCapacity,
std::size_t piece_limit,
std::size_t small_piece_threshold,
std::size_t max_consolidation_bytes);
PieceTable(const PieceTable &other); PieceTable(const PieceTable &other);
PieceTable &operator=(const PieceTable &other); PieceTable &operator=(const PieceTable &other);
@@ -70,6 +112,38 @@ public:
return materialized_.capacity(); return materialized_.capacity();
} }
// ===== New buffer-wide API (Phase 1) =====
// Byte-based editing operations
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
void Delete(std::size_t byte_offset, std::size_t len);
// Line-based queries
[[nodiscard]] std::size_t LineCount() const; // number of logical lines
[[nodiscard]] std::string GetLine(std::size_t line_num) const;
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // [start,end)
// Position conversion
[[nodiscard]] std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
[[nodiscard]] std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
// Substring extraction
[[nodiscard]] std::string GetRange(std::size_t byte_offset, std::size_t len) const;
// Simple search utility; returns byte offset or npos
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
// Stream out content without materializing the entire buffer
void WriteToStream(std::ostream &out) const;
// Heuristic configuration
void SetConsolidationParams(std::size_t piece_limit,
std::size_t small_piece_threshold,
std::size_t max_consolidation_bytes);
private: private:
enum class Source : unsigned char { Original, Add }; enum class Source : unsigned char { Original, Add };
@@ -85,14 +159,63 @@ private:
void materialize() const; void materialize() const;
// Helper: locate piece index and inner offset for a global byte offset
[[nodiscard]] std::pair<std::size_t, std::size_t> locate(std::size_t byte_offset) const;
// Helper: try to coalesce neighboring pieces around index
void coalesceNeighbors(std::size_t index);
// Consolidation helpers and heuristics
void maybeConsolidate();
void consolidateRange(std::size_t start_idx, std::size_t end_idx);
void appendPieceDataTo(std::string &out, const Piece &p) const;
// Line index support (rebuilt lazily on demand)
void InvalidateLineIndex() const;
void RebuildLineIndex() const;
// Underlying storages // Underlying storages
std::string original_; // unused for builder use-case, but kept for API symmetry std::string original_; // unused for builder use-case, but kept for API symmetry
std::string add_; std::string add_;
std::vector<Piece> pieces_; std::vector<Piece> pieces_;
mutable std::string materialized_; mutable std::string materialized_;
mutable bool dirty_ = true; mutable bool dirty_ = true;
std::size_t total_size_ = 0; // Monotonic content version. Increment on any mutation that affects content layout
}; mutable std::uint64_t version_ = 0;
std::size_t total_size_ = 0;
#endif // KTE_PIECETABLE_H // Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
mutable std::vector<std::size_t> line_index_;
mutable bool line_index_dirty_ = true;
// Heuristic knobs
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
std::size_t small_piece_threshold_ = 64; // bytes
std::size_t max_consolidation_bytes_ = 4096; // cap per consolidation run
// Lightweight caches to avoid redundant work when callers query the same range repeatedly
struct RangeCache {
bool valid = false;
std::uint64_t version = 0;
std::size_t off = 0;
std::size_t len = 0;
std::string data;
};
struct FindCache {
bool valid = false;
std::uint64_t version = 0;
std::string needle;
std::size_t start = 0;
std::size_t result = std::numeric_limits<std::size_t>::max();
};
mutable RangeCache range_cache_;
mutable FindCache find_cache_;
mutable std::mutex mutex_;
};

View File

@@ -1,85 +0,0 @@
#include "PythonHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
PythonHighlighter::PythonHighlighter()
{
const char* kw[] = {"and","as","assert","break","class","continue","def","del","elif","else","except","False","finally","for","from","global","if","import","in","is","lambda","None","nonlocal","not","or","pass","raise","return","True","try","while","with","yield"};
for (auto s: kw) kws_.insert(s);
}
void PythonHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
LineState st; (void)HighlightLineStateful(buf, row, st, out);
}
StatefulHighlighter::LineState PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
auto pos = s.find(state.raw_delim);
if (pos == std::string::npos) {
push(out, 0, n, TokenKind::String);
return state; // still inside
} else {
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String);
// remainder processed normally
s = s.substr(end);
n = static_cast<int>(s.size());
state.in_raw_string = false; state.raw_delim.clear();
// Continue parsing remainder as a separate small loop
int base = end; // original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
// For simplicity, mark rest as Default
if (n>0) push(out, base, base + n, TokenKind::Default);
return state;
}
}
int i = 0;
// Detect comment start '#', ignoring inside strings
while (i < n) {
char c = s[i];
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
if (c=='#') { push(out,i,n,TokenKind::Comment); break; }
// Strings: triple quotes and single-line
if (c=='"' || c=='\'') {
char q=c;
// triple?
if (i+2 < n && s[i+1]==q && s[i+2]==q) {
std::string delim(3, q);
int j = i+3; // search for closing triple
auto pos = s.find(delim, static_cast<std::size_t>(j));
if (pos == std::string::npos) {
push(out,i,n,TokenKind::String);
state.in_raw_string = true; state.raw_delim = delim; return state;
} else {
int end = static_cast<int>(pos + 3);
push(out,i,end,TokenKind::String); i=end; continue;
}
} else {
int j=i+1; bool esc=false; while (j<n) { char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d==q) break; }
push(out,i,j,TokenKind::String); i=j; continue;
}
}
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; push(out,i,j,k); i=j; continue; }
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==':'||c==','||c=='('||c==')'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
push(out,i,i+1,TokenKind::Default); ++i;
}
return state;
}
} // namespace kte

View File

@@ -1,18 +0,0 @@
// PythonHighlighter.h - simple Python highlighter with triple-quote state
#pragma once
#include "LanguageHighlighter.h"
#include <unordered_set>
namespace kte {
class PythonHighlighter final : public StatefulHighlighter {
public:
PythonHighlighter();
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
private:
std::unordered_set<std::string> kws_;
};
} // namespace kte

983
QtFrontend.cc Normal file
View File

@@ -0,0 +1,983 @@
#include "QtFrontend.h"
#include <QApplication>
#include <QWidget>
#include <QKeyEvent>
#include <QTimer>
#include <QScreen>
#include <QFont>
#include <QFontMetrics>
#include <QFontDatabase>
#include <QFileDialog>
#include <QFontDialog>
#include <QPainter>
#include <QPaintEvent>
#include <QWheelEvent>
#include <regex>
#include "Editor.h"
#include "Command.h"
#include "Buffer.h"
#include "GUITheme.h"
#include "Highlight.h"
namespace {
class MainWindow : public QWidget {
public:
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
: QWidget(parent), input_(ih)
{
// Match ImGui window title format
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
+ QStringLiteral(KTE_VERSION_STR));
resize(1280, 800);
setFocusPolicy(Qt::StrongFocus);
}
bool WasClosed() const
{
return closed_;
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
void SetFontFamilyAndSize(QString family, int px)
{
if (family.isEmpty())
family = QStringLiteral("Brass Mono");
if (px <= 0)
px = 18;
font_family_ = std::move(family);
font_px_ = px;
update();
}
protected:
void keyPressEvent(QKeyEvent *event) override
{
// Route to editor keymap; if handled, accept and stop propagation so
// Qt doesn't trigger any default widget shortcuts.
if (input_.ProcessKeyEvent(*event)) {
event->accept();
return;
}
QWidget::keyPressEvent(event);
}
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
p.setRenderHint(QPainter::TextAntialiasing, true);
// Colors from GUITheme palette (Qt branch)
auto to_qcolor = [](const KteColor &c) -> QColor {
int r = int(std::round(c.x * 255.0f));
int g = int(std::round(c.y * 255.0f));
int b = int(std::round(c.z * 255.0f));
int a = int(std::round(c.w * 255.0f));
return QColor(r, g, b, a);
};
const auto pal = kte::GetPalette();
const QColor bg = to_qcolor(pal.bg);
const QColor fg = to_qcolor(pal.fg);
const QColor sel_bg = to_qcolor(pal.sel_bg);
const QColor cur_bg = to_qcolor(pal.cur_bg);
const QColor status_bg = to_qcolor(pal.status_bg);
const QColor status_fg = to_qcolor(pal.status_fg);
// Background
p.fillRect(rect(), bg);
// Font/metrics (configured or defaults)
QFont f(font_family_, font_px_);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
// Layout metrics
const int pad_l = 8;
const int pad_t = 6;
const int pad_r = 8;
const int pad_b = 6;
const int status_h = line_h + 6; // status bar height
// Content area (text viewport)
const QRect content_rect(pad_l,
pad_t,
width() - pad_l - pad_r,
height() - pad_t - pad_b - status_h);
// Text viewport occupies all content area (no extra title row)
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
// Draw buffer contents
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
const std::size_t nrows = buf->Nrows();
const std::size_t rowoffs = buf->Rowoffs();
const std::size_t coloffs = buf->Coloffs();
const std::size_t cy = buf->Cury();
const std::size_t cx = buf->Curx();
// Visible line count
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
const std::size_t last_row = std::min<std::size_t>(
nrows, rowoffs + std::max(0, max_lines));
// Tab width: follow ImGuiRenderer default of 4
const std::size_t tabw = 4;
// Prepare painter clip to viewport
p.save();
p.setClipRect(viewport);
// Iterate visible lines
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
// Get line as string for regex/iterator usage and general string ops.
const std::string line = buf->GetLineString(i);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent();
// Helper: convert src col -> rx with tab expansion
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
// Search-match background highlights first (under text)
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
// Compute ranges per line (source indices)
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);
}
} catch (const std::regex_error &) {
// Invalid regex: ignore, status line already shows errors
}
} else {
const std::string &q = ed_->SearchQuery();
if (!q.empty()) {
std::size_t pos = 0;
while ((pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
if (!hl_src_ranges.empty()) {
const bool has_current =
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
const std::size_t cur_end = has_current
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
: 0;
for (const auto &rg: hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e <= coloffs)
continue; // fully left of view
int vx0 = viewport.x() + static_cast<int>((
(rx_s > coloffs ? rx_s - coloffs : 0)
* ch_w));
int vx1 = viewport.x() + static_cast<int>((
(rx_e - coloffs) * ch_w));
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (r.width() <= 0)
continue;
bool is_current =
has_current && sx == cur_x && ex == cur_end;
QColor col = is_current
? QColor(255, 220, 120, 140)
: QColor(200, 200, 0, 90);
p.fillRect(r, col);
}
}
}
// Selection background (if active on this line)
if (buf->MarkSet() && (
i == buf->MarkCury() || i == cy || (
i > std::min(buf->MarkCury(), cy) && i < std::max(
buf->MarkCury(), cy)))) {
std::size_t sx = 0, ex = 0;
if (buf->MarkCury() == i && cy == i) {
sx = std::min(buf->MarkCurx(), cx);
ex = std::max(buf->MarkCurx(), cx);
} else if (i == buf->MarkCury()) {
sx = buf->MarkCurx();
ex = line.size();
} else if (i == cy) {
sx = 0;
ex = cx;
} else {
sx = 0;
ex = line.size();
}
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e > coloffs) {
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
? rx_s - coloffs
: 0) * ch_w);
int vx1 = viewport.x() + static_cast<int>(
(rx_e - coloffs) * ch_w);
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (sel_r.width() > 0)
p.fillRect(sel_r, sel_bg);
}
}
// Build expanded line (tabs -> spaces) for drawing
std::string expanded;
expanded.reserve(line.size() + 8);
std::size_t rx_acc = 0;
for (char c: line) {
if (c == '\t') {
std::size_t adv = (tabw - (rx_acc % tabw));
expanded.append(adv, ' ');
rx_acc += adv;
} else {
expanded.push_back(c);
rx_acc += 1;
}
}
// Syntax highlighting spans or plain text
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version());
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, (int) line_len)));
std::size_t e = static_cast<std::size_t>(std::max(
(int) s, std::min(e_raw, (int) line_len)));
if (s < e)
spans.push_back({s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(),
[](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
auto colorFor = [](kte::TokenKind k) -> QColor {
// GUITheme provides colors via ImGui vector; avoid direct dependency types
const auto v = kte::SyntaxInk(k);
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
int(v.z * 255.0f), int(v.w * 255.0f));
};
// Helper to convert src col to expanded rx
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
if (spans.empty()) {
// No highlight spans: draw the whole (visible) expanded line in default fg
if (coloffs < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline,
QString::fromUtf8(start));
}
} else {
// Draw colored spans
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)
continue; // left of viewport
std::size_t draw_start = (rx_s > coloffs)
? rx_s
: coloffs;
std::size_t draw_end = std::min<std::size_t>(
rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
std::size_t screen_x = draw_start - coloffs;
int px = viewport.x() + int(screen_x * ch_w);
int len = int(draw_end - draw_start);
p.setPen(colorFor(sp.k));
p.drawText(px, baseline,
QString::fromUtf8(
expanded.c_str() + draw_start, len));
}
}
} else {
// Draw expanded text clipped by coloffs
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
}
}
// Cursor indicator on current line
if (i == cy) {
std::size_t rx_cur = src_to_rx_line(cx);
if (rx_cur >= coloffs) {
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
std::size_t start = std::min<std::size_t>(
coloffs, expanded.size());
std::size_t end = std::min<
std::size_t>(rx_cur, expanded.size());
int px_advance = 0;
if (end > start) {
const QString sub = QString::fromUtf8(
expanded.c_str() + start,
static_cast<int>(end - start));
px_advance = fm.horizontalAdvance(sub);
}
int x0 = viewport.x() + px_advance;
QRect r(x0, y, ch_w, line_h);
p.fillRect(r, cur_bg);
}
}
}
p.restore();
}
}
// Status bar
const int bar_y = height() - status_h;
QRect status_rect(0, bar_y, width(), status_h);
p.fillRect(status_rect, status_bg);
p.setPen(status_fg);
if (ed_) {
const int pad = 6;
const int left_x = status_rect.x() + pad;
const int right_x_max = status_rect.x() + status_rect.width() - pad;
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
if (ed_->PromptActive()) {
std::string label = ed_->PromptLabel();
std::string text = ed_->PromptText();
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
auto kind = ed_->CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
if (text.rfind(home, 0) == 0) {
std::string rest = text.substr(home.size());
if (rest.empty())
text = "~";
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
text = std::string("~") + rest;
}
}
}
std::string prefix;
if (kind == Editor::PromptKind::Command)
prefix = ": ";
else if (!label.empty())
prefix = label + ": ";
// Compose text and elide per behavior:
const int max_w = status_rect.width() - 2 * pad;
QString qprefix = QString::fromStdString(prefix);
QString qtext = QString::fromStdString(text);
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
Qt::TextElideMode mode = Qt::ElideRight;
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
mode = Qt::ElideLeft;
}
QString shown = fm.elidedText(qtext, mode, avail_w);
p.drawText(left_x, baseline_y, qprefix + shown);
} else {
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
QString left;
left += QStringLiteral("kge ");
left += QStringLiteral(KTE_VERSION_STR);
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
// buffer index/total
std::size_t total = ed_->BufferCount();
if (total > 0) {
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] ");
} else {
left += QStringLiteral(" ");
}
// buffer display name
std::string disp;
try {
disp = ed_->DisplayNameFor(*buf);
} catch (...) {
disp = buf->Filename();
}
if (disp.empty())
disp = "[No Name]";
left += QString::fromStdString(disp);
if (buf->Dirty())
left += QStringLiteral(" *");
// total lines suffix " <n>L"
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L");
}
// Build right segment: cursor and mark
QString right;
if (buf) {
int row1 = static_cast<int>(buf->Cury()) + 1;
int col1 = static_cast<int>(buf->Curx()) + 1;
bool have_mark = buf->MarkSet();
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
if (have_mark)
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
mcol1);
else
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
}
// Middle message: status text
QString mid = QString::fromStdString(ed_->Status());
// Measure and layout
int left_w = fm.horizontalAdvance(left);
int right_w = fm.horizontalAdvance(right);
int lx = left_x;
int rx = std::max(left_x, right_x_max - right_w);
// If overlap, elide left to make space for right
if (lx + left_w + pad > rx) {
int max_left_w = std::max(0, rx - lx - pad);
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
left_w = fm.horizontalAdvance(left);
}
// Draw left and right
p.drawText(lx, baseline_y, left);
if (!right.isEmpty())
p.drawText(rx, baseline_y, right);
// Middle message clipped between end of left and start of right
int mid_left = lx + left_w + pad;
int mid_right = std::max(mid_left, rx - pad);
int mid_w = std::max(0, mid_right - mid_left);
if (mid_w > 0 && !mid.isEmpty()) {
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
p.save();
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
p.drawText(mid_left, baseline_y, mid_show);
p.restore();
}
}
}
}
void resizeEvent(QResizeEvent *event) override
{
QWidget::resizeEvent(event);
if (!ed_)
return;
// Update editor dimensions based on new size
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
const int status_h = line_h + 6;
const int avail_w = std::max(0, width() - pad_l - pad_r);
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed_->SetDimensions(rows, cols);
}
void wheelEvent(QWheelEvent *event) override
{
if (!ed_) {
QWidget::wheelEvent(event);
return;
}
Buffer *buf = ed_->CurrentBuffer();
if (!buf) {
QWidget::wheelEvent(event);
return;
}
// Recompute metrics to map pixel deltas to rows/cols
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
QPoint pixel = event->pixelDelta();
QPoint angle = event->angleDelta();
double v_lines_delta = 0.0;
double h_cols_delta = 0.0;
// Horizontal scroll with Shift or explicit horizontal delta
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
!angle.isNull() && angle.x() != 0);
if (!pixel.isNull()) {
// Trackpad smooth scrolling (pixels)
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
} else if (!angle.isNull()) {
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
// For horizontal wheels, each notch scrolls 8 columns
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
}
// Accumulate fractional deltas across events
v_scroll_accum_ += v_lines_delta;
h_scroll_accum_ += h_cols_delta;
int d_rows = 0;
int d_cols = 0;
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
h_scroll_accum_))) {
d_rows = static_cast<int>(v_scroll_accum_);
v_scroll_accum_ -= d_rows;
}
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
v_scroll_accum_))) {
d_cols = static_cast<int>(h_scroll_accum_);
h_scroll_accum_ -= d_cols;
}
if (d_rows != 0 || d_cols != 0) {
std::size_t new_rowoffs = buf->Rowoffs();
std::size_t new_coloffs = buf->Coloffs();
// Clamp vertical between 0 and last row (leaving at least one visible line)
if (d_rows != 0) {
long nr = static_cast<long>(new_rowoffs) + d_rows;
if (nr < 0)
nr = 0;
const auto nrows = static_cast<long>(buf->Rows().size());
if (nr > std::max(0L, nrows - 1))
nr = std::max(0L, nrows - 1);
new_rowoffs = static_cast<std::size_t>(nr);
}
if (d_cols != 0) {
long nc = static_cast<long>(new_coloffs) + d_cols;
if (nc < 0)
nc = 0;
new_coloffs = static_cast<std::size_t>(nc);
}
buf->SetOffsets(new_rowoffs, new_coloffs);
update();
event->accept();
return;
}
QWidget::wheelEvent(event);
}
void closeEvent(QCloseEvent *event) override
{
closed_ = true;
QWidget::closeEvent(event);
}
private:
QtInputHandler &input_;
bool closed_ = false;
Editor *ed_ = nullptr;
double v_scroll_accum_ = 0.0;
double h_scroll_accum_ = 0.0;
QString font_family_ = QStringLiteral("Brass Mono");
int font_px_ = 18;
};
} // namespace
bool
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_);
window_->show();
// Ensure the window becomes the active, focused window so it receives key events
window_->activateWindow();
window_->raise();
window_->setFocus(Qt::OtherFocusReason);
renderer_.Attach(window_);
input_.Attach(&ed);
if (auto *mw = dynamic_cast<MainWindow *>(window_))
mw->SetEditor(&ed);
// Load GUI configuration (kge.ini) and configure font for Qt
config_ = GUIConfig::Load();
// Apply background mode from config to match ImGui frontend behavior
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
// Apply theme by name for Qt palette-based theming (maps to named palettes).
// If unknown, falls back to the generic light/dark palette.
(void) kte::ApplyQtThemeByName(config_.theme);
if (window_)
window_->update();
// Map GUIConfig font name to a system family (Qt uses installed fonts)
auto choose_family = [](const std::string &name) -> QString {
QString fam;
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose a fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Preferred fallback chain on macOS; otherwise, try common monospace families
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
// As a last resort, return the request (Qt will substitute)
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString family = choose_family(config_.font);
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(family, px_size);
}
// Track current font in globals for command/status queries
kte::gCurrentFontFamily = family.toStdString();
kte::gCurrentFontSize = static_cast<float>(px_size);
// Set initial dimensions based on font metrics
QFont f(family, px_size);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_->width();
const int h = window_->height();
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
return true;
}
void
GUIFrontend::Step(Editor &ed, bool &running)
{
// Pump Qt events
if (app_)
app_->processEvents();
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
// Drain input queue
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count);
}
}
if (ed.QuitRequested()) {
running = false;
}
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
if (ed.FilePickerVisible()) {
QString startDir;
if (!ed.FilePickerDir().empty()) {
startDir = QString::fromStdString(ed.FilePickerDir());
}
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
dlg.setFileMode(QFileDialog::ExistingFile);
if (dlg.exec() == QDialog::Accepted) {
const QStringList files = dlg.selectedFiles();
if (!files.isEmpty()) {
const QString fp = files.front();
ed.RequestOpenFile(fp.toStdString());
(void) ed.ProcessPendingOpens();
// Update picker dir for next time
QFileInfo info(fp);
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
}
}
// Close picker overlay regardless of outcome
ed.SetFilePickerVisible(false);
if (window_)
window_->update();
}
// Apply any queued theme change requests (from command handler)
if (kte::gThemeChangePending) {
if (!kte::gThemeChangeRequest.empty()) {
// Apply Qt palette theme by name; if unknown, keep current palette
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
}
kte::gThemeChangePending = false;
kte::gThemeChangeRequest.clear();
if (window_)
window_->update();
}
// Visual font picker request (Qt only)
if (kte::gFontDialogRequested) {
// Seed initial font from current or default
QFont seed;
if (!kte::gCurrentFontFamily.empty()) {
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
} else {
seed = window_ ? window_->font() : QFont();
}
bool ok = false;
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
if (ok) {
// Queue font change via existing hooks
kte::gFontFamilyRequest = chosen.family().toStdString();
// Use pixel size if available, otherwise convert from point size approximately
int px = chosen.pixelSize();
if (px <= 0) {
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
if (px <= 0)
px = 18;
}
kte::gFontSizeRequest = static_cast<float>(px);
kte::gFontChangePending = true;
}
kte::gFontDialogRequested = false;
if (window_)
window_->update();
}
// Apply any queued font change requests (Qt)
if (kte::gFontChangePending) {
// Derive target family
auto map_family = [](const std::string &name) -> QString {
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
QString fam;
if (n == "brass" || n == "brassmono" || n == "brass mono") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Fallback chain
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString target_family;
if (!kte::gFontFamilyRequest.empty()) {
target_family = map_family(kte::gFontFamilyRequest);
} else if (!kte::gCurrentFontFamily.empty()) {
target_family = QString::fromStdString(kte::gCurrentFontFamily);
}
int target_px = 0;
if (kte::gFontSizeRequest > 0.0f) {
target_px = (int) std::lround(kte::gFontSizeRequest);
} else if (kte::gCurrentFontSize > 0.0f) {
target_px = (int) std::lround(kte::gCurrentFontSize);
}
if (target_px <= 0)
target_px = 18;
if (target_family.isEmpty())
target_family = QStringLiteral("Monospace");
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(target_family, target_px);
}
// Update globals
kte::gCurrentFontFamily = target_family.toStdString();
kte::gCurrentFontSize = static_cast<float>(target_px);
// Reset requests
kte::gFontChangePending = false;
kte::gFontFamilyRequest.clear();
kte::gFontSizeRequest = 0.0f;
// Recompute editor dimensions to match new metrics
QFont f(target_family, target_px);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_ ? window_->width() : 0;
const int h = window_ ? window_->height() : 0;
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
if (window_)
window_->update();
}
// Draw current frame (request repaint)
renderer_.Draw(ed);
// Detect window close
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
if (mw->WasClosed()) {
running = false;
}
}
}
void
GUIFrontend::Shutdown()
{
if (window_) {
window_->close();
delete window_;
window_ = nullptr;
}
if (app_) {
delete app_;
app_ = nullptr;
}
}

36
QtFrontend.h Normal file
View File

@@ -0,0 +1,36 @@
/*
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
*/
#pragma once
#include "Frontend.h"
#include "GUIConfig.h"
#include "QtInputHandler.h"
#include "QtRenderer.h"
class QApplication;
class QWidget;
// Keep the public class name GUIFrontend to match main.cc selection logic.
class GUIFrontend final : public Frontend {
public:
GUIFrontend() = default;
~GUIFrontend() override = default;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;
void Shutdown() override;
private:
GUIConfig config_{};
QtInputHandler input_{};
QtRenderer renderer_{};
QApplication *app_ = nullptr; // owned
QWidget *window_ = nullptr; // owned
int width_ = 1280;
int height_ = 800;
};

536
QtInputHandler.cc Normal file
View File

@@ -0,0 +1,536 @@
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
#include "QtInputHandler.h"
#include <QKeyEvent>
#include <ncurses.h>
#include "Editor.h"
#include "KKeymap.h"
// Temporary verbose logging to debug macOS Qt key translation issues
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
#ifndef QT_IH_DEBUG
#define QT_IH_DEBUG 0
#endif
#if QT_IH_DEBUG
#include <cstdio>
static const char *
mods_str(Qt::KeyboardModifiers m)
{
static thread_local char buf[64];
buf[0] = '\0';
bool first = true;
auto add = [&](const char *s) {
if (!first)
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
first = false;
};
if (m & Qt::ShiftModifier)
add("Shift");
if (m & Qt::ControlModifier)
add("Ctrl");
if (m & Qt::AltModifier)
add("Alt");
if (m & Qt::MetaModifier)
add("Meta");
if (first)
std::snprintf(buf, sizeof(buf), "none");
return buf;
}
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
#else
#define LOGF(...) ((void)0)
#endif
static bool
IsPrintableQt(const QKeyEvent &e)
{
// Printable if it yields non-empty text and no Ctrl/Meta modifier
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
return false;
const QString t = e.text();
return !t.isEmpty() && !t.at(0).isNull();
}
static int
ToAsciiKey(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return KLowerAscii(c.unicode());
}
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
// Fall back to mapping common virtual keys to ASCII.
switch (e.key()) {
case Qt::Key_A:
return 'a';
case Qt::Key_B:
return 'b';
case Qt::Key_C:
return 'c';
case Qt::Key_D:
return 'd';
case Qt::Key_E:
return 'e';
case Qt::Key_F:
return 'f';
case Qt::Key_G:
return 'g';
case Qt::Key_H:
return 'h';
case Qt::Key_I:
return 'i';
case Qt::Key_J:
return 'j';
case Qt::Key_K:
return 'k';
case Qt::Key_L:
return 'l';
case Qt::Key_M:
return 'm';
case Qt::Key_N:
return 'n';
case Qt::Key_O:
return 'o';
case Qt::Key_P:
return 'p';
case Qt::Key_Q:
return 'q';
case Qt::Key_R:
return 'r';
case Qt::Key_S:
return 's';
case Qt::Key_T:
return 't';
case Qt::Key_U:
return 'u';
case Qt::Key_V:
return 'v';
case Qt::Key_W:
return 'w';
case Qt::Key_X:
return 'x';
case Qt::Key_Y:
return 'y';
case Qt::Key_Z:
return 'z';
case Qt::Key_0:
return '0';
case Qt::Key_1:
return '1';
case Qt::Key_2:
return '2';
case Qt::Key_3:
return '3';
case Qt::Key_4:
return '4';
case Qt::Key_5:
return '5';
case Qt::Key_6:
return '6';
case Qt::Key_7:
return '7';
case Qt::Key_8:
return '8';
case Qt::Key_9:
return '9';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
// Case-preserving ASCII derivation for k-prefix handling where we need to
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
// event text is unavailable (common when Control/Meta held on macOS).
static int
ToAsciiKeyPreserveCase(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return c.unicode();
}
// Fall back to virtual key mapping (letters as uppercase A..Z)
switch (e.key()) {
case Qt::Key_A:
return 'A';
case Qt::Key_B:
return 'B';
case Qt::Key_C:
return 'C';
case Qt::Key_D:
return 'D';
case Qt::Key_E:
return 'E';
case Qt::Key_F:
return 'F';
case Qt::Key_G:
return 'G';
case Qt::Key_H:
return 'H';
case Qt::Key_I:
return 'I';
case Qt::Key_J:
return 'J';
case Qt::Key_K:
return 'K';
case Qt::Key_L:
return 'L';
case Qt::Key_M:
return 'M';
case Qt::Key_N:
return 'N';
case Qt::Key_O:
return 'O';
case Qt::Key_P:
return 'P';
case Qt::Key_Q:
return 'Q';
case Qt::Key_R:
return 'R';
case Qt::Key_S:
return 'S';
case Qt::Key_T:
return 'T';
case Qt::Key_U:
return 'U';
case Qt::Key_V:
return 'V';
case Qt::Key_W:
return 'W';
case Qt::Key_X:
return 'X';
case Qt::Key_Y:
return 'Y';
case Qt::Key_Z:
return 'Z';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
bool
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
{
const Qt::KeyboardModifiers mods = e.modifiers();
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
(int)esc_meta_);
// Control-chord detection: only treat the physical Control key as control-like.
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
const bool ctrl_like = (mods & Qt::ControlModifier);
// 1) Universal argument digits (when active), consume digits without enqueuing commands
if (ed_ && ed_
->
UArg() != 0
) {
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
int d = e.key() - Qt::Key_0;
ed_->UArgDigit(d);
// request status refresh
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
return true;
}
}
}
// 2) Enter k-prefix on C-k
if (ctrl_like && (e.key() == Qt::Key_K)) {
k_prefix_ = true;
k_ctrl_pending_ = false;
LOGF("[QtIH] Enter KPrefix\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
return true;
}
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
if (k_prefix_) {
// ESC/meta prefix should not interfere with k-suffix resolution
esc_meta_ = false;
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
// like C-k c (BufferClose).
int ascii_raw = ToAsciiKeyPreserveCase(e);
if (ascii_raw == 'C' || ascii_raw == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
return true; // consume, wait for next key
}
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
int lower = KLowerAscii(ascii_key);
// Only pass a control suffix for specific supported keys (d/x/q),
// matching ImGui behavior so that holding Ctrl during the suffix
// doesn't break other mappings like C-k c (BufferClose).
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
if (ascii_key != 0) {
CommandId id;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
} else {
// Unknown k-command: notify
std::string a;
a.push_back(static_cast<char>(ascii_key));
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
}
k_prefix_ = false;
return true;
}
// If not resolvable, consume and exit k-prefix
k_prefix_ = false;
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
return true;
}
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
return true;
}
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
if (e.key() == Qt::Key_Escape) {
esc_meta_ = true;
LOGF("[QtIH] ESC: set esc_meta\n");
return true; // consumed
}
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
#if defined(__APPLE__)
if (esc_meta_ || (mods & Qt::AltModifier)) {
#else
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
#endif
int ascii_key = 0;
if (e.key() == Qt::Key_Backspace) {
ascii_key = KEY_BACKSPACE;
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
ascii_key = 'a' + (e.key() - Qt::Key_A);
} else if (e.key() == Qt::Key_Comma) {
ascii_key = '<';
} else if (e.key() == Qt::Key_Period) {
ascii_key = '>';
}
// If still unknown, try deriving from text (covers digits, punctuation, locale)
if (ascii_key == 0) {
ascii_key = ToAsciiKey(e);
}
esc_meta_ = false; // one-shot regardless
if (ascii_key != 0) {
ascii_key = KLowerAscii(ascii_key);
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
} else {
// Report invalid ESC sequence just like ImGui path
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
return true;
}
}
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
return true;
}
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
if (ctrl_like) {
// Universal argument handling: C-u starts collection; C-g cancels
if (e.key() == Qt::Key_U) {
if (ed_)
ed_->UArgStart();
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
return true;
}
if (e.key() == Qt::Key_G) {
if (ed_)
ed_->UArgClear();
k_ctrl_pending_ = false;
k_prefix_ = false;
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
// Fall through to map C-g to Refresh via ctrl map
}
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
int ascii_key = 'a' + (e.key() - Qt::Key_A);
CommandId id;
if (KLookupCtrlCommand(ascii_key, id)) {
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// If no mapping, continue to allow other keys below
}
// 7) Special navigation/edit keys (match ImGui behavior)
{
CommandId id;
bool has = false;
switch (e.key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
id = CommandId::Newline;
has = true;
break;
case Qt::Key_Backspace:
id = CommandId::Backspace;
has = true;
break;
case Qt::Key_Delete:
id = CommandId::DeleteChar;
has = true;
break;
case Qt::Key_Left:
id = CommandId::MoveLeft;
has = true;
break;
case Qt::Key_Right:
id = CommandId::MoveRight;
has = true;
break;
case Qt::Key_Up:
id = CommandId::MoveUp;
has = true;
break;
case Qt::Key_Down:
id = CommandId::MoveDown;
has = true;
break;
case Qt::Key_Home:
id = CommandId::MoveHome;
has = true;
break;
case Qt::Key_End:
id = CommandId::MoveEnd;
has = true;
break;
case Qt::Key_PageUp:
id = CommandId::PageUp;
has = true;
break;
case Qt::Key_PageDown:
id = CommandId::PageDown;
has = true;
break;
default:
break;
}
if (has) {
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// 8) Insert printable text
if (IsPrintableQt(e)) {
std::string s = e.text().toStdString();
if (!s.empty()) {
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
return true;
}
}
LOGF("[QtIH] Unhandled key\n");
return false;
}
bool
QtInputHandler::Poll(MappedInput &out)
{
std::lock_guard<std::mutex> lock(mu_);
if (q_.empty())
return false;
out = q_.front();
q_.pop();
return true;
}

40
QtInputHandler.h Normal file
View File

@@ -0,0 +1,40 @@
/*
* QtInputHandler - Qt-based input mapping for GUI mode
*/
#pragma once
#include <mutex>
#include <queue>
#include "InputHandler.h"
class QKeyEvent;
class QtInputHandler final : public InputHandler {
public:
QtInputHandler() = default;
~QtInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
// Translate a Qt key event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input.
bool ProcessKeyEvent(const QKeyEvent &e);
bool Poll(MappedInput &out) override;
private:
std::mutex mu_;
std::queue<MappedInput> q_;
bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // C-k C-… qualifier
bool esc_meta_ = false; // ESC-prefix for next key
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
Editor *ed_ = nullptr;
};

76
QtRenderer.cc Normal file
View File

@@ -0,0 +1,76 @@
#include "QtRenderer.h"
#include <QWidget>
#include <QPainter>
#include <QPaintEvent>
#include <QFont>
#include <QFontMetrics>
#include "Editor.h"
namespace {
class EditorWidget : public QWidget {
public:
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
{
setAttribute(Qt::WA_OpaquePaintEvent);
setFocusPolicy(Qt::StrongFocus);
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
protected:
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
// Background
const QColor bg(28, 28, 30);
p.fillRect(rect(), bg);
// Font and metrics
QFont f("JetBrains Mono", 13);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
// Title
p.setPen(QColor(220, 220, 220));
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
// Status bar at bottom
const int bar_h = line_h + 6; // padding
const int bar_y = height() - bar_h;
QRect status_rect(0, bar_y, width(), bar_h);
p.fillRect(status_rect, QColor(40, 40, 44));
p.setPen(QColor(180, 180, 140));
if (ed_) {
const QString status = QString::fromStdString(ed_->Status());
// draw at baseline within the bar
const int baseline = bar_y + 3 + fm.ascent();
p.drawText(8, baseline, status);
}
}
private:
Editor *ed_ = nullptr;
};
} // namespace
void
QtRenderer::Draw(Editor &ed)
{
if (!widget_)
return;
// If our widget is an EditorWidget, pass the editor pointer for painting
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
ew->SetEditor(&ed);
}
// Request a repaint
widget_->update();
}

27
QtRenderer.h Normal file
View File

@@ -0,0 +1,27 @@
/*
* QtRenderer - minimal Qt-based renderer
*/
#pragma once
#include "Renderer.h"
class QWidget;
class QtRenderer final : public Renderer {
public:
QtRenderer() = default;
~QtRenderer() override = default;
void Attach(QWidget *widget)
{
widget_ = widget;
}
void Draw(Editor &ed) override;
private:
QWidget *widget_ = nullptr; // not owned
};

View File

@@ -32,27 +32,27 @@ Project Goals
Keybindings Keybindings
----------- -----------
kte maintains kes command model while internals evolve. Highlights (subject to refinement): kte maintains kes command model while internals evolve. Highlights (
subject to refinement):
- Kcommand prefix: `C-k` enters kcommand mode; exit with `ESC` or - Kcommand prefix: `C-k` enters kcommand mode; exit with `ESC` or
`C-g`. `C-g`.
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit), - Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
`C-k q` (quit with confirm), `C-k C-q` (quit immediately). `C-k q` (quit with confirm), `C-k C-q` (quit immediately).
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k - Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u` region), `C-y` (yank), `C-u` (universal argument).
(universal argument).
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search), - Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word). `ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c` - Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
(close), `C-k C-r` (reload). (close), `C-k l` (reload).
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g` - Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
(goto line).
See `ke.md` for the canonical ke reference retained for now. See `ke.md` for the canonical ke reference retained for now.
Build and Run Build and Run
------------- -------------
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs. Prerequisites: C++20 compiler, CMake, and ncurses development
headers/libs.
Dependencies by platform Dependencies by platform
------------------------ ------------------------
@@ -62,30 +62,38 @@ Dependencies by platform
- `brew install ncurses` - `brew install ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- `brew install sdl2 freetype` - `brew install sdl2 freetype`
- OpenGL is provided by the system framework on macOS; no package needed. - OpenGL is provided by the system framework on macOS; no
package needed.
- Debian/Ubuntu - Debian/Ubuntu
- Terminal (default): - Terminal (default):
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev` - `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev` -
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`). `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
- The `mesa-common-dev` package provides OpenGL headers/libs (
`libGL`).
- NixOS/Nix - NixOS/Nix
- Terminal (default): - Terminal (default):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses` - Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL` - Ad-hoc shell:
- With flakes/devshell (example `flake.nix` inputs not provided): include `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell. - With flakes/devshell (example `flake.nix` inputs not provided):
include
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
devShell.
Notes Notes
----- -----
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by - The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
it by
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
installed for your platform. installed for your platform.
- If you previously configured with GUI ON and want to disable it, reconfigure - If you previously configured with GUI ON and want to disable it,
reconfigure
the build directory with `-DBUILD_GUI=OFF`. the build directory with `-DBUILD_GUI=OFF`.
Example build: Example build:
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
GUI build example GUI build example
----------------- -----------------
To build with the optional GUI (after installing the GUI dependencies listed above): To build with the optional GUI (after installing the GUI dependencies
listed above):
``` ```
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON

2502
REWRITE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,12 @@ ROADMAP / TODO:
- [x] Search + Replace - [x] Search + Replace
- [x] Regex search + replace - [x] Regex search + replace
- [ ] The undo system should actually work
- [x] Able to mark buffers as read-only - [x] Able to mark buffers as read-only
- [x] Built-in help text - [x] Built-in help text
- [x] Shorten paths in the homedir with ~ - [x] Shorten paths in the homedir with ~
- [x] When the filename is longer than the message window, scoot left to - [x] When the filename is longer than the message window, scoot left to
keep it in view keep it in view
- [x] Syntax highlighting
- [ ] Swap files (crash recovery). See `docs/plans/swap-files.md`
- [ ] The undo system should actually work
- [ ] LSP integration

View File

@@ -1,9 +1,7 @@
/* /*
* Renderer.h - rendering abstraction * Renderer.h - rendering abstraction
*/ */
#ifndef KTE_RENDERER_H #pragma once
#define KTE_RENDERER_H
class Editor; class Editor;
@@ -13,5 +11,3 @@ public:
virtual void Draw(Editor &ed) = 0; virtual void Draw(Editor &ed) = 0;
}; };
#endif // KTE_RENDERER_H

View File

@@ -1,39 +0,0 @@
#include "RustHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
RustHighlighter::RustHighlighter()
{
const char* kw[] = {"as","break","const","continue","crate","else","enum","extern","false","fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return","self","Self","static","struct","super","trait","true","type","unsafe","use","where","while","dyn","async","await","try"};
for (auto s: kw) kws_.insert(s);
const char* tp[] = {"u8","u16","u32","u64","u128","usize","i8","i16","i32","i64","i128","isize","f32","f64","bool","char","str"};
for (auto s: tp) types_.insert(s);
}
void RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
int i = 0;
while (i < n) {
char c = s[i];
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
if (c=='/' && i+1<n && s[i+1]=='*') { int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; } if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; } }
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
push(out,i,i+1,TokenKind::Default); ++i;
}
}
} // namespace kte

View File

@@ -1,43 +0,0 @@
#include "ShellHighlighter.h"
#include "Buffer.h"
#include <cctype>
namespace kte {
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
void ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
int n = static_cast<int>(s.size());
int i = 0;
// if first non-space is '#', whole line is comment
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
if (bol < n && s[bol] == '#') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
while (i < n) {
char c = s[i];
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
if (c == '#') { push(out, i, n, TokenKind::Comment); break; }
if (c == '\'' || c == '"') {
char q = c; int j = i+1; bool esc=false; while (j<n) { char d=s[j++]; if (q=='"') { if (esc) {esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } else { if (d=='\'') break; } }
push(out,i,j,TokenKind::String); i=j; continue;
}
// simple keywords
if (std::isalpha(static_cast<unsigned char>(c))) {
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='_')) ++j; std::string id=s.substr(i,j-i);
static const char* kws[] = {"if","then","fi","for","in","do","done","case","esac","while","function","elif","else"};
bool kw=false; for (auto k: kws) if (id==k) { kw=true; break; }
push(out,i,j, kw?TokenKind::Keyword:TokenKind::Identifier); i=j; continue;
}
if (std::ispunct(static_cast<unsigned char>(c))) {
TokenKind k = TokenKind::Operator;
if (c=='('||c==')'||c=='{'||c=='}'||c==','||c==';') k=TokenKind::Punctuation;
push(out,i,i+1,k); ++i; continue;
}
push(out,i,i+1,TokenKind::Default); ++i;
}
}
} // namespace kte

1520
Swap.cc Normal file

File diff suppressed because it is too large Load Diff

253
Swap.h Normal file
View File

@@ -0,0 +1,253 @@
// Swap.h - swap journal (crash recovery) writer/manager for kte
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
#include <unordered_map>
#include <memory>
#include <mutex>
#include <condition_variable>
#include <deque>
#include <thread>
#include <atomic>
#include "SwapRecorder.h"
#include "ErrorRecovery.h"
class Buffer;
namespace kte {
// Minimal record types for stage 1
enum class SwapRecType : std::uint8_t {
INS = 1,
DEL = 2,
SPLIT = 3,
JOIN = 4,
META = 0xF0,
CHKPT = 0xFE,
};
struct SwapConfig {
// Grouping and durability knobs (stage 1 defaults)
unsigned flush_interval_ms{200}; // group small writes
unsigned fsync_interval_ms{1000}; // at most once per second
// Checkpoint/compaction knobs (stage 2 defaults)
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
// Compaction rewrites the swap file to contain just the latest checkpoint.
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
// Cleanup / retention (best-effort)
bool prune_on_startup{true};
unsigned prune_max_age_days{30};
std::size_t prune_max_files{2048};
};
// SwapManager manages sidecar swap files and a single background writer thread.
class SwapManager final {
public:
SwapManager();
~SwapManager();
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf);
// Detach and close journal.
// If remove_file is true, the swap file is deleted after closing.
// Intended for clean shutdown/close flows.
void Detach(Buffer *buf, bool remove_file = false);
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
void ResetJournal(Buffer &buf);
// Best-effort pruning of old swap files under the swap directory.
// Never touches non-`.swp` files.
void PruneSwapDir();
// Block until all currently queued records have been written.
// If buf is non-null, flushes all records (stage 1) but is primarily intended
// for tests and shutdown.
void Flush(Buffer *buf = nullptr);
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
void Checkpoint(Buffer *buf = nullptr);
void SetConfig(const SwapConfig &cfg)
{
std::lock_guard<std::mutex> lg(mtx_);
cfg_ = cfg;
cv_.notify_one();
}
// Obtain a per-buffer recorder adapter that emits records for that buffer.
// The returned pointer is owned by the SwapManager and remains valid until
// Detach(buf) or SwapManager destruction.
SwapRecorder *RecorderFor(Buffer *buf);
// Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf);
// Replay a swap journal into an already-open buffer.
// On success, the buffer content reflects all valid journal records.
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
// state results from applying records up to the failure point; callers should
// treat this as a recovery failure and surface `err`.
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
// Compute the swap path for a file-backed buffer by filename.
// Returns empty string if filename is empty.
static std::string ComputeSwapPathForFilename(const std::string &filename);
// Test-only hook to keep swap path logic centralized.
// (Avoid duplicating naming rules in unit tests.)
#ifdef KTE_TESTS
static std::string ComputeSwapPathForTests(const Buffer &buf)
{
return ComputeSidecarPath(buf);
}
#endif
// RAII guard to suspend recording for internal operations
class SuspendGuard {
public:
SuspendGuard(SwapManager &m, Buffer *b);
~SuspendGuard();
private:
SwapManager &m_;
Buffer *buf_;
bool prev_;
};
// Per-buffer toggle
void SetSuspended(Buffer &buf, bool on);
// Error reporting for background thread
struct SwapError {
std::uint64_t timestamp_ns{0};
std::string message;
std::string buffer_name; // filename or "<unnamed>"
};
// Query error state (thread-safe)
bool HasErrors() const;
std::string GetLastError() const;
std::size_t GetErrorCount() const;
private:
class BufferRecorder final : public SwapRecorder {
public:
BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {}
void OnInsert(int row, int col, std::string_view bytes) override;
void OnDelete(int row, int col, std::size_t len) override;
private:
SwapManager &m_;
Buffer &buf_;
};
void RecordInsert(Buffer &buf, int row, int col, std::string_view text);
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
void RecordSplit(Buffer &buf, int row, int col);
void RecordJoin(Buffer &buf, int row);
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
struct JournalCtx {
std::string path;
int fd{-1};
bool header_ok{false};
bool suspended{false};
std::uint64_t last_flush_ns{0};
std::uint64_t last_fsync_ns{0};
std::uint64_t last_chkpt_ns{0};
std::uint64_t edit_bytes_since_chkpt{0};
std::uint64_t approx_size_bytes{0};
};
struct Pending {
Buffer *buf{nullptr};
SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false};
std::uint64_t seq{0};
};
// Helpers
static std::string ComputeSidecarPath(const Buffer &buf);
static std::string ComputeSidecarPathForFilename(const std::string &filename);
static std::uint64_t now_ns();
static bool ensure_parent_dir(const std::string &path);
static std::string SwapDirRoot();
static bool write_header(int fd);
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
static void close_ctx(JournalCtx &ctx);
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
std::string &err);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
void enqueue(Pending &&p);
void writer_loop();
void process_one(const Pending &p);
// Error reporting helper (called from writer thread)
void report_error(const std::string &message, Buffer *buf = nullptr);
// State
SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_;
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
mutable std::mutex mtx_;
std::condition_variable cv_;
std::vector<Pending> queue_;
std::uint64_t next_seq_{0};
std::uint64_t last_processed_{0};
std::uint64_t inflight_{0};
std::atomic<bool> running_{false};
std::thread worker_;
// Error tracking (protected by mtx_)
std::deque<SwapError> errors_; // bounded to max 100 entries
std::size_t total_error_count_{0};
// Circuit breaker for swap operations (protected by mtx_)
CircuitBreaker circuit_breaker_;
};
} // namespace kte

19
SwapRecorder.h Normal file
View File

@@ -0,0 +1,19 @@
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
#pragma once
#include <cstddef>
#include <string_view>
namespace kte {
// SwapRecorder is a tiny, non-blocking callback interface.
// Implementations must return quickly; Buffer calls these hooks after a
// mutation succeeds.
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
virtual void OnDelete(int row, int col, std::size_t len) = 0;
};
} // namespace kte

76
SyscallWrappers.cc Normal file
View File

@@ -0,0 +1,76 @@
#include "SyscallWrappers.h"
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cerrno>
#include <cstdlib>
namespace kte {
namespace syscall {
int
Open(const char *path, int flags, mode_t mode)
{
int fd;
do {
fd = ::open(path, flags, mode);
} while (fd == -1 && errno == EINTR);
return fd;
}
int
Close(int fd)
{
int ret;
do {
ret = ::close(fd);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Fsync(int fd)
{
int ret;
do {
ret = ::fsync(fd);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Fstat(int fd, struct stat *buf)
{
int ret;
do {
ret = ::fstat(fd, buf);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Fchmod(int fd, mode_t mode)
{
int ret;
do {
ret = ::fchmod(fd, mode);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Mkstemp(char *template_str)
{
int fd;
do {
fd = ::mkstemp(template_str);
} while (fd == -1 && errno == EINTR);
return fd;
}
} // namespace syscall
} // namespace kte

47
SyscallWrappers.h Normal file
View File

@@ -0,0 +1,47 @@
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
#pragma once
#include <string>
#include <cstddef>
#include <sys/stat.h>
namespace kte {
namespace syscall {
// EINTR-safe wrapper for open(2).
// Returns file descriptor on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Open(const char *path, int flags, mode_t mode = 0);
// EINTR-safe wrapper for close(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
// Note: Some systems may not restart close() on EINTR, but we retry anyway
// as recommended by POSIX.1-2008.
int Close(int fd);
// EINTR-safe wrapper for fsync(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fsync(int fd);
// EINTR-safe wrapper for fstat(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fstat(int fd, struct stat *buf);
// EINTR-safe wrapper for fchmod(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fchmod(int fd, mode_t mode);
// EINTR-safe wrapper for mkstemp(3).
// Returns file descriptor on success, -1 on failure (errno set).
// Automatically retries on EINTR.
// Note: template_str must be a mutable buffer ending in "XXXXXX".
int Mkstemp(char *template_str);
// Note: rename(2) and unlink(2) are not wrapped because they operate on
// filesystem metadata and typically complete atomically without EINTR.
// If interrupted, they either succeed or fail without partial state.
} // namespace syscall
} // namespace kte

View File

@@ -8,8 +8,10 @@
bool bool
TerminalFrontend::Init(Editor &ed) TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};
@@ -42,19 +44,38 @@ TerminalFrontend::Init(Editor &ed)
meta(stdscr, TRUE); meta(stdscr, TRUE);
// Make ESC key sequences resolve quickly so ESC+<key> works as meta // Make ESC key sequences resolve quickly so ESC+<key> works as meta
#ifdef set_escdelay #ifdef set_escdelay
set_escdelay(50); set_escdelay(TerminalFrontend::kEscDelayMs);
#endif #endif
nodelay(stdscr, TRUE); // Make getch() block briefly instead of busy-looping; reduces CPU when idle
// Equivalent to nodelay(FALSE) with a small timeout.
timeout(16); // ~16ms (about 60Hz)
curs_set(1); curs_set(1);
// Enable mouse support if available // Enable mouse support if available
mouseinterval(0); mouseinterval(0);
mousemask(ALL_MOUSE_EVENTS, nullptr); mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
int r = 0, c = 0; int r = 0, c = 0;
getmaxyx(stdscr, r, c); getmaxyx(stdscr, r, c);
prev_r_ = r; prev_r_ = r;
prev_c_ = c; prev_c_ = c;
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c)); ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
// Ignore SIGINT (Ctrl-C) so it doesn't terminate the TUI.
// We'll restore the previous handler on Shutdown().
{
struct sigaction sa{};
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
struct sigaction old{};
if (sigaction(SIGINT, &sa, &old) == 0) {
old_sigint_ = old;
have_old_sigint_ = true;
}
}
return true; return true;
} }
@@ -73,14 +94,14 @@ TerminalFrontend::Step(Editor &ed, bool &running)
} }
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c)); ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
MappedInput mi; MappedInput mi;
if (input_.Poll(mi)) { if (input_.Poll(mi)) {
if (mi.hasCommand) { if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count); Execute(ed, mi.id, mi.arg, mi.count);
} }
} else {
// Avoid busy loop
usleep(1000);
} }
if (ed.QuitRequested()) { if (ed.QuitRequested()) {
@@ -99,5 +120,10 @@ TerminalFrontend::Shutdown()
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_); (void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
have_orig_tio_ = false; have_orig_tio_ = false;
} }
// Restore previous SIGINT handler
if (have_old_sigint_) {
(void) sigaction(SIGINT, &old_sigint_, nullptr);
have_old_sigint_ = false;
}
endwin(); endwin();
} }

View File

@@ -1,10 +1,9 @@
/* /*
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle * TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
*/ */
#ifndef KTE_TERMINAL_FRONTEND_H #pragma once
#define KTE_TERMINAL_FRONTEND_H
#include <termios.h> #include <termios.h>
#include <signal.h>
#include "Frontend.h" #include "Frontend.h"
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -17,7 +16,12 @@ public:
~TerminalFrontend() override = default; ~TerminalFrontend() override = default;
bool Init(Editor &ed) override; // Configurable ESC key delay (ms) for ncurses' set_escdelay().
// Controls how long ncurses waits to distinguish ESC vs. meta sequences.
// Adjust if your terminal needs a different threshold.
static constexpr int kEscDelayMs = 50;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;
@@ -31,6 +35,7 @@ private:
// Saved terminal attributes to restore on shutdown // Saved terminal attributes to restore on shutdown
bool have_orig_tio_ = false; bool have_orig_tio_ = false;
struct termios orig_tio_{}; struct termios orig_tio_{};
// Saved SIGINT handler to restore on shutdown
bool have_old_sigint_ = false;
struct sigaction old_sigint_{};
}; };
#endif // KTE_TERMINAL_FRONTEND_H

View File

@@ -3,6 +3,8 @@
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Command.h"
#include "Editor.h"
namespace { namespace {
constexpr int constexpr int
@@ -21,40 +23,73 @@ static bool
map_key_to_command(const int ch, map_key_to_command(const int ch,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
// universal-argument state (by ref) bool &k_ctrl_pending,
bool &uarg_active, bool &mouse_selecting,
bool &uarg_collecting, Editor *ed,
bool &uarg_negative,
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
MappedInput &out) MappedInput &out)
{ {
// Handle special keys from ncurses // Handle special keys from ncurses
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) { switch (ch) {
case KEY_ENTER:
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
case KEY_MOUSE: { case KEY_MOUSE: {
k_prefix = false;
k_ctrl_pending = false;
MEVENT ev{}; MEVENT ev{};
if (getmouse(&ev) == OK) { if (getmouse(&ev) == OK) {
// Mouse wheel → map to MoveUp/MoveDown one line per wheel notch // Mouse wheel → scroll viewport without moving cursor
#ifdef BUTTON4_PRESSED #ifdef BUTTON4_PRESSED
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) { if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::MoveUp, "", 0}; out = {true, CommandId::ScrollUp, "", 0};
return true; return true;
} }
#endif #endif
#ifdef BUTTON5_PRESSED #ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) { if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::MoveDown, "", 0}; out = {true, CommandId::ScrollDown, "", 0};
return true; return true;
} }
#endif #endif
// React to left button click/press // React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
REPORT_MOUSE_POSITION))) {
char buf[64]; char buf[64];
// Use screen coordinates; command handler will translate via offsets // Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x); std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0}; const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
return true; const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
if (pressed) {
mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
// We don't set the mark on simple click anymore in ncurses either,
// to be consistent. ncurses doesn't easily support double-click
// or drag-threshold in a platform-independent way here,
// but we can at least only set mark on MOVED.
out.hasCommand = false;
return true;
}
if (mouse_selecting && moved) {
if (Buffer *b = ed->CurrentBuffer()) {
if (!b->MarkSet()) {
// Set mark at CURRENT cursor position (which is where we were before this move)
b->SetMark(b->Curx(), b->Cury());
}
}
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false;
return true;
}
if (released) {
mouse_selecting = false;
out.hasCommand = false;
return true;
}
} }
} }
// No actionable mouse event // No actionable mouse event
@@ -62,34 +97,54 @@ map_key_to_command(const int ch,
return true; return true;
} }
case KEY_LEFT: case KEY_LEFT:
out = {true, CommandId::MoveLeft, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true; return true;
case KEY_RIGHT: case KEY_RIGHT:
out = {true, CommandId::MoveRight, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true; return true;
case KEY_UP: case KEY_UP:
out = {true, CommandId::MoveUp, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true; return true;
case KEY_DOWN: case KEY_DOWN:
out = {true, CommandId::MoveDown, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true; return true;
case KEY_HOME: case KEY_HOME:
out = {true, CommandId::MoveHome, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true; return true;
case KEY_END: case KEY_END:
out = {true, CommandId::MoveEnd, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true; return true;
case KEY_PPAGE: case KEY_PPAGE:
out = {true, CommandId::PageUp, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true; return true;
case KEY_NPAGE: case KEY_NPAGE:
out = {true, CommandId::PageDown, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true; return true;
case KEY_DC: case KEY_DC:
out = {true, CommandId::DeleteChar, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true; return true;
case KEY_RESIZE: case KEY_RESIZE:
out = {true, CommandId::Refresh, "", 0}; k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Refresh, "", 0};
return true; return true;
default: default:
break; break;
@@ -99,6 +154,7 @@ map_key_to_command(const int ch,
if (ch == 27) { if (ch == 27) {
// ESC // ESC
k_prefix = false; k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be considered meta-modified esc_meta = true; // next key will be considered meta-modified
out.hasCommand = false; // no command yet out.hasCommand = false; // no command yet
return true; return true;
@@ -107,59 +163,33 @@ map_key_to_command(const int ch,
// Control keys // Control keys
if (ch == CTRL('K')) { if (ch == CTRL('K')) {
// C-k prefix // C-k prefix
k_prefix = true; k_prefix = true;
out = {true, CommandId::KPrefix, "", 0}; k_ctrl_pending = false;
out = {true, CommandId::KPrefix, "", 0};
return true; return true;
} }
if (ch == CTRL('G')) { if (ch == CTRL('G')) {
// cancel // cancel
k_prefix = false; k_prefix = false;
esc_meta = false; k_ctrl_pending = false;
esc_meta = false;
// cancel universal argument as well // cancel universal argument as well
uarg_active = false; if (ed)
uarg_collecting = false; ed->UArgClear();
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 0;
uarg_text.clear();
out = {true, CommandId::Refresh, "", 0}; out = {true, CommandId::Refresh, "", 0};
return true; return true;
} }
// Universal argument: C-u // Universal argument: C-u
if (ch == CTRL('U')) { if (ch == CTRL('U')) {
// Start or extend universal argument if (ed)
if (!uarg_active) { ed->UArgStart();
uarg_active = true; out.hasCommand = false; // C-u itself doesn't issue a command
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
// Reset collected text and emit status update
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
// Bare repeated C-u multiplies by 4
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4;
// Keep showing status (no digits yet)
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// If digits or '-' have been entered, C-u ends the argument (ready for next command)
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
// No command produced by C-u itself
out.hasCommand = false;
return true; return true;
} }
// Tab (note: terminals encode Tab and C-i as the same code 9) // Tab (note: terminals encode Tab and C-i as the same code 9)
if (ch == '\t') { if (ch == '\t') {
k_prefix = false; k_prefix = false;
k_ctrl_pending = false;
out.hasCommand = true; out.hasCommand = true;
out.id = CommandId::InsertText; out.id = CommandId::InsertText;
out.arg = "\t"; out.arg = "\t";
@@ -170,22 +200,40 @@ map_key_to_command(const int ch,
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted // IMPORTANT: if we're in k-prefix, the very next key must be interpreted
// via the C-k keymap first, even if it's a Control chord like C-d. // via the C-k keymap first, even if it's a Control chord like C-d.
if (k_prefix) { if (k_prefix) {
k_prefix = false; // consume the prefix for this one key // In k-prefix: allow a control qualifier via literal 'C' or '^'
// Detect Control keycodes first
bool ctrl = false; bool ctrl = false;
int ascii_key = ch; int ascii_key = ch;
if (ch >= 1 && ch <= 26) { if (ch >= 1 && ch <= 26) {
ctrl = true; ctrl = true;
ascii_key = 'a' + (ch - 1); ascii_key = 'a' + (ch - 1);
} }
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending = true;
if (ed)
ed->SetStatus("C-k C _");
out.hasCommand = false;
return true;
}
// For actual suffix, consume the k-prefix
k_prefix = false;
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings // Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
CommandId id; CommandId id;
if (KLookupKCommand(ascii_key, ctrl, id)) { bool pass_ctrl = (ctrl || k_ctrl_pending);
k_ctrl_pending = false;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
out = {true, id, "", 0}; out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
} else { } else {
int shown = KLowerAscii(ascii_key); int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?'; char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c); std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0}; out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
} }
return true; return true;
} }
@@ -201,8 +249,9 @@ map_key_to_command(const int ch,
// Enter // Enter
if (ch == '\n' || ch == '\r') { if (ch == '\n' || ch == '\r') {
k_prefix = false; k_prefix = false;
out = {true, CommandId::Newline, "", 0}; k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true; return true;
} }
// If previous key was ESC, interpret as meta and use ESC keymap // If previous key was ESC, interpret as meta and use ESC keymap
@@ -212,6 +261,12 @@ map_key_to_command(const int ch,
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace) // Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
ascii_key = KEY_BACKSPACE; // normalized value for lookup ascii_key = KEY_BACKSPACE; // normalized value for lookup
} else if (ch == ',') {
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
ascii_key = '<';
} else if (ch == '.') {
// Likewise, map '.' to '>'
ascii_key = '>';
} else if (ascii_key >= 'A' && ascii_key <= 'Z') { } else if (ascii_key >= 'A' && ascii_key <= 'Z') {
ascii_key = ascii_key - 'A' + 'a'; ascii_key = ascii_key - 'A' + 'a';
} }
@@ -220,48 +275,26 @@ map_key_to_command(const int ch,
out = {true, id, "", 0}; out = {true, id, "", 0};
return true; return true;
} }
// Unhandled meta key: no command // Unhandled ESC sequence: exit escape mode and show status
out.hasCommand = false; out = {true, CommandId::UnknownEscCommand, "", 0};
return true; return true;
} }
// Backspace in ncurses can be KEY_BACKSPACE or 127 // Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
k_prefix = false; k_prefix = false;
out = {true, CommandId::Backspace, "", 0}; k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true; return true;
} }
// k_prefix handled earlier // k_prefix handled earlier
// If collecting universal arg, handle digits and optional leading '-' // If universal argument is active at editor level and we get a digit, feed it
if (uarg_active && uarg_collecting) { if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
if (ch >= '0' && ch <= '9') { ed->UArgDigit(ch - '0');
int d = ch - '0'; out.hasCommand = false; // keep collecting, no command yet
if (!uarg_had_digits) { return true;
// First digit overrides any 4^n default
uarg_value = 0;
uarg_had_digits = true;
}
if (uarg_value < 100000000) {
// avoid overflow
uarg_value = uarg_value * 10 + d;
}
// Update raw text and status to reflect collected digits
uarg_text.push_back(static_cast<char>(ch));
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
if (ch == '-' && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
// Show leading minus in status
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will be processed as a command; fall through to mapping below
// but mark collection finished so we apply the argument to that command
uarg_collecting = false;
} }
// Printable ASCII // Printable ASCII
@@ -288,29 +321,12 @@ TerminalInputHandler::decode_(MappedInput &out)
bool consumed = map_key_to_command( bool consumed = map_key_to_command(
ch, ch,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_, k_ctrl_pending_,
mouse_selecting_,
ed_,
out); out);
if (!consumed) if (!consumed)
return false; return false;
// If a command was produced and a universal argument is active, attach it and clear state
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
// No explicit digits: use current value (default 4 or 4^n)
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
out.count = count;
// Clear state
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
}
return true; return true;
} }
@@ -320,4 +336,4 @@ TerminalInputHandler::Poll(MappedInput &out)
{ {
out = {}; out = {};
return decode_(out) && out.hasCommand; return decode_(out) && out.hasCommand;
} }

View File

@@ -1,9 +1,7 @@
/* /*
* TerminalInputHandler - ncurses-based input handling for terminal mode * TerminalInputHandler - ncurses-based input handling for terminal mode
*/ */
#ifndef KTE_TERMINAL_INPUT_HANDLER_H #pragma once
#define KTE_TERMINAL_INPUT_HANDLER_H
#include "InputHandler.h" #include "InputHandler.h"
@@ -13,6 +11,13 @@ public:
~TerminalInputHandler() override; ~TerminalInputHandler() override;
void Attach(Editor *ed) override
{
ed_ = ed;
}
bool Poll(MappedInput &out) override; bool Poll(MappedInput &out) override;
private: private:
@@ -20,16 +25,13 @@ private:
// ke-style prefix state // ke-style prefix state
bool k_prefix_ = false; // true after C-k until next key or ESC bool k_prefix_ = false; // true after C-k until next key or ESC
// Optional control qualifier inside k-prefix (e.g., user typed literal 'C' or '^')
bool k_ctrl_pending_ = false;
// Simple meta (ESC) state for ESC sequences like ESC b/f // Simple meta (ESC) state for ESC sequences like ESC b/f
bool esc_meta_ = false; bool esc_meta_ = false;
// Universal argument (C-u) state // Mouse drag selection state
bool uarg_active_ = false; // an argument is pending for the next command bool mouse_selecting_ = false;
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
};
#endif // KTE_TERMINAL_INPUT_HANDLER_H Editor *ed_ = nullptr; // attached editor for uarg handling
};

View File

@@ -1,3 +1,6 @@
#include <clocale>
#define _XOPEN_SOURCE_EXTENDED 1
#include <cwchar>
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
@@ -34,6 +37,8 @@ TerminalRenderer::Draw(Editor &ed)
const Buffer *buf = ed.CurrentBuffer(); const Buffer *buf = ed.CurrentBuffer();
int content_rows = rows - 1; // last line is status int content_rows = rows - 1; // last line is status
if (content_rows < 1)
content_rows = 1;
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
if (buf) { if (buf) {
@@ -42,18 +47,18 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t coloffs = buf->Coloffs(); std::size_t coloffs = buf->Coloffs();
const int tabw = 8; const int tabw = 8;
// Phase 3: prefetch visible viewport highlights (current terminal area) // Phase 3: prefetch visible viewport highlights (current terminal area)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(rowoffs); int fr = static_cast<int>(rowoffs);
int rc = std::max(0, content_rows); int rc = std::max(0, content_rows);
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version()); buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
} }
for (int r = 0; r < content_rows; ++r) { for (int r = 0; r < content_rows; ++r) {
move(r, 0); move(r, 0);
std::size_t li = rowoffs + static_cast<std::size_t>(r); std::size_t li = rowoffs + static_cast<std::size_t>(r);
std::size_t render_col = 0; std::size_t render_col = 0;
std::size_t src_i = 0; std::size_t src_i = 0;
// Compute matches for this line if search highlighting is active // Compute matches for this line if search highlighting is active
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty(); bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end) std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
@@ -102,59 +107,168 @@ TerminalRenderer::Draw(Editor &ed)
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0; const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0; const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
bool hl_on = false;
bool cur_on = false; // Mark selection (mark -> cursor), in source coordinates
int written = 0; bool sel_active = false;
if (li < lines.size()) { std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
std::string line = static_cast<std::string>(lines[li]); if (buf->MarkSet()) {
src_i = 0; sel_sy = buf->MarkCury();
render_col = 0; sel_sx = buf->MarkCurx();
// Syntax highlighting: fetch per-line spans sel_ey = buf->Cury();
const kte::LineHighlight *lh_ptr = nullptr; sel_ex = buf->Curx();
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
lh_ptr = &buf->Highlighter()->GetLine(*buf, static_cast<int>(li), buf->Version()); std::swap(sel_sy, sel_ey);
} std::swap(sel_sx, sel_ex);
auto token_at = [&](std::size_t src_index) -> kte::TokenKind { }
if (!lh_ptr) return kte::TokenKind::Default; sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
for (const auto &sp: lh_ptr->spans) { }
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(src_index) < sp.col_end) // Visual-line selection: full-line selection range
return sp.kind; const bool vsel_active = buf->VisualLineActive();
} const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
return kte::TokenKind::Default; const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
}; auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
auto apply_token_attr = [&](kte::TokenKind k) { if (!sel_active)
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below return false;
attrset(A_NORMAL); if (y < sel_sy || y > sel_ey)
switch (k) { return false;
case kte::TokenKind::Keyword: if (sel_sy == sel_ey)
case kte::TokenKind::Type: return sx >= sel_sx && sx < sel_ex;
case kte::TokenKind::Constant: if (y == sel_sy)
case kte::TokenKind::Function: return sx >= sel_sx;
attron(A_BOLD); if (y == sel_ey)
break; return sx < sel_ex;
case kte::TokenKind::Comment: return true;
attron(A_DIM); };
break; int written = 0;
case kte::TokenKind::String: if (li < lines.size()) {
case kte::TokenKind::Char: std::string line = static_cast<std::string>(lines[li]);
case kte::TokenKind::Number: const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
// standout a bit using A_UNDERLINE if available const std::size_t vsel_spot_src = vsel_on_line
attron(A_UNDERLINE); ? std::min(buf->Curx(), line.size())
break; : 0;
default: const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
break; std::size_t vsel_line_rx = 0;
} if (vsel_spot_is_eol) {
}; // Compute the rendered (column) width of the line so we can highlight a
while (written < cols) { // single cell at EOL when the spot falls beyond the last character.
char ch = ' '; std::size_t rc = 0;
bool from_src = false; std::size_t si = 0;
while (si < line.size()) {
wchar_t wch = L' ';
int wch_len = 1;
std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
wch = static_cast<unsigned char>(line[si]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
constexpr std::size_t tab_width = 8;
const std::size_t next_tab = tab_width - (rc % tab_width);
rc += next_tab;
} else {
int w = wcwidth(wch);
if (w < 0)
w = 1;
rc += static_cast<std::size_t>(w);
}
si += static_cast<std::size_t>(wch_len);
}
vsel_line_rx = rc;
}
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
}
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
if (sane_spans.empty())
return kte::TokenKind::Default;
int si = static_cast<int>(src_index);
for (const auto &sp: sane_spans) {
if (si < sp.col_start)
break;
if (si >= sp.col_start && si < sp.col_end)
return sp.kind;
}
return kte::TokenKind::Default;
};
auto token_attr = [&](kte::TokenKind k) -> attr_t {
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
return A_BOLD;
case kte::TokenKind::Comment:
return A_DIM;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
return A_UNDERLINE;
default:
return 0;
}
};
while (written < cols) {
bool from_src = false;
wchar_t wch = L' ';
int wch_len = 1;
int disp_w = 1;
if (src_i < line.size()) { if (src_i < line.size()) {
unsigned char c = static_cast<unsigned char>(line[src_i]); // Decode UTF-8
if (c == '\t') { std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(
&wch, &line[src_i], line.size() - src_i, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
// Invalid or incomplete; treat as single byte
wch = static_cast<unsigned char>(line[src_i]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col % tabw); std::size_t next_tab = tabw - (render_col % tabw);
if (render_col + next_tab <= coloffs) { if (render_col + next_tab <= coloffs) {
render_col += next_tab; render_col += next_tab;
++src_i; src_i += wch_len;
continue; continue;
} }
// Emit spaces for tab // Emit spaces for tab
@@ -163,111 +277,148 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t to_skip = std::min<std::size_t>( std::size_t to_skip = std::min<std::size_t>(
next_tab, coloffs - render_col); next_tab, coloffs - render_col);
render_col += to_skip; render_col += to_skip;
next_tab -= to_skip; next_tab -= to_skip;
} }
// Now render visible spaces // Now render visible spaces
while (next_tab > 0 && written < cols) { while (next_tab > 0 && written < cols) {
bool in_hl = search_mode && is_src_in_hl(src_i); bool in_mark = is_src_in_mark_sel(li, src_i);
bool in_cur = bool in_vsel =
has_current && li == cur_my && src_i >= cur_mx vsel_on_line && !vsel_spot_is_eol && src_i ==
&& src_i < cur_mend; vsel_spot_src;
// Toggle highlight attributes bool in_sel = in_mark || in_vsel;
int attr = 0; bool in_hl = search_mode && is_src_in_hl(src_i);
if (in_hl) bool in_cur =
attr |= A_STANDOUT; has_current && li == cur_my && src_i >= cur_mx
if (in_cur) &&
attr |= A_BOLD; src_i < cur_mend;
if ((attr & A_STANDOUT) && !hl_on) { attr_t a = A_NORMAL;
attron(A_STANDOUT); a |= token_attr(token_at(src_i));
hl_on = true; if (in_sel) {
} a |= A_REVERSE;
if (!(attr & A_STANDOUT) && hl_on) { } else {
attroff(A_STANDOUT); if (in_hl)
hl_on = false; a |= A_STANDOUT;
} if (in_cur)
if ((attr & A_BOLD) && !cur_on) { a |= A_BOLD;
attron(A_BOLD); }
cur_on = true; attrset(a);
} addch(' ');
if (!(attr & A_BOLD) && cur_on) { ++written;
attroff(A_BOLD);
cur_on = false;
}
// Apply syntax attribute only if not in search highlight
if (!in_hl) {
apply_token_attr(token_at(src_i));
}
addch(' ');
++written;
++render_col;
--next_tab;
}
++src_i;
continue;
} else {
// normal char
if (render_col < coloffs) {
++render_col; ++render_col;
++src_i; --next_tab;
}
src_i += wch_len;
continue;
} else {
// normal char
disp_w = wcwidth(wch);
if (disp_w < 0)
disp_w = 1; // non-printable or similar
if (render_col < coloffs) {
render_col += disp_w;
src_i += wch_len;
continue; continue;
} }
ch = static_cast<char>(c);
from_src = true; from_src = true;
} }
} else { } else {
// beyond EOL, fill spaces // beyond EOL, fill spaces
ch = ' '; wch = L' ';
wch_len = 1;
disp_w = 1;
from_src = false; from_src = false;
} }
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur = if (written + disp_w > cols) {
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < // would overflow, just break
cur_mend; break;
if (in_hl && !hl_on) { }
attron(A_STANDOUT);
hl_on = true; bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
} bool in_vsel = false;
if (!in_hl && hl_on) { if (vsel_on_line) {
attroff(A_STANDOUT); if (from_src) {
hl_on = false; in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
} } else {
if (in_cur && !cur_on) { in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
attron(A_BOLD); }
cur_on = true; }
} bool in_sel = in_mark || in_vsel;
if (!in_cur && cur_on) { bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
attroff(A_BOLD); bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
cur_on = false; src_i < cur_mend;
} attr_t a = A_NORMAL;
if (!in_hl && from_src) { if (from_src)
apply_token_attr(token_at(src_i)); a |= token_attr(token_at(src_i));
} if (in_sel) {
addch(static_cast<unsigned char>(ch)); a |= A_REVERSE;
++written; } else {
++render_col; if (in_hl)
if (from_src) a |= A_STANDOUT;
++src_i; if (in_cur)
a |= A_BOLD;
}
attrset(a);
if (from_src) {
cchar_t cch;
wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch);
} else {
addch(' ');
}
written += disp_w;
render_col += disp_w;
if (from_src)
src_i += wch_len;
if (src_i >= line.size() && written >= cols) if (src_i >= line.size() && written >= cols)
break; break;
} }
} }
if (hl_on) { attrset(A_NORMAL);
attroff(A_STANDOUT); clrtoeol();
hl_on = false; }
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
attrset(A_NORMAL);
clrtoeol();
}
// Place terminal cursor at logical position accounting for tabs and coloffs // Place terminal cursor at logical position accounting for tabs and coloffs.
std::size_t cy = buf->Cury(); // Recompute the rendered X using the same logic as the drawing loop to avoid
std::size_t rx = buf->Rx(); // render x computed by command layer // any drift between the command-layer computation and the terminal renderer.
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs()); std::size_t cy = buf->Cury();
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs()); std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0;
if (cy < lines.size()) {
const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0;
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
std::mbstate_t state = std::mbstate_t();
wchar_t wch;
size_t res = std::mbrtowc(
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
&state);
if (res == (size_t) -1 || res == (size_t) -2) {
render_col_cur += 1;
src_i_cur += 1;
} else if (res == 0) {
src_i_cur += 1;
} else {
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else {
int dw = wcwidth(wch);
render_col_cur += (dw < 0) ? 1 : dw;
}
src_i_cur += res;
}
}
rx_recomputed = render_col_cur;
}
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) { if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
// remember where to leave the terminal cursor after status is drawn // remember where to leave the terminal cursor after status is drawn
saved_cur_y = cur_y; saved_cur_y = cur_y;
@@ -352,9 +503,9 @@ TerminalRenderer::Draw(Editor &ed)
{ {
const char *app = "kte"; const char *app = "kte";
left.reserve(256); left.reserve(256);
left += app; left += app;
left += " "; left += " ";
left += KTE_VERSION_STR; // already includes leading 'v' left += KTE_VERSION_STR; // already includes leading 'v'
const Buffer *b = buf; const Buffer *b = buf;
std::string fname; std::string fname;
if (b) { if (b) {
@@ -375,11 +526,11 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount(); std::size_t total = ed.BufferCount();
if (total > 0) { if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
left += "["; left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1)); left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/"; left += "/";
left += std::to_string(static_cast<unsigned long long>(total)); left += std::to_string(static_cast<unsigned long long>(total));
left += "] "; left += "] ";
} }
} }
left += fname; left += fname;
@@ -391,9 +542,9 @@ TerminalRenderer::Draw(Editor &ed)
// Append total line count as "<n>L" // Append total line count as "<n>L"
if (b) { if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Rows().size()); unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
} }
} }

View File

@@ -1,9 +1,7 @@
/* /*
* TerminalRenderer - ncurses-based renderer for terminal mode * TerminalRenderer - ncurses-based renderer for terminal mode
*/ */
#ifndef KTE_TERMINAL_RENDERER_H #pragma once
#define KTE_TERMINAL_RENDERER_H
#include "Renderer.h" #include "Renderer.h"
@@ -15,5 +13,3 @@ public:
void Draw(Editor &ed) override; void Draw(Editor &ed) override;
}; };
#endif // KTE_TERMINAL_RENDERER_H

View File

@@ -4,8 +4,10 @@
bool bool
TestFrontend::Init(Editor &ed) TestFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
ed.SetDimensions(24, 80); ed.SetDimensions(24, 80);
return true; return true;
} }
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
void void
TestFrontend::Step(Editor &ed, bool &running) TestFrontend::Step(Editor &ed, bool &running)
{ {
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
MappedInput mi; MappedInput mi;
if (input_.Poll(mi)) { if (input_.Poll(mi)) {
if (mi.hasCommand) { if (mi.hasCommand) {

View File

@@ -1,9 +1,7 @@
/* /*
* TestFrontend.h - headless frontend for testing with programmable input * TestFrontend.h - headless frontend for testing with programmable input
*/ */
#ifndef KTE_TEST_FRONTEND_H #pragma once
#define KTE_TEST_FRONTEND_H
#include "Frontend.h" #include "Frontend.h"
#include "TestInputHandler.h" #include "TestInputHandler.h"
#include "TestRenderer.h" #include "TestRenderer.h"
@@ -15,7 +13,7 @@ public:
~TestFrontend() override = default; ~TestFrontend() override = default;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;
@@ -37,5 +35,3 @@ private:
TestInputHandler input_{}; TestInputHandler input_{};
TestRenderer renderer_{}; TestRenderer renderer_{};
}; };
#endif // KTE_TEST_FRONTEND_H

View File

@@ -1,9 +1,7 @@
/* /*
* TestInputHandler.h - programmable input handler for testing * TestInputHandler.h - programmable input handler for testing
*/ */
#ifndef KTE_TEST_INPUT_HANDLER_H #pragma once
#define KTE_TEST_INPUT_HANDLER_H
#include <queue> #include <queue>
#include "InputHandler.h" #include "InputHandler.h"
@@ -30,5 +28,3 @@ public:
private: private:
std::queue<MappedInput> queue_; std::queue<MappedInput> queue_;
}; };
#endif // KTE_TEST_INPUT_HANDLER_H

View File

@@ -1,9 +1,7 @@
/* /*
* TestRenderer.h - minimal renderer for testing (no actual display) * TestRenderer.h - minimal renderer for testing (no actual display)
*/ */
#ifndef KTE_TEST_RENDERER_H #pragma once
#define KTE_TEST_RENDERER_H
#include <cstddef> #include <cstddef>
#include "Renderer.h" #include "Renderer.h"
@@ -32,5 +30,3 @@ public:
private: private:
std::size_t draw_count_ = 0; std::size_t draw_count_ = 0;
}; };
#endif // KTE_TEST_RENDERER_H

View File

@@ -1,46 +0,0 @@
#include "TreeSitterHighlighter.h"
#ifdef KTE_ENABLE_TREESITTER
#include "Buffer.h"
#include <utility>
namespace kte {
TreeSitterHighlighter::TreeSitterHighlighter(const TSLanguage* lang, std::string filetype)
: language_(lang), filetype_(std::move(filetype))
{
}
TreeSitterHighlighter::~TreeSitterHighlighter()
{
disposeParser();
}
void TreeSitterHighlighter::ensureParsed(const Buffer& /*buf*/) const
{
// Intentionally a stub to avoid pulling the Tree-sitter API and library by default.
// In future, when linking against tree-sitter, initialize parser_, set language_,
// and build tree_ from the buffer contents.
}
void TreeSitterHighlighter::disposeParser() const
{
// Stub; nothing to dispose when not actually creating parser/tree
}
void TreeSitterHighlighter::HighlightLine(const Buffer &/*buf*/, int /*row*/, std::vector<HighlightSpan> &/*out*/) const
{
// For now, no-op. When tree-sitter is wired, map nodes to TokenKind spans per line.
}
std::unique_ptr<LanguageHighlighter> CreateTreeSitterHighlighter(const char* filetype,
const void* (*get_lang)())
{
const auto* lang = reinterpret_cast<const TSLanguage*>(get_lang ? get_lang() : nullptr);
return std::make_unique<TreeSitterHighlighter>(lang, filetype ? std::string(filetype) : std::string());
}
} // namespace kte
#endif // KTE_ENABLE_TREESITTER

View File

@@ -1,6 +1,4 @@
#ifndef KTE_UNDONODE_H #pragma once
#define KTE_UNDONODE_H
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -11,16 +9,16 @@ enum class UndoType : std::uint8_t {
Paste, Paste,
Newline, Newline,
DeleteRow, DeleteRow,
InsertRow,
}; };
struct UndoNode { struct UndoNode {
UndoType type{}; UndoType type{};
int row{}; int row{};
int col{}; int col{};
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
std::string text; std::string text;
UndoNode *child = nullptr; // next in current timeline UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *next = nullptr; // redo branch UndoNode *child = nullptr; // next in current timeline
}; UndoNode *next = nullptr; // redo branch
};
#endif // KTE_UNDONODE_H

63
UndoNodePool.h Normal file
View File

@@ -0,0 +1,63 @@
#pragma once
#include <stack>
#include <vector>
#include <memory>
#include "UndoNode.h"
// Pool allocator for UndoNode to eliminate frequent malloc/free.
// Uses fixed-size blocks to keep node addresses stable.
class UndoNodePool {
public:
explicit UndoNodePool(std::size_t block_size = 64)
: block_size_(block_size) {}
UndoNode *acquire()
{
if (available_.empty())
allocate_block();
auto *node = available_.top();
available_.pop();
// Node comes zeroed; ensure links are reset
node->text.clear();
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
return node;
}
void release(UndoNode *node)
{
if (!node)
return;
// Clear heavy fields to free memory held by strings
node->text.clear();
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
available_.push(node);
}
private:
void allocate_block()
{
// allocate a new block; keep ownership so memory stays valid
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
UndoNode *base = block.get();
blocks_.push_back(std::move(block));
for (std::size_t i = 0; i < block_size_; ++i) {
// ensure the node is reset; rely on default constructor/zero init
available_.push(&base[i]);
}
}
std::size_t block_size_;
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
std::stack<UndoNode *> available_;
};

View File

@@ -8,60 +8,93 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
: buf_(&owner), tree_(tree) {} : buf_(&owner), tree_(tree) {}
std::uint64_t
UndoSystem::BeginGroup()
{
// Ensure any pending typed run is sealed so the group is a distinct undo step.
commit();
if (active_group_id_ == 0)
active_group_id_ = next_group_id_++;
return active_group_id_;
}
void
UndoSystem::EndGroup()
{
commit();
active_group_id_ = 0;
}
void void
UndoSystem::Begin(UndoType type) UndoSystem::Begin(UndoType type)
{ {
#ifdef KTE_UNDO_DEBUG if (!buf_)
debug_log("Begin"); return;
#endif
// Reuse pending if batching conditions are met
const int row = static_cast<int>(buf_->Cury()); const int row = static_cast<int>(buf_->Cury());
const int col = static_cast<int>(buf_->Curx()); const int col = static_cast<int>(buf_->Curx());
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
if (type == UndoType::Delete) { // Some operations should always be standalone undo steps.
// Support batching both forward deletes (DeleteChar) and backspace (prepend case) const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
// Forward delete: cursor stays at anchor col; keep batching when col == anchor UndoType::InsertRow);
const auto anchor = static_cast<std::size_t>(tree_.pending->col); if (always_standalone) {
if (anchor == static_cast<std::size_t>(col)) { commit();
pending_prepend_ = false; }
return; // keep batching forward delete
if (tree_.pending) {
if (tree_.pending->type == type) {
// Typed-run coalescing rules.
switch (type) {
case UndoType::Insert:
case UndoType::Paste: {
// Cursor must be at the end of the pending insert.
if (tree_.pending->row == row
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
pending_mode_ = PendingAppendMode::Append;
return;
}
break;
} }
// Backspace: cursor moved left by exactly one position relative to current anchor. case UndoType::Delete: {
// Extend batch by shifting anchor left and prepending the deleted byte. if (tree_.pending->row == row) {
if (static_cast<std::size_t>(col) + 1 == anchor) { // Two common delete shapes:
tree_.pending->col = col; // 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
pending_prepend_ = true; // 2) delete-run: cursor stays, always deleting at the same col
return; if (col == tree_.pending->col) {
pending_mode_ = PendingAppendMode::Append;
return;
}
if (col + 1 == tree_.pending->col) {
// Extend a backspace run to the left; update the start column now.
tree_.pending->col = col;
pending_mode_ = PendingAppendMode::Prepend;
return;
}
}
break;
} }
} else { case UndoType::Newline:
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text. case UndoType::DeleteRow:
size(); case UndoType::InsertRow:
if (expected == static_cast<std::size_t>(col)) { break;
pending_prepend_ = false;
return; // keep batching
} }
} }
// Can't coalesce: seal the previous pending step.
commit();
} }
// Otherwise commit any existing batch and start a new node
commit();
auto *node = new UndoNode();
node->type = type;
node->row = row;
node->col = col;
node->child = nullptr;
node->next = nullptr;
tree_.pending = node;
pending_prepend_ = false;
#ifdef KTE_UNDO_DEBUG // Start a new pending node.
debug_log("Begin:new"); tree_.pending = new UndoNode{};
#endif tree_.pending->type = type;
// Assert pending is detached from the tree tree_.pending->row = row;
assert(tree_.pending && "pending must exist after Begin"); tree_.pending->col = col;
assert(tree_.pending != tree_.root); tree_.pending->group_id = active_group_id_;
assert(tree_.pending != tree_.current); tree_.pending->text.clear();
assert(tree_.pending != tree_.saved); tree_.pending->parent = nullptr;
assert(!is_descendant(tree_.root, tree_.pending)); tree_.pending->child = nullptr;
tree_.pending->next = nullptr;
pending_mode_ = PendingAppendMode::Append;
} }
@@ -70,15 +103,11 @@ UndoSystem::Append(char ch)
{ {
if (!tree_.pending) if (!tree_.pending)
return; return;
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) { if (pending_mode_ == PendingAppendMode::Prepend) {
// Prepend for backspace so that text is in increasing column order
tree_.pending->text.insert(tree_.pending->text.begin(), ch); tree_.pending->text.insert(tree_.pending->text.begin(), ch);
} else { } else {
tree_.pending->text.push_back(ch); tree_.pending->text.push_back(ch);
} }
#ifdef KTE_UNDO_DEBUG
debug_log("Append:ch");
#endif
} }
@@ -87,108 +116,131 @@ UndoSystem::Append(std::string_view text)
{ {
if (!tree_.pending) if (!tree_.pending)
return; return;
tree_.pending->text.append(text.data(), text.size()); if (text.empty())
#ifdef KTE_UNDO_DEBUG return;
debug_log("Append:sv"); if (pending_mode_ == PendingAppendMode::Prepend) {
#endif tree_.pending->text.insert(0, text.data(), text.size());
} else {
tree_.pending->text.append(text.data(), text.size());
}
} }
void void
UndoSystem::commit() UndoSystem::commit()
{ {
#ifdef KTE_UNDO_DEBUG
debug_log("commit:enter");
#endif
if (!tree_.pending) if (!tree_.pending)
return; return;
// If we have redo branches from current, discard them (non-linear behavior) // Drop empty text batches for text-based operations.
if (tree_.current && tree_.current->child) { if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
free_node(tree_.current->child); || tree_.pending->type == UndoType::Paste)
tree_.current->child = nullptr; && tree_.pending->text.empty()) {
// We diverged; saved snapshot cannot be on discarded branch anymore delete tree_.pending;
if (tree_.saved) { tree_.pending = nullptr;
// If saved is not equal to current, keep it; if it was on discarded branch we cannot easily detect now. pending_mode_ = PendingAppendMode::Append;
// For simplicity, leave saved as-is; dirty flag uses pointer equality. return;
}
} }
// Attach pending as next state
if (!tree_.root) { if (!tree_.root) {
tree_.root = tree_.pending; tree_.root = tree_.pending;
tree_.current = tree_.pending; tree_.pending->parent = nullptr;
tree_.current = tree_.pending;
} else if (!tree_.current) { } else if (!tree_.current) {
// Should not happen if root exists, but handle gracefully // We are at the "pre-first-edit" state (undo past the first node).
tree_.current = tree_.pending; // In branching history, preserve the existing root chain as an alternate branch.
tree_.pending->parent = nullptr;
tree_.pending->next = tree_.root;
tree_.root = tree_.pending;
tree_.current = tree_.pending;
} else { } else {
// Attach as primary child (head of redo list) // Branching semantics: attach as a new redo branch under current.
tree_.pending->next = nullptr; // Make the new edit the active child by inserting it at the head.
tree_.current->child = tree_.pending; tree_.pending->parent = tree_.current;
tree_.current = tree_.pending; if (!tree_.current->child) {
tree_.current->child = tree_.pending;
} else {
tree_.pending->next = tree_.current->child;
tree_.current->child = tree_.pending;
}
tree_.current = tree_.pending;
} }
tree_.pending = nullptr; tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("commit:done");
#endif
// post-conditions
assert(tree_.pending == nullptr && "pending must be cleared after commit");
} }
void void
UndoSystem::undo() UndoSystem::undo()
{ {
// Close any pending batch // Seal any in-progress typed run before undo.
commit(); commit();
if (!tree_.current) if (!tree_.current)
return; return;
UndoNode *parent = find_parent(tree_.root, tree_.current);
UndoNode *node = tree_.current;
// Apply inverse of current node
apply(node, -1);
tree_.current = parent;
update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("undo"); debug_log("undo");
#endif const std::uint64_t gid = tree_.current->group_id;
do {
UndoNode *node = tree_.current;
apply(node, -1);
tree_.current = node->parent;
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
update_dirty_flag();
} }
void void
UndoSystem::redo() UndoSystem::redo(int branch_index)
{ {
// Redo next child along current timeline commit();
if (tree_.pending) { UndoNode **head = nullptr;
// If app added pending edits, finalize them before redo chain
commit();
}
UndoNode *next = nullptr;
if (!tree_.current) { if (!tree_.current) {
next = tree_.root; // if nothing yet, try applying first node head = &tree_.root;
} else { } else {
next = tree_.current->child; head = &tree_.current->child;
} }
if (!next) if (!head || !*head)
return; return;
apply(next, +1); if (branch_index < 0)
tree_.current = next; branch_index = 0;
update_dirty_flag();
#ifdef KTE_UNDO_DEBUG // Select the Nth sibling from the branch list and make it the active head.
UndoNode *prev = nullptr;
UndoNode *sel = *head;
for (int i = 0; i < branch_index && sel; ++i) {
prev = sel;
sel = sel->next;
}
if (!sel)
return;
if (prev) {
prev->next = sel->next;
sel->next = *head;
*head = sel;
}
debug_log("redo"); debug_log("redo");
#endif UndoNode *node = *head;
const std::uint64_t gid = node->group_id;
apply(node, +1);
tree_.current = node;
while (gid != 0 && tree_.current && tree_.current->child
&& tree_.current->child->group_id == gid) {
UndoNode *child = tree_.current->child;
apply(child, +1);
tree_.current = child;
}
update_dirty_flag();
} }
void void
UndoSystem::mark_saved() UndoSystem::mark_saved()
{ {
commit();
tree_.saved = tree_.current; tree_.saved = tree_.current;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("mark_saved");
#endif
} }
@@ -199,26 +251,21 @@ UndoSystem::discard_pending()
delete tree_.pending; delete tree_.pending;
tree_.pending = nullptr; tree_.pending = nullptr;
} }
#ifdef KTE_UNDO_DEBUG pending_mode_ = PendingAppendMode::Append;
debug_log("discard_pending");
#endif
} }
void void
UndoSystem::clear() UndoSystem::clear()
{ {
if (tree_.root) { discard_pending();
free_node(tree_.root); free_node(tree_.root);
} tree_.root = nullptr;
if (tree_.pending) { tree_.current = nullptr;
delete tree_.pending; tree_.saved = nullptr;
} active_group_id_ = 0;
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr; next_group_id_ = 1;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("clear");
#endif
} }
@@ -227,34 +274,55 @@ UndoSystem::apply(const UndoNode *node, int direction)
{ {
if (!node) if (!node)
return; return;
// Cursor positioning: keep the point at a sensible location after undo/redo.
// Low-level Buffer edit primitives do not move the cursor.
switch (node->type) { switch (node->type) {
case UndoType::Insert: case UndoType::Insert:
case UndoType::Paste: case UndoType::Paste:
if (direction > 0) { if (direction > 0) {
buf_->insert_text(node->row, node->col, node->text); buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
} else { } else {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Delete: case UndoType::Delete:
if (direction > 0) { if (direction > 0) {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_text(node->row, node->col, node->text); buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Newline: case UndoType::Newline:
if (direction > 0) { if (direction > 0) {
buf_->split_line(node->row, node->col); buf_->split_line(node->row, node->col);
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
} else { } else {
buf_->join_lines(node->row); buf_->join_lines(node->row);
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::DeleteRow: case UndoType::DeleteRow:
if (direction > 0) { if (direction > 0) {
buf_->delete_row(node->row); buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_row(node->row, node->text); buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
}
break;
case UndoType::InsertRow:
if (direction > 0) {
buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else {
buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} }
break; break;
} }
@@ -354,6 +422,8 @@ UndoSystem::type_str(UndoType t)
return "Newline"; return "Newline";
case UndoType::DeleteRow: case UndoType::DeleteRow:
return "DeleteRow"; return "DeleteRow";
case UndoType::InsertRow:
return "InsertRow";
} }
return "?"; return "?";
} }
@@ -395,4 +465,4 @@ UndoSystem::debug_log(const char *op) const
#else #else
(void) op; (void) op;
#endif #endif
} }

View File

@@ -1,6 +1,45 @@
#ifndef KTE_UNDOSYSTEM_H /*
#define KTE_UNDOSYSTEM_H * UndoSystem.h - undo/redo system with tree-based branching
*
* UndoSystem manages the undo/redo history for a Buffer. It provides:
*
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
* - Atomic grouping: Multiple operations can be undone/redone as a single step
* - Dirty tracking: Marks when buffer matches last saved state
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
*
* Key concepts:
*
* 1. Undo tree structure:
* - Each edit creates a node in the tree
* - Undo moves up the tree (toward root)
* - Redo moves down the tree (toward leaves)
* - Multiple redo branches preserved (not lost on new edits after undo)
*
* 2. Operation lifecycle:
* - Begin(type): Start recording an operation (insert/delete)
* - Append(text): Add content to the pending operation
* - commit(): Finalize and add to undo tree
* - discard_pending(): Cancel without recording
*
* 3. Atomic grouping:
* - BeginGroup()/EndGroup(): Bracket multiple operations
* - All operations in a group share the same group_id
* - Undo/redo treats the entire group as one step
*
* 4. Integration with Buffer:
* - UndoSystem holds a reference to its owning Buffer
* - apply() executes undo/redo by calling Buffer's editing methods
* - Buffer's dirty flag updated automatically
*
* Usage pattern:
* undo_system.Begin(UndoType::Insert);
* undo_system.Append("text");
* undo_system.commit(); // Now undoable
*
* See also: UndoTree.h (storage), UndoNode.h (node structure)
*/
#pragma once
#include <string_view> #include <string_view>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -14,6 +53,12 @@ class UndoSystem {
public: public:
explicit UndoSystem(Buffer &owner, UndoTree &tree); explicit UndoSystem(Buffer &owner, UndoTree &tree);
// Begin an atomic group: subsequent committed nodes with the same group_id will be
// undone/redone as a single step. Returns the active group id.
std::uint64_t BeginGroup();
void EndGroup();
void Begin(UndoType type); void Begin(UndoType type);
void Append(char ch); void Append(char ch);
@@ -24,7 +69,10 @@ public:
void undo(); void undo();
void redo(); // Redo the current node's active child branch.
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
void redo(int branch_index = 0);
void mark_saved(); void mark_saved();
@@ -34,7 +82,20 @@ public:
void UpdateBufferReference(Buffer &new_buf); void UpdateBufferReference(Buffer &new_buf);
#if defined(KTE_TESTS)
// Test-only introspection hook.
const UndoTree &TreeForTests() const
{
return tree_;
}
#endif
private: private:
enum class PendingAppendMode : std::uint8_t {
Append,
Prepend,
};
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node); void free_node(UndoNode *node);
@@ -50,10 +111,11 @@ private:
void update_dirty_flag(); void update_dirty_flag();
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0;
std::uint64_t next_group_id_ = 1;
Buffer *buf_; Buffer *buf_;
UndoTree &tree_; UndoTree &tree_;
// Internal hint for Delete batching: whether next Append() should prepend };
bool pending_prepend_ = false;
};
#endif // KTE_UNDOSYSTEM_H

View File

@@ -1,6 +1,4 @@
#ifndef KTE_UNDOTREE_H #pragma once
#define KTE_UNDOTREE_H
#include "UndoNode.h" #include "UndoNode.h"
@@ -10,6 +8,3 @@ struct UndoTree {
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag) UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
UndoNode *pending = nullptr; // in-progress batch (detached) UndoNode *pending = nullptr; // in-progress batch (detached)
}; };
#endif // KTE_UNDOTREE_H

View File

@@ -24,5 +24,8 @@
<string>10.13</string> <string>10.13</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<true/> <true/>
<!-- Allow running multiple instances of the app -->
<key>LSMultipleInstancesProhibited</key>
<false/>
</dict> </dict>
</plist> </plist>

78
cmake/fix_bundle.cmake Normal file
View File

@@ -0,0 +1,78 @@
cmake_minimum_required(VERSION 3.15)
# Fix up a macOS .app bundle by copying non-Qt dylibs into
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
#
# Usage:
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
if (NOT APP_BUNDLE)
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
endif ()
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
if (NOT EXISTS "${EXECUTABLE}")
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
endif ()
include(BundleUtilities)
# Directories to search when resolving prerequisites. We include Homebrew so that
# if any deps are currently resolved from there, fixup_bundle will copy them into
# the bundle and rewrite install names to be self-contained.
set(DIRS
"/usr/local/lib"
"/opt/homebrew/lib"
"/opt/homebrew/opt"
)
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
# libs it references recursively. Qt frameworks already live in the bundle after
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
# dbus, etc.).
# fixup_bundle often fails if copied libraries are read-only.
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
set(gp_otool "/usr/bin/otool")
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
set(CMAKE_OTOOL "/usr/bin/otool")
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
fixup_bundle("${APP_DIR}" "" "${DIRS}")
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
# for it to be allowed to run.
# We sign deep, but sometimes explicit signing of components is more reliable.
message(STATUS "Re-signing ${APP_DIR} after fixup...")
# 1. Sign dylibs in Frameworks
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
foreach (DYLIB ${DYLIBS})
message(STATUS "Signing ${DYLIB}...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
endforeach ()
# 2. Sign nested executables
message(STATUS "Signing nested kte...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
# 3. Sign the main executable explicitly
message(STATUS "Signing main kge...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
# 4. Sign the main bundle
execute_process(
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
RESULT_VARIABLE CODESIGN_RESULT
)
if (NOT CODESIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
endif ()
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")

View File

@@ -1,14 +1,17 @@
{ {
lib, pkgs ? import <nixpkgs> {},
lib ? pkgs.lib,
stdenv, stdenv,
cmake, cmake,
ncurses, ncurses,
SDL2, SDL2,
libGL, libGL,
xorg, xorg,
kdePackages,
qt6Packages ? kdePackages.qt6Packages,
installShellFiles, installShellFiles,
graphical ? false, graphical ? false,
graphical-qt ? false,
... ...
}: }:
let let
@@ -34,11 +37,18 @@ stdenv.mkDerivation {
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
]
++ lib.optionals graphical-qt [
kdePackages.qt6ct
qt6Packages.qtbase
qt6Packages.wrapQtAppsHook
]; ];
cmakeFlags = [ cmakeFlags = [
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}" "-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug" "-DCMAKE_BUILD_TYPE=Debug"
"-DKTE_STATIC_LINK=OFF"
]; ];
installPhase = '' installPhase = ''
@@ -46,17 +56,23 @@ stdenv.mkDerivation {
mkdir -p $out/bin mkdir -p $out/bin
cp kte $out/bin/ cp kte $out/bin/
installManPage ../docs/kte.1 installManPage ../docs/kte.1
'' ${lib.optionalString graphical ''
+ lib.optionalString graphical '' mkdir -p $out/bin
cp kge $out/bin/
installManPage ../docs/kge.1 ${if graphical-qt then ''
mkdir -p $out/share/icons cp kge $out/bin/kge-qt
cp ../kge.png $out/share/icons/ '' else ''
'' cp kge $out/bin/kge
+ '' ''}
installManPage ../docs/kge.1
mkdir -p $out/share/icons/hicolor/256x256/apps
cp ../kge.png $out/share/icons/hicolor/256x256/apps/kge.png
''}
runHook postInstall runHook postInstall
''; '';
} }

28
docker-build.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Helper script to test Linux builds using Docker/Podman
# This script mounts the current source tree into a Linux container,
# builds kte in terminal-only mode, and runs the test suite.
set -e
# Detect whether to use docker or podman
if command -v docker &> /dev/null; then
CONTAINER_CMD="docker"
elif command -v podman &> /dev/null; then
CONTAINER_CMD="podman"
else
echo "Error: Neither docker nor podman found in PATH"
exit 1
fi
IMAGE_NAME="kte-linux"
# Check if image exists, if not, build it
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
echo "Building $IMAGE_NAME image..."
$CONTAINER_CMD build -t "$IMAGE_NAME" .
fi
# Run the container with the current directory mounted
echo "Running Linux build and tests..."
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"

245
docs/BENCHMARKS.md Normal file
View File

@@ -0,0 +1,245 @@
# kte Benchmarking and Testing Guide
This document describes the benchmarking infrastructure and testing
improvements added to ensure high performance and correctness of core
operations.
## Overview
The kte test suite now includes comprehensive benchmarks and migration
coverage tests to:
- Measure performance of core operations (PieceTable, Buffer, syntax
highlighting)
- Ensure no performance regressions from refactorings
- Validate correctness of API migrations (Buffer::Rows() →
GetLineString/GetLineView)
- Provide performance baselines for future optimizations
## Running Tests
### All Tests (including benchmarks)
```bash
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
```
### Test Organization
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
search, etc.
- **15 benchmark tests**: Performance measurements for critical
operations
- **30 migration coverage tests**: Edge cases and correctness validation
Total: **98 tests**
## Benchmark Results
### Buffer Iteration Patterns (5,000 lines)
| Pattern | Time | Speedup vs Rows() |
|-----------------------------------------|---------|-------------------|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
**Key Insight**: `GetLineView()` provides zero-copy access and is
dramatically faster than materializing the entire rows cache.
### PieceTable Operations (10,000 lines)
| Operation | Time |
|-----------------------------|---------|
| Sequential inserts (10K) | 2.1 ms |
| Random inserts (5K) | 32.9 ms |
| `GetLine()` sequential | 4.7 ms |
| `GetLineRange()` sequential | 1.3 ms |
### Buffer Operations
| Operation | Time |
|--------------------------------------|---------|
| `Nrows()` (1M calls) | 13.0 ms |
| `GetLineString()` (10K lines) | 4.8 ms |
| `GetLineView()` (10K lines) | 1.6 ms |
| `Rows()` materialization (10K lines) | 6.2 ms |
### Syntax Highlighting
| Operation | Time | Notes |
|------------------------------------|---------|----------------|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
| HighlighterEngine cache population | 19.9 ms | |
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
### Large File Performance
| Operation | Time |
|---------------------------------|---------|
| Insert 50K lines | 0.53 ms |
| Iterate 50K lines (GetLineView) | 2.7 ms |
| Random access (10K accesses) | 1.8 ms |
## API Differences: GetLineString vs GetLineView
Understanding the difference between these APIs is critical:
### `GetLineString(row)`
- Returns: `std::string` (copy)
- Content: Line text **without** trailing newline
- Use case: When you need to modify the string or store it
- Example: `"hello"` for line `"hello\n"`
### `GetLineView(row)`
- Returns: `std::string_view` (zero-copy)
- Content: Raw line range **including** trailing newline
- Use case: Read-only access, maximum performance
- Example: `"hello\n"` for line `"hello\n"`
- **Warning**: View becomes invalid after buffer modifications
### `Rows()`
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
- Content: Lines **without** trailing newlines
- Use case: Legacy code, being phased out
- Performance: Slower due to materialization overhead
## Migration Coverage Tests
The `test_migration_coverage.cc` file provides 30 tests covering:
### Edge Cases
- Empty buffers
- Single lines (with/without newlines)
- Very long lines (10,000 characters)
- Many empty lines (1,000 newlines)
### Consistency
- `GetLineString()` vs `GetLineView()` vs `Rows()`
- Consistency after edits (insert, delete, split, join)
### Boundary Conditions
- First line access
- Last line access
- Line range boundaries
### Special Characters
- Tabs, carriage returns, null bytes
- Unicode (UTF-8 multibyte characters)
### Stress Tests
- Large files (10,000 lines)
- Many small operations (100+ inserts)
- Alternating insert/delete patterns
### Regression Tests
- Shebang detection pattern (Editor.cc)
- Empty buffer check pattern (Editor.cc)
- Syntax highlighter pattern (all highlighters)
- Swap snapshot pattern (Swap.cc)
## Performance Recommendations
Based on benchmark results:
1. **Prefer `GetLineView()` for read-only access**
- 11x faster than `Rows()` for iteration
- Zero-copy, minimal overhead
- Use immediately (view invalidates on edit)
2. **Use `GetLineString()` when you need a copy**
- Still 1.7x faster than `Rows()`
- Safe to store and modify
- Strips trailing newlines automatically
3. **Avoid `Rows()` in hot paths**
- Materializes entire line cache
- Slower for large files
- Being phased out (legacy API)
4. **Cache `Nrows()` in tight loops**
- Very fast (13ms for 1M calls)
- But still worth caching in inner loops
5. **Leverage HighlighterEngine caching**
- 38x speedup on cache hits
- Automatically invalidates on edits
- Prefetch viewport for smooth scrolling
## Adding New Benchmarks
To add a new benchmark:
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
2. Use `BenchmarkTimer` to measure critical sections:
```cpp
{
BenchmarkTimer timer("Operation description");
// ... code to benchmark ...
}
```
3. Print section headers with `std::cout` for clarity
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
Example:
```cpp
TEST(Benchmark_MyOperation) {
std::cout << "\n=== My Operation Benchmark ===\n";
// Setup
Buffer buf;
std::string data = generate_test_data();
buf.insert_text(0, 0, data);
std::size_t result = 0;
{
BenchmarkTimer timer("My operation on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
result += my_operation(buf, i);
}
}
EXPECT_TRUE(result > 0);
}
```
## Continuous Performance Monitoring
Run benchmarks regularly to detect regressions:
```bash
# Run tests and save output
./cmake-build-debug/kte_tests > benchmark_results.txt
# Compare with baseline
diff benchmark_baseline.txt benchmark_results.txt
```
Look for:
- Significant time increases (>20%) in any benchmark
- New operations that are slower than expected
- Cache effectiveness degradation
## Conclusion
The benchmark suite provides:
- **Performance validation**: Ensures migrations don't regress
performance
- **Optimization guidance**: Identifies fastest APIs for each use case
- **Regression detection**: Catches performance issues early
- **Documentation**: Demonstrates correct API usage patterns
All 98 tests pass with 0 failures, confirming both correctness and
performance of the migrated codebase.

1138
docs/DEVELOPER_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,27 +2,43 @@
## Overview ## Overview
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state. `TestFrontend` is a headless implementation of the `Frontend` interface
designed to facilitate programmatic testing of editor features. It
allows you to queue commands and text input manually, execute them
step-by-step, and inspect the editor/buffer state.
## Components ## Components
### TestInputHandler ### TestInputHandler
A programmable input handler that uses a queue-based system: A programmable input handler that uses a queue-based system:
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (character by character) -
`QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` -
Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (
character by character)
- `Poll(MappedInput &out)` - Returns queued commands one at a time - `Poll(MappedInput &out)` - Returns queued commands one at a time
- `IsEmpty()` - Check if the input queue is empty - `IsEmpty()` - Check if the input queue is empty
### TestRenderer ### TestRenderer
A minimal no-op renderer for testing: A minimal no-op renderer for testing:
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
- `Draw(Editor &ed)` - No-op implementation, just increments draw
counter
- `GetDrawCount()` - Returns the number of times Draw() was called - `GetDrawCount()` - Returns the number of times Draw() was called
- `ResetDrawCount()` - Resets the draw counter - `ResetDrawCount()` - Resets the draw counter
### TestFrontend ### TestFrontend
The main frontend class that integrates TestInputHandler and TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80) The main frontend class that integrates TestInputHandler and
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions
to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the
queue and renders
- `Shutdown()` - Cleanup (no-op for TestFrontend) - `Shutdown()` - Cleanup (no-op for TestFrontend)
- `Input()` - Access the TestInputHandler - `Input()` - Access the TestInputHandler
- `Renderer()` - Access the TestRenderer - `Renderer()` - Access the TestRenderer
@@ -75,31 +91,55 @@ int main() {
## Key Features ## Key Features
1. **Programmable Input**: Queue any sequence of commands or text programmatically 1. **Programmable Input**: Queue any sequence of commands or text
programmatically
2. **Step-by-Step Execution**: Run the editor one command at a time 2. **Step-by-Step Execution**: Run the editor one command at a time
3. **State Inspection**: Access and verify editor/buffer state between commands 3. **State Inspection**: Access and verify editor/buffer state between
4. **No UI Dependencies**: Headless operation, no terminal or GUI required commands
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc. 4. **No UI Dependencies**: Headless operation, no terminal or GUI
required
5. **Integration Testing**: Test command sequences, undo/redo,
multi-line editing, etc.
## Available Commands ## Available Commands
All commands from `CommandId` enum can be queued, including: All commands from `CommandId` enum can be queued, including:
- `CommandId::InsertText` - Insert text (use `QueueText()` helper) - `CommandId::InsertText` - Insert text (use `QueueText()` helper)
- `CommandId::Newline` - Insert newline - `CommandId::Newline` - Insert newline
- `CommandId::Backspace` - Delete character before cursor - `CommandId::Backspace` - Delete character before cursor
- `CommandId::DeleteChar` - Delete character at cursor - `CommandId::DeleteChar` - Delete character at cursor
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement - `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor
movement
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations - `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
- `CommandId::Save`, `CommandId::Quit` - File operations - `CommandId::Save`, `CommandId::Quit` - File operations
- And many more (see Command.h) - And many more (see Command.h)
## Integration ## Integration
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses. TestFrontend is built into both `kte` and `kge` executables as part of
the common source files. You can create standalone test programs by
linking against the same source files and ncurses.
## Notes ## Notes
- Always call `InstallDefaultCommands()` before using any commands - Always call `InstallDefaultCommands()` before using any commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands - Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before
queuing edit commands
- Undo/redo requires the buffer to have an UndoSystem attached - Undo/redo requires the buffer to have an UndoSystem attached
- The test frontend sets editor dimensions to 24x80 by default - The test frontend sets editor dimensions to 24x80 by default
## Highlighter stress harness
For renderer/highlighter race testing without a UI, `kte` provides a
lightweight stress mode:
```
kte --stress-highlighter=5
```
This runs a short synthetic workload (5 seconds by default) that edits
and scrolls a buffer while
exercising `HighlighterEngine::PrefetchViewport` and `GetLine`
concurrently. Use Debug builds with
AddressSanitizer enabled for best effect.

Some files were not shown because too many files have changed in this diff Show More