Compare commits

..

16 Commits

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

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

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

Bump version to 1.11.2.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:03:25 -07:00
d1a45581bf Bump patch version. 2026-03-31 23:48:01 -07:00
81a5c25071 Fix proportional font rendering with pixel-based horizontal scroll
The GUI renderer had two competing horizontal scroll systems: a
character-based one (coloffs × space_w) and a pixel-based one. For
proportional fonts the character-based system used "M" width to
calculate viewport columns, triggering premature scrolling at ~50%
of the actual display width.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 02:07:31 -07:00
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
36 changed files with 25967 additions and 405 deletions

View File

@@ -231,10 +231,12 @@ Buffer::Buffer(const Buffer &other)
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
// Copy syntax/highlighting flags
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// Copy edit mode + syntax/highlighting flags
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// Fresh undo system for the copy
undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
@@ -279,11 +281,13 @@ Buffer::operator=(const Buffer &other)
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
@@ -326,13 +330,15 @@ Buffer::Buffer(Buffer &&other) noexcept
undo_tree_(std::move(other.undo_tree_)),
undo_sys_(std::move(other.undo_sys_))
{
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
@@ -364,13 +370,15 @@ Buffer::operator=(Buffer &&other) noexcept
undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_);
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);

View File

@@ -35,9 +35,12 @@
*/
#pragma once
#include <algorithm>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <string>
#include <unordered_set>
#include <vector>
#include <string_view>
@@ -48,6 +51,26 @@
#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;
@@ -484,6 +507,35 @@ public:
}
// Edit mode (code vs writing)
[[nodiscard]] EditMode GetEditMode() const
{
return edit_mode_;
}
void SetEditMode(EditMode m)
{
edit_mode_ = m;
edit_mode_detected_ = true;
}
void ToggleEditMode()
{
edit_mode_ = (edit_mode_ == EditMode::Code)
? EditMode::Writing
: EditMode::Code;
edit_mode_detected_ = true;
}
[[nodiscard]] bool EditModeDetected() const
{
return edit_mode_detected_;
}
void SetSyntaxEnabled(bool on)
{
syntax_enabled_ = on;
@@ -614,6 +666,10 @@ private:
std::unique_ptr<struct UndoTree> undo_tree_;
std::unique_ptr<UndoSystem> undo_sys_;
// Edit mode (code vs writing)
EditMode edit_mode_ = EditMode::Code;
bool edit_mode_detected_ = false; // true after initial auto-detection
// Syntax/highlighting state
std::uint64_t version_ = 0; // increment on edits
bool syntax_enabled_ = true;

115
CLAUDE.md Normal file
View File

@@ -0,0 +1,115 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**kte** (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and optional GUI frontends (ImGui via SDL2/OpenGL/Freetype, or Qt6). It uses a WordStar/VDE-style command model. The terminal editor is `kte`; the GUI editor is `kge`.
## Build Commands
```bash
# Configure (from project root, build dir is "build")
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
# Build everything
cmake --build build
# Build specific targets
cmake --build build --target kte # terminal editor
cmake --build build --target kge # GUI editor (requires -DBUILD_GUI=ON)
cmake --build build --target kte_tests # test suite
# Run all tests
cmake --build build --target kte_tests && ./build/kte_tests
```
There is no single-test runner; the test binary runs all tests. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros.
### Key CMake Options
| Flag | Default | Purpose |
|------|---------|---------|
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
| `KTE_USE_QT` | OFF | Use Qt6 instead of ImGui for GUI |
| `BUILD_TESTS` | ON | Build test suite |
| `ENABLE_ASAN` | OFF | AddressSanitizer |
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
### Nix
`flake.nix` provides devshells: `default` (ImGui+debug tools), `terminal`, `qt`.
### Docker (cross-platform Linux testing)
```bash
docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux
```
## Architecture
Three-layer design with strict frontend independence:
```
Frontend Layer (Terminal / ImGui / Qt / Test)
InputHandler.h, Renderer.h, Frontend.h interfaces
Command Layer
CommandId enum → CommandRegistry → handler functions in Command.cc
Core Model Layer
Editor → Buffer → PieceTable
UndoSystem (tree-based, records at PieceTable level)
SwapManager (crash recovery journal per buffer)
```
### Core Components
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable. Prefer `GetLineView(row)` (zero-copy) or `GetLineString(row)` over `Rows()` (legacy, materializes all lines). All text mutations must go through PieceTable API (`insert_text`, `delete_text`) to ensure undo and swap recording work.
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
### Frontend Implementations
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
- **Terminal**: ncurses-based (always built)
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
- **Qt**: Qt6 (built with `-DBUILD_GUI=ON -DKTE_USE_QT=ON`)
- **Test**: Programmatic frontend for testing (always built, no UI deps)
## Code Style
- **C++20**, compiled with `-Wall -Wextra -Werror -pedantic`
- **Clang** uses `-stdlib=libc++`
- **Naming**: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g., `pieces_`)
- **Indentation**: Tabs
- **Error handling**: Fallible ops use `bool func(args..., std::string &err)` pattern. Always clear `err` at start, capture `errno` immediately after syscall failure. Use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
- **ErrorHandler**: Centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
## Testing
Tests live in `tests/test_*.cc`. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
Key test files by area:
- PieceTable: `test_piece_table.cc`
- Buffer I/O: `test_buffer_io.cc`
- Commands: `test_command_semantics.cc`
- Search/replace: `test_search.cc`, `test_search_replace_flow.cc`
- Undo: `test_undo.cc`
- Swap (crash recovery): `test_swap_*.cc` (7 files)
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
- Integration: `test_daily_workflows.cc`
## Important Caveats
- `Buffer::Rows()` is legacy; use `GetLineView()` / `GetLineString()` in new code
- `GetLineView()` returns a `string_view` valid only until next buffer modification
- After editing ops, call `ensure_cursor_visible()` to update viewport
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser)
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI)

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.8.0")
set(KTE_VERSION "1.11.2")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -14,7 +14,7 @@ set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
option(KTE_STATIC_LINK "Enable static linking on Linux" ON)
option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
# Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -51,6 +51,7 @@ else ()
)
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++")
add_link_options("-stdlib=libc++")
else ()
# nothing special for gcc at the moment
endif ()
@@ -205,6 +206,8 @@ set(FONT_HEADERS
fonts/FontList.h
fonts/B612Mono.h
fonts/BrassMono.h
fonts/CrimsonPro.h
fonts/ETBook.h
fonts/BrassMonoCode.h
fonts/FiraCode.h
fonts/Go.h
@@ -216,6 +219,7 @@ set(FONT_HEADERS
fonts/IosevkaExtended.h
fonts/ShareTech.h
fonts/SpaceMono.h
fonts/Spectral.h
fonts/Syne.h
fonts/Triplicate.h
fonts/Unispace.h
@@ -327,6 +331,7 @@ if (BUILD_TESTS)
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

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

View File

@@ -752,6 +752,8 @@ cmd_save_and_quit(CommandContext &ctx)
if (buf->IsFileBacked()) {
if (buf->Save(err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
@@ -759,6 +761,8 @@ cmd_save_and_quit(CommandContext &ctx)
} else if (!buf->Filename().empty()) {
if (buf->SaveAs(buf->Filename(), err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
@@ -1331,6 +1335,43 @@ cmd_font_set_size(CommandContext &ctx)
#endif
// Toggle edit mode (code/writing) for current buffer
static bool
cmd_toggle_edit_mode(const CommandContext &ctx)
{
Buffer *b = ctx.editor.CurrentBuffer();
if (!b)
return false;
std::string arg = ctx.arg;
std::transform(arg.begin(), arg.end(), arg.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
// Trim whitespace
auto start = arg.find_first_not_of(" \t");
if (start != std::string::npos)
arg = arg.substr(start);
auto end = arg.find_last_not_of(" \t");
if (end != std::string::npos)
arg = arg.substr(0, end + 1);
if (arg == "code") {
b->SetEditMode(EditMode::Code);
} else if (arg == "writing") {
b->SetEditMode(EditMode::Writing);
} else {
b->ToggleEditMode();
}
// Writing mode disables syntax highlighting; code mode re-enables it.
b->SetSyntaxEnabled(b->GetEditMode() == EditMode::Code);
const char *mode_str = (b->GetEditMode() == EditMode::Writing) ? "writing" : "code";
ctx.editor.SetStatus(std::string("Mode: ") + mode_str);
return true;
}
// Background set command (GUI, ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
@@ -1353,6 +1394,10 @@ cmd_background_set(const CommandContext &ctx)
std::transform(mode.begin(), mode.end(), mode.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (mode.empty()) {
ctx.editor.SetStatus(std::string("Background: ") + kte::BackgroundModeName());
return true;
}
if (mode != "light" && mode != "dark") {
ctx.editor.SetStatus("background: expected 'light' or 'dark'");
return true;
@@ -1880,15 +1925,15 @@ cmd_insert_text(CommandContext &ctx)
#endif
}
if (cmd == "font") {
#if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Complete against installed font families (case-insensitive prefix)
std::vector<std::string> cands;
QStringList fams = QFontDatabase::families();
std::string apfx_lower = argprefix;
std::transform(apfx_lower.begin(), apfx_lower.end(), apfx_lower.begin(),
[](unsigned char c) {
return (char) std::tolower(c);
});
#if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt: complete against system font families
QStringList fams = QFontDatabase::families();
for (const auto &fam: fams) {
std::string n = fam.toStdString();
std::string nlower = n;
@@ -1899,6 +1944,13 @@ cmd_insert_text(CommandContext &ctx)
if (apfx_lower.empty() || nlower.rfind(apfx_lower, 0) == 0)
cands.push_back(n);
}
#elif defined(KTE_BUILD_GUI)
// ImGui: complete against embedded font registry
for (const auto &n : kte::Fonts::FontRegistry::Instance().FontNames()) {
if (apfx_lower.empty() || n.rfind(apfx_lower, 0) == 0)
cands.push_back(n);
}
#endif
if (cands.empty()) {
// no change
} else if (cands.size() == 1) {
@@ -1919,9 +1971,19 @@ cmd_insert_text(CommandContext &ctx)
}
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
return true;
#else
(void) argprefix;
#endif
}
if (cmd == "mode") {
std::vector<std::string> modes = {"code", "writing"};
std::vector<std::string> cands;
for (const auto &m : modes) {
if (argprefix.empty() || m.rfind(argprefix, 0) == 0)
cands.push_back(m);
}
if (cands.size() == 1) {
ctx.editor.SetPromptText(cmd + std::string(" ") + cands[0]);
}
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
return true;
}
// default: no special arg completion
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
@@ -2568,6 +2630,10 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
@@ -5013,6 +5079,11 @@ InstallDefaultCommands()
CommandId::NewWindow, "new-window", "Open a new editor window (GUI only)", cmd_new_window,
false, false
});
// Edit mode toggle (public)
CommandRegistry::Register({
CommandId::ToggleEditMode, "mode", "Toggle or set edit mode: code|writing",
cmd_toggle_edit_mode, true, false
});
}

View File

@@ -113,6 +113,12 @@ enum class CommandId {
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,
};

View File

@@ -69,20 +69,22 @@ Editor::SetStatus(const std::string &message)
Buffer *
Editor::CurrentBuffer()
{
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr;
}
return &buffers_[curbuf_];
return &bufs[curbuf_];
}
const Buffer *
Editor::CurrentBuffer() const
{
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
const auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr;
}
return &buffers_[curbuf_];
return &bufs[curbuf_];
}
@@ -117,8 +119,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
// Prepare list of other buffer paths
std::vector<std::vector<std::filesystem::path> > others;
others.reserve(buffers_.size());
for (const auto &b: buffers_) {
const auto &bufs = Buffers();
others.reserve(bufs.size());
for (const auto &b: bufs) {
if (&b == &buf)
continue;
if (b.Filename().empty())
@@ -161,41 +164,44 @@ Editor::DisplayNameFor(const Buffer &buf) const
std::size_t
Editor::AddBuffer(const Buffer &buf)
{
buffers_.push_back(buf);
auto &bufs = Buffers();
bufs.push_back(buf);
// Attach swap recorder
if (swap_) {
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (buffers_.size() == 1) {
if (bufs.size() == 1) {
curbuf_ = 0;
}
return buffers_.size() - 1;
return bufs.size() - 1;
}
std::size_t
Editor::AddBuffer(Buffer &&buf)
{
buffers_.push_back(std::move(buf));
auto &bufs = Buffers();
bufs.push_back(std::move(buf));
if (swap_) {
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (buffers_.size() == 1) {
if (bufs.size() == 1) {
curbuf_ = 0;
}
return buffers_.size() - 1;
return bufs.size() - 1;
}
bool
Editor::OpenFile(const std::string &path, std::string &err)
{
// If there is exactly one unnamed, empty, clean buffer, reuse it instead
// of creating a new one.
if (buffers_.size() == 1) {
Buffer &cur = buffers_[curbuf_];
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// it instead of creating a new one.
auto &bufs_ref = Buffers();
if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
Buffer &cur = bufs_ref[curbuf_];
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
const bool clean = !cur.Dirty();
const std::size_t nrows = cur.Nrows();
@@ -268,7 +274,7 @@ Editor::OpenFile(const std::string &path, std::string &err)
// Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b));
if (swap_) {
swap_->NotifyFilenameChanged(buffers_[idx]);
swap_->NotifyFilenameChanged(Buffers()[idx]);
}
SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open
@@ -446,12 +452,13 @@ Editor::ProcessPendingOpens()
bool
Editor::SwitchTo(std::size_t index)
{
if (index >= buffers_.size()) {
auto &bufs = Buffers();
if (index >= bufs.size()) {
return false;
}
curbuf_ = index;
// Robustness: ensure a valid highlighter is installed when switching buffers
Buffer &b = buffers_[curbuf_];
Buffer &b = bufs[curbuf_];
if (b.SyntaxEnabled()) {
b.EnsureHighlighter();
if (auto *eng = b.Highlighter()) {
@@ -478,21 +485,22 @@ Editor::SwitchTo(std::size_t index)
bool
Editor::CloseBuffer(std::size_t index)
{
if (index >= buffers_.size()) {
auto &bufs = Buffers();
if (index >= bufs.size()) {
return false;
}
if (swap_) {
// 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(&buffers_[index], true);
buffers_[index].SetSwapRecorder(nullptr);
swap_->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr);
}
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) {
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
if (bufs.empty()) {
curbuf_ = 0;
} else if (curbuf_ >= buffers_.size()) {
curbuf_ = buffers_.size() - 1;
} else if (curbuf_ >= bufs.size()) {
curbuf_ = bufs.size() - 1;
}
return true;
}
@@ -516,7 +524,12 @@ Editor::Reset()
// Reset close-confirm/save state
close_confirm_pending_ = false;
close_after_save_ = false;
buffers_.clear();
auto &bufs = Buffers();
if (swap_) {
for (auto &buf : bufs)
swap_->Detach(&buf, true);
}
bufs.clear();
curbuf_ = 0;
}

View File

@@ -521,7 +521,7 @@ public:
// Buffers
[[nodiscard]] std::size_t BufferCount() const
{
return buffers_.size();
return Buffers().size();
}
@@ -531,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();
const Buffer *CurrentBuffer() const;

View File

@@ -3,9 +3,29 @@
#include <fstream>
#include <sstream>
#include <algorithm>
#include <filesystem>
#include <iostream>
#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
trim(std::string &s)
@@ -19,37 +39,124 @@ trim(std::string &s)
static std::string
default_config_path()
config_dir()
{
const char *home = std::getenv("HOME");
if (!home || !*home)
return {};
std::string path(home);
path += "/.config/kte/kge.ini";
return path;
return std::string(home) + "/.config/kte";
}
GUIConfig
GUIConfig::Load()
{
GUIConfig cfg; // defaults already set
const std::string path = default_config_path();
GUIConfig cfg;
std::string dir = config_dir();
if (dir.empty())
return cfg;
if (!path.empty()) {
cfg.LoadFromFile(path);
// Try TOML first
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;
}
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);
if (!in.good())
return false;
bool explicit_code_font = false;
bool explicit_writing_font = false;
std::string line;
while (std::getline(in, line)) {
// Remove comments starting with '#' or ';'
@@ -104,6 +211,12 @@ GUIConfig::LoadFromFile(const std::string &path)
}
} else if (key == "font") {
font = val;
} else if (key == "code_font") {
code_font = val;
explicit_code_font = true;
} else if (key == "writing_font") {
writing_font = val;
explicit_writing_font = true;
} else if (key == "theme") {
theme = val;
} else if (key == "background" || key == "bg") {
@@ -126,5 +239,13 @@ GUIConfig::LoadFromFile(const std::string &path)
}
}
// If code_font was not explicitly set, default it to the main font
// so that the edit-mode font switcher doesn't immediately switch away
// from the font loaded during Init.
if (!explicit_code_font)
code_font = font;
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
writing_font = font;
return true;
}

View File

@@ -1,5 +1,7 @@
/*
* 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.
*/
#pragma once
@@ -22,12 +24,18 @@ public:
std::string background = "dark";
// 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; // default: enabled
bool syntax = true;
// 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();
// Load from explicit path. Returns true if file existed and was parsed.
bool LoadFromFile(const std::string &path);
// Load from explicit TOML path. Returns true if file existed and was parsed.
bool LoadFromTOML(const std::string &path);
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
bool LoadFromINI(const std::string &path);
};

View File

@@ -312,7 +312,7 @@ namespace kte {
enum class BackgroundMode { Light, Dark };
// Global background mode; default to Dark to match prior defaults
static inline auto gBackgroundMode = BackgroundMode::Dark;
inline auto gBackgroundMode = BackgroundMode::Dark;
// Basic theme identifier (kept minimal; some ids are aliases)
enum class ThemeId {
@@ -330,11 +330,13 @@ enum class ThemeId {
Amber = 10,
WeylandYutani = 11,
Orbital = 12,
Tufte = 13,
Leuchtturm = 14,
};
// Current theme tracking
static inline auto gCurrentTheme = ThemeId::Nord;
static inline std::size_t gCurrentThemeIndex = 6; // Nord index
inline auto gCurrentTheme = ThemeId::Nord;
inline std::size_t gCurrentThemeIndex = 7; // Nord index
// Forward declarations for helpers used below
static size_t ThemeIndexFromId(ThemeId id);
@@ -372,11 +374,13 @@ BackgroundModeName()
#include "themes/Everforest.h"
#include "themes/KanagawaPaper.h"
#include "themes/LCARS.h"
#include "themes/Leuchtturm.h"
#include "themes/OldBook.h"
#include "themes/Amber.h"
#include "themes/WeylandYutani.h"
#include "themes/Zenburn.h"
#include "themes/Orbital.h"
#include "themes/Tufte.h"
// Theme abstraction and registry (generalized theme system)
@@ -409,6 +413,28 @@ struct LCARSTheme final : Theme {
}
};
struct LeuchtturmTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "leuchtturm";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyLeuchtturmDarkTheme();
else
ApplyLeuchtturmLightTheme();
}
ThemeId Id() override
{
return ThemeId::Leuchtturm;
}
};
struct EverforestTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
@@ -488,6 +514,28 @@ struct OrbitalTheme final : Theme {
}
};
struct TufteTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "tufte";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyTufteDarkTheme();
else
ApplyTufteLightTheme();
}
ThemeId Id() override
{
return ThemeId::Tufte;
}
};
struct ZenburnTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
@@ -657,18 +705,20 @@ ThemeRegistry()
static std::vector<std::unique_ptr<Theme> > reg;
if (reg.empty()) {
// Alphabetical by canonical name:
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, orbital, plan9, solarized, weyland-yutani, zenburn
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord, old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
reg.emplace_back(std::make_unique<detail::AmberTheme>());
reg.emplace_back(std::make_unique<detail::EInkTheme>());
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
reg.emplace_back(std::make_unique<detail::LCARSTheme>());
reg.emplace_back(std::make_unique<detail::LeuchtturmTheme>());
reg.emplace_back(std::make_unique<detail::NordTheme>());
reg.emplace_back(std::make_unique<detail::OldBookTheme>());
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
reg.emplace_back(std::make_unique<detail::TufteTheme>());
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
}
@@ -845,20 +895,24 @@ ThemeIndexFromId(const ThemeId id)
return 4;
case ThemeId::LCARS:
return 5;
case ThemeId::Nord:
case ThemeId::Leuchtturm:
return 6;
case ThemeId::OldBook:
case ThemeId::Nord:
return 7;
case ThemeId::Orbital:
case ThemeId::OldBook:
return 8;
case ThemeId::Plan9:
case ThemeId::Orbital:
return 9;
case ThemeId::Solarized:
case ThemeId::Plan9:
return 10;
case ThemeId::WeylandYutani:
case ThemeId::Solarized:
return 11;
case ThemeId::Zenburn:
case ThemeId::Tufte:
return 12;
case ThemeId::WeylandYutani:
return 13;
case ThemeId::Zenburn:
return 14;
}
return 0;
}
@@ -882,30 +936,144 @@ ThemeIdFromIndex(const size_t idx)
case 5:
return ThemeId::LCARS;
case 6:
return ThemeId::Nord;
return ThemeId::Leuchtturm;
case 7:
return ThemeId::OldBook;
return ThemeId::Nord;
case 8:
return ThemeId::Orbital;
return ThemeId::OldBook;
case 9:
return ThemeId::Plan9;
return ThemeId::Orbital;
case 10:
return ThemeId::Solarized;
return ThemeId::Plan9;
case 11:
return ThemeId::WeylandYutani;
return ThemeId::Solarized;
case 12:
return ThemeId::Tufte;
case 13:
return ThemeId::WeylandYutani;
case 14:
return ThemeId::Zenburn;
}
}
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
// Tufte palette: high-contrast, restrained color. Body text is true black on
// cream; only keywords and links get subtle color to avoid a "christmas tree."
static ImVec4
SyntaxInkTufte(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xEAE6DE) : RGBA(0x111111); // body text
const ImVec4 dim = dark ? RGBA(0x8A8680) : RGBA(0x555555); // comments
const ImVec4 red = dark ? RGBA(0xD06060) : RGBA(0x8B0000); // keywords/preproc
const ImVec4 navy = dark ? RGBA(0x7098C0) : RGBA(0x1A3A5C); // functions/links
const ImVec4 grn = dark ? RGBA(0x8AAA6E) : RGBA(0x2E5E2E); // strings
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return red;
case TokenKind::String:
case TokenKind::Char:
return grn;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xC8A85A) : RGBA(0x6B4C00);
case TokenKind::Type:
return dark ? RGBA(0xBBAA90) : RGBA(0x333333);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xCC0000);
default:
return ink;
}
}
// Leuchtturm palette: blue-black fountain pen ink with brass and bronze accents.
// Body text is ink-colored; accents drawn from the pen metals.
static ImVec4
SyntaxInkLeuchtturm(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xE5DDD0) : RGBA(0x040720); // fountain pen ink
const ImVec4 dim = dark ? RGBA(0x7A7060) : RGBA(0x6A6558); // comments
const ImVec4 brass = dark ? RGBA(0xB8A060) : RGBA(0x504518); // patinated brass
const ImVec4 bronze= dark ? RGBA(0xC08050) : RGBA(0x5C3010); // dark bronze
const ImVec4 navy = dark ? RGBA(0x8898B0) : RGBA(0x1C2E4A); // deep navy
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return brass;
case TokenKind::String:
case TokenKind::Char:
return bronze;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xA89060) : RGBA(0x483C10);
case TokenKind::Type:
return dark ? RGBA(0xC0B898) : RGBA(0x222238);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xA02020);
default:
return ink;
}
}
// Everforest: warm forest palette on dark green-gray (bg 0x2B3339).
// Default comment color (0x616E88) is too dim; boost it and tune others.
static ImVec4
SyntaxInkEverforest(const TokenKind k)
{
switch (k) {
case TokenKind::Keyword:
return RGBA(0xE67E80); // everforest red
case TokenKind::Type:
return RGBA(0xD699B6); // everforest purple
case TokenKind::String:
case TokenKind::Char:
return RGBA(0xA7C080); // everforest green
case TokenKind::Comment:
return RGBA(0x859289); // boosted from 0x616E88 for contrast
case TokenKind::Number:
case TokenKind::Constant:
return RGBA(0xD8A657); // everforest yellow/orange
case TokenKind::Preproc:
return RGBA(0xE69875); // everforest orange
case TokenKind::Function:
return RGBA(0x83C092); // everforest aqua
case TokenKind::Operator:
case TokenKind::Punctuation:
return RGBA(0xD3C6AA); // everforest fg
case TokenKind::Error:
return RGBA(0xE67E80);
default:
return RGBA(0xD3C6AA); // everforest fg
}
}
[[maybe_unused]] static ImVec4
SyntaxInk(const TokenKind k)
{
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
// Base text
// Per-theme syntax palettes
if (gCurrentTheme == ThemeId::Tufte)
return SyntaxInkTufte(k, dark);
if (gCurrentTheme == ThemeId::Leuchtturm)
return SyntaxInkLeuchtturm(k, dark);
if (gCurrentTheme == ThemeId::Everforest)
return SyntaxInkEverforest(k);
// Default palettes tuned for Nord-ish themes
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
switch (k) {
case TokenKind::Keyword:

View File

@@ -41,6 +41,7 @@ HelpText::Text()
" 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 m Toggle edit mode (code/writing)\n"
" C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n"
" C-k p Next buffer\n"
@@ -82,12 +83,24 @@ HelpText::Text()
"\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n"
"GUI appearance (command prompt):\n"
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
"Edit modes:\n"
" code Monospace font (default for source files)\n"
" writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
" C-k m or : mode [code|writing] to toggle\n"
"\n"
"GUI config file options:\n"
" font_size=NUM Set font size in pixels (default: 16; e.g., font_size=18)\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"
@@ -95,4 +108,4 @@ HelpText::Text()
" Close window Secondary windows close independently; closing the\n"
" primary window quits the editor\n"
);
}
}

View File

@@ -30,7 +30,7 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
// ---------------------------------------------------------------------------
// Helpers shared between Init and OpenNewWindow_
// Helpers
// ---------------------------------------------------------------------------
static void
@@ -38,7 +38,14 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
{
if (!b)
return;
if (cfg.syntax) {
// Auto-detect edit mode from file extension once per buffer so that
// manual toggles (C-k m / : mode) are not overridden every frame.
if (!b->EditModeDetected() && !b->Filename().empty())
b->SetEditMode(DetectEditMode(b->Filename()));
// Writing mode disables syntax; otherwise follow the global config.
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
b->SetSyntaxEnabled(true);
b->EnsureHighlighter();
if (auto *eng = b->Highlighter()) {
@@ -71,7 +78,9 @@ static void
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
{
float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x;
// Use average character width rather than "M" (the widest character)
// so that column count is reasonable for proportional fonts too.
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
if (row_h <= 0.0f)
row_h = 16.0f;
if (ch_w <= 0.0f)
@@ -96,6 +105,63 @@ update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
}
// ---------------------------------------------------------------------------
// 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)
{
@@ -172,9 +238,10 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
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")) {
@@ -236,11 +303,12 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
}
// Build primary WindowState
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->width = init_w;
ws->height = init_h;
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.
@@ -255,8 +323,6 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
bool
GUIFrontend::OpenNewWindow_(Editor &primary)
{
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
int w = windows_[0]->width;
int h = windows_[0]->height;
@@ -277,25 +343,48 @@ GUIFrontend::OpenNewWindow_(Editor &primary)
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1);
// Secondary windows share the ImGui context already created in Init.
// We need to init the SDL2/OpenGL backends for this new window.
// ImGui_ImplSDL2 supports multiple windows via SDL_GetWindowID checks.
ImGui_ImplOpenGL3_Init(kGlslVersion);
// 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);
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->width = w;
ws->height = h;
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 GL context as current
// Restore primary context
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
return true;
}
@@ -305,10 +394,10 @@ 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)) {
ImGui_ImplSDL2_ProcessEvent(&e);
// Determine which window this event belongs to
Uint32 event_win_id = 0;
switch (e.type) {
@@ -329,6 +418,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
case SDL_MOUSEWHEEL:
event_win_id = e.wheel.windowID;
break;
case SDL_MOUSEMOTION:
event_win_id = e.motion.windowID;
break;
default:
break;
}
@@ -338,59 +430,67 @@ GUIFrontend::Step(Editor &ed, bool &running)
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) {
// Mark the window as dead; primary window close = quit
for (std::size_t i = 0; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == e.window.windowID) {
if (i == 0) {
running = false;
} else {
windows_[i]->alive = false;
}
break;
if (target) {
if (target_idx == 0) {
running = false;
} else {
target->alive = false;
}
}
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
for (auto &ws: windows_) {
if (SDL_GetWindowID(ws->window) == e.window.windowID) {
ws->width = e.window.data1;
ws->height = e.window.data2;
break;
}
if (target) {
target->width = e.window.data1;
target->height = e.window.data2;
}
}
}
// Route input events to the correct window's input handler
if (event_win_id != 0) {
// Primary window (index 0) uses the external editor &ed
if (windows_.size() > 0 &&
SDL_GetWindowID(windows_[0]->window) == event_win_id) {
windows_[0]->input.ProcessSDLEvent(e);
} else {
for (std::size_t i = 1; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
windows_[i]->input.ProcessSDLEvent(e);
break;
}
}
}
if (target) {
target->input.ProcessSDLEvent(e);
}
}
if (!running)
return;
// --- Apply pending font change ---
// --- 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) {
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
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();
}
}
}
}
@@ -404,7 +504,12 @@ GUIFrontend::Step(Editor &ed, bool &running)
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();
@@ -422,6 +527,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
// 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;
@@ -431,6 +539,21 @@ GUIFrontend::Step(Editor &ed, bool &running)
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);
@@ -442,16 +565,27 @@ GUIFrontend::Step(Editor &ed, bool &running)
}
}
// Handle new-window request
if (wed.NewWindowRequested()) {
wed.SetNewWindowRequested(false);
OpenNewWindow_(ed); // always share primary editor's buffers
}
if (wi == 0 && wed.QuitRequested()) {
running = false;
}
// Switch font based on current buffer's edit mode (deferred to next frame)
{
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);
@@ -466,52 +600,40 @@ GUIFrontend::Step(Editor &ed, bool &running)
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) {
SDL_GL_MakeCurrent((*it)->window, (*it)->gl_ctx);
ImGui_ImplOpenGL3_Shutdown();
SDL_GL_DeleteContext((*it)->gl_ctx);
SDL_DestroyWindow((*it)->window);
DestroyWindowResources_(**it);
it = windows_.erase(it);
// Restore primary context
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
} 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 secondary windows first
for (std::size_t i = 1; i < windows_.size(); ++i) {
SDL_GL_MakeCurrent(windows_[i]->window, windows_[i]->gl_ctx);
ImGui_ImplOpenGL3_Shutdown();
SDL_GL_DeleteContext(windows_[i]->gl_ctx);
SDL_DestroyWindow(windows_[i]->window);
}
windows_.resize(std::min(windows_.size(), std::size_t(1)));
// Destroy primary window
if (!windows_.empty()) {
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext();
if (!windows_.empty()) {
if (windows_[0]->gl_ctx) {
SDL_GL_DeleteContext(windows_[0]->gl_ctx);
windows_[0]->gl_ctx = nullptr;
}
if (windows_[0]->window) {
SDL_DestroyWindow(windows_[0]->window);
windows_[0]->window = nullptr;
}
// Destroy all windows (secondary first, then primary)
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
DestroyWindowResources_(**it);
}
windows_.clear();
SDL_Quit();
@@ -549,4 +671,4 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
io.Fonts->Build();
return true;
}
}

View File

@@ -13,6 +13,7 @@
struct SDL_Window;
struct ImGuiContext;
typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend {
@@ -28,10 +29,13 @@ public:
void Shutdown() override;
private:
// Per-window state
// 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;
SDL_Window *window = nullptr;
SDL_GLContext gl_ctx = nullptr;
ImGuiContext *imgui_ctx = nullptr;
ImGuiInputHandler input{};
ImGuiRenderer renderer{};
Editor editor{};
@@ -44,6 +48,9 @@ private:
// 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_{};

View File

@@ -349,6 +349,26 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
}
// 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)
// 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)) {

View File

@@ -76,23 +76,18 @@ ImGuiRenderer::Draw(Editor &ed)
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - 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.
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
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) {
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));
}
// Horizontal scroll is handled purely in pixel space (see
// cursor-visibility block after the line loop) so we don't
// convert the character-based coloffs to an ImGui scroll here.
// Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height
@@ -111,44 +106,92 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 child_window_pos = ImGui::GetWindowPos();
float scroll_y = ImGui::GetScrollY();
float scroll_x = ImGui::GetScrollX();
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
bool forced_scroll = false;
{
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 scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
forced_scroll = true;
}
// If user scrolled (not programmatic), update buffer offsets accordingly
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) {
// If user scrolled vertically (not programmatic), update buffer row offset
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs());
}
}
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left)));
}
}
// Horizontal scroll is pixel-based and managed by the cursor
// visibility block below; we don't sync it back to coloffs.
// Update trackers for next frame
prev_scroll_y = scroll_y;
prev_scroll_x = scroll_x;
prev_scroll_y_ = scroll_y;
prev_scroll_x_ = scroll_x;
}
prev_buf_rowoffs_ = buf_rowoffs;
prev_buf_coloffs_ = buf_coloffs;
// Max-line-width cache: reset when the buffer or font changes. We only
// measure visible lines per frame and maintain a running max, so the
// scrollbar may be slightly conservative on first view of a file until
// the user scrolls, but idle CPU drops dramatically on large files.
// Edits bump the buffer version but we intentionally do NOT reset the
// max on version changes — keeping a conservative (possibly too-wide)
// scrollbar is preferable to per-keystroke jitter.
{
ImFont *cur_font = ImGui::GetFont();
float cur_fsize = ImGui::GetFontSize();
if (buf != max_width_buf_
|| cur_font != max_width_font_
|| cur_fsize != max_width_font_size_) {
max_width_buf_ = buf;
max_width_font_ = cur_font;
max_width_font_size_ = cur_fsize;
max_width_px_ = 0.0f;
}
max_width_version_ = buf->Version();
}
// Hoist the search-regex compilation out of the per-line loop. Compiling
// std::regex per line per frame was a large source of idle CPU on macOS.
const bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
const bool regex_mode = search_mode && ed.PromptActive() && (
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
std::regex search_rx;
bool search_rx_valid = false;
if (regex_mode) {
try {
search_rx = std::regex(ed.SearchQuery());
search_rx_valid = true;
} catch (const std::regex_error &) {
search_rx_valid = false;
}
}
// Compute the visible row range and skip rendering work for off-screen
// lines. ImGui clips drawing, but string allocation, tab expansion,
// syntax-highlight lookups, and width measurement are not free.
const std::size_t total_rows = lines.size();
std::size_t first_vis = 0;
std::size_t last_vis = total_rows; // exclusive
if (row_h > 0.0f && total_rows > 0) {
const long margin = 4; // render a few extra rows above/below for smoother scrolling
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
long lv = static_cast<long>(
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
if (fv < 0) fv = 0;
if (lv < 0) lv = 0;
if (static_cast<std::size_t>(fv) > total_rows)
fv = static_cast<long>(total_rows);
if (static_cast<std::size_t>(lv) > total_rows)
lv = static_cast<long>(total_rows);
first_vis = static_cast<std::size_t>(fv);
last_vis = static_cast<std::size_t>(lv);
}
prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs;
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs();
// Mark selection state (mark -> cursor), in source coordinates
bool sel_active = false;
@@ -169,7 +212,7 @@ ImGuiRenderer::Draw(Editor &ed)
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false;
// (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
@@ -181,29 +224,54 @@ ImGuiRenderer::Draw(Editor &ed)
if (by >= lines.size())
by = lines.empty() ? 0 : (lines.size() - 1);
// Convert mouse pos to rendered x
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;
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
// Add scroll offset in pixels
visual_x += scroll_x;
// Convert rendered column to source column
if (lines.empty())
return {0, 0};
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
std::size_t rx = 0;
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx);
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
// 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 = i;
}
if (i < line_clicked.size()) {
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
best_col = ci;
}
}
return {by, best_col};
@@ -211,7 +279,7 @@ ImGuiRenderer::Draw(Editor &ed)
// 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;
mouse_selecting_ = true;
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
@@ -225,7 +293,7 @@ ImGuiRenderer::Draw(Editor &ed)
}
}
}
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
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)) {
@@ -242,38 +310,65 @@ ImGuiRenderer::Draw(Editor &ed)
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;
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting_ = false;
}
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Advance the cursor Y so the first visible line draws at its correct
// scroll position. Skipped rows simply leave the layout cursor untouched.
if (first_vis > 0) {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
static_cast<float>(first_vis) * row_h);
}
for (std::size_t i = first_vis; i < last_vis; ++i) {
// Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos();
std::string line = static_cast<std::string>(lines[i]);
// 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;
std::string expanded;
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 an absolute
// pixel x offset from the start of the line. ImGui's scroll
// handles viewport clipping so we measure from column 0.
auto rx_to_px = [&](std::size_t rx_col) -> float {
std::size_t end = std::min(expanded.size(), rx_col);
if (end == 0)
return 0.0f;
return ImGui::CalcTextSize(expanded.c_str(),
expanded.c_str() + end).x;
};
// Compute search highlight ranges for this line in source indices
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
if (search_mode) {
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
if (ed.PromptActive() && (
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
// In regex mode, reuse the compiled regex hoisted above the loop.
if (regex_mode) {
if (search_rx_valid) {
try {
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} else {
const std::string &q = ed.SearchQuery();
@@ -306,13 +401,8 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now)
continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now;
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,
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);
// Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end;
@@ -349,18 +439,12 @@ ImGuiRenderer::Draw(Editor &ed)
if (line_has) {
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) {
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
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);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
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);
}
}
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
@@ -374,32 +458,13 @@ ImGuiRenderer::Draw(Editor &ed)
// EOL spot: draw a 1-cell highlight just past the last character.
rx_end = rx_start + 1;
}
if (rx_end > coloffs_now) {
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
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);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
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);
}
// 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)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine(
@@ -442,19 +507,14 @@ ImGuiRenderer::Draw(Editor &ed)
for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs_now)
continue; // fully left of viewport
// Clamp to visible portion and expanded length
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
std::size_t draw_start = rx_s;
if (draw_start >= expanded.size())
continue; // fully right of expanded text
continue;
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
// Screen position is relative to coloffs_now
std::size_t screen_x = draw_start - coloffs_now;
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
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);
@@ -463,49 +523,49 @@ ImGuiRenderer::Draw(Editor &ed)
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else {
// No syntax: draw as one run, accounting for horizontal scroll offset
if (coloffs_now < expanded.size()) {
// No syntax: draw the full line; ImGui scroll handles clipping.
if (!expanded.empty()) {
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
ImGui::GetWindowDrawList()->AddText(
p, ImGui::GetColorU32(ImGuiCol_Text),
expanded.c_str() + coloffs_now);
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else {
// Line is fully scrolled out of view horizontally
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
expanded.c_str());
}
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
}
// Draw a visible cursor indicator on the current line
if (i == cy) {
// Compute rendered X (rx) from source column with tab expansion
std::size_t rx_abs = 0;
for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) {
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;
// For proportional fonts (Linux GUI), avoid accumulating drift by computing
// the exact pixel width of the expanded substring up to the cursor.
// expanded contains the line with tabs expanded to spaces and is what we draw.
float cursor_px = 0.0f;
if (rx_viewport > 0 && coloffs_now < expanded.size()) {
std::size_t start = coloffs_now;
std::size_t end = std::min(expanded.size(), start + rx_viewport);
// Measure substring width in pixels
ImVec2 sz = ImGui::CalcTextSize(expanded.c_str() + start,
expanded.c_str() + end);
cursor_px = sz.x;
}
std::size_t rx_abs = src_to_rx(cx);
float cursor_px = rx_to_px(rx_abs);
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
// Track widest line for content width reporting. We only measure
// visible lines and fold the result into a monotonic max cached on
// the renderer (see max_width_* members). Off-screen lines are not
// measured per frame; if the user scrolls or edits, the cache is
// refreshed accordingly.
if (!expanded.empty()) {
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
if (line_w > max_width_px_)
max_width_px_ = line_w;
}
}
// After the visible-range loop, advance the layout cursor to the end of
// the (virtual) content so ImGui sees the correct total size. Vertical
// height comes from total_rows; horizontal width comes from the cached
// max line width. A Dummy at the final position records both.
if (total_rows > last_vis) {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
static_cast<float>(total_rows - last_vis) * row_h);
}
if (max_width_px_ > 0.0f) {
ImGui::SetCursorPosX(max_width_px_);
}
ImGui::Dummy(ImVec2(0, 0));
// Synchronize cursor and scrolling after rendering all lines so content size is known.
{
float child_h_actual = ImGui::GetWindowHeight();
@@ -545,29 +605,40 @@ ImGuiRenderer::Draw(Editor &ed)
last_row = first_row + vis_rows - 1;
}
// Horizontal scroll: ensure cursor column is visible
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w));
if (vis_cols < 1)
vis_cols = 1;
long first_col = static_cast<long>(scroll_x_now / space_w);
long last_col = first_col + vis_cols - 1;
std::size_t cursor_rx = 0;
// 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;
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
if (cur_line[i] == '\t') {
cursor_rx += tabw - (cursor_rx % tabw);
// 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 {
cursor_rx += 1;
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;
}
long cxr = static_cast<long>(cursor_rx);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
target_x -= (child_w_actual / 2.0f);
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();

View File

@@ -2,8 +2,13 @@
* ImGuiRenderer - ImGui-based renderer for GUI mode
*/
#pragma once
#include <cstdint>
#include <string>
#include "Renderer.h"
struct ImFont;
class Buffer;
class ImGuiRenderer final : public Renderer {
public:
ImGuiRenderer() = default;
@@ -11,4 +16,22 @@ public:
~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;
// Max-line-width cache for the horizontal scrollbar. Measuring every line
// every frame is prohibitively expensive on large files; we only update the
// running max from visible lines and reset when buffer/version/font changes.
const Buffer *max_width_buf_ = nullptr;
std::uint64_t max_width_version_ = 0;
ImFont *max_width_font_ = nullptr;
float max_width_font_size_ = 0.0f;
float max_width_px_ = 0.0f;
};

View File

@@ -84,6 +84,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'l':
out = CommandId::ReloadBuffer;
return true;
case 'm':
out = CommandId::ToggleEditMode;
return true;
case 'n':
out = CommandId::BufferPrev;
return true;

View File

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

View File

@@ -23,29 +23,34 @@ Current themes (alphabetically):
- **gruvbox** — Retro groove color scheme (light/dark variants)
- **kanagawa-paper** — Inspired by traditional Japanese art
- **lcars** — Star Trek LCARS interface style
- **leuchtturm** — Modern, clean theme (light/dark variants)
- **nord** — Arctic, north-bluish color palette
- **old-book** — Sepia-toned vintage book aesthetic (light/dark
variants)
- **orbital** — Space-themed dark palette
- **plan9** — Minimalist Plan 9 from Bell Labs inspired
- **solarized** — Ethan Schoonover's Solarized (light/dark variants)
- **tufte** — Edward Tufte-inspired minimalist theme (light/dark variants)
- **weyland-yutani** — Alien franchise corporate aesthetic
- **zenburn** — Low-contrast, easy-on-the-eyes theme
Configuration
-------------
Themes are configured via `$HOME/.config/kte/kge.ini`:
Themes are configured via `$HOME/.config/kte/kge.toml`:
```ini
theme = nord
background = dark
```toml
[appearance]
theme = "nord"
background = "dark"
```
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
- `background` — Either "dark" or "light" (for themes supporting both
variants)
Legacy `kge.ini` format is also supported (see CONFIG.md).
Themes can also be switched at runtime using the `:theme <name>`
command.

17748
ext/tomlplusplus/toml.hpp Normal file

File diff suppressed because it is too large Load Diff

View File

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

1768
fonts/CrimsonPro.h Normal file

File diff suppressed because it is too large Load Diff

1203
fonts/ETBook.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@
#include "BerkeleyMono.h"
#include "BrassMono.h"
#include "BrassMonoCode.h"
#include "CrimsonPro.h"
#include "ETBook.h"
#include "FiraCode.h"
#include "Go.h"
#include "IBMPlexMono.h"
@@ -13,6 +15,7 @@
#include "IosevkaExtended.h"
#include "ShareTech.h"
#include "SpaceMono.h"
#include "Spectral.h"
#include "Syne.h"
#include "Triplicate.h"
#include "Unispace.h"

View File

@@ -45,6 +45,16 @@ InstallDefaultFonts()
BrassMonoCode::DefaultFontBoldCompressedData,
BrassMonoCode::DefaultFontBoldCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"crimsonpro",
CrimsonPro::DefaultFontRegularCompressedData,
CrimsonPro::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"etbook",
ETBook::DefaultFontRegularCompressedData,
ETBook::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"fira",
FiraCode::DefaultFontRegularCompressedData,
@@ -95,6 +105,11 @@ InstallDefaultFonts()
SpaceMono::DefaultFontRegularCompressedData,
SpaceMono::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"spectral",
Spectral::DefaultFontRegularCompressedData,
Spectral::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"syne",
Syne::DefaultFontRegularCompressedData,

View File

@@ -1,10 +1,12 @@
#pragma once
#include <algorithm>
#include <cassert>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include "Font.h"
@@ -87,6 +89,19 @@ public:
}
// Return all registered font names (sorted)
std::vector<std::string> FontNames() const
{
std::lock_guard lock(mutex_);
std::vector<std::string> names;
names.reserve(fonts_.size());
for (const auto &[name, _] : fonts_)
names.push_back(name);
std::sort(names.begin(), names.end());
return names;
}
// Current font name/size as last successfully loaded via LoadFont()
std::string CurrentFontName() const
{

3227
fonts/Spectral.h Normal file

File diff suppressed because it is too large Load Diff

24
kge.toml.example Normal file
View File

@@ -0,0 +1,24 @@
# kge configuration
# Place at ~/.config/kte/kge.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) — for .txt, .md, .rst, .org, .tex, etc.
writing = "crimsonpro"
[appearance]
theme = "nord"
# "dark" or "light" for themes with variants
background = "dark"
[editor]
syntax = true

14
main.cc
View File

@@ -12,6 +12,7 @@
#include <random>
#include <thread>
#include <signal.h>
#include <filesystem>
#include <string>
#include <unistd.h>
#include <sys/stat.h>
@@ -255,7 +256,18 @@ main(int argc, char *argv[])
// Fall through: not a +number, treat as filename starting with '+'
}
const std::string path = arg;
// Resolve to absolute path now, before any
// chdir (macOS GUI changes CWD to HOME before
// deferred opens are processed).
std::string path = arg;
try {
std::filesystem::path p(path);
if (p.is_relative()) {
path = std::filesystem::absolute(p).string();
}
} catch (...) {
// Fall through with original path
}
editor.RequestOpenFile(path, pending_line);
pending_line = 0; // consumed (if set)
}

View File

@@ -15,20 +15,18 @@ sha256sum kge.app.zip
open .
cd ..
mkdir -p cmake-build-release-qt
cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cd cmake-build-release-qt
make clean
rm -fr kge.app* kge-qt.app*
make
mv -f kge.app kge-qt.app
# Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
macdeployqt kge-qt.app -always-overwrite -verbose=3
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
zip -r kge-qt.app.zip kge-qt.app
sha256sum kge-qt.app.zip
open .
cd ..
# Qt build disabled — ImGui frontend is the primary GUI.
# mkdir -p cmake-build-release-qt
# cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
#
# cd cmake-build-release-qt
# make clean
# rm -fr kge.app* kge-qt.app*
# make
# mv -f kge.app kge-qt.app
# macdeployqt kge-qt.app -always-overwrite -verbose=3
# cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
# zip -r kge-qt.app.zip kge-qt.app
# sha256sum kge-qt.app.zip
# open .
# cd ..

View File

@@ -22,5 +22,6 @@ fi
git tag "${KTE_VERSION}"
git push && git push --tags
git push github && git push github --tags
( ./make-app-release )

125
tests/test_swap_cleanup2.cc Normal file
View File

@@ -0,0 +1,125 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h"
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
// RAII helper to set XDG_STATE_HOME for the duration of a test and clean up.
struct XdgStateGuard {
fs::path root;
std::string old_xdg;
bool had_old;
explicit XdgStateGuard(const std::string &suffix)
{
root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_") + suffix + "_" + std::to_string((int) ::getpid()));
fs::remove_all(root);
fs::create_directories(root);
const char *p = std::getenv("XDG_STATE_HOME");
had_old = (p != nullptr);
if (p)
old_xdg = p;
setenv("XDG_STATE_HOME", root.string().c_str(), 1);
}
~XdgStateGuard()
{
if (had_old)
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(root);
}
};
TEST(SwapCleanup_SaveAndQuit)
{
ktet::InstallDefaultCommandsOnce();
XdgStateGuard xdg("save_quit");
const std::string path = (xdg.root / "work" / "file.txt").string();
fs::create_directories(xdg.root / "work");
write_file_bytes(path, "hello\n");
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to create swap file
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Z"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Save-and-quit should clean up the swap file
ASSERT_TRUE(Execute(ed, CommandId::SaveAndQuit));
ed.Swap()->Flush(b);
ASSERT_TRUE(!fs::exists(swp));
// Cleanup
std::remove(path.c_str());
}
TEST(SwapCleanup_EditorReset)
{
ktet::InstallDefaultCommandsOnce();
XdgStateGuard xdg("editor_reset");
const std::string path = (xdg.root / "work" / "file.txt").string();
fs::create_directories(xdg.root / "work");
write_file_bytes(path, "hello\n");
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to create swap file
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "W"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Reset (simulates clean editor exit) should remove swap files
ed.Reset();
ASSERT_TRUE(!fs::exists(swp));
// Cleanup
std::remove(path.c_str());
}

204
themes/Leuchtturm.h Normal file
View File

@@ -0,0 +1,204 @@
// themes/Leuchtturm.h — Fountain pen on cream paper, brass and leather (header-only)
// Inspired by Kaweco Brass/Bronze Sport pens on Leuchtturm1917 notebook paper.
// Light: warm cream paper with blue-black fountain pen ink.
// Dark: leather case and patinated metal.
#pragma once
#include "ThemeHelpers.h"
static inline void
ApplyLeuchtturmLightTheme()
{
// Notebook paper and fountain pen ink
const ImVec4 paper = RGBA(0xF2ECDF); // Leuchtturm cream paper
const ImVec4 bg1 = RGBA(0xE8E2D5); // slightly darker cream
const ImVec4 bg2 = RGBA(0xDDD7CA); // UI elements
const ImVec4 bg3 = RGBA(0xD1CBBD); // hover/active
const ImVec4 ink = RGBA(0x040720); // blue-black fountain pen ink
const ImVec4 dim = RGBA(0x7A756A); // faded text (like printed headers)
const ImVec4 border = RGBA(0xCCC6B4); // faint ruled lines
// Metal accents from the pens
const ImVec4 brass = RGBA(0x6B5E2A); // dark patinated brass
const ImVec4 brown = RGBA(0x5C3D28); // leather/bronze
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = paper;
colors[ImGuiCol_ChildBg] = paper;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = paper;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
colors[ImGuiCol_ScrollbarGrabActive] = border;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = brass;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = brass;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(brass.x, brass.y, brass.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = brass;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(brass.x, brass.y, brass.z, 0.18f);
colors[ImGuiCol_DragDropTarget] = brass;
colors[ImGuiCol_NavHighlight] = brass;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_PlotLines] = brown;
colors[ImGuiCol_PlotLinesHovered] = brass;
colors[ImGuiCol_PlotHistogram] = brown;
colors[ImGuiCol_PlotHistogramHovered] = brass;
}
// Dark variant — leather pen case with warm metal and cream accents
static inline void
ApplyLeuchtturmDarkTheme()
{
const ImVec4 bg0 = RGBA(0x1C1610); // dark leather
const ImVec4 bg1 = RGBA(0x251E16); // slightly lighter
const ImVec4 bg2 = RGBA(0x30281E); // UI elements
const ImVec4 bg3 = RGBA(0x3E3428); // hover/active
const ImVec4 ink = RGBA(0xE5DDD0); // warm cream text
const ImVec4 dim = RGBA(0x978E7C); // secondary text
const ImVec4 border = RGBA(0x4A3E30); // subtle borders
const ImVec4 brass = RGBA(0xB8A060); // polished brass
const ImVec4 brown = RGBA(0x8B6848); // bronze pen
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = bg0;
colors[ImGuiCol_ChildBg] = bg0;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = bg0;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = border;
colors[ImGuiCol_ScrollbarGrabActive] = dim;
colors[ImGuiCol_CheckMark] = brass;
colors[ImGuiCol_SliderGrab] = brass;
colors[ImGuiCol_SliderGrabActive] = brown;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = brass;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(brass.x, brass.y, brass.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = brass;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(brass.x, brass.y, brass.z, 0.22f);
colors[ImGuiCol_DragDropTarget] = brass;
colors[ImGuiCol_NavHighlight] = brass;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_PlotLines] = brass;
colors[ImGuiCol_PlotLinesHovered] = brown;
colors[ImGuiCol_PlotHistogram] = brass;
colors[ImGuiCol_PlotHistogramHovered] = brown;
}

203
themes/Tufte.h Normal file
View File

@@ -0,0 +1,203 @@
// themes/Tufte.h — Edward Tufte inspired ImGui theme (header-only)
// Warm cream paper, dark ink, minimal chrome, restrained accent colors.
#pragma once
#include "ThemeHelpers.h"
// Light variant (primary — Tufte's books are fundamentally light)
static inline void
ApplyTufteLightTheme()
{
// Tufte palette: warm cream paper with near-black ink
const ImVec4 paper = RGBA(0xFFFFF8); // Tufte's signature warm white
const ImVec4 bg1 = RGBA(0xF4F0E8); // slightly darker cream
const ImVec4 bg2 = RGBA(0xEAE6DE); // UI elements
const ImVec4 bg3 = RGBA(0xDDD9D1); // hover/active
const ImVec4 ink = RGBA(0x111111); // near-black text
const ImVec4 dim = RGBA(0x6B6B6B); // disabled/secondary text
const ImVec4 border = RGBA(0xD0CCC4); // subtle borders
// Tufte uses color sparingly: muted red for emphasis, navy for links
const ImVec4 red = RGBA(0xA00000); // restrained dark red
const ImVec4 blue = RGBA(0x1F3F6F); // dark navy
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f; // sharp edges — typographic, not app-like
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f; // minimal frame borders
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = paper;
colors[ImGuiCol_ChildBg] = paper;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = paper;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
colors[ImGuiCol_ScrollbarGrabActive] = border;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = blue;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = red;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(red.x, red.y, red.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = red;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(red.x, red.y, red.z, 0.15f);
colors[ImGuiCol_DragDropTarget] = red;
colors[ImGuiCol_NavHighlight] = red;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_PlotLines] = blue;
colors[ImGuiCol_PlotLinesHovered] = red;
colors[ImGuiCol_PlotHistogram] = blue;
colors[ImGuiCol_PlotHistogramHovered] = red;
}
// Dark variant — warm charcoal with cream ink, same restrained accents
static inline void
ApplyTufteDarkTheme()
{
const ImVec4 bg0 = RGBA(0x1C1B19); // warm near-black
const ImVec4 bg1 = RGBA(0x252420); // slightly lighter
const ImVec4 bg2 = RGBA(0x302F2A); // UI elements
const ImVec4 bg3 = RGBA(0x3D3C36); // hover/active
const ImVec4 ink = RGBA(0xEAE6DE); // cream text (inverted paper)
const ImVec4 dim = RGBA(0x9A9690); // disabled text
const ImVec4 border = RGBA(0x4A4840); // subtle borders
const ImVec4 red = RGBA(0xD06060); // warmer red for dark bg
const ImVec4 blue = RGBA(0x7098C0); // lighter navy for dark bg
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = bg0;
colors[ImGuiCol_ChildBg] = bg0;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = bg0;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = border;
colors[ImGuiCol_ScrollbarGrabActive] = dim;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = blue;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = red;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(red.x, red.y, red.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = red;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(red.x, red.y, red.z, 0.20f);
colors[ImGuiCol_DragDropTarget] = red;
colors[ImGuiCol_NavHighlight] = red;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_PlotLines] = blue;
colors[ImGuiCol_PlotLinesHovered] = red;
colors[ImGuiCol_PlotHistogram] = blue;
colors[ImGuiCol_PlotHistogramHovered] = red;
}