diff --git a/.idea/workspace.xml b/.idea/workspace.xml index b240c10..d74344c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -22,31 +22,40 @@ + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + @@ -124,7 +142,7 @@ diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 9d208c0..2b32fd8 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -11,7 +11,7 @@ development practices for kte. ## Goals - Keep the core small, fast, and understandable. -- Provide a terminal-first editing experience, with an optional ImGui GUI. +- Provide an ncurses-based terminal-first editing experience, with an optional ImGui GUI. - Preserve familiar keybindings from ke while modernizing the internals. - Favor simple data structures (e.g., gap buffer) and incremental evolution. @@ -22,7 +22,10 @@ development practices for kte. ## Build and Run -Prerequisites: a C++17 compiler and CMake. +Prerequisites: a C++17 compiler, CMake, and ncurses development headers/libs. + +- macOS (Homebrew): `brew install ncurses` +- Debian/Ubuntu: `sudo apt-get install libncurses5-dev libncursesw5-dev` - Configure and build (example): - `cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug` @@ -38,9 +41,9 @@ Project entry point: `main.cpp` - GapBuffer: editable in-memory text representation (`GapBuffer.h/.cpp`). - PieceTable: experimental/alternative representation (`PieceTable.h/.cpp`). - InputHandler: interface for handling text input (`InputHandler.h/`), along - with `TerminalInputHandler` and `GUIInputHandler`. + with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`. - Renderer: interface for rendering text (`Renderer.h`), along with - `TerminalRenderer` and `GUIRenderer`. + `TerminalRenderer` (ncurses-based) and `GUIRenderer`. - Editor: top-level editor state (`Editor.h/.cpp`). - Command: command model (`Command.h/.cpp`). - General purpose editor functionality (`Editing.h/.cpp`) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9fb5d53..e9a7d4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,8 +2,11 @@ cmake_minimum_required(VERSION 3.15) project(kte) set(CMAKE_CXX_STANDARD 17) +set (KTE_VERSION "0.0.1") -set(BUILD_GUI ON CACHE BOOL "Disable building the graphical version.") +# Default to terminal-only build to avoid SDL/OpenGL dependency by default. +# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. +set(BUILD_GUI OFF CACHE BOOL "Enable building the graphical version.") option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" OFF) @@ -20,7 +23,6 @@ else () "-Wall" "-Wextra" "-Werror" - "-static" "$<$:-g>" "$<$:-O2>") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") @@ -38,11 +40,19 @@ if (${BUILD_GUI}) include(cmake/imgui.cmake) endif () +# NCurses for terminal mode +find_package(Curses REQUIRED) +include_directories(${CURSES_INCLUDE_DIR}) + set(SOURCES GapBuffer.cpp PieceTable.cpp Buffer.cpp Editor.cpp + Command.cpp + TerminalInputHandler.cpp + TerminalRenderer.cpp + TerminalFrontend.cpp ) set(HEADERS @@ -51,6 +61,13 @@ set(HEADERS Buffer.h Editor.h AppendBuffer.h + Command.h + InputHandler.h + TerminalInputHandler.h + Renderer.h + TerminalRenderer.h + Frontend.h + TerminalFrontend.h ) add_executable(kte @@ -63,6 +80,16 @@ if (KTE_USE_PIECE_TABLE) target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1) endif () +target_link_libraries(kte ${CURSES_LIBRARIES}) + if (${BUILD_GUI}) - target_link_libraries(kge imgui) + target_sources(kte PRIVATE + GUIRenderer.cpp + GUIRenderer.h + GUIInputHandler.cpp + GUIInputHandler.h + GUIFrontend.cpp + GUIFrontend.h) + target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1) + target_link_libraries(kte imgui) endif () diff --git a/Command.cpp b/Command.cpp new file mode 100644 index 0000000..cad5802 --- /dev/null +++ b/Command.cpp @@ -0,0 +1,466 @@ +#include "Command.h" + +#include + +#include "Editor.h" +#include "Buffer.h" + +// Keep buffer viewport offsets so that the cursor stays within the visible +// window based on the editor's current dimensions. The bottom row is reserved +// for the status line. +static void ensure_cursor_visible(Editor &ed, Buffer &buf) +{ + std::size_t rows = ed.Rows(); + std::size_t cols = ed.Cols(); + if (rows == 0 || cols == 0) return; + + std::size_t content_rows = rows > 0 ? rows - 1 : 0; // last row = status + std::size_t cury = buf.Cury(); + std::size_t curx = buf.Curx(); + std::size_t rowoffs = buf.Rowoffs(); + std::size_t coloffs = buf.Coloffs(); + + // Vertical scrolling + if (cury < rowoffs) { + rowoffs = cury; + } else if (content_rows > 0 && cury >= rowoffs + content_rows) { + rowoffs = cury - content_rows + 1; + } + + // Clamp vertical offset to available content + const auto total_rows = buf.Rows().size(); + if (content_rows < total_rows) { + std::size_t max_rowoffs = total_rows - content_rows; + if (rowoffs > max_rowoffs) rowoffs = max_rowoffs; + } else { + rowoffs = 0; + } + + // Horizontal scrolling + if (curx < coloffs) { + coloffs = curx; + } else if (curx >= coloffs + cols) { + coloffs = curx - cols + 1; + } + + buf.SetOffsets(rowoffs, coloffs); +} + +static void ensure_at_least_one_line(Buffer &buf) +{ + if (buf.Rows().empty()) { + buf.Rows().push_back(""); + buf.SetDirty(true); + } +} + +// (helper removed) + +// --- File/Session commands --- +static bool cmd_save(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to save"); + return false; + } + std::string err; + if (!buf->IsFileBacked()) { + ctx.editor.SetStatus("Buffer is not file-backed; use save-as"); + return false; + } + if (!buf->Save(err)) { + ctx.editor.SetStatus(err); + return false; + } + buf->SetDirty(false); + ctx.editor.SetStatus("Saved " + buf->Filename()); + return true; +} + + +static bool cmd_save_as(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to save"); + return false; + } + if (ctx.arg.empty()) { + ctx.editor.SetStatus("save-as requires a filename"); + return false; + } + std::string err; + if (!buf->SaveAs(ctx.arg, err)) { + ctx.editor.SetStatus(err); + return false; + } + ctx.editor.SetStatus("Saved as " + ctx.arg); + return true; +} + + +static bool cmd_quit(CommandContext &ctx) +{ + // Placeholder: actual app loop should react to this status or a future flag + ctx.editor.SetStatus("Quit requested"); + return true; +} + + +static bool cmd_save_and_quit(CommandContext &ctx) +{ + // Try save current buffer (if any), then mark quit requested. + Buffer *buf = ctx.editor.CurrentBuffer(); + if (buf && buf->Dirty()) { + std::string err; + if (buf->IsFileBacked()) { + if (buf->Save(err)) { + buf->SetDirty(false); + } else { + ctx.editor.SetStatus(err); + return false; + } + } else { + ctx.editor.SetStatus("Buffer not file-backed; use save-as before quitting"); + return false; + } + } + ctx.editor.SetStatus("Save and quit requested"); + return true; +} + +static bool cmd_refresh(CommandContext &ctx) +{ + // Placeholder: renderer will handle this in Milestone 3 + ctx.editor.SetStatus("Refresh requested"); + return true; +} + +static bool cmd_find_start(CommandContext &ctx) +{ + // Placeholder for incremental search start + ctx.editor.SetStatus("Find (incremental) start"); + return true; +} + + +// --- Editing --- +static bool cmd_insert_text(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to edit"); + return false; + } + // Disallow newlines in InsertText; they should come via Newline + if (ctx.arg.find('\n') != std::string::npos || ctx.arg.find('\r') != std::string::npos) { + ctx.editor.SetStatus("InsertText arg must not contain newlines"); + return false; + } + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + if (y >= rows.size()) { + rows.resize(y + 1); + } + int repeat = ctx.count > 0 ? ctx.count : 1; + for (int i = 0; i < repeat; ++i) { + rows[y].insert(x, ctx.arg); + x += ctx.arg.size(); + } + buf->SetCursor(x, y); + buf->SetDirty(true); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_newline(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to edit"); + return false; + } + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + for (int i = 0; i < repeat; ++i) { + if (y >= rows.size()) rows.resize(y + 1); + std::string &line = rows[y]; + std::string tail; + if (x < line.size()) { + tail = line.substr(x); + line.erase(x); + } + rows.insert(rows.begin() + static_cast(y + 1), tail); + y += 1; + x = 0; + } + buf->SetCursor(x, y); + buf->SetDirty(true); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_backspace(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to edit"); + return false; + } + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + for (int i = 0; i < repeat; ++i) { + if (x > 0) { + rows[y].erase(x - 1, 1); + --x; + } else if (y > 0) { + // join with previous line + std::size_t prev_len = rows[y - 1].size(); + rows[y - 1] += rows[y]; + rows.erase(rows.begin() + static_cast(y)); + y = y - 1; + x = prev_len; + } else { + // at very start; nothing to do + break; + } + } + buf->SetCursor(x, y); + buf->SetDirty(true); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_delete_char(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer to edit"); + return false; + } + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + for (int i = 0; i < repeat; ++i) { + if (y >= rows.size()) break; + if (x < rows[y].size()) { + rows[y].erase(x, 1); + } else if (y + 1 < rows.size()) { + // join next line + rows[y] += rows[y + 1]; + rows.erase(rows.begin() + static_cast(y + 1)); + } else { + break; + } + } + buf->SetDirty(true); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +// --- Navigation --- +// (helper removed) + +static bool cmd_move_left(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + while (repeat-- > 0) { + if (x > 0) { + --x; + } else if (y > 0) { + --y; + x = rows[y].size(); + } + } + buf->SetCursor(x, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_move_right(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + while (repeat-- > 0) { + if (y < rows.size() && x < rows[y].size()) { + ++x; + } else if (y + 1 < rows.size()) { + ++y; + x = 0; + } + } + buf->SetCursor(x, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_move_up(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + if (repeat > static_cast(y)) repeat = static_cast(y); + y -= static_cast(repeat); + if (x > rows[y].size()) x = rows[y].size(); + buf->SetCursor(x, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_move_down(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = buf->Curx(); + int repeat = ctx.count > 0 ? ctx.count : 1; + std::size_t max_down = rows.size() - 1 - y; + if (repeat > static_cast(max_down)) repeat = static_cast(max_down); + y += static_cast(repeat); + if (x > rows[y].size()) x = rows[y].size(); + buf->SetCursor(x, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_move_home(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + std::size_t y = buf->Cury(); + buf->SetCursor(0, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + +static bool cmd_move_end(CommandContext &ctx) +{ + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) return false; + ensure_at_least_one_line(*buf); + auto &rows = buf->Rows(); + std::size_t y = buf->Cury(); + std::size_t x = (y < rows.size()) ? rows[y].size() : 0; + buf->SetCursor(x, y); + ensure_cursor_visible(ctx.editor, *buf); + return true; +} + + +std::vector & +CommandRegistry::storage_() +{ + static std::vector cmds; + return cmds; +} + + +void +CommandRegistry::Register(const Command &cmd) +{ + auto &v = storage_(); + // Replace existing with same id or name + auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) { + return c.id == cmd.id || c.name == cmd.name; + }); + if (it != v.end()) { + *it = cmd; + } else { + v.push_back(cmd); + } +} + + +const Command * +CommandRegistry::FindById(CommandId id) +{ + auto &v = storage_(); + auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) { return c.id == id; }); + return it == v.end() ? nullptr : &*it; +} + + +const Command * +CommandRegistry::FindByName(const std::string &name) +{ + auto &v = storage_(); + auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) { return c.name == name; }); + return it == v.end() ? nullptr : &*it; +} + + +const std::vector & +CommandRegistry::All() +{ + return storage_(); +} + + +void +InstallDefaultCommands() +{ + CommandRegistry::Register({CommandId::Save, "save", "Save current buffer", cmd_save}); + CommandRegistry::Register({CommandId::SaveAs, "save-as", "Save current buffer as...", cmd_save_as}); + CommandRegistry::Register({CommandId::Quit, "quit", "Quit editor (request)", cmd_quit}); + CommandRegistry::Register({CommandId::SaveAndQuit, "save-quit", "Save and quit (request)", cmd_save_and_quit}); + CommandRegistry::Register({CommandId::Refresh, "refresh", "Force redraw", cmd_refresh}); + CommandRegistry::Register({CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start}); + // Editing + CommandRegistry::Register({CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text}); + CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline}); + CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace}); + CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char}); + // Navigation + CommandRegistry::Register({CommandId::MoveLeft, "left", "Move cursor left", cmd_move_left}); + CommandRegistry::Register({CommandId::MoveRight, "right", "Move cursor right", cmd_move_right}); + CommandRegistry::Register({CommandId::MoveUp, "up", "Move cursor up", cmd_move_up}); + CommandRegistry::Register({CommandId::MoveDown, "down", "Move cursor down", cmd_move_down}); + CommandRegistry::Register({CommandId::MoveHome, "home", "Move to beginning of line", cmd_move_home}); + CommandRegistry::Register({CommandId::MoveEnd, "end", "Move to end of line", cmd_move_end}); +} + + +bool Execute(Editor &ed, CommandId id, const std::string &arg, int count) +{ + const Command *cmd = CommandRegistry::FindById(id); + if (!cmd) return false; + CommandContext ctx{ed, arg, count}; + return cmd->handler ? cmd->handler(ctx) : false; +} + +bool Execute(Editor &ed, const std::string &name, const std::string &arg, int count) +{ + const Command *cmd = CommandRegistry::FindByName(name); + if (!cmd) return false; + CommandContext ctx{ed, arg, count}; + return cmd->handler ? cmd->handler(ctx) : false; +} diff --git a/Command.h b/Command.h new file mode 100644 index 0000000..0f3ac8a --- /dev/null +++ b/Command.h @@ -0,0 +1,86 @@ +/* + * Command.h - command model and registry for editor actions + */ +#ifndef KTE_COMMAND_H +#define KTE_COMMAND_H + +#include +#include +#include +#include + +class Editor; + +// Identifiers for editor commands. This is intentionally small for now and +// will grow as features are implemented. +enum class CommandId { + Save, + SaveAs, + // Placeholders for future commands from ke's model + Quit, + SaveAndQuit, + Refresh, // force redraw + FindStart, // begin incremental search (placeholder) + // Editing + InsertText, // arg: text to insert at cursor (UTF-8, no newlines) + Newline, // insert a newline at cursor + Backspace, // delete char before cursor (may join lines) + DeleteChar, // delete char at cursor (may join lines) + // Navigation (basic) + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveHome, + MoveEnd, +}; + + +// Context passed to command handlers. +struct CommandContext { + Editor &editor; + + // Optional argument string (e.g., filename for SaveAs). + std::string arg; + + // Optional repeat count (C-u support). 0 means not provided. + int count = 0; +}; + + +using CommandHandler = std::function; // return true on success + + +struct Command { + CommandId id; + std::string name; // stable, unique name (e.g., "save", "save-as") + std::string help; // short help/description + CommandHandler handler; +}; + + +// Simple global registry. Not thread-safe; suitable for this app. +class CommandRegistry { +public: + static void Register(const Command &cmd); + + static const Command *FindById(CommandId id); + + static const Command *FindByName(const std::string &name); + + static const std::vector &All(); + +private: + static std::vector &storage_(); +}; + + +// Built-in command installers +void InstallDefaultCommands(); + +// Dispatcher entry points for the input layer +// Returns true if the command executed successfully. +bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0); +bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0); + +#endif // KTE_COMMAND_H diff --git a/Frontend.h b/Frontend.h new file mode 100644 index 0000000..414865a --- /dev/null +++ b/Frontend.h @@ -0,0 +1,27 @@ +/* + * Frontend.h - top-level container that couples Input + Renderer and runs the loop + */ +#ifndef KTE_FRONTEND_H +#define KTE_FRONTEND_H + +#include + +class Editor; +class InputHandler; +class Renderer; + +class Frontend { +public: + virtual ~Frontend() = default; + + // Initialize the frontend (create window/terminal, etc.) + virtual bool Init(Editor &ed) = 0; + + // Execute one iteration (poll input, dispatch, draw). Set running=false to exit. + virtual void Step(Editor &ed, bool &running) = 0; + + // Shutdown/cleanup + virtual void Shutdown() = 0; +}; + +#endif // KTE_FRONTEND_H diff --git a/GUIFrontend.cpp b/GUIFrontend.cpp new file mode 100644 index 0000000..dce1833 --- /dev/null +++ b/GUIFrontend.cpp @@ -0,0 +1,121 @@ +#include "GUIFrontend.h" + +#include +#include + +#include +#include +#include + +#include "Editor.h" +#include "Command.h" + +static const char *kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible) + +bool GUIFrontend::Init(Editor &ed) +{ + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) { + return false; + } + + // GL attributes for core profile + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); + SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); + + window_ = SDL_CreateWindow( + "kte", + SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, + width_, height_, + SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI); + if (!window_) return false; + + gl_ctx_ = SDL_GL_CreateContext(window_); + if (!gl_ctx_) return false; + SDL_GL_MakeCurrent(window_, gl_ctx_); + SDL_GL_SetSwapInterval(1); // vsync + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); (void)io; + ImGui::StyleColorsDark(); + + if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_)) return false; + if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) return false; + + // Initialize editor reported dimensions to pixels for now + int w, h; SDL_GetWindowSize(window_, &w, &h); + width_ = w; height_ = h; + ed.SetDimensions(static_cast(height_), static_cast(width_)); + + return true; +} + +void GUIFrontend::Step(Editor &ed, bool &running) +{ + SDL_Event e; + while (SDL_PollEvent(&e)) { + ImGui_ImplSDL2_ProcessEvent(&e); + switch (e.type) { + case SDL_QUIT: + running = false; + break; + case SDL_WINDOWEVENT: + if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { + width_ = e.window.data1; + height_ = e.window.data2; + ed.SetDimensions(static_cast(height_), static_cast(width_)); + } + break; + default: + break; + } + // Map input to commands + input_.ProcessSDLEvent(e); + } + + // Execute pending mapped inputs (drain queue) + for (;;) { + MappedInput mi; + if (!input_.Poll(mi)) break; + if (mi.hasCommand) { + Execute(ed, mi.id, mi.arg, mi.count); + if (mi.id == CommandId::Quit || mi.id == CommandId::SaveAndQuit) { + running = false; + } + } + } + + // Start a new ImGui frame + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL2_NewFrame(window_); + ImGui::NewFrame(); + + // Draw editor UI + renderer_.Draw(ed); + + // Render + ImGui::Render(); + int display_w, display_h; + SDL_GL_GetDrawableSize(window_, &display_w, &display_h); + glViewport(0, 0, display_w, display_h); + glClearColor(0.1f, 0.1f, 0.11f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + SDL_GL_SwapWindow(window_); +} + +void GUIFrontend::Shutdown() +{ + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplSDL2_Shutdown(); + ImGui::DestroyContext(); + + if (gl_ctx_) { SDL_GL_DeleteContext(gl_ctx_); gl_ctx_ = nullptr; } + if (window_) { SDL_DestroyWindow(window_); window_ = nullptr; } + SDL_Quit(); +} diff --git a/GUIFrontend.h b/GUIFrontend.h new file mode 100644 index 0000000..d2ec371 --- /dev/null +++ b/GUIFrontend.h @@ -0,0 +1,32 @@ +/* + * GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle + */ +#ifndef KTE_GUI_FRONTEND_H +#define KTE_GUI_FRONTEND_H + +#include "Frontend.h" +#include "GUIInputHandler.h" +#include "GUIRenderer.h" + +struct SDL_Window; +typedef void *SDL_GLContext; + +class GUIFrontend : public Frontend { +public: + GUIFrontend() = default; + ~GUIFrontend() override = default; + + bool Init(Editor &ed) override; + void Step(Editor &ed, bool &running) override; + void Shutdown() override; + +private: + GUIInputHandler input_{}; + GUIRenderer renderer_{}; + SDL_Window *window_ = nullptr; + SDL_GLContext gl_ctx_ = nullptr; + int width_ = 1280; + int height_ = 800; +}; + +#endif // KTE_GUI_FRONTEND_H diff --git a/GUIInputHandler.cpp b/GUIInputHandler.cpp new file mode 100644 index 0000000..e6f87d1 --- /dev/null +++ b/GUIInputHandler.cpp @@ -0,0 +1,92 @@ +#include "GUIInputHandler.h" + +#include + +static bool map_key(SDL_Keycode key, SDL_Keymod mod, bool &k_prefix, MappedInput &out) +{ + // Ctrl handling + bool is_ctrl = (mod & KMOD_CTRL) != 0; + + // Movement and basic keys + switch (key) { + case SDLK_LEFT: out = {true, CommandId::MoveLeft, "", 0}; return true; + case SDLK_RIGHT: out = {true, CommandId::MoveRight, "", 0}; return true; + case SDLK_UP: out = {true, CommandId::MoveUp, "", 0}; return true; + case SDLK_DOWN: out = {true, CommandId::MoveDown, "", 0}; return true; + case SDLK_HOME: out = {true, CommandId::MoveHome, "", 0}; return true; + case SDLK_END: out = {true, CommandId::MoveEnd, "", 0}; return true; + case SDLK_DELETE: out = {true, CommandId::DeleteChar, "", 0}; return true; + case SDLK_BACKSPACE: out = {true, CommandId::Backspace, "", 0}; return true; + case SDLK_RETURN: case SDLK_KP_ENTER: out = {true, CommandId::Newline, "", 0}; return true; + case SDLK_ESCAPE: k_prefix = false; out = {true, CommandId::Refresh, "", 0}; return true; + default: break; + } + + if (is_ctrl) { + switch (key) { + case SDLK_k: case SDLK_KP_EQUALS: // treat Ctrl-K + k_prefix = true; + out = {true, CommandId::Refresh, "", 0}; + return true; + case SDLK_g: + k_prefix = false; + out = {true, CommandId::Refresh, "", 0}; + return true; + case SDLK_l: out = {true, CommandId::Refresh, "", 0}; return true; + case SDLK_s: out = {true, CommandId::FindStart, "", 0}; return true; + case SDLK_q: out = {true, CommandId::Quit, "", 0}; return true; + case SDLK_x: out = {true, CommandId::SaveAndQuit, "", 0}; return true; + default: break; + } + } + + if (k_prefix) { + k_prefix = false; + switch (key) { + case SDLK_s: out = {true, CommandId::Save, "", 0}; return true; + case SDLK_x: out = {true, CommandId::SaveAndQuit, "", 0}; return true; + case SDLK_q: out = {true, CommandId::Quit, "", 0}; return true; + default: break; + } + out.hasCommand = false; + return true; + } + + return false; +} + +bool GUIInputHandler::ProcessSDLEvent(const SDL_Event &e) +{ + MappedInput mi; + bool produced = false; + switch (e.type) { + case SDL_KEYDOWN: + produced = map_key(e.key.keysym.sym, SDL_Keymod(e.key.keysym.mod), k_prefix_, mi); + break; + case SDL_TEXTINPUT: + if (e.text.text[0] != '\0') { + mi.hasCommand = true; + mi.id = CommandId::InsertText; + mi.arg = std::string(e.text.text); + mi.count = 0; + produced = true; + } + break; + default: + break; + } + if (produced && mi.hasCommand) { + std::lock_guard lk(mu_); + q_.push(mi); + } + return produced; +} + +bool GUIInputHandler::Poll(MappedInput &out) +{ + std::lock_guard lk(mu_); + if (q_.empty()) return false; + out = q_.front(); + q_.pop(); + return true; +} diff --git a/GUIInputHandler.h b/GUIInputHandler.h new file mode 100644 index 0000000..d3820a7 --- /dev/null +++ b/GUIInputHandler.h @@ -0,0 +1,31 @@ +/* + * GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode + */ +#ifndef KTE_GUI_INPUT_HANDLER_H +#define KTE_GUI_INPUT_HANDLER_H + +#include +#include + +#include "InputHandler.h" + +union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union) + +class GUIInputHandler : public InputHandler { +public: + GUIInputHandler() = default; + ~GUIInputHandler() override = default; + + // Translate an SDL event to editor command and enqueue if applicable. + // Returns true if it produced a mapped command or consumed input. + bool ProcessSDLEvent(const SDL_Event &e); + + bool Poll(MappedInput &out) override; + +private: + std::mutex mu_; + std::queue q_; + bool k_prefix_ = false; +}; + +#endif // KTE_GUI_INPUT_HANDLER_H diff --git a/GUIRenderer.cpp b/GUIRenderer.cpp new file mode 100644 index 0000000..ab56a09 --- /dev/null +++ b/GUIRenderer.cpp @@ -0,0 +1,37 @@ +#include "GUIRenderer.h" + +#include "Editor.h" +#include "Buffer.h" + +#include + +void GUIRenderer::Draw(const Editor &ed) +{ + ImGui::Begin("kte"); + + const Buffer *buf = ed.CurrentBuffer(); + if (!buf) { + ImGui::TextUnformatted("[no buffer]"); + } else { + const auto &lines = buf->Rows(); + // Reserve space for status bar at bottom + ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false, ImGuiWindowFlags_HorizontalScrollbar); + std::size_t rowoffs = buf->Rowoffs(); + for (std::size_t i = rowoffs; i < lines.size(); ++i) { + ImGui::TextUnformatted(lines[i].c_str()); + } + ImGui::EndChild(); + + // Status bar + ImGui::Separator(); + const char *fname = (buf->IsFileBacked()) ? buf->Filename().c_str() : "(new)"; + bool dirty = buf->Dirty(); + ImGui::Text("%s%s %zux%zu %s", + fname, + dirty ? "*" : "", + ed.Rows(), ed.Cols(), + ed.Status().c_str()); + } + + ImGui::End(); +} diff --git a/GUIRenderer.h b/GUIRenderer.h new file mode 100644 index 0000000..96a5f4a --- /dev/null +++ b/GUIRenderer.h @@ -0,0 +1,17 @@ +/* + * GUIRenderer - ImGui-based renderer for GUI mode + */ +#ifndef KTE_GUI_RENDERER_H +#define KTE_GUI_RENDERER_H + +#include "Renderer.h" + +class GUIRenderer : public Renderer { +public: + GUIRenderer() = default; + ~GUIRenderer() override = default; + + void Draw(const Editor &ed) override; +}; + +#endif // KTE_GUI_RENDERER_H diff --git a/InputHandler.h b/InputHandler.h new file mode 100644 index 0000000..cd6ef83 --- /dev/null +++ b/InputHandler.h @@ -0,0 +1,28 @@ +/* + * InputHandler.h - input abstraction and mapping to commands + */ +#ifndef KTE_INPUT_HANDLER_H +#define KTE_INPUT_HANDLER_H + +#include + +#include "Command.h" + +// Result of translating raw input into an editor command. +struct MappedInput { + bool hasCommand = false; + CommandId id = CommandId::Refresh; + std::string arg; // optional argument (e.g., text for InsertText) + int count = 0; // optional repeat (C-u not yet implemented) +}; + +class InputHandler { +public: + virtual ~InputHandler() = default; + + // Poll for input and translate it to a command. Non-blocking. + // Returns true if a command is available in 'out'. Returns false if no input. + virtual bool Poll(MappedInput &out) = 0; +}; + +#endif // KTE_INPUT_HANDLER_H diff --git a/README.md b/README.md index 30fd3f7..0e564fa 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ Interfaces ---------- - CLI: the primary interface. `kte [files]` starts in the terminal, - adopting your `$TERM` capabilities. + adopting your `$TERM` capabilities. Terminal mode is implemented + using ncurses. - GUI: an optional ImGui‑based frontend that embeds the same editor core. @@ -130,7 +131,41 @@ See `ke.md` for the canonical ke reference retained for now. Build and Run ------------- -Prerequisites: C++17 compiler and CMake. +Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs. + +Dependencies by platform +------------------------ + +- macOS (Homebrew) + - Terminal (default): + - `brew install ncurses` + - Optional GUI (enable with `-DBUILD_GUI=ON`): + - `brew install sdl2 freetype` + - OpenGL is provided by the system framework on macOS; no package needed. + +- Debian/Ubuntu + - Terminal (default): + - `sudo apt-get install -y libncurses5-dev libncursesw5-dev` + - Optional GUI (enable with `-DBUILD_GUI=ON`): + - `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev` + - The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`). + +- NixOS/Nix + - Terminal (default): + - Ad-hoc shell: `nix-shell -p cmake gcc ncurses` + - Optional GUI (enable with `-DBUILD_GUI=ON`): + - Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL` + - With flakes/devshell (example `flake.nix` inputs not provided): include + `ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell. + +Notes +----- + +- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by + configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are + installed for your platform. +- If you previously configured with GUI ON and want to disable it, reconfigure + the build directory with `-DBUILD_GUI=OFF`. Example build: @@ -145,13 +180,50 @@ Run: ./cmake-build-debug/kte [files] ``` +CLI usage +--------- + +``` +kte [OPTIONS] [files] + +Options: + -g, --gui Use GUI frontend (if built) + -t, --term Use terminal (ncurses) frontend [default] + -h, --help Show help and exit + -V, --version Show version and exit +``` + +Examples: + +``` +# Terminal (default) +kte foo.txt bar.txt + +# Explicit terminal +kte -t foo.txt + +# GUI (requires building with -DBUILD_GUI=ON and GUI deps installed) +kte --gui foo.txt +``` + +GUI build example +----------------- + +To build with the optional GUI (after installing the GUI dependencies listed above): + +``` +cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON +cmake --build cmake-build-debug +./cmake-build-debug/kte --gui [files] +``` + Status ------ - The project is under active evolution toward the above architecture - and UX. The terminal interface is the leading target; GUI work will - follow as a thin, optional layer. ke compatibility remains a primary - constraint while internals modernize. + and UX. The terminal interface now uses ncurses for input and + rendering. GUI work will follow as a thin, optional layer. ke + compatibility remains a primary constraint while internals modernize. Roadmap (high level) -------------------- @@ -161,7 +233,7 @@ Roadmap (high level) 2. Introduce structured undo/redo and search/replace with highlighting. 3. Stabilize terminal renderer and input handling across common - terminals. + terminals. (initial ncurses implementation landed) 4. Add piece table as an alternative backend with runtime selection per buffer. 5. Optional GUI frontend using ImGui; shared command palette. @@ -174,4 +246,4 @@ References - [ke](https://git.wntrmute.dev/kyle/ke) manual and keybinding reference: `ke.md` -- Inspirations: Antirez’ kilo, WordStar/VDE, Emacs, and `mg(1)` +- Inspirations: Antirez’ kilo, WordStar/VDE, Emacs, and `mg(1)` \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..85629ac --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,97 @@ +kte ROADMAP — from skeleton to a working editor + +Scope for “working editor” v0.1 +- Runs in a terminal; opens files passed on the CLI or an empty buffer. +- Basic navigation, insert/delete, newline handling. +- Status line and message area; shows filename, dirty flag, cursor position. +- Save file(s) to disk safely; quit/confirm on dirty buffers. +- Core ke key chords: C-g (cancel), C-k s/x/q/C-q, C-l, basic arrows, Enter/Backspace, C-s (simple find). + +Guiding principles +- Keep the core small and understandable; evolve incrementally. +- Separate model (Buffer/Editor), control (Input/Command), and view (Renderer). +- Favor terminal first; GUI hooks arrive later behind interfaces. + +✓ Milestone 0 — Wire up a minimal app shell +1. main.cpp + - Replace demo printing with real startup using `Editor`. + - Parse CLI args; open each path into a buffer (create empty if none). ✓ when `kte file1 file2` loads buffers and exits cleanly. +2. Editor integration + - Ensure `Editor` can open/switch/close buffers and hold status messages. + - Add a temporary “headless loop” to prove open/save calls work. + +✓ Milestone 1 — Command model +1. Command vocabulary + - Flesh out `Command.h/.cpp`: enums/struct for operations and data (e.g., InsertChar, MoveCursor, Save, Quit, FindNext, etc.). + - Provide a dispatcher entry point callable from the input layer to mutate `Editor`/`Buffer`. + - Definition of done: commands exist for minimal edit/navigation/save/quit; no rendering yet. + +✓ Milestone 2 — Terminal input +1. Input interfaces + - Add `InputHandler.h` interface plus `TerminalInputHandler` implementation. + - Terminal input via ncurses (`getch`, `keypad`, non‑blocking with `nodelay`), basic key decoding (arrows, Ctrl, ESC sequences). +2. Keymap + - Map ke chords to `Command` (C-k prefix handling, C-g cancel, C-l refresh, C-k s/x/q/C-q, C-s find start, text input → InsertChar). +3. Event loop + - Introduce the core loop in main: read key → translate to `Command` → dispatch → trigger render. + +Milestone 3 — Terminal renderer +1. View interfaces + - Add `Renderer.h` with `TerminalRenderer` implementation (ncurses‑based). +2. Minimal draw + - Render viewport lines from current buffer; draw status bar (filename, dirty, row:col, message). + - Handle scrolling when cursor moves past edges; support window resize (SIGWINCH). +3. Cursor + - Place terminal cursor at logical buffer location (account for tabs later; start with plain text). + +Milestone 4 — Buffer fundamentals to support editing +1. GapBuffer + - Ensure `GapBuffer` supports insert char, backspace, delete, newline, and efficient cursor moves. +2. Buffer API + - File I/O (open/save), dirty tracking, encoding/line ending kept simple (UTF‑8, LF) for v0.1. + - Cursor state, mark (optional later), and viewport bookkeeping. +3. Basic motions + - Left/Right/Up/Down, Home/End, PageUp/PageDown; word f/b (optional in v0.1). + +Milestone 5 — Core editing loop complete +1. Tighten loop timing + - Ensure keystroke→update→render latency is reliably low; avoid unnecessary redraws. +2. Status/messages + - `Editor::SetStatus()` shows transient messages; C-l forces full refresh. +3. Prompts + - Minimal prompt line for save‑as/confirm quit; blocking read in prompt mode is acceptable for v0.1. + +Milestone 6 — Search (minimal) +1. Incremental search (C-s) + - Simple forward substring search with live highlight of current match; arrow keys navigate matches while in search mode (ke‑style quirk acceptable). + - ESC/C-g exits search; Enter confirms and leaves cursor on match. + +Milestone 7 — Safety and polish for v0.1 +1. Safe writes + - Write to temp file then rename; preserve permissions where possible. +2. Dirty/quit logic + - Confirm on quit when any buffer is dirty; `C-k C-q` bypasses confirmation. +3. Resize/terminal quirks + - Handle small terminals gracefully; no crashes on narrow widths. +4. Basic tests + - Unit tests for `GapBuffer`, Buffer open/save round‑trip, and command mapping. + +Out of scope for v0.1 (tracked, not blocking) +- Undo/redo, regex search, kill ring, word motions, tabs/render width, syntax highlighting, piece table selection, GUI. + +Implementation notes (files to add) +- Input: `InputHandler.h`, `TerminalInputHandler.cpp/h` (ncurses). +- Rendering: `Renderer.h`, `TerminalRenderer.cpp/h` (ncurses). +- Prompt helpers: minimal utility for line input in raw mode. +- Platform: small termios wrapper; SIGWINCH handler. + +Acceptance checklist for v0.1 +- Start: `./kte [files]` opens files or an empty buffer. +- Edit: insert text, backspace, newlines; move cursor; content scrolls. +- Save: `C-k s` writes file atomically; dirty flag clears; status shows bytes written. +- Quit: `C-k q` confirms if dirty; `C-k C-q` exits without confirm; `C-k x` save+exit. +- Refresh: `C-l` redraws. +- Search: `C-s` finds next while typing; ESC cancels. + +Next concrete step +- Stabilize cursor placement and scrolling logic; add resize handling and begin minimal prompt for save‑as. \ No newline at end of file diff --git a/Renderer.h b/Renderer.h new file mode 100644 index 0000000..9bb0668 --- /dev/null +++ b/Renderer.h @@ -0,0 +1,15 @@ +/* + * Renderer.h - rendering abstraction + */ +#ifndef KTE_RENDERER_H +#define KTE_RENDERER_H + +class Editor; + +class Renderer { +public: + virtual ~Renderer() = default; + virtual void Draw(const Editor &ed) = 0; +}; + +#endif // KTE_RENDERER_H diff --git a/TerminalFrontend.cpp b/TerminalFrontend.cpp new file mode 100644 index 0000000..22cf1f2 --- /dev/null +++ b/TerminalFrontend.cpp @@ -0,0 +1,56 @@ +#include "TerminalFrontend.h" + +#include +#include + +#include "Editor.h" +#include "Command.h" + +bool TerminalFrontend::Init(Editor &ed) +{ + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + nodelay(stdscr, TRUE); + curs_set(1); + + int r = 0, c = 0; + getmaxyx(stdscr, r, c); + prev_r_ = r; prev_c_ = c; + ed.SetDimensions(static_cast(r), static_cast(c)); + return true; +} + +void TerminalFrontend::Step(Editor &ed, bool &running) +{ + // Handle resize and keep editor dimensions synced + int r, c; + getmaxyx(stdscr, r, c); + if (r != prev_r_ || c != prev_c_) { + resizeterm(r, c); + clear(); + prev_r_ = r; prev_c_ = c; + } + ed.SetDimensions(static_cast(r), static_cast(c)); + + MappedInput mi; + if (input_.Poll(mi)) { + if (mi.hasCommand) { + Execute(ed, mi.id, mi.arg, mi.count); + if (mi.id == CommandId::Quit || mi.id == CommandId::SaveAndQuit) { + running = false; + } + } + } else { + // Avoid busy loop + usleep(1000); + } + + renderer_.Draw(ed); +} + +void TerminalFrontend::Shutdown() +{ + endwin(); +} diff --git a/TerminalFrontend.h b/TerminalFrontend.h new file mode 100644 index 0000000..d59b77b --- /dev/null +++ b/TerminalFrontend.h @@ -0,0 +1,27 @@ +/* + * TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle + */ +#ifndef KTE_TERMINAL_FRONTEND_H +#define KTE_TERMINAL_FRONTEND_H + +#include "Frontend.h" +#include "TerminalInputHandler.h" +#include "TerminalRenderer.h" + +class TerminalFrontend : public Frontend { +public: + TerminalFrontend() = default; + ~TerminalFrontend() override = default; + + bool Init(Editor &ed) override; + void Step(Editor &ed, bool &running) override; + void Shutdown() override; + +private: + TerminalInputHandler input_{}; + TerminalRenderer renderer_{}; + int prev_r_ = 0; + int prev_c_ = 0; +}; + +#endif // KTE_TERMINAL_FRONTEND_H diff --git a/TerminalInputHandler.cpp b/TerminalInputHandler.cpp new file mode 100644 index 0000000..e6345d2 --- /dev/null +++ b/TerminalInputHandler.cpp @@ -0,0 +1,93 @@ +#include "TerminalInputHandler.h" + +#include + +namespace { +constexpr int CTRL(char c) { return c & 0x1F; } +} + +TerminalInputHandler::TerminalInputHandler() = default; + +TerminalInputHandler::~TerminalInputHandler() = default; + +static bool map_key_to_command(int ch, bool &k_prefix, MappedInput &out) +{ + // Handle special keys from ncurses + switch (ch) { + case KEY_LEFT: out = {true, CommandId::MoveLeft, "", 0}; return true; + case KEY_RIGHT: out = {true, CommandId::MoveRight, "", 0}; return true; + case KEY_UP: out = {true, CommandId::MoveUp, "", 0}; return true; + case KEY_DOWN: out = {true, CommandId::MoveDown, "", 0}; return true; + case KEY_HOME: out = {true, CommandId::MoveHome, "", 0}; return true; + case KEY_END: out = {true, CommandId::MoveEnd, "", 0}; return true; + case KEY_DC: out = {true, CommandId::DeleteChar,"", 0}; return true; + case KEY_RESIZE: out = {true, CommandId::Refresh, "", 0}; return true; + default: break; + } + + // ESC as cancel of prefix; many terminals send meta sequences as ESC+... + if (ch == 27) { // ESC + k_prefix = false; + out = {true, CommandId::Refresh, "", 0}; + return true; + } + + // Control keys + if (ch == CTRL('K')) { // C-k prefix + k_prefix = true; + out = {true, CommandId::Refresh, "", 0}; + return true; + } + if (ch == CTRL('G')) { // cancel + k_prefix = false; + out = {true, CommandId::Refresh, "", 0}; + return true; + } + if (ch == CTRL('L')) { out = {true, CommandId::Refresh, "", 0}; return true; } + if (ch == CTRL('S')) { out = {true, CommandId::FindStart, "", 0}; return true; } + + // Enter + if (ch == '\n' || ch == '\r') { k_prefix = false; out = {true, CommandId::Newline, "", 0}; return true; } + // Backspace in ncurses can be KEY_BACKSPACE or 127 + if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { k_prefix = false; out = {true, CommandId::Backspace, "", 0}; return true; } + + if (k_prefix) { + k_prefix = false; // single next key only + switch (ch) { + case 's': case 'S': out = {true, CommandId::Save, "", 0}; return true; + case 'x': case 'X': out = {true, CommandId::SaveAndQuit, "", 0}; return true; + case 'q': case 'Q': out = {true, CommandId::Quit, "", 0}; return true; + default: break; + } + if (ch == CTRL('Q')) { out = {true, CommandId::Quit, "", 0}; return true; } + out.hasCommand = false; // unknown chord + return true; + } + + // Printable ASCII + if (ch >= 0x20 && ch <= 0x7E) { + out.hasCommand = true; + out.id = CommandId::InsertText; + out.arg.assign(1, static_cast(ch)); + out.count = 0; + return true; + } + + out.hasCommand = false; + return true; // consumed a key +} + +bool TerminalInputHandler::decode_(MappedInput &out) +{ + int ch = getch(); + if (ch == ERR) { + return false; // no input + } + return map_key_to_command(ch, k_prefix_, out); +} + +bool TerminalInputHandler::Poll(MappedInput &out) +{ + out = {}; + return decode_(out) && out.hasCommand; +} diff --git a/TerminalInputHandler.h b/TerminalInputHandler.h new file mode 100644 index 0000000..c1af4d8 --- /dev/null +++ b/TerminalInputHandler.h @@ -0,0 +1,25 @@ +/* + * TerminalInputHandler - ncurses-based input handling for terminal mode + */ +#ifndef KTE_TERMINAL_INPUT_HANDLER_H +#define KTE_TERMINAL_INPUT_HANDLER_H + +#include + +#include "InputHandler.h" + +class TerminalInputHandler : public InputHandler { +public: + TerminalInputHandler(); + ~TerminalInputHandler() override; + + bool Poll(MappedInput &out) override; + +private: + bool decode_(MappedInput &out); + + // ke-style prefix state + bool k_prefix_ = false; // true after C-k until next key or ESC +}; + +#endif // KTE_TERMINAL_INPUT_HANDLER_H diff --git a/TerminalRenderer.cpp b/TerminalRenderer.cpp new file mode 100644 index 0000000..c9524d1 --- /dev/null +++ b/TerminalRenderer.cpp @@ -0,0 +1,74 @@ +#include "TerminalRenderer.h" + +#include +#include + +#include "Editor.h" +#include "Buffer.h" + +TerminalRenderer::TerminalRenderer() = default; + +TerminalRenderer::~TerminalRenderer() = default; + +void TerminalRenderer::Draw(const Editor &ed) +{ + // Determine terminal size and keep editor dimensions in sync + int rows, cols; + getmaxyx(stdscr, rows, cols); + + // Clear screen + erase(); + + const Buffer *buf = ed.CurrentBuffer(); + int content_rows = rows - 1; // last line is status + + if (buf) { + const auto &lines = buf->Rows(); + std::size_t rowoffs = buf->Rowoffs(); + std::size_t coloffs = buf->Coloffs(); + + for (int r = 0; r < content_rows; ++r) { + int y = r; + int x = 0; + move(y, x); + std::size_t li = rowoffs + static_cast(r); + if (li < lines.size()) { + const std::string &line = lines[li]; + if (coloffs < line.size()) { + // Render a windowed slice of the line + std::size_t len = std::min(static_cast(cols), line.size() - coloffs); + addnstr(line.c_str() + static_cast(coloffs), static_cast(len)); + } + } + clrtoeol(); + } + + // Place cursor (best-effort; tabs etc. not handled yet) + std::size_t cy = buf->Cury(); + std::size_t cx = buf->Curx(); + int cur_y = static_cast(cy - buf->Rowoffs()); + int cur_x = static_cast(cx - buf->Coloffs()); + if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) { + move(cur_y, cur_x); + } + } else { + mvaddstr(0, 0, "[no buffer]"); + } + + // Status line (inverse) + move(rows - 1, 0); + attron(A_REVERSE); + char status[1024]; + const char *fname = (buf && buf->IsFileBacked()) ? buf->Filename().c_str() : "(new)"; + int dirty = (buf && buf->Dirty()) ? 1 : 0; + snprintf(status, sizeof(status), " %s%s %zux%zu %s ", + fname, + dirty ? "*" : "", + ed.Rows(), ed.Cols(), + ed.Status().c_str()); + addnstr(status, cols); + clrtoeol(); + attroff(A_REVERSE); + + refresh(); +} diff --git a/TerminalRenderer.h b/TerminalRenderer.h new file mode 100644 index 0000000..42247b6 --- /dev/null +++ b/TerminalRenderer.h @@ -0,0 +1,17 @@ +/* + * TerminalRenderer - ncurses-based renderer for terminal mode + */ +#ifndef KTE_TERMINAL_RENDERER_H +#define KTE_TERMINAL_RENDERER_H + +#include "Renderer.h" + +class TerminalRenderer : public Renderer { +public: + TerminalRenderer(); + ~TerminalRenderer() override; + + void Draw(const Editor &ed) override; +}; + +#endif // KTE_TERMINAL_RENDERER_H diff --git a/cmake/packaging.cmake b/cmake/packaging.cmake index 0ee61a3..2525a46 100644 --- a/cmake/packaging.cmake +++ b/cmake/packaging.cmake @@ -28,12 +28,12 @@ set(CPACK_DEB_COMPONENT_INSTALL ON) set(CPACK_DEBIAN_PACKAGE_MAINTAINER "K. Isom") set(CPACK_PACKAGE_nox_DESCRIPTION_SUMMARY "kyle's editor") set(CPACK_PACKAGE_nox_DESCRIPTION ${CPACK_PACKAGE_DESCRIPTION}) -set(CPACK_PACKAGE_nox_PACKAGE_NAME "kge") +set(CPACK_PACKAGE_nox_PACKAGE_NAME "kte") set(CPACK_DEBIAN_nox_PACKAGE_NAME "ke") if(BUILD_GUI) - set(CPACK_PACKAGE_gui_PACKAGE_NAME "kge") - set(CPACK_DEBIAN_gui_PACKAGE_NAME "kge") + set(CPACK_PACKAGE_gui_PACKAGE_NAME "kte") + set(CPACK_DEBIAN_gui_PACKAGE_NAME "kte") set(CPACK_PACKAGE_gui_DESCRIPTION_SUMMARY " graphical front-end for kyle's editor") set(CPACK_PACKAGE_gui_DESCRIPTION "graphical front-end for ${CPACK_PACKAGE_DESCRIPTION} ") endif() diff --git a/main.cpp b/main.cpp index 56fd243..4789b5d 100644 --- a/main.cpp +++ b/main.cpp @@ -1,26 +1,141 @@ #include +#include +#include +#include -#include "Buffer.h" +#include "Editor.h" +#include "Command.h" +#include "Frontend.h" +#include "TerminalFrontend.h" +#if defined(KTE_BUILD_GUI) +#include "GUIFrontend.h" +#endif -// TIP To Run code, press or click the icon in the gutter. -int main(const int argc, const char *argv[]) + +#ifndef KGE_VERSION +# define KTE_VERSION_STR "dev" +#else +# define KTE_STR_HELPER(x) #x +# define KTE_STR(x) KTE_STR_HELPER(x) +# define KTE_VERSION_STR KTE_STR(KGE_VERSION) +#endif + +static void PrintUsage(const char* prog) { - const auto buffers = new std::vector(); + std::cerr << "Usage: " << prog << " [OPTIONS] [files]\n" + << "Options:\n" + << " -g, --gui Use GUI frontend (if built)\n" + << " -t, --term Use terminal (ncurses) frontend [default]\n" + << " -h, --help Show this help and exit\n" + << " -V, --version Show version and exit\n"; +} - for (int i = 1; i < argc; i++) { - auto buffer = Buffer(argv[i]); - if (i % 2 == 0) { - buffer.SetDirty(true); - } +int +main(int argc, const char *argv[]) +{ + Editor editor; - buffers->emplace_back(buffer); - } + // CLI parsing using getopt_long + bool req_gui = false; + bool req_term = false; + bool show_help = false; + bool show_version = false; - std::cout << buffers->size() << " files loaded.\n"; - for (const auto &buffer : *buffers) { - std::cout << buffer.AsString() << "\n"; - } + static struct option long_opts[] = { + {"gui", no_argument, nullptr, 'g'}, + {"term", no_argument, nullptr, 't'}, + {"help", no_argument, nullptr, 'h'}, + {"version", no_argument, nullptr, 'V'}, + {nullptr, 0, nullptr, 0 } + }; - delete buffers; - return 0; -} \ No newline at end of file + int opt; + int long_index = 0; + while ((opt = getopt_long(argc, const_cast(argv), "gthV", long_opts, &long_index)) != -1) { + switch (opt) { + case 'g': req_gui = true; break; + case 't': req_term = true; break; + case 'h': show_help = true; break; + case 'V': show_version = true; break; + case '?': + default: + PrintUsage(argv[0]); + return 2; + } + } + + if (show_help) { + PrintUsage(argv[0]); + return 0; + } + if (show_version) { + std::cout << "kte " << KTE_VERSION_STR << "\n"; + return 0; + } + +#if !defined(KTE_BUILD_GUI) + (void)req_term; // suppress unused warning when GUI is not compiled in +#endif + + // Determine frontend +#if !defined(KTE_BUILD_GUI) + if (req_gui) { + std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." << std::endl; + return 2; + } +#else + bool use_gui = false; + if (req_gui) { + use_gui = true; + } else if (req_term) { + use_gui = false; + } else { + // Default to terminal + use_gui = false; + } +#endif + + // Open files passed on the CLI; if none, create an empty buffer + if (optind < argc) { + for (int i = optind; i < argc; ++i) { + std::string err; + const std::string path = argv[i]; + if (!editor.OpenFile(path, err)) { + editor.SetStatus("open: " + err); + std::cerr << "kte: " << err << "\n"; + } + } + } else { + // Create a single empty buffer + editor.AddBuffer(Buffer()); + editor.SetStatus("new: empty buffer"); + } + + // Install built-in commands + InstallDefaultCommands(); + + // Select frontend + std::unique_ptr fe; +#if defined(KTE_BUILD_GUI) + if (use_gui) { + fe.reset(new GUIFrontend()); + } else +#endif + { + fe.reset(new TerminalFrontend()); + } + + if (!fe->Init(editor)) { + std::cerr << "kte: failed to initialize frontend" << std::endl; + return 1; + } + + bool running = true; + while (running) { + fe->Step(editor, running); + } + + fe->Shutdown(); + + return 0; +}