Refine help text, keybindings, GUI themes, and undo system.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Expanded help text and command documentation with detailed keybinding descriptions. - Added theme customization support to GUIConfig (Nord default, light/dark variants). - Adjusted for consistent indentation and debug instrumentation in undo system. - Enhanced test cases for multi-line, UTF-8, and branching scenarios.
This commit is contained in:
28
.idea/workspace.xml
generated
28
.idea/workspace.xml
generated
@@ -7,6 +7,7 @@
|
||||
<option name="/Default/Environment/Hierarchy/GeneratedFilesCacheKey/Timestamp/@EntryValue" value="3" type="long" />
|
||||
<option name="/Default/Housekeeping/FeatureSuggestion/FeatureSuggestionManager/DisabledSuggesters/=SwitchToGoToActionSuggester/@EntryIndexedValue" value="true" type="bool" />
|
||||
<option name="/Default/Housekeeping/GlobalSettingsUpgraded/IsUpgraded/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/Housekeeping/LiveTemplatesHousekeeping/HotspotSessionHintIsShown/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/Housekeeping/OptionsDialog/SelectedPageId/@EntryValue" value="CppFormatterOtherPage" type="string" />
|
||||
<option name="/Default/Housekeeping/RefactoringsMru/RenameRefactoring/DoSearchForTextInStrings/@EntryValue" value="true" type="bool" />
|
||||
<option name="/Default/RiderDebugger/RiderRestoreDecompile/RestoreDecompileSetting/@EntryValue" value="false" type="bool" />
|
||||
@@ -34,7 +35,29 @@
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add Nord theme for real">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Buffer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Buffer.h" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/CMakeLists.txt" beforeDir="false" afterPath="$PROJECT_DIR$/CMakeLists.txt" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Command.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Command.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Command.h" beforeDir="false" afterPath="$PROJECT_DIR$/Command.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/Editor.h" beforeDir="false" afterPath="$PROJECT_DIR$/Editor.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIConfig.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIConfig.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIConfig.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUIConfig.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIFrontend.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIFrontend.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIFrontend.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUIFrontend.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIInputHandler.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIInputHandler.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUIRenderer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIRenderer.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/GUITheme.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUITheme.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/HelpText.cc" beforeDir="false" afterPath="$PROJECT_DIR$/HelpText.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/HelpText.h" beforeDir="false" afterPath="$PROJECT_DIR$/HelpText.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/KKeymap.cc" beforeDir="false" afterPath="$PROJECT_DIR$/KKeymap.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/TerminalRenderer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/TerminalRenderer.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/UndoSystem.cc" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.cc" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/UndoSystem.h" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.h" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/docs/kge.1" beforeDir="false" afterPath="$PROJECT_DIR$/docs/kge.1" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/docs/kte.1" beforeDir="false" afterPath="$PROJECT_DIR$/docs/kte.1" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/test_undo.cc" beforeDir="false" afterPath="$PROJECT_DIR$/test_undo.cc" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -63,12 +86,13 @@
|
||||
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
|
||||
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
|
||||
</component>
|
||||
<component name="OptimizeOnSaveOptions">
|
||||
<option name="myRunOnSave" value="true" />
|
||||
</component>
|
||||
<component name="ProblemsViewState">
|
||||
<option name="selectedTabId" value="AISelfReview" />
|
||||
<option name="selectedTabId" value="CurrentFile" />
|
||||
</component>
|
||||
<component name="ProjectApplicationVersion">
|
||||
<option name="ide" value="CLion" />
|
||||
@@ -173,7 +197,7 @@
|
||||
<workItem from="1764539556448" duration="156000" />
|
||||
<workItem from="1764539725338" duration="1075000" />
|
||||
<workItem from="1764542392763" duration="3512000" />
|
||||
<workItem from="1764548345516" duration="39312000" />
|
||||
<workItem from="1764548345516" duration="50201000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Add undo/redo infrastructure and buffer management additions.">
|
||||
<option name="closed" value="true" />
|
||||
|
||||
12
Buffer.cc
12
Buffer.cc
@@ -368,9 +368,9 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
||||
rows_[y].insert(x, seg);
|
||||
x += seg.size();
|
||||
// Split line at x
|
||||
std::string tail = rows_[y].substr(x);
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
std::string tail = rows_[y].substr(x);
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
y += 1;
|
||||
x = 0;
|
||||
remain.erase(0, pos + 1);
|
||||
@@ -430,8 +430,8 @@ Buffer::split_line(int row, const int col)
|
||||
const auto y = static_cast<std::size_t>(row);
|
||||
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
|
||||
const auto tail = rows_[y].substr(x);
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@ Buffer::insert_row(int row, const std::string_view text)
|
||||
row = 0;
|
||||
if (static_cast<std::size_t>(row) > rows_.size())
|
||||
row = static_cast<int>(rows_.size());
|
||||
rows_.insert(rows_.begin() + row, Line(std::string(text)));
|
||||
rows_.insert(rows_.begin() + row, Line(std::string(text)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
60
Buffer.h
60
Buffer.h
@@ -16,7 +16,7 @@
|
||||
|
||||
class Buffer {
|
||||
public:
|
||||
Buffer();
|
||||
Buffer();
|
||||
|
||||
Buffer(const Buffer &other);
|
||||
|
||||
@@ -262,11 +262,12 @@ public:
|
||||
return filename_;
|
||||
}
|
||||
|
||||
|
||||
// Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+"
|
||||
// This does not mark the buffer as file-backed.
|
||||
void SetVirtualName(const std::string &name)
|
||||
{
|
||||
filename_ = name;
|
||||
filename_ = name;
|
||||
is_file_backed_ = false;
|
||||
}
|
||||
|
||||
@@ -277,26 +278,29 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool Dirty() const
|
||||
{
|
||||
return dirty_;
|
||||
}
|
||||
[[nodiscard]] bool Dirty() const
|
||||
{
|
||||
return dirty_;
|
||||
}
|
||||
|
||||
// Read-only flag
|
||||
[[nodiscard]] bool IsReadOnly() const
|
||||
{
|
||||
return read_only_;
|
||||
}
|
||||
|
||||
void SetReadOnly(bool ro)
|
||||
{
|
||||
read_only_ = ro;
|
||||
}
|
||||
// Read-only flag
|
||||
[[nodiscard]] bool IsReadOnly() const
|
||||
{
|
||||
return read_only_;
|
||||
}
|
||||
|
||||
void ToggleReadOnly()
|
||||
{
|
||||
read_only_ = !read_only_;
|
||||
}
|
||||
|
||||
void SetReadOnly(bool ro)
|
||||
{
|
||||
read_only_ = ro;
|
||||
}
|
||||
|
||||
|
||||
void ToggleReadOnly()
|
||||
{
|
||||
read_only_ = !read_only_;
|
||||
}
|
||||
|
||||
|
||||
void SetCursor(const std::size_t x, const std::size_t y)
|
||||
@@ -380,18 +384,18 @@ public:
|
||||
[[nodiscard]] const UndoSystem *Undo() const;
|
||||
|
||||
private:
|
||||
// State mirroring original C struct (without undo_tree)
|
||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||
std::size_t nrows_ = 0; // number of rows
|
||||
// State mirroring original C struct (without undo_tree)
|
||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||
std::size_t nrows_ = 0; // number of rows
|
||||
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
||||
std::vector<Line> rows_; // buffer rows (without trailing newlines)
|
||||
std::string filename_;
|
||||
bool is_file_backed_ = false;
|
||||
bool dirty_ = false;
|
||||
bool read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
bool is_file_backed_ = false;
|
||||
bool dirty_ = false;
|
||||
bool read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
|
||||
// Per-buffer undo state
|
||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||
|
||||
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(KTE_VERSION "1.1.0")
|
||||
set(KTE_VERSION "1.1.1")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
|
||||
1149
Command.cc
1149
Command.cc
File diff suppressed because it is too large
Load Diff
11
Command.h
11
Command.h
@@ -69,6 +69,9 @@ enum class CommandId {
|
||||
Redo,
|
||||
// UI/status helpers
|
||||
UArgStatus, // update status line during universal-argument collection
|
||||
// Themes (GUI)
|
||||
ThemeNext,
|
||||
ThemePrev,
|
||||
// Region formatting
|
||||
IndentRegion, // indent region (C-k =)
|
||||
UnindentRegion, // unindent region (C-k -)
|
||||
@@ -86,6 +89,12 @@ enum class CommandId {
|
||||
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
|
||||
// Meta
|
||||
UnknownKCommand, // arg: single character that was not recognized after C-k
|
||||
// Generic command prompt
|
||||
CommandPromptStart, // begin generic command prompt (C-k ;)
|
||||
// Theme by name
|
||||
ThemeSetByName,
|
||||
// Background mode (GUI)
|
||||
BackgroundSet,
|
||||
};
|
||||
|
||||
|
||||
@@ -109,6 +118,8 @@ struct Command {
|
||||
std::string name; // stable, unique name (e.g., "save", "save-as")
|
||||
std::string help; // short help/description
|
||||
CommandHandler handler;
|
||||
// Public commands are exposed in the ": " prompt (C-k ;)
|
||||
bool isPublic = false;
|
||||
};
|
||||
|
||||
|
||||
|
||||
71
Editor.h
71
Editor.h
@@ -301,22 +301,23 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
|
||||
enum class PromptKind {
|
||||
None = 0,
|
||||
Search,
|
||||
RegexSearch,
|
||||
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
|
||||
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
|
||||
OpenFile,
|
||||
SaveAs,
|
||||
Confirm,
|
||||
BufferSwitch,
|
||||
GotoLine,
|
||||
Chdir,
|
||||
ReplaceFind, // step 1 of Search & Replace: find what
|
||||
ReplaceWith // step 2 of Search & Replace: replace with
|
||||
};
|
||||
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
|
||||
enum class PromptKind {
|
||||
None = 0,
|
||||
Search,
|
||||
RegexSearch,
|
||||
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
|
||||
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
|
||||
OpenFile,
|
||||
SaveAs,
|
||||
Confirm,
|
||||
BufferSwitch,
|
||||
GotoLine,
|
||||
Chdir,
|
||||
ReplaceFind, // step 1 of Search & Replace: find what
|
||||
ReplaceWith, // step 2 of Search & Replace: replace with
|
||||
Command // generic command prompt (": ")
|
||||
};
|
||||
|
||||
|
||||
void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial)
|
||||
@@ -518,20 +519,38 @@ private:
|
||||
std::string prompt_text_;
|
||||
std::string pending_overwrite_path_;
|
||||
|
||||
// GUI-only state (safe no-op in terminal builds)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
// GUI-only state (safe no-op in terminal builds)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
|
||||
// Temporary state for Search & Replace flow
|
||||
// Temporary state for Search & Replace flow
|
||||
public:
|
||||
void SetReplaceFindTmp(const std::string &s) { replace_find_tmp_ = s; }
|
||||
void SetReplaceWithTmp(const std::string &s) { replace_with_tmp_ = s; }
|
||||
[[nodiscard]] const std::string &ReplaceFindTmp() const { return replace_find_tmp_; }
|
||||
[[nodiscard]] const std::string &ReplaceWithTmp() const { return replace_with_tmp_; }
|
||||
void SetReplaceFindTmp(const std::string &s)
|
||||
{
|
||||
replace_find_tmp_ = s;
|
||||
}
|
||||
|
||||
|
||||
void SetReplaceWithTmp(const std::string &s)
|
||||
{
|
||||
replace_with_tmp_ = s;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const std::string &ReplaceFindTmp() const
|
||||
{
|
||||
return replace_find_tmp_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const std::string &ReplaceWithTmp() const
|
||||
{
|
||||
return replace_with_tmp_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string replace_find_tmp_;
|
||||
std::string replace_with_tmp_;
|
||||
std::string replace_find_tmp_;
|
||||
std::string replace_with_tmp_;
|
||||
};
|
||||
|
||||
#endif // KTE_EDITOR_H
|
||||
|
||||
@@ -102,6 +102,15 @@ GUIConfig::LoadFromFile(const std::string &path)
|
||||
if (v > 0.0f) {
|
||||
font_size = v;
|
||||
}
|
||||
} else if (key == "theme") {
|
||||
theme = val;
|
||||
} else if (key == "background" || key == "bg") {
|
||||
std::string v = val;
|
||||
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
|
||||
return (char) std::tolower(c);
|
||||
});
|
||||
if (v == "light" || v == "dark")
|
||||
background = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
GUIConfig.h
12
GUIConfig.h
@@ -12,10 +12,14 @@
|
||||
|
||||
class GUIConfig {
|
||||
public:
|
||||
bool fullscreen = false;
|
||||
int columns = 80;
|
||||
int rows = 42;
|
||||
float font_size = (float) KTE_FONT_SIZE;
|
||||
bool fullscreen = false;
|
||||
int columns = 80;
|
||||
int rows = 42;
|
||||
float font_size = (float) KTE_FONT_SIZE;
|
||||
std::string theme = "nord";
|
||||
// Background mode for themes that support light/dark variants
|
||||
// Values: "dark" (default), "light"
|
||||
std::string background = "dark";
|
||||
|
||||
// Load from default path: $HOME/.config/kte/kge.ini
|
||||
static GUIConfig Load();
|
||||
|
||||
@@ -32,8 +32,8 @@ GUIFrontend::Init(Editor &ed)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size)
|
||||
const auto [fullscreen, columns, rows, font_size] = GUIConfig::Load();
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
GUIConfig cfg = GUIConfig::Load();
|
||||
|
||||
// GL attributes for core profile
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
||||
@@ -47,7 +47,7 @@ GUIFrontend::Init(Editor &ed)
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
if (fullscreen) {
|
||||
if (cfg.fullscreen) {
|
||||
// "Fullscreen": fill the usable bounds of the primary display.
|
||||
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
|
||||
SDL_Rect usable{};
|
||||
@@ -61,8 +61,8 @@ GUIFrontend::Init(Editor &ed)
|
||||
#endif
|
||||
} else {
|
||||
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
|
||||
int w = static_cast<int>(columns * font_size);
|
||||
int h = static_cast<int>((rows * 2) * font_size);
|
||||
int w = cfg.columns * static_cast<int>(cfg.font_size);
|
||||
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
|
||||
|
||||
// As a safety, clamp to display usable bounds if retrievable
|
||||
SDL_Rect usable{};
|
||||
@@ -86,7 +86,7 @@ GUIFrontend::Init(Editor &ed)
|
||||
// macOS: when "fullscreen" is requested, position the window at the
|
||||
// top-left of the usable display area to mimic fullscreen while keeping
|
||||
// the system menu bar visible.
|
||||
if (fullscreen) {
|
||||
if (cfg.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(window_, usable.x, usable.y);
|
||||
@@ -105,8 +105,13 @@ GUIFrontend::Init(Editor &ed)
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
(void) io;
|
||||
ImGui::StyleColorsDark();
|
||||
// Apply a Nord-inspired theme
|
||||
kte::ApplyNordImGuiTheme();
|
||||
|
||||
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
|
||||
if (cfg.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(cfg.theme);
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
|
||||
return false;
|
||||
@@ -135,7 +140,7 @@ GUIFrontend::Init(Editor &ed)
|
||||
#endif
|
||||
|
||||
// Initialize GUI font from embedded default (use configured size or compiled default)
|
||||
LoadGuiFont_(nullptr, (float) font_size);
|
||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -214,7 +219,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
|
||||
|
||||
// Visible content rows inside the scroll child
|
||||
std::size_t content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
|
||||
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
|
||||
// Editor::Rows includes the status line; add 1 back for it.
|
||||
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
|
||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||
@@ -264,11 +269,11 @@ GUIFrontend::Shutdown()
|
||||
bool
|
||||
GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
(void *) DefaultFontBoldCompressedData,
|
||||
(int) DefaultFontBoldCompressedSize,
|
||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
DefaultFontBoldCompressedData,
|
||||
DefaultFontBoldCompressedSize,
|
||||
size_px);
|
||||
if (!font) {
|
||||
font = io.Fonts->AddFontDefault();
|
||||
|
||||
@@ -25,7 +25,7 @@ public:
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
bool LoadGuiFont_(const char *path, float size_px);
|
||||
static bool LoadGuiFont_(const char *path, float size_px);
|
||||
|
||||
GUIInputHandler input_{};
|
||||
GUIRenderer renderer_{};
|
||||
|
||||
@@ -92,10 +92,14 @@ map_key(const SDL_Keycode key,
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
case SDLK_TAB:
|
||||
// Do not insert text on KEYDOWN; allow SDL_TEXTINPUT to deliver '\t'
|
||||
// as printable input so that all printable characters flow via TEXTINPUT.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
// Insert a literal tab character when not interpreting a k-prefix suffix.
|
||||
// If k-prefix is active, let the k-prefix handler below consume the key
|
||||
// (so Tab doesn't leave k-prefix stuck).
|
||||
if (!k_prefix) {
|
||||
out = {true, CommandId::InsertText, std::string("\t"), 0};
|
||||
return true;
|
||||
}
|
||||
break; // fall through so k-prefix handler can process
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER:
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
@@ -347,6 +351,12 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
uarg_text_,
|
||||
mi);
|
||||
|
||||
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
|
||||
// for this keystroke to avoid double insertion on platforms that emit it.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
|
||||
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
|
||||
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
|
||||
|
||||
713
GUIRenderer.cc
713
GUIRenderer.cc
@@ -152,14 +152,14 @@ GUIRenderer::Draw(Editor &ed)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
// Compute viewport-relative row so (0) is top row of the visible area
|
||||
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
|
||||
long vy = static_cast<long>(vy_f);
|
||||
if (vy < 0)
|
||||
vy = 0;
|
||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
// Compute viewport-relative row so (0) is top row of the visible area
|
||||
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
|
||||
long vy = static_cast<long>(vy_f);
|
||||
if (vy < 0)
|
||||
vy = 0;
|
||||
|
||||
// Clamp vy within visible content height to avoid huge jumps
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
@@ -171,163 +171,169 @@ GUIRenderer::Draw(Editor &ed)
|
||||
if (vy >= vis_rows)
|
||||
vy = vis_rows - 1;
|
||||
|
||||
// Translate viewport row to buffer row using Buffer::Rowoffs
|
||||
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
|
||||
if (by >= lines.size()) {
|
||||
if (!lines.empty())
|
||||
by = lines.size() - 1;
|
||||
else
|
||||
by = 0;
|
||||
}
|
||||
// Translate viewport row to buffer row using Buffer::Rowoffs
|
||||
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
|
||||
if (by >= lines.size()) {
|
||||
if (!lines.empty())
|
||||
by = lines.size() - 1;
|
||||
else
|
||||
by = 0;
|
||||
}
|
||||
|
||||
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
|
||||
float px = (mp.x - list_origin.x - scroll_x);
|
||||
if (px < 0.0f)
|
||||
px = 0.0f;
|
||||
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
|
||||
float px = (mp.x - list_origin.x - scroll_x);
|
||||
if (px < 0.0f)
|
||||
px = 0.0f;
|
||||
|
||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
||||
if (lines.empty()) {
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
||||
} else {
|
||||
// Convert pixel X to a render-column target including horizontal col offset
|
||||
// Use our own tab expansion of width 8 to match command layer logic.
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
|
||||
// then translate to viewport-space by subtracting Coloffs.
|
||||
std::size_t coloffs = buf->Coloffs();
|
||||
std::size_t rx_abs = 0; // absolute rendered column
|
||||
std::size_t i = 0; // source column iterator
|
||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
||||
if (lines.empty()) {
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
||||
} else {
|
||||
// Convert pixel X to a render-column target including horizontal col offset
|
||||
// Use our own tab expansion of width 8 to match command layer logic.
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
|
||||
// then translate to viewport-space by subtracting Coloffs.
|
||||
std::size_t coloffs = buf->Coloffs();
|
||||
std::size_t rx_abs = 0; // absolute rendered column
|
||||
std::size_t i = 0; // source column iterator
|
||||
|
||||
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
|
||||
if (!line_clicked.empty() && coloffs > 0) {
|
||||
while (i < line_clicked.size() && rx_abs < coloffs) {
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
|
||||
if (!line_clicked.empty() && coloffs > 0) {
|
||||
while (i < line_clicked.size() && rx_abs < coloffs) {
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
// Now search for closest source column to clicked px within/after viewport
|
||||
std::size_t best_col = i; // default to first visible column
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
while (true) {
|
||||
// For i in [current..size], evaluate candidate including the implicit end position
|
||||
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
|
||||
float rx_px = static_cast<float>(rx_view) * space_w;
|
||||
float dist = std::fabs(px - rx_px);
|
||||
if (dist <= best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = i;
|
||||
}
|
||||
if (i == line_clicked.size())
|
||||
break;
|
||||
// advance to next source column
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
// Now search for closest source column to clicked px within/after viewport
|
||||
std::size_t best_col = i; // default to first visible column
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
while (true) {
|
||||
// For i in [current..size], evaluate candidate including the implicit end position
|
||||
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
|
||||
float rx_px = static_cast<float>(rx_view) * space_w;
|
||||
float dist = std::fabs(px - rx_px);
|
||||
if (dist <= best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = i;
|
||||
}
|
||||
if (i == line_clicked.size())
|
||||
break;
|
||||
// advance to next source column
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
// Dispatch absolute buffer coordinates (row:col)
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
}
|
||||
}
|
||||
// Dispatch absolute buffer coordinates (row:col)
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
}
|
||||
}
|
||||
// Cache current horizontal offset in rendered columns
|
||||
const std::size_t coloffs_now = buf->Coloffs();
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||
// Capture the screen position before drawing the line
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = static_cast<std::string>(lines[i]);
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++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
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
std::size_t s = 0;
|
||||
while (s < upto_src_exclusive && s < line.size()) {
|
||||
if (line[s] == '\t')
|
||||
rx += (tabw - (rx % tabw));
|
||||
else
|
||||
rx += 1;
|
||||
++s;
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
// Draw background highlights (under text)
|
||||
if (search_mode && !hl_src_ranges.empty()) {
|
||||
// Current match emphasis
|
||||
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
|
||||
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
|
||||
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
for (const auto &rg : hl_src_ranges) {
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_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, line_pos.y + line_h);
|
||||
// Choose color: current match stronger
|
||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||
ImU32 col = is_current ? IM_COL32(255, 220, 120, 140) : IM_COL32(200, 200, 0, 90);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
// Emit entire line (ImGui child scrolling will handle clipping)
|
||||
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));
|
||||
// Emit spaces for the tab
|
||||
expanded.append(adv, ' ');
|
||||
rx_abs_draw += adv;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
}
|
||||
}
|
||||
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
std::size_t s = 0;
|
||||
while (s < upto_src_exclusive && s < line.size()) {
|
||||
if (line[s] == '\t')
|
||||
rx += (tabw - (rx % tabw));
|
||||
else
|
||||
rx += 1;
|
||||
++s;
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
// Draw background highlights (under text)
|
||||
if (search_mode && !hl_src_ranges.empty()) {
|
||||
// Current match emphasis
|
||||
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
|
||||
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
|
||||
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
for (const auto &rg: hl_src_ranges) {
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_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,
|
||||
line_pos.y + line_h);
|
||||
// Choose color: current match stronger
|
||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||
ImU32 col = is_current
|
||||
? IM_COL32(255, 220, 120, 140)
|
||||
: IM_COL32(200, 200, 0, 90);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
// Emit entire line (ImGui child scrolling will handle clipping)
|
||||
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));
|
||||
// Emit spaces for the tab
|
||||
expanded.append(adv, ' ');
|
||||
rx_abs_draw += adv;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted(expanded.c_str());
|
||||
ImGui::TextUnformatted(expanded.c_str());
|
||||
|
||||
// Draw a visible cursor indicator on the current line
|
||||
if (i == cy) {
|
||||
@@ -349,207 +355,220 @@ GUIRenderer::Draw(Editor &ed)
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// Status bar spanning full width
|
||||
ImGui::Separator();
|
||||
// Status bar spanning full width
|
||||
ImGui::Separator();
|
||||
|
||||
// Compute full content width and draw a filled background rectangle
|
||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float x0 = win_pos.x + cr_min.x;
|
||||
float x1 = win_pos.x + cr_max.x;
|
||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
||||
float bar_h = ImGui::GetFrameHeight();
|
||||
ImVec2 p0(x0, cursor.y);
|
||||
ImVec2 p1(x1, cursor.y + bar_h);
|
||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||
// If a prompt is active, replace the entire status bar with the prompt text
|
||||
if (ed.PromptActive()) {
|
||||
std::string label = ed.PromptLabel();
|
||||
std::string ptext = ed.PromptText();
|
||||
auto kind = ed.CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
if (ptext.rfind(home, 0) == 0) {
|
||||
std::string rest = ptext.substr(home.size());
|
||||
if (rest.empty())
|
||||
ptext = "~";
|
||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
||||
ptext = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad;
|
||||
float max_px = std::max(0.0f, right_x - left_x);
|
||||
|
||||
std::string prefix;
|
||||
if (!label.empty()) prefix = label + ": ";
|
||||
|
||||
// Compose showing right-end of filename portion when too long for space
|
||||
std::string final_msg;
|
||||
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
|
||||
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
|
||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind == Editor::PromptKind::Chdir) && avail_px > 0.0f) {
|
||||
// Trim from left until it fits by pixel width
|
||||
std::string tail = ptext;
|
||||
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
|
||||
if (tail_sz.x > avail_px) {
|
||||
// Remove leading chars until it fits
|
||||
// Use a simple loop; text lengths are small here
|
||||
size_t start = 0;
|
||||
// To avoid O(n^2) worst-case, remove chunks
|
||||
while (start < tail.size()) {
|
||||
// Estimate how many chars to skip based on ratio
|
||||
float ratio = tail_sz.x / avail_px;
|
||||
size_t skip = ratio > 1.5f ? std::min(tail.size() - start, (size_t)std::max<size_t>(1, (size_t)(tail.size() / 4))) : 1;
|
||||
start += skip;
|
||||
std::string candidate = tail.substr(start);
|
||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||
if (cand_sz.x <= avail_px) {
|
||||
tail = candidate;
|
||||
tail_sz = cand_sz;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
|
||||
// As a last resort, ensure fit by chopping exactly
|
||||
// binary reduce
|
||||
size_t lo = 0, hi = tail.size();
|
||||
while (lo < hi) {
|
||||
size_t mid = (lo + hi) / 2;
|
||||
std::string cand = tail.substr(mid);
|
||||
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px) hi = mid; else lo = mid + 1;
|
||||
}
|
||||
tail = tail.substr(lo);
|
||||
}
|
||||
}
|
||||
final_msg = prefix + tail;
|
||||
} else {
|
||||
final_msg = prefix + ptext;
|
||||
}
|
||||
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(final_msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
} else {
|
||||
// Build left text
|
||||
std::string left;
|
||||
left.reserve(256);
|
||||
left += "kge"; // GUI app name
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR;
|
||||
std::string fname;
|
||||
try {
|
||||
fname = ed.DisplayNameFor(*buf);
|
||||
} catch (...) {
|
||||
fname = buf->Filename();
|
||||
try {
|
||||
fname = std::filesystem::path(fname).filename().string();
|
||||
} catch (...) {}
|
||||
}
|
||||
left += " ";
|
||||
// Insert buffer position prefix "[x/N] " before filename
|
||||
{
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
if (buf->Dirty())
|
||||
left += " *";
|
||||
// Append total line count as "<n>L"
|
||||
{
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
|
||||
// Build right text (cursor/mark)
|
||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
||||
bool have_mark = buf->MarkSet();
|
||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
||||
char rbuf[128];
|
||||
if (have_mark)
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
|
||||
else
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
|
||||
std::string right = rbuf;
|
||||
|
||||
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
|
||||
std::string msg;
|
||||
// Compute full content width and draw a filled background rectangle
|
||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float x0 = win_pos.x + cr_min.x;
|
||||
float x1 = win_pos.x + cr_max.x;
|
||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
||||
float bar_h = ImGui::GetFrameHeight();
|
||||
ImVec2 p0(x0, cursor.y);
|
||||
ImVec2 p1(x1, cursor.y + bar_h);
|
||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||
// If a prompt is active, replace the entire status bar with the prompt text
|
||||
if (ed.PromptActive()) {
|
||||
msg = ed.PromptLabel();
|
||||
if (!msg.empty())
|
||||
msg += ": ";
|
||||
msg += ed.PromptText();
|
||||
} else {
|
||||
msg = ed.Status();
|
||||
}
|
||||
std::string label = ed.PromptLabel();
|
||||
std::string ptext = ed.PromptText();
|
||||
auto kind = ed.CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
if (ptext.rfind(home, 0) == 0) {
|
||||
std::string rest = ptext.substr(home.size());
|
||||
if (rest.empty())
|
||||
ptext = "~";
|
||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
||||
ptext = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Measurements
|
||||
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
|
||||
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad - right_sz.x;
|
||||
if (right_x < left_x + left_sz.x + pad) {
|
||||
// Not enough room; clip left to fit
|
||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||
// Render a clipped left using a child region
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad;
|
||||
float max_px = std::max(0.0f, right_x - left_x);
|
||||
|
||||
std::string prefix;
|
||||
if (kind == Editor::PromptKind::Command) {
|
||||
prefix = ": ";
|
||||
} else if (!label.empty()) {
|
||||
prefix = label + ": ";
|
||||
}
|
||||
|
||||
// Compose showing right-end of filename portion when too long for space
|
||||
std::string final_msg;
|
||||
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
|
||||
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
|
||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
|
||||
Editor::PromptKind::Chdir) && avail_px > 0.0f) {
|
||||
// Trim from left until it fits by pixel width
|
||||
std::string tail = ptext;
|
||||
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
|
||||
if (tail_sz.x > avail_px) {
|
||||
// Remove leading chars until it fits
|
||||
// Use a simple loop; text lengths are small here
|
||||
size_t start = 0;
|
||||
// To avoid O(n^2) worst-case, remove chunks
|
||||
while (start < tail.size()) {
|
||||
// Estimate how many chars to skip based on ratio
|
||||
float ratio = tail_sz.x / avail_px;
|
||||
size_t skip = ratio > 1.5f
|
||||
? std::min(tail.size() - start,
|
||||
(size_t) std::max<size_t>(
|
||||
1, (size_t) (tail.size() / 4)))
|
||||
: 1;
|
||||
start += skip;
|
||||
std::string candidate = tail.substr(start);
|
||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||
if (cand_sz.x <= avail_px) {
|
||||
tail = candidate;
|
||||
tail_sz = cand_sz;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
|
||||
// As a last resort, ensure fit by chopping exactly
|
||||
// binary reduce
|
||||
size_t lo = 0, hi = tail.size();
|
||||
while (lo < hi) {
|
||||
size_t mid = (lo + hi) / 2;
|
||||
std::string cand = tail.substr(mid);
|
||||
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px)
|
||||
hi = mid;
|
||||
else
|
||||
lo = mid + 1;
|
||||
}
|
||||
tail = tail.substr(lo);
|
||||
}
|
||||
}
|
||||
final_msg = prefix + tail;
|
||||
} else {
|
||||
final_msg = prefix + ptext;
|
||||
}
|
||||
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(final_msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
} else {
|
||||
// Build left text
|
||||
std::string left;
|
||||
left.reserve(256);
|
||||
left += "kge"; // GUI app name
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR;
|
||||
std::string fname;
|
||||
try {
|
||||
fname = ed.DisplayNameFor(*buf);
|
||||
} catch (...) {
|
||||
fname = buf->Filename();
|
||||
try {
|
||||
fname = std::filesystem::path(fname).filename().string();
|
||||
} catch (...) {}
|
||||
}
|
||||
left += " ";
|
||||
// Insert buffer position prefix "[x/N] " before filename
|
||||
{
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
if (buf->Dirty())
|
||||
left += " *";
|
||||
// Append total line count as "<n>L"
|
||||
{
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
|
||||
// Build right text (cursor/mark)
|
||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
||||
bool have_mark = buf->MarkSet();
|
||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
||||
char rbuf[128];
|
||||
if (have_mark)
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
|
||||
else
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
|
||||
std::string right = rbuf;
|
||||
|
||||
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
|
||||
std::string msg;
|
||||
if (ed.PromptActive()) {
|
||||
msg = ed.PromptLabel();
|
||||
if (!msg.empty())
|
||||
msg += ": ";
|
||||
msg += ed.PromptText();
|
||||
} else {
|
||||
msg = ed.Status();
|
||||
}
|
||||
|
||||
// Measurements
|
||||
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
|
||||
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad - right_sz.x;
|
||||
if (right_x < left_x + left_sz.x + pad) {
|
||||
// Not enough room; clip left to fit
|
||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||
// Render a clipped left using a child region
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
} else {
|
||||
// Draw left normally
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
} else {
|
||||
// Draw left normally
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
}
|
||||
|
||||
// Draw right
|
||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x), p0.y + (bar_h - right_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(right.c_str());
|
||||
// Draw right
|
||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(right.c_str());
|
||||
|
||||
// Draw middle message centered in remaining space
|
||||
if (!msg.empty()) {
|
||||
float mid_left = left_x + left_sz.x + pad;
|
||||
float mid_right = std::max(right_x - pad, mid_left);
|
||||
float mid_w = std::max(0.0f, mid_right - mid_left);
|
||||
if (mid_w > 1.0f) {
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||
// Clip to middle region
|
||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Draw middle message centered in remaining space
|
||||
if (!msg.empty()) {
|
||||
float mid_left = left_x + left_sz.x + pad;
|
||||
float mid_right = std::max(right_x - pad, mid_left);
|
||||
float mid_w = std::max(0.0f, mid_right - mid_left);
|
||||
if (mid_w > 1.0f) {
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||
// Clip to middle region
|
||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
}
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
}
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
|
||||
1021
GUITheme.h
1021
GUITheme.h
File diff suppressed because it is too large
Load Diff
50
HelpText.cc
50
HelpText.cc
@@ -15,24 +15,26 @@ HelpText::Text()
|
||||
return std::string(
|
||||
"KTE - Kyle's Text Editor\n\n"
|
||||
"About:\n"
|
||||
" kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n"
|
||||
" inspired by Antirez' kilo text editor by way of someone's writeup of the\n"
|
||||
" process of writing a text editor from scratch. It has keybindings inspired by\n"
|
||||
" VDE (and the Wordstar family) and emacs; its spiritual parent is mg(1).\n"
|
||||
" kte is Kyle's Text Editor. It keeps a small, fast core and uses a\n"
|
||||
" WordStar/VDE-style command model with some emacs influences.\n"
|
||||
"\n"
|
||||
"Core keybindings:\n"
|
||||
"K-commands (prefix C-k):\n"
|
||||
" C-k ' Toggle read-only\n"
|
||||
" C-k - Unindent region\n"
|
||||
" C-k = Indent region\n"
|
||||
" C-k - Unindent region (mark required)\n"
|
||||
" C-k = Indent region (mark required)\n"
|
||||
" C-k ; Command prompt (:\\ )\n"
|
||||
" C-k C-d Kill entire line\n"
|
||||
" C-k C-q Quit now (no confirm)\n"
|
||||
" C-k a Mark all and jump to end\n"
|
||||
" C-k C-x Save and quit\n"
|
||||
" C-k a Mark start of file, jump to end\n"
|
||||
" C-k b Switch buffer\n"
|
||||
" C-k c Close current buffer\n"
|
||||
" C-k d Kill to end of line\n"
|
||||
" C-k e Open file (prompt)\n"
|
||||
" C-k f Flush kill ring\n"
|
||||
" C-k g Jump to line\n"
|
||||
" C-k h Show this help\n"
|
||||
" C-k j Jump to mark\n"
|
||||
" C-k l Reload buffer from disk\n"
|
||||
" C-k n Previous buffer\n"
|
||||
" C-k o Change working directory (prompt)\n"
|
||||
@@ -44,12 +46,36 @@ HelpText::Text()
|
||||
" C-k v Toggle visual file picker (GUI)\n"
|
||||
" C-k w Show working directory\n"
|
||||
" C-k x Save and quit\n"
|
||||
" C-k y Yank\n"
|
||||
"\n"
|
||||
"ESC/Alt commands:\n"
|
||||
" ESC < Go to beginning of file\n"
|
||||
" ESC > Go to end of file\n"
|
||||
" ESC m Toggle mark\n"
|
||||
" ESC w Copy region to kill ring (Alt-w)\n"
|
||||
" ESC b Previous word\n"
|
||||
" ESC f Next word\n"
|
||||
" ESC d Delete next word (Alt-d)\n"
|
||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||
" ESC q Reflow paragraph\n"
|
||||
" ESC BACKSPACE Delete previous word\n"
|
||||
" ESC d Delete next word\n"
|
||||
" Alt-w Copy region to kill ring\n\n"
|
||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n"
|
||||
"\n"
|
||||
"Control keys:\n"
|
||||
" C-a C-e Line start / end\n"
|
||||
" C-b C-f Move left / right\n"
|
||||
" C-n C-p Move down / up\n"
|
||||
" C-d Delete char\n"
|
||||
" C-w / C-y Kill region / Yank\n"
|
||||
" C-s Incremental find\n"
|
||||
" C-r Regex search\n"
|
||||
" C-t Regex search & replace\n"
|
||||
" C-h Search & replace\n"
|
||||
" C-l / C-g Refresh / Cancel\n"
|
||||
" C-u [digits] Universal argument (repeat count)\n"
|
||||
"\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 (eink, gruvbox, nord, plan9, solarized)\n"
|
||||
" : background MODE Set background: light | dark (affects eink, gruvbox, solarized)\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
class HelpText {
|
||||
public:
|
||||
// Returns the embedded help text as a single string with newlines.
|
||||
// Project maintainers can customize the returned string below
|
||||
// (in HelpText.cc) without touching the help command logic.
|
||||
static std::string Text();
|
||||
// Returns the embedded help text as a single string with newlines.
|
||||
// Project maintainers can customize the returned string below
|
||||
// (in HelpText.cc) without touching the help command logic.
|
||||
static std::string Text();
|
||||
};
|
||||
|
||||
#endif // KTE_HELPTEXT_H
|
||||
|
||||
25
KKeymap.cc
25
KKeymap.cc
@@ -33,10 +33,10 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
out = CommandId::Redo; // C-k r (redo)
|
||||
return true;
|
||||
}
|
||||
if (ascii_key == '\'') {
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
return true;
|
||||
}
|
||||
if (ascii_key == '\'') {
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (k_lower) {
|
||||
case 'a':
|
||||
@@ -108,6 +108,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case '=':
|
||||
out = CommandId::IndentRegion;
|
||||
return true;
|
||||
case ';':
|
||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -121,7 +124,7 @@ auto
|
||||
KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool
|
||||
{
|
||||
const int k = KLowerAscii(ascii_key);
|
||||
switch (k) {
|
||||
switch (k) {
|
||||
case 'w':
|
||||
out = CommandId::KillRegion; // C-w
|
||||
return true;
|
||||
@@ -152,12 +155,12 @@ KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool
|
||||
case 's':
|
||||
out = CommandId::FindStart;
|
||||
return true;
|
||||
case 'r':
|
||||
out = CommandId::RegexFindStart; // C-r regex search
|
||||
return true;
|
||||
case 't':
|
||||
out = CommandId::RegexpReplace; // C-t regex search & replace
|
||||
return true;
|
||||
case 'r':
|
||||
out = CommandId::RegexFindStart; // C-r regex search
|
||||
return true;
|
||||
case 't':
|
||||
out = CommandId::RegexpReplace; // C-t regex search & replace
|
||||
return true;
|
||||
case 'h':
|
||||
out = CommandId::SearchReplace; // C-h: search & replace
|
||||
return true;
|
||||
|
||||
@@ -41,140 +41,175 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t coloffs = buf->Coloffs();
|
||||
|
||||
const int tabw = 8;
|
||||
for (int r = 0; r < content_rows; ++r) {
|
||||
move(r, 0);
|
||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||
std::size_t render_col = 0;
|
||||
std::size_t src_i = 0;
|
||||
// Compute matches for this line if search highlighting is active
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t>> ranges; // [start, end)
|
||||
if (search_mode && li < lines.size()) {
|
||||
std::string sline = static_cast<std::string>(lines[li]);
|
||||
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
|
||||
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(sline.begin(), sline.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());
|
||||
ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status shows error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
|
||||
ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
||||
if (ranges.empty()) return false;
|
||||
// ranges are non-overlapping and ordered by construction
|
||||
// linear scan is fine for now
|
||||
for (const auto &rg : ranges) {
|
||||
if (si < rg.first) break;
|
||||
if (si >= rg.first && si < rg.second) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Track current-match to optionally emphasize
|
||||
const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0;
|
||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
bool hl_on = false;
|
||||
bool cur_on = false;
|
||||
int written = 0;
|
||||
if (li < lines.size()) {
|
||||
std::string line = static_cast<std::string>(lines[li]);
|
||||
src_i = 0;
|
||||
render_col = 0;
|
||||
while (written < cols) {
|
||||
char ch = ' ';
|
||||
bool from_src = false;
|
||||
if (src_i < line.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
||||
if (c == '\t') {
|
||||
std::size_t next_tab = tabw - (render_col % tabw);
|
||||
if (render_col + next_tab <= coloffs) {
|
||||
render_col += next_tab;
|
||||
++src_i;
|
||||
continue;
|
||||
}
|
||||
// Emit spaces for tab
|
||||
if (render_col < coloffs) {
|
||||
// skip to coloffs
|
||||
std::size_t to_skip = std::min<std::size_t>(
|
||||
next_tab, coloffs - render_col);
|
||||
render_col += to_skip;
|
||||
next_tab -= to_skip;
|
||||
}
|
||||
// Now render visible spaces
|
||||
while (next_tab > 0 && written < cols) {
|
||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||
bool in_cur = has_current && li == cur_my && src_i >= cur_mx && src_i < cur_mend;
|
||||
// Toggle highlight attributes
|
||||
int attr = 0;
|
||||
if (in_hl) attr |= A_STANDOUT;
|
||||
if (in_cur) attr |= A_BOLD;
|
||||
if ((attr & A_STANDOUT) && !hl_on) { attron(A_STANDOUT); hl_on = true; }
|
||||
if (!(attr & A_STANDOUT) && hl_on) { attroff(A_STANDOUT); hl_on = false; }
|
||||
if ((attr & A_BOLD) && !cur_on) { attron(A_BOLD); cur_on = true; }
|
||||
if (!(attr & A_BOLD) && cur_on) { attroff(A_BOLD); cur_on = false; }
|
||||
addch(' ');
|
||||
++written;
|
||||
++render_col;
|
||||
--next_tab;
|
||||
}
|
||||
++src_i;
|
||||
continue;
|
||||
} else {
|
||||
// normal char
|
||||
if (render_col < coloffs) {
|
||||
++render_col;
|
||||
++src_i;
|
||||
continue;
|
||||
}
|
||||
ch = static_cast<char>(c);
|
||||
from_src = true;
|
||||
}
|
||||
} else {
|
||||
// beyond EOL, fill spaces
|
||||
ch = ' ';
|
||||
from_src = false;
|
||||
}
|
||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < cur_mend;
|
||||
if (in_hl && !hl_on) { attron(A_STANDOUT); hl_on = true; }
|
||||
if (!in_hl && hl_on) { attroff(A_STANDOUT); hl_on = false; }
|
||||
if (in_cur && !cur_on) { attron(A_BOLD); cur_on = true; }
|
||||
if (!in_cur && cur_on) { attroff(A_BOLD); cur_on = false; }
|
||||
addch(static_cast<unsigned char>(ch));
|
||||
++written;
|
||||
++render_col;
|
||||
if (from_src)
|
||||
++src_i;
|
||||
if (src_i >= line.size() && written >= cols)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
clrtoeol();
|
||||
}
|
||||
for (int r = 0; r < content_rows; ++r) {
|
||||
move(r, 0);
|
||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||
std::size_t render_col = 0;
|
||||
std::size_t src_i = 0;
|
||||
// Compute matches for this line if search highlighting is active
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
||||
if (search_mode && li < lines.size()) {
|
||||
std::string sline = static_cast<std::string>(lines[li]);
|
||||
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
|
||||
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(sline.begin(), sline.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());
|
||||
ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status shows error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
|
||||
ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
||||
if (ranges.empty())
|
||||
return false;
|
||||
// ranges are non-overlapping and ordered by construction
|
||||
// linear scan is fine for now
|
||||
for (const auto &rg: ranges) {
|
||||
if (si < rg.first)
|
||||
break;
|
||||
if (si >= rg.first && si < rg.second)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Track current-match to optionally emphasize
|
||||
const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0;
|
||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
bool hl_on = false;
|
||||
bool cur_on = false;
|
||||
int written = 0;
|
||||
if (li < lines.size()) {
|
||||
std::string line = static_cast<std::string>(lines[li]);
|
||||
src_i = 0;
|
||||
render_col = 0;
|
||||
while (written < cols) {
|
||||
char ch = ' ';
|
||||
bool from_src = false;
|
||||
if (src_i < line.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
||||
if (c == '\t') {
|
||||
std::size_t next_tab = tabw - (render_col % tabw);
|
||||
if (render_col + next_tab <= coloffs) {
|
||||
render_col += next_tab;
|
||||
++src_i;
|
||||
continue;
|
||||
}
|
||||
// Emit spaces for tab
|
||||
if (render_col < coloffs) {
|
||||
// skip to coloffs
|
||||
std::size_t to_skip = std::min<std::size_t>(
|
||||
next_tab, coloffs - render_col);
|
||||
render_col += to_skip;
|
||||
next_tab -= to_skip;
|
||||
}
|
||||
// Now render visible spaces
|
||||
while (next_tab > 0 && written < cols) {
|
||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||
bool in_cur =
|
||||
has_current && li == cur_my && src_i >= cur_mx
|
||||
&& src_i < cur_mend;
|
||||
// Toggle highlight attributes
|
||||
int attr = 0;
|
||||
if (in_hl)
|
||||
attr |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
attr |= A_BOLD;
|
||||
if ((attr & A_STANDOUT) && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
}
|
||||
if (!(attr & A_STANDOUT) && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if ((attr & A_BOLD) && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!(attr & A_BOLD) && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
addch(' ');
|
||||
++written;
|
||||
++render_col;
|
||||
--next_tab;
|
||||
}
|
||||
++src_i;
|
||||
continue;
|
||||
} else {
|
||||
// normal char
|
||||
if (render_col < coloffs) {
|
||||
++render_col;
|
||||
++src_i;
|
||||
continue;
|
||||
}
|
||||
ch = static_cast<char>(c);
|
||||
from_src = true;
|
||||
}
|
||||
} else {
|
||||
// beyond EOL, fill spaces
|
||||
ch = ' ';
|
||||
from_src = false;
|
||||
}
|
||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||
bool in_cur =
|
||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
||||
cur_mend;
|
||||
if (in_hl && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
}
|
||||
if (!in_hl && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (in_cur && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!in_cur && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
addch(static_cast<unsigned char>(ch));
|
||||
++written;
|
||||
++render_col;
|
||||
if (from_src)
|
||||
++src_i;
|
||||
if (src_i >= line.size() && written >= cols)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
clrtoeol();
|
||||
}
|
||||
|
||||
// Place terminal cursor at logical position accounting for tabs and coloffs
|
||||
std::size_t cy = buf->Cury();
|
||||
@@ -191,71 +226,74 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
mvaddstr(0, 0, "[no buffer]");
|
||||
}
|
||||
|
||||
// Status line (inverse)
|
||||
move(rows - 1, 0);
|
||||
attron(A_REVERSE);
|
||||
// Status line (inverse)
|
||||
move(rows - 1, 0);
|
||||
attron(A_REVERSE);
|
||||
|
||||
// Fill the status line with spaces first
|
||||
for (int i = 0; i < cols; ++i)
|
||||
addch(' ');
|
||||
// Fill the status line with spaces first
|
||||
for (int i = 0; i < cols; ++i)
|
||||
addch(' ');
|
||||
|
||||
// If a prompt is active, replace the status bar with the full prompt text
|
||||
if (ed.PromptActive()) {
|
||||
// Build prompt text: "Label: text" and shorten HOME path for file-related prompts
|
||||
std::string label = ed.PromptLabel();
|
||||
std::string ptext = ed.PromptText();
|
||||
auto kind = ed.CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
// Ensure we match only at the start
|
||||
if (ptext.rfind(home, 0) == 0) {
|
||||
std::string rest = ptext.substr(home.size());
|
||||
if (rest.empty())
|
||||
ptext = "~";
|
||||
else if (rest[0] == '/' || rest[0] == '\\')
|
||||
ptext = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefer keeping the tail of the filename visible when it exceeds the window
|
||||
std::string msg;
|
||||
if (!label.empty()) {
|
||||
msg = label + ": ";
|
||||
}
|
||||
// When dealing with file-related prompts, left-trim the filename text so the tail stays visible
|
||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind == Editor::PromptKind::Chdir) && cols > 0) {
|
||||
int avail = cols - static_cast<int>(msg.size());
|
||||
if (avail <= 0) {
|
||||
// No room for label; fall back to showing the rightmost portion of the whole string
|
||||
std::string whole = msg + ptext;
|
||||
if ((int)whole.size() > cols)
|
||||
whole = whole.substr(whole.size() - cols);
|
||||
msg = whole;
|
||||
} else {
|
||||
if ((int)ptext.size() > avail) {
|
||||
ptext = ptext.substr(ptext.size() - avail);
|
||||
}
|
||||
msg += ptext;
|
||||
}
|
||||
} else {
|
||||
// Non-file prompts: simple concatenation and clip by terminal
|
||||
msg += ptext;
|
||||
}
|
||||
// If a prompt is active, replace the status bar with the full prompt text
|
||||
if (ed.PromptActive()) {
|
||||
// Build prompt text: "Label: text" and shorten HOME path for file-related prompts
|
||||
std::string label = ed.PromptLabel();
|
||||
std::string ptext = ed.PromptText();
|
||||
auto kind = ed.CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
// Ensure we match only at the start
|
||||
if (ptext.rfind(home, 0) == 0) {
|
||||
std::string rest = ptext.substr(home.size());
|
||||
if (rest.empty())
|
||||
ptext = "~";
|
||||
else if (rest[0] == '/' || rest[0] == '\\')
|
||||
ptext = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefer keeping the tail of the filename visible when it exceeds the window
|
||||
std::string msg;
|
||||
if (kind == Editor::PromptKind::Command) {
|
||||
msg = ": ";
|
||||
} else if (!label.empty()) {
|
||||
msg = label + ": ";
|
||||
}
|
||||
// When dealing with file-related prompts, left-trim the filename text so the tail stays visible
|
||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
|
||||
Editor::PromptKind::Chdir) && cols > 0) {
|
||||
int avail = cols - static_cast<int>(msg.size());
|
||||
if (avail <= 0) {
|
||||
// No room for label; fall back to showing the rightmost portion of the whole string
|
||||
std::string whole = msg + ptext;
|
||||
if ((int) whole.size() > cols)
|
||||
whole = whole.substr(whole.size() - cols);
|
||||
msg = whole;
|
||||
} else {
|
||||
if ((int) ptext.size() > avail) {
|
||||
ptext = ptext.substr(ptext.size() - avail);
|
||||
}
|
||||
msg += ptext;
|
||||
}
|
||||
} else {
|
||||
// Non-file prompts: simple concatenation and clip by terminal
|
||||
msg += ptext;
|
||||
}
|
||||
|
||||
// Draw left-aligned, clipped to width
|
||||
if (!msg.empty())
|
||||
mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols));
|
||||
// Draw left-aligned, clipped to width
|
||||
if (!msg.empty())
|
||||
mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols));
|
||||
|
||||
// End status rendering for prompt mode
|
||||
attroff(A_REVERSE);
|
||||
// Restore logical cursor position in content area
|
||||
if (saved_cur_y >= 0 && saved_cur_x >= 0)
|
||||
move(saved_cur_y, saved_cur_x);
|
||||
return;
|
||||
}
|
||||
// End status rendering for prompt mode
|
||||
attroff(A_REVERSE);
|
||||
// Restore logical cursor position in content area
|
||||
if (saved_cur_y >= 0 && saved_cur_x >= 0)
|
||||
move(saved_cur_y, saved_cur_x);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build left segment
|
||||
std::string left;
|
||||
@@ -346,10 +384,10 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
if (llen > 0)
|
||||
mvaddnstr(rows - 1, 0, left.c_str(), llen);
|
||||
|
||||
// Draw right, flush to end
|
||||
int rstart = std::max(0, cols - rlen);
|
||||
if (rlen > 0)
|
||||
mvaddnstr(rows - 1, rstart, right.c_str(), rlen);
|
||||
// Draw right, flush to end
|
||||
int rstart = std::max(0, cols - rlen);
|
||||
if (rlen > 0)
|
||||
mvaddnstr(rows - 1, rstart, right.c_str(), rlen);
|
||||
|
||||
// Middle message
|
||||
const std::string &msg = ed.Status();
|
||||
@@ -365,7 +403,7 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
}
|
||||
}
|
||||
|
||||
attroff(A_REVERSE);
|
||||
attroff(A_REVERSE);
|
||||
|
||||
// Restore terminal cursor to the content position so a visible caret
|
||||
// remains in the editing area (not on the status line).
|
||||
|
||||
303
UndoSystem.cc
303
UndoSystem.cc
@@ -5,79 +5,79 @@
|
||||
|
||||
|
||||
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||
: buf_(&owner), tree_(tree) {}
|
||||
: buf_(&owner), tree_(tree) {}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Begin(UndoType type)
|
||||
{
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Begin");
|
||||
debug_log("Begin");
|
||||
#endif
|
||||
// Reuse pending if batching conditions are met
|
||||
const int row = static_cast<int>(buf_->Cury());
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
|
||||
if (type == UndoType::Delete) {
|
||||
// Support batching both forward deletes (DeleteChar) and backspace (prepend case)
|
||||
// Forward delete: cursor stays at anchor col; keep batching when col == anchor
|
||||
const auto anchor = static_cast<std::size_t>(tree_.pending->col);
|
||||
if (anchor == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching forward delete
|
||||
}
|
||||
// Backspace: cursor moved left by exactly one position relative to current anchor.
|
||||
// Extend batch by shifting anchor left and prepending the deleted byte.
|
||||
if (static_cast<std::size_t>(col) + 1 == anchor) {
|
||||
tree_.pending->col = col;
|
||||
pending_prepend_ = true;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
|
||||
size();
|
||||
if (expected == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise commit any existing batch and start a new node
|
||||
commit();
|
||||
auto *node = new UndoNode();
|
||||
node->type = type;
|
||||
node->row = row;
|
||||
node->col = col;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
tree_.pending = node;
|
||||
pending_prepend_ = false;
|
||||
// Reuse pending if batching conditions are met
|
||||
const int row = static_cast<int>(buf_->Cury());
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
|
||||
if (type == UndoType::Delete) {
|
||||
// Support batching both forward deletes (DeleteChar) and backspace (prepend case)
|
||||
// Forward delete: cursor stays at anchor col; keep batching when col == anchor
|
||||
const auto anchor = static_cast<std::size_t>(tree_.pending->col);
|
||||
if (anchor == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching forward delete
|
||||
}
|
||||
// Backspace: cursor moved left by exactly one position relative to current anchor.
|
||||
// Extend batch by shifting anchor left and prepending the deleted byte.
|
||||
if (static_cast<std::size_t>(col) + 1 == anchor) {
|
||||
tree_.pending->col = col;
|
||||
pending_prepend_ = true;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
|
||||
size();
|
||||
if (expected == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise commit any existing batch and start a new node
|
||||
commit();
|
||||
auto *node = new UndoNode();
|
||||
node->type = type;
|
||||
node->row = row;
|
||||
node->col = col;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
tree_.pending = node;
|
||||
pending_prepend_ = false;
|
||||
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Begin:new");
|
||||
debug_log("Begin:new");
|
||||
#endif
|
||||
// Assert pending is detached from the tree
|
||||
assert(tree_.pending && "pending must exist after Begin");
|
||||
assert(tree_.pending != tree_.root);
|
||||
assert(tree_.pending != tree_.current);
|
||||
assert(tree_.pending != tree_.saved);
|
||||
assert(!is_descendant(tree_.root, tree_.pending));
|
||||
// Assert pending is detached from the tree
|
||||
assert(tree_.pending && "pending must exist after Begin");
|
||||
assert(tree_.pending != tree_.root);
|
||||
assert(tree_.pending != tree_.current);
|
||||
assert(tree_.pending != tree_.saved);
|
||||
assert(!is_descendant(tree_.root, tree_.pending));
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(char ch)
|
||||
{
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) {
|
||||
// Prepend for backspace so that text is in increasing column order
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) {
|
||||
// Prepend for backspace so that text is in increasing column order
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Append:ch");
|
||||
debug_log("Append:ch");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -85,11 +85,11 @@ UndoSystem::Append(char ch)
|
||||
void
|
||||
UndoSystem::Append(std::string_view text)
|
||||
{
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
tree_.pending->text.append(text.data(), text.size());
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
tree_.pending->text.append(text.data(), text.size());
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Append:sv");
|
||||
debug_log("Append:sv");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -98,10 +98,10 @@ void
|
||||
UndoSystem::commit()
|
||||
{
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("commit:enter");
|
||||
debug_log("commit:enter");
|
||||
#endif
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
|
||||
// If we have redo branches from current, discard them (non-linear behavior)
|
||||
if (tree_.current && tree_.current->child) {
|
||||
@@ -127,31 +127,31 @@ UndoSystem::commit()
|
||||
tree_.current->child = tree_.pending;
|
||||
tree_.current = tree_.pending;
|
||||
}
|
||||
tree_.pending = nullptr;
|
||||
update_dirty_flag();
|
||||
tree_.pending = nullptr;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("commit:done");
|
||||
debug_log("commit:done");
|
||||
#endif
|
||||
// post-conditions
|
||||
assert(tree_.pending == nullptr && "pending must be cleared after commit");
|
||||
// post-conditions
|
||||
assert(tree_.pending == nullptr && "pending must be cleared after commit");
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::undo()
|
||||
{
|
||||
// Close any pending batch
|
||||
commit();
|
||||
if (!tree_.current)
|
||||
return;
|
||||
UndoNode *parent = find_parent(tree_.root, tree_.current);
|
||||
UndoNode *node = tree_.current;
|
||||
// Apply inverse of current node
|
||||
apply(node, -1);
|
||||
tree_.current = parent;
|
||||
update_dirty_flag();
|
||||
// Close any pending batch
|
||||
commit();
|
||||
if (!tree_.current)
|
||||
return;
|
||||
UndoNode *parent = find_parent(tree_.root, tree_.current);
|
||||
UndoNode *node = tree_.current;
|
||||
// Apply inverse of current node
|
||||
apply(node, -1);
|
||||
tree_.current = parent;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("undo");
|
||||
debug_log("undo");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -159,24 +159,24 @@ UndoSystem::undo()
|
||||
void
|
||||
UndoSystem::redo()
|
||||
{
|
||||
// Redo next child along current timeline
|
||||
if (tree_.pending) {
|
||||
// If app added pending edits, finalize them before redo chain
|
||||
commit();
|
||||
}
|
||||
UndoNode *next = nullptr;
|
||||
if (!tree_.current) {
|
||||
next = tree_.root; // if nothing yet, try applying first node
|
||||
} else {
|
||||
next = tree_.current->child;
|
||||
}
|
||||
if (!next)
|
||||
return;
|
||||
apply(next, +1);
|
||||
tree_.current = next;
|
||||
update_dirty_flag();
|
||||
// Redo next child along current timeline
|
||||
if (tree_.pending) {
|
||||
// If app added pending edits, finalize them before redo chain
|
||||
commit();
|
||||
}
|
||||
UndoNode *next = nullptr;
|
||||
if (!tree_.current) {
|
||||
next = tree_.root; // if nothing yet, try applying first node
|
||||
} else {
|
||||
next = tree_.current->child;
|
||||
}
|
||||
if (!next)
|
||||
return;
|
||||
apply(next, +1);
|
||||
tree_.current = next;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("redo");
|
||||
debug_log("redo");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -184,10 +184,10 @@ UndoSystem::redo()
|
||||
void
|
||||
UndoSystem::mark_saved()
|
||||
{
|
||||
tree_.saved = tree_.current;
|
||||
update_dirty_flag();
|
||||
tree_.saved = tree_.current;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("mark_saved");
|
||||
debug_log("mark_saved");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -195,12 +195,12 @@ UndoSystem::mark_saved()
|
||||
void
|
||||
UndoSystem::discard_pending()
|
||||
{
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
}
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
}
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("discard_pending");
|
||||
debug_log("discard_pending");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -208,16 +208,16 @@ UndoSystem::discard_pending()
|
||||
void
|
||||
UndoSystem::clear()
|
||||
{
|
||||
if (tree_.root) {
|
||||
free_node(tree_.root);
|
||||
}
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
}
|
||||
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr;
|
||||
update_dirty_flag();
|
||||
if (tree_.root) {
|
||||
free_node(tree_.root);
|
||||
}
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
}
|
||||
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("clear");
|
||||
debug_log("clear");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -326,62 +326,73 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
||||
void
|
||||
UndoSystem::update_dirty_flag()
|
||||
{
|
||||
// dirty if current != saved
|
||||
bool dirty = (tree_.current != tree_.saved);
|
||||
buf_->SetDirty(dirty);
|
||||
// dirty if current != saved
|
||||
bool dirty = (tree_.current != tree_.saved);
|
||||
buf_->SetDirty(dirty);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::UpdateBufferReference(Buffer &new_buf)
|
||||
{
|
||||
buf_ = &new_buf;
|
||||
buf_ = &new_buf;
|
||||
}
|
||||
|
||||
|
||||
// ---- Debug helpers ----
|
||||
const char *
|
||||
UndoSystem::type_str(UndoType t)
|
||||
{
|
||||
switch (t) {
|
||||
case UndoType::Insert: return "Insert";
|
||||
case UndoType::Delete: return "Delete";
|
||||
case UndoType::Paste: return "Paste";
|
||||
case UndoType::Newline: return "Newline";
|
||||
case UndoType::DeleteRow: return "DeleteRow";
|
||||
}
|
||||
return "?";
|
||||
switch (t) {
|
||||
case UndoType::Insert:
|
||||
return "Insert";
|
||||
case UndoType::Delete:
|
||||
return "Delete";
|
||||
case UndoType::Paste:
|
||||
return "Paste";
|
||||
case UndoType::Newline:
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
||||
{
|
||||
if (!root || !target) return false;
|
||||
if (root == target) return true;
|
||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
||||
if (is_descendant(child, target)) return true;
|
||||
}
|
||||
return false;
|
||||
if (!root || !target)
|
||||
return false;
|
||||
if (root == target)
|
||||
return true;
|
||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
||||
if (is_descendant(child, target))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::debug_log(const char *op) const
|
||||
{
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
int row = static_cast<int>(buf_->Cury());
|
||||
int col = static_cast<int>(buf_->Curx());
|
||||
const UndoNode *p = tree_.pending;
|
||||
std::fprintf(stderr,
|
||||
"[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n",
|
||||
op,
|
||||
row, col,
|
||||
(const void*)p,
|
||||
p ? type_str(p->type) : "-",
|
||||
p ? p->row : -1,
|
||||
p ? p->col : -1,
|
||||
p ? p->text.size() : 0,
|
||||
(void*)tree_.current,
|
||||
(void*)tree_.saved);
|
||||
int row = static_cast<int>(buf_->Cury());
|
||||
int col = static_cast<int>(buf_->Curx());
|
||||
const UndoNode *p = tree_.pending;
|
||||
std::fprintf(stderr,
|
||||
"[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n",
|
||||
op,
|
||||
row, col,
|
||||
(const void *) p,
|
||||
p ? type_str(p->type) : "-",
|
||||
p ? p->row : -1,
|
||||
p ? p->col : -1,
|
||||
p ? p->text.size() : 0,
|
||||
(void *) tree_.current,
|
||||
(void *) tree_.saved);
|
||||
#else
|
||||
(void)op;
|
||||
(void) op;
|
||||
#endif
|
||||
}
|
||||
|
||||
30
UndoSystem.h
30
UndoSystem.h
@@ -12,7 +12,7 @@ class Buffer;
|
||||
|
||||
class UndoSystem {
|
||||
public:
|
||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||
|
||||
void Begin(UndoType type);
|
||||
|
||||
@@ -30,28 +30,30 @@ public:
|
||||
|
||||
void discard_pending();
|
||||
|
||||
void clear();
|
||||
void clear();
|
||||
|
||||
void UpdateBufferReference(Buffer &new_buf);
|
||||
void UpdateBufferReference(Buffer &new_buf);
|
||||
|
||||
private:
|
||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||
void free_node(UndoNode *node);
|
||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||
void free_node(UndoNode *node);
|
||||
|
||||
void free_branch(UndoNode *node); // frees redo siblings only
|
||||
UndoNode *find_parent(UndoNode *from, UndoNode *target);
|
||||
void free_branch(UndoNode *node); // frees redo siblings only
|
||||
UndoNode *find_parent(UndoNode *from, UndoNode *target);
|
||||
|
||||
// Debug helpers (compiled only when KTE_UNDO_DEBUG is defined)
|
||||
void debug_log(const char *op) const;
|
||||
static const char *type_str(UndoType t);
|
||||
static bool is_descendant(UndoNode *root, const UndoNode *target);
|
||||
// Debug helpers (compiled only when KTE_UNDO_DEBUG is defined)
|
||||
void debug_log(const char *op) const;
|
||||
|
||||
void update_dirty_flag();
|
||||
static const char *type_str(UndoType t);
|
||||
|
||||
static bool is_descendant(UndoNode *root, const UndoNode *target);
|
||||
|
||||
void update_dirty_flag();
|
||||
|
||||
Buffer *buf_;
|
||||
UndoTree &tree_;
|
||||
// Internal hint for Delete batching: whether next Append() should prepend
|
||||
bool pending_prepend_ = false;
|
||||
// Internal hint for Delete batching: whether next Append() should prepend
|
||||
bool pending_prepend_ = false;
|
||||
};
|
||||
|
||||
#endif // KTE_UNDOSYSTEM_H
|
||||
|
||||
74
docs/kge.1
74
docs/kge.1
@@ -1,7 +1,7 @@
|
||||
.\" kge(1) — Kyle's Graphical Editor (GUI-first)
|
||||
.\"
|
||||
.\" Project homepage: https://github.com/wntrmute/kte
|
||||
.TH KGE 1 "2025-11-30" "kte 0.1.0" "User Commands"
|
||||
.TH KGE 1 "2025-12-01" "kte 0.1.0" "User Commands"
|
||||
.SH NAME
|
||||
kge \- Kyle's Graphical Editor (GUI-first)
|
||||
.SH SYNOPSIS
|
||||
@@ -52,11 +52,8 @@ tree for the canonical reference and notes:
|
||||
.PP
|
||||
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
|
||||
.TP
|
||||
.B C-k BACKSPACE
|
||||
Delete from the cursor to the beginning of the line.
|
||||
.TP
|
||||
.B C-k SPACE
|
||||
Toggle the mark.
|
||||
.B C-k '
|
||||
Toggle read-only for the current buffer.
|
||||
.TP
|
||||
.B C-k -
|
||||
If the mark is set, unindent the region.
|
||||
@@ -64,6 +61,9 @@ If the mark is set, unindent the region.
|
||||
.B C-k =
|
||||
If the mark is set, indent the region.
|
||||
.TP
|
||||
.B C-k ;
|
||||
Open the generic command prompt (": ").
|
||||
.TP
|
||||
.B C-k a
|
||||
Set the mark at the beginning of the file, then jump to the end of the file.
|
||||
.TP
|
||||
@@ -80,7 +80,7 @@ Delete from the cursor to the end of the line.
|
||||
Delete the entire line.
|
||||
.TP
|
||||
.B C-k e
|
||||
Edit a new file.
|
||||
Edit (open) a new file.
|
||||
.TP
|
||||
.B C-k f
|
||||
Flush the kill ring.
|
||||
@@ -88,14 +88,20 @@ Flush the kill ring.
|
||||
.B C-k g
|
||||
Go to a specific line.
|
||||
.TP
|
||||
.B C-k h
|
||||
Show the built-in help (+HELP+ buffer).
|
||||
.TP
|
||||
.B C-k j
|
||||
Jump to the mark.
|
||||
.TP
|
||||
.B C-k l
|
||||
Reload the current buffer from disk.
|
||||
.TP
|
||||
.B C-k m
|
||||
Run make(1), reporting success or failure.
|
||||
.B C-k n
|
||||
Switch to the previous buffer.
|
||||
.TP
|
||||
.B C-k o
|
||||
Change working directory (prompt).
|
||||
.TP
|
||||
.B C-k p
|
||||
Switch to the next buffer.
|
||||
@@ -106,14 +112,20 @@ Exit the editor. If the file has unsaved changes, a warning will be printed; a s
|
||||
.B C-k C-q
|
||||
Immediately exit the editor.
|
||||
.TP
|
||||
.B C-k r
|
||||
Redo changes.
|
||||
.TP
|
||||
.B C-k s
|
||||
Save the file, prompting for a filename if needed.
|
||||
.TP
|
||||
.B C-k u
|
||||
Undo.
|
||||
.TP
|
||||
.B C-k r
|
||||
Redo changes.
|
||||
.B C-k v
|
||||
Toggle visual file picker (GUI).
|
||||
.TP
|
||||
.B C-k w
|
||||
Show the current working directory.
|
||||
.TP
|
||||
.B C-k x
|
||||
Save the file and exit. Also C-k C-x.
|
||||
@@ -121,23 +133,50 @@ Save the file and exit. Also C-k C-x.
|
||||
.B C-k y
|
||||
Yank the kill ring.
|
||||
.TP
|
||||
.B C-k \e
|
||||
Dump core.
|
||||
.B C-k C-x
|
||||
Save the file and exit.
|
||||
|
||||
.SS Other keybindings
|
||||
.TP
|
||||
.B C-g
|
||||
Cancel the current operation.
|
||||
.TP
|
||||
.B C-a
|
||||
Move to the beginning of the line.
|
||||
.TP
|
||||
.B C-e
|
||||
Move to the end of the line.
|
||||
.TP
|
||||
.B C-b
|
||||
Move left.
|
||||
.TP
|
||||
.B C-f
|
||||
Move right.
|
||||
.TP
|
||||
.B C-n
|
||||
Move down.
|
||||
.TP
|
||||
.B C-p
|
||||
Move up.
|
||||
.TP
|
||||
.B C-l
|
||||
Refresh the display.
|
||||
.TP
|
||||
.B C-d
|
||||
Delete the character at the cursor.
|
||||
.TP
|
||||
.B C-r
|
||||
Regex search.
|
||||
.TP
|
||||
.B C-s
|
||||
Incremental find.
|
||||
.TP
|
||||
.B C-t
|
||||
Regex search and replace.
|
||||
.TP
|
||||
.B C-h
|
||||
Search and replace.
|
||||
.TP
|
||||
.B C-u
|
||||
Universal argument. C-u followed by numbers will repeat an operation n times.
|
||||
.TP
|
||||
@@ -147,6 +186,15 @@ Kill the region if the mark is set.
|
||||
.B C-y
|
||||
Yank the kill ring.
|
||||
.TP
|
||||
.B ESC <
|
||||
Move to the beginning of the file.
|
||||
.TP
|
||||
.B ESC >
|
||||
Move to the end of the file.
|
||||
.TP
|
||||
.B ESC m
|
||||
Toggle the mark.
|
||||
.TP
|
||||
.B ESC BACKSPACE
|
||||
Delete the previous word.
|
||||
.TP
|
||||
|
||||
100
docs/kte.1
100
docs/kte.1
@@ -1,7 +1,7 @@
|
||||
.\" kte(1) — Kyle's Text Editor (terminal-first)
|
||||
.\"
|
||||
.\" Project homepage: https://github.com/wntrmute/kte
|
||||
.TH KTE 1 "2025-11-30" "kte 0.1.0" "User Commands"
|
||||
.TH KTE 1 "2025-12-01" "kte 0.1.0" "User Commands"
|
||||
.SH NAME
|
||||
kte \- Kyle's Text Editor (terminal-first)
|
||||
.SH SYNOPSIS
|
||||
@@ -57,11 +57,8 @@ in the source tree for the canonical reference and notes.
|
||||
.PP
|
||||
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
|
||||
.TP
|
||||
.B C-k BACKSPACE
|
||||
Delete from the cursor to the beginning of the line.
|
||||
.TP
|
||||
.B C-k SPACE
|
||||
Toggle the mark.
|
||||
.B C-k '
|
||||
Toggle read-only for the current buffer.
|
||||
.TP
|
||||
.B C-k -
|
||||
If the mark is set, unindent the region.
|
||||
@@ -69,6 +66,9 @@ If the mark is set, unindent the region.
|
||||
.B C-k =
|
||||
If the mark is set, indent the region.
|
||||
.TP
|
||||
.B C-k ;
|
||||
Open the generic command prompt (": ").
|
||||
.TP
|
||||
.B C-k a
|
||||
Set the mark at the beginning of the file, then jump to the end of the file.
|
||||
.TP
|
||||
@@ -85,7 +85,7 @@ Delete from the cursor to the end of the line.
|
||||
Delete the entire line.
|
||||
.TP
|
||||
.B C-k e
|
||||
Edit a new file.
|
||||
Edit (open) a new file.
|
||||
.TP
|
||||
.B C-k f
|
||||
Flush the kill ring.
|
||||
@@ -93,14 +93,20 @@ Flush the kill ring.
|
||||
.B C-k g
|
||||
Go to a specific line.
|
||||
.TP
|
||||
.B C-k h
|
||||
Show the built-in help (+HELP+ buffer).
|
||||
.TP
|
||||
.B C-k j
|
||||
Jump to the mark.
|
||||
.TP
|
||||
.B C-k l
|
||||
Reload the current buffer from disk.
|
||||
.TP
|
||||
.B C-k m
|
||||
Run make(1), reporting success or failure.
|
||||
.B C-k n
|
||||
Switch to the previous buffer.
|
||||
.TP
|
||||
.B C-k o
|
||||
Change working directory (prompt).
|
||||
.TP
|
||||
.B C-k p
|
||||
Switch to the next buffer.
|
||||
@@ -111,14 +117,20 @@ Exit the editor. If the file has unsaved changes, a warning will be printed; a s
|
||||
.B C-k C-q
|
||||
Immediately exit the editor.
|
||||
.TP
|
||||
.B C-k r
|
||||
Redo changes.
|
||||
.TP
|
||||
.B C-k s
|
||||
Save the file, prompting for a filename if needed.
|
||||
.TP
|
||||
.B C-k u
|
||||
Undo.
|
||||
.TP
|
||||
.B C-k r
|
||||
Redo changes.
|
||||
.B C-k v
|
||||
Toggle visual file picker (GUI).
|
||||
.TP
|
||||
.B C-k w
|
||||
Show the current working directory.
|
||||
.TP
|
||||
.B C-k x
|
||||
Save the file and exit. Also C-k C-x.
|
||||
@@ -126,23 +138,76 @@ Save the file and exit. Also C-k C-x.
|
||||
.B C-k y
|
||||
Yank the kill ring.
|
||||
.TP
|
||||
.B C-k \e
|
||||
Dump core.
|
||||
.B C-k C-x
|
||||
Save the file and exit.
|
||||
|
||||
.SH GUI APPEARANCE
|
||||
When running the GUI frontend, you can control appearance via the generic
|
||||
command prompt (type "C-k ;" then enter commands):
|
||||
.TP
|
||||
.B : theme NAME
|
||||
Set the GUI theme. Available names: "nord", "gruvbox", "plan9", "solarized", "eink".
|
||||
Compatibility aliases are also accepted: "gruvbox-dark", "gruvbox-light",
|
||||
"solarized-dark", "solarized-light", "eink-dark", "eink-light".
|
||||
.TP
|
||||
.B : background MODE
|
||||
Set background mode for supported themes. MODE is either "light" or "dark".
|
||||
Themes that respond to background: eink, gruvbox, solarized. The
|
||||
"nord" and "plan9" themes do not vary with background.
|
||||
|
||||
.SH CONFIGURATION
|
||||
The GUI reads a simple configuration file at
|
||||
~/.config/kte/kge.ini. Recognized keys include:
|
||||
.IP "fullscreen=on|off"
|
||||
.IP "columns=NUM"
|
||||
.IP "rows=NUM"
|
||||
.IP "font_size=NUM"
|
||||
.IP "theme=NAME"
|
||||
.IP "background=light|dark"
|
||||
The theme name accepts the values listed above. The background key controls
|
||||
light/dark variants when the selected theme supports it.
|
||||
|
||||
.SS Other keybindings
|
||||
.TP
|
||||
.B C-g
|
||||
Cancel the current operation.
|
||||
.TP
|
||||
.B C-a
|
||||
Move to the beginning of the line.
|
||||
.TP
|
||||
.B C-e
|
||||
Move to the end of the line.
|
||||
.TP
|
||||
.B C-b
|
||||
Move left.
|
||||
.TP
|
||||
.B C-f
|
||||
Move right.
|
||||
.TP
|
||||
.B C-n
|
||||
Move down.
|
||||
.TP
|
||||
.B C-p
|
||||
Move up.
|
||||
.TP
|
||||
.B C-l
|
||||
Refresh the display.
|
||||
.TP
|
||||
.B C-d
|
||||
Delete the character at the cursor.
|
||||
.TP
|
||||
.B C-r
|
||||
Regex search.
|
||||
.TP
|
||||
.B C-s
|
||||
Incremental find.
|
||||
.TP
|
||||
.B C-t
|
||||
Regex search and replace.
|
||||
.TP
|
||||
.B C-h
|
||||
Search and replace.
|
||||
.TP
|
||||
.B C-u
|
||||
Universal argument. C-u followed by numbers will repeat an operation n times.
|
||||
.TP
|
||||
@@ -152,6 +217,15 @@ Kill the region if the mark is set.
|
||||
.B C-y
|
||||
Yank the kill ring.
|
||||
.TP
|
||||
.B ESC <
|
||||
Move to the beginning of the file.
|
||||
.TP
|
||||
.B ESC >
|
||||
Move to the end of the file.
|
||||
.TP
|
||||
.B ESC m
|
||||
Toggle the mark.
|
||||
.TP
|
||||
.B ESC BACKSPACE
|
||||
Delete the previous word.
|
||||
.TP
|
||||
|
||||
102
docs/syntax on.md
Normal file
102
docs/syntax on.md
Normal file
@@ -0,0 +1,102 @@
|
||||
### Objective
|
||||
Introduce fast, minimal‑dependency syntax highlighting to kte, consistent with current architecture (Editor/Buffer + GUI/Terminal renderers), preserving ke UX and performance.
|
||||
|
||||
### Guiding principles
|
||||
- Keep core small and fast; no heavy deps (C++17 only).
|
||||
- Start simple (stateless line regex), evolve incrementally (stateful, caching).
|
||||
- Work in both Terminal (ncurses) and GUI (ImGui) with consistent token classes and theme mapping.
|
||||
- Integrate without disrupting existing search highlight, selection, or cursor rendering.
|
||||
|
||||
### Scope of v1
|
||||
- Languages: plain text (off), C/C++ minimal set (keywords, types, strings, chars, comments, numbers, preprocessor).
|
||||
- Stateless per‑line highlighting; handle single‑line comments and strings; defer multi‑line state to v2.
|
||||
- Toggle: `:syntax on|off` and per‑buffer filetype selection.
|
||||
|
||||
### Architecture
|
||||
1. Core types (new):
|
||||
- `enum class TokenKind { Default, Keyword, Type, String, Char, Comment, Number, Preproc, Constant, Function, Operator, Punctuation, Identifier, Whitespace, Error };`
|
||||
- `struct HighlightSpan { int col_start; int col_end; TokenKind kind; };` // 0‑based columns in buffer indices per rendered line
|
||||
- `struct LineHighlight { std::vector<HighlightSpan> spans; uint64_t version; };`
|
||||
|
||||
2. Interfaces (new):
|
||||
- `class LanguageHighlighter { public: virtual ~LanguageHighlighter() = default; virtual void HighlightLine(const Buffer& buf, int row, std::vector<HighlightSpan>& out) const = 0; virtual bool Stateful() const { return false; } };`
|
||||
- `class HighlighterEngine { public: void SetHighlighter(std::unique_ptr<LanguageHighlighter>); const LineHighlight& GetLine(const Buffer&, int row, uint64_t buf_version); void InvalidateFrom(int row); };`
|
||||
- `class HighlighterRegistry { public: static const LanguageHighlighter& ForFiletype(std::string_view ft); static std::string DetectForPath(std::string_view path, std::string_view first_line); };`
|
||||
|
||||
3. Editor/Buffer integration:
|
||||
- Per‑Buffer settings: `bool syntax_enabled; std::string filetype; std::unique_ptr<HighlighterEngine> highlighter;`
|
||||
- Buffer emits a monotonically increasing `version` on edit; renderers request line highlights by `(row, version)`.
|
||||
- Invalidate cache minimally on edits (v1: current line only; v2: from current line down when stateful constructs present).
|
||||
|
||||
### Rendering integration
|
||||
- TerminalRenderer/GUIRenderer changes:
|
||||
- During line rendering, query `Editor.CurrentBuffer()->highlighter->GetLine(buf, row, buf_version)` to obtain spans.
|
||||
- Apply token styles while drawing glyph runs.
|
||||
- Z‑order and blending:
|
||||
1) Backgrounds (e.g., selection, search highlight rectangles)
|
||||
2) Text with syntax colors
|
||||
3) Cursor/IME decorations
|
||||
- Search highlights must remain visible over syntax colors:
|
||||
- Terminal: combine color/attr with reverse/bold for search; if color conflicts, prefer search.
|
||||
- GUI: draw semi‑transparent rects behind text (already present); keep syntax color for text.
|
||||
|
||||
### Theme and color mapping
|
||||
- Extend `GUITheme.h` with a `SyntaxPalette` mapping `TokenKind -> ImVec4 ink` (and optional background tint for comments/strings disabled by default). Provide default Light/Dark palettes.
|
||||
- Terminal: map `TokenKind` to ncurses color pairs where available; degrade gracefully on 8/16‑color terminals (e.g., comments=dim, keywords=bold, strings=yellow/green if available).
|
||||
|
||||
### Language detection
|
||||
- v1: by file extension; allow manual `:set filetype=<lang>`.
|
||||
- v2: add shebang detection for scripts, simple modelines (optional).
|
||||
|
||||
### Commands/UX
|
||||
- `:syntax on|off` — global default; buffer inherits on open.
|
||||
- `:set filetype=<lang>` — per‑buffer override.
|
||||
- `:syntax reload` — rebuild patterns/themes.
|
||||
- Status line shows filetype and syntax state when changed.
|
||||
|
||||
### Implementation plan (phased)
|
||||
1. Phase 1 — Minimal regex highlighter for C/C++
|
||||
- Implement `CppRegexHighlighter : LanguageHighlighter` with precompiled `std::regex` (or hand‑rolled simple scanners to avoid regex backtracking). Classes: line comment `//…`, block comment start `/*` (no state), string `"…"`, char `'…'` (no multiline), numbers, keywords/types, preprocessor `^\s*#\w+`.
|
||||
- Add `HighlighterEngine` with a simple per‑row cache keyed by `(row, buf_version)`; no background worker.
|
||||
- Integrate into both renderers; add palette to `GUITheme.h`; add terminal color selection.
|
||||
- Add commands.
|
||||
|
||||
2. Phase 2 — Stateful constructs and more languages
|
||||
- Add state machine for multiline comments `/*…*/` and multiline strings (C++11 raw strings), with invalidation from edit line downward until state stabilizes.
|
||||
- Add simple highlighters: JSON (strings, numbers, booleans, null, punctuation), Markdown (headers/emphasis/code fences), Shell (comments, strings, keywords), Go (types, constants, keywords), Python (strings, comments, keywords), Rust (strings, comments, keywords), Lisp (comments, strings, keywords),.
|
||||
- Filetype detection by extension + shebang.
|
||||
|
||||
3. Phase 3 — Performance and caching
|
||||
- Viewport‑first highlighting: compute only visible rows each frame; background task warms cache around viewport.
|
||||
- Reuse span buffers, avoid allocations; small‑vector optimization if needed.
|
||||
- Bench with large files; ensure O(n_visible) cost per frame.
|
||||
|
||||
4. Phase 4 — Extensibility
|
||||
- Public registration API for external highlighters.
|
||||
- Optional Tree‑sitter adapter behind a compile flag (off by default) to keep dependencies minimal.
|
||||
|
||||
### Data flow (per frame)
|
||||
- Renderer asks Editor for Buffer and viewport rows.
|
||||
- For each row: `engine.GetLine(buf, row, buf.version)` → spans.
|
||||
- Renderer emits runs with style from `SyntaxPalette[kind]`.
|
||||
- Search highlights are applied as separate background rectangles (GUI) or attribute toggles (Terminal), not overriding text color.
|
||||
|
||||
### Testing
|
||||
- Unit tests for tokenization per language: golden inputs → spans.
|
||||
- Fuzz/edge cases: escaped quotes, numeric literals, preprocessor lines.
|
||||
- Renderer tests with `TestRenderer` asserting the sequence of style changes for a line.
|
||||
- Performance tests: highlight 1k visible lines repeatedly; assert time under threshold.
|
||||
|
||||
### Risks and mitigations
|
||||
- Regex backtracking/perf: prefer linear scans; precompute keyword tables; avoid nested regex.
|
||||
- Terminal color limitations: feature‑detect colors; provide bold/dim fallbacks.
|
||||
- Stateful correctness: invalidate conservatively (from edit line downward) and cap work per frame.
|
||||
|
||||
### Deliverables
|
||||
- New files: `Highlight.h/.cc`, `HighlighterEngine.h/.cc`, `LanguageHighlighter.h`, `CppHighlighter.h/.cc`, optional `HighlighterRegistry.h/.cc`.
|
||||
- Renderer updates: `GUIRenderer.cc`, `TerminalRenderer.cc` to consume spans.
|
||||
- Theming: `GUITheme.h` additions for syntax colors.
|
||||
- Editor/Buffer: per‑buffer syntax settings and highlighter handle.
|
||||
- Commands in `Command.cc` and help text updates.
|
||||
- Docs: README/ROADMAP update and a brief `docs/syntax.md`.
|
||||
- Tests: unit and renderer golden tests.
|
||||
460
test_undo.cc
460
test_undo.cc
@@ -47,14 +47,14 @@ main()
|
||||
// Initialize cursor to (0,0) explicitly
|
||||
buf->SetCursor(0, 0);
|
||||
|
||||
std::cout << "test_undo: Testing undo/redo system\n";
|
||||
std::cout << "====================================\n\n";
|
||||
std::cout << "test_undo: Testing undo/redo system\n";
|
||||
std::cout << "====================================\n\n";
|
||||
|
||||
bool running = true;
|
||||
|
||||
// Test 1: Insert text and verify buffer contains expected text
|
||||
std::cout << "Test 1: Insert text 'Hello'\n";
|
||||
frontend.Input().QueueText("Hello");
|
||||
// Test 1: Insert text and verify buffer contains expected text
|
||||
std::cout << "Test 1: Insert text 'Hello'\n";
|
||||
frontend.Input().QueueText("Hello");
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
@@ -66,9 +66,9 @@ main()
|
||||
std::cout << " Buffer content: '" << line_after_insert << "'\n";
|
||||
std::cout << " ✓ Text insertion verified\n\n";
|
||||
|
||||
// Test 2: Undo insertion - text should be removed
|
||||
std::cout << "Test 2: Undo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
// Test 2: Undo insertion - text should be removed
|
||||
std::cout << "Test 2: Undo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
@@ -80,9 +80,9 @@ main()
|
||||
std::cout << " Buffer content: '" << line_after_undo << "'\n";
|
||||
std::cout << " ✓ Undo successful - text removed\n\n";
|
||||
|
||||
// Test 3: Redo insertion - text should be restored
|
||||
std::cout << "Test 3: Redo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
// Test 3: Redo insertion - text should be restored
|
||||
std::cout << "Test 3: Redo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
@@ -94,242 +94,242 @@ main()
|
||||
std::cout << " Buffer content: '" << line_after_redo << "'\n";
|
||||
std::cout << " ✓ Redo successful - text restored\n\n";
|
||||
|
||||
// Test 4: Branching behavior – redo is discarded after new edits
|
||||
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
|
||||
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
|
||||
// Ensure buffer is empty before starting this scenario
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Test 4: Branching behavior – redo is discarded after new edits
|
||||
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
|
||||
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
|
||||
// Ensure buffer is empty before starting this scenario
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Type a contiguous word 'abc' (single batch)
|
||||
frontend.Input().QueueText("abc");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
// Type a contiguous word 'abc' (single batch)
|
||||
frontend.Input().QueueText("abc");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
|
||||
// Undo once – should remove the whole batch and leave empty
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Undo once – should remove the whole batch and leave empty
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Now type new text 'X' – this should create a new branch and discard old redo chain
|
||||
frontend.Input().QueueText("X");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
// Now type new text 'X' – this should create a new branch and discard old redo chain
|
||||
frontend.Input().QueueText("X");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
|
||||
// Attempt Redo – should be a no-op (redo branch was discarded by new edit)
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
// Undo and Redo along the new branch should still work
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
|
||||
// Attempt Redo – should be a no-op (redo branch was discarded by new edit)
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
// Undo and Redo along the new branch should still work
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
|
||||
|
||||
// Clear buffer state for next tests: undo to empty if needed
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Clear buffer state for next tests: undo to empty if needed
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Test 5: UTF-8 insertion and undo/redo round-trip
|
||||
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
|
||||
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
|
||||
frontend.Input().QueueText(utf8_text);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
// Undo should remove the entire contiguous insertion batch
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Redo restores it
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
|
||||
// Test 5: UTF-8 insertion and undo/redo round-trip
|
||||
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
|
||||
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
|
||||
frontend.Input().QueueText(utf8_text);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
// Undo should remove the entire contiguous insertion batch
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Redo restores it
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
|
||||
|
||||
// Clear for next test
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Clear for next test
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
|
||||
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
|
||||
// Insert "ab" then newline then "cd" → expect two lines
|
||||
frontend.Input().QueueText("ab");
|
||||
frontend.Input().QueueCommand(CommandId::Newline);
|
||||
frontend.Input().QueueText("cd");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Split into two lines\n";
|
||||
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
|
||||
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
|
||||
// Insert "ab" then newline then "cd" → expect two lines
|
||||
frontend.Input().QueueText("ab");
|
||||
frontend.Input().QueueCommand(CommandId::Newline);
|
||||
frontend.Input().QueueText("cd");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Split into two lines\n";
|
||||
|
||||
// Undo once – should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Current design batches typing on the second line; after undo, the second line should exist but be empty
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "");
|
||||
// Undo once – should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Current design batches typing on the second line; after undo, the second line should exist but be empty
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "");
|
||||
|
||||
// Undo the newline – should rejoin to a single line "ab"
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
// Undo the newline – should rejoin to a single line "ab"
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
|
||||
// Redo twice to get back to ["ab","cd"]
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Newline undo/redo round-trip\n";
|
||||
// Redo twice to get back to ["ab","cd"]
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Newline undo/redo round-trip\n";
|
||||
|
||||
// Now join via Backspace at beginning of second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
|
||||
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Backspace at BOL joins lines\n";
|
||||
// Now join via Backspace at beginning of second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
|
||||
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Backspace at BOL joins lines\n";
|
||||
|
||||
// Undo/Redo the join
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Join undo/redo round-trip\n\n";
|
||||
// Undo/Redo the join
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Join undo/redo round-trip\n\n";
|
||||
|
||||
// Test 7: Typing batching – a contiguous word undone in one step
|
||||
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
|
||||
// Clear current line first
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
// Type a word and verify one undo clears it
|
||||
frontend.Input().QueueText("hello");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
|
||||
// Test 7: Typing batching – a contiguous word undone in one step
|
||||
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
|
||||
// Clear current line first
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
// Type a word and verify one undo clears it
|
||||
frontend.Input().QueueText("hello");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
|
||||
|
||||
// Test 8: Forward delete batching at a fixed anchor column
|
||||
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
|
||||
// Prepare line content
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
frontend.Input().QueueText("abcdef");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Ensure cursor at anchor column 0
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
// Delete three chars at cursor; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
// Single undo should restore the entire deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo should remove the same run again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
|
||||
// Test 8: Forward delete batching at a fixed anchor column
|
||||
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
|
||||
// Prepare line content
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
frontend.Input().QueueText("abcdef");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Ensure cursor at anchor column 0
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
// Delete three chars at cursor; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
// Single undo should restore the entire deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo should remove the same run again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
|
||||
|
||||
// Test 9: Backspace batching with prepend rule (cursor moves left)
|
||||
std::cout << "Test 9: Backspace batching with prepend rule\n";
|
||||
// Restore to full string then backspace a run
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Move to end and backspace three characters; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::MoveEnd);
|
||||
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
// Single undo restores the deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo removes it again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
|
||||
// Test 9: Backspace batching with prepend rule (cursor moves left)
|
||||
std::cout << "Test 9: Backspace batching with prepend rule\n";
|
||||
// Restore to full string then backspace a run
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Move to end and backspace three characters; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::MoveEnd);
|
||||
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
// Single undo restores the deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo removes it again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
|
||||
|
||||
frontend.Shutdown();
|
||||
frontend.Shutdown();
|
||||
|
||||
std::cout << "====================================\n";
|
||||
std::cout << "All tests passed!\n";
|
||||
|
||||
Reference in New Issue
Block a user