Browse Source

Initial commit

Bernardo Magri 1 month ago
commit
b9eeba41b5
10 changed files with 1313 additions and 0 deletions
  1. 80 0
      CMakeLists.txt
  2. 149 0
      LUA_API.md
  3. 84 0
      README.md
  4. 82 0
      init.lua
  5. 38 0
      shell.nix
  6. 268 0
      src/buffer.cpp
  7. 151 0
      src/editor_core.cpp
  8. 160 0
      src/lua_api.cpp
  9. 299 0
      src/main.cpp
  10. 2 0
      tests/CMakeLists.txt

+ 80 - 0
CMakeLists.txt

@@ -0,0 +1,80 @@
+cmake_minimum_required(VERSION 3.20)
+project(lumacs VERSION 0.1.0 LANGUAGES CXX)
+
+# C++20 standard
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+# Export compile commands for clangd/LSP
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+# Build type
+if(NOT CMAKE_BUILD_TYPE)
+    set(CMAKE_BUILD_TYPE Release)
+endif()
+
+# Compiler warnings
+if(MSVC)
+    add_compile_options(/W4 /WX)
+else()
+    add_compile_options(-Wall -Wextra -Wpedantic -Werror)
+endif()
+
+# Dependencies
+include(FetchContent)
+
+# FTXUI for TUI
+FetchContent_Declare(
+    ftxui
+    GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
+    GIT_TAG v5.0.0
+)
+set(FTXUI_BUILD_DOCS OFF CACHE BOOL "" FORCE)
+set(FTXUI_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
+set(FTXUI_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(ftxui)
+
+# Lua and sol2
+find_package(Lua 5.4 REQUIRED)
+
+# sol2 - use develop branch for latest compatibility fixes
+FetchContent_Declare(
+    sol2
+    GIT_REPOSITORY https://github.com/ThePhD/sol2.git
+    GIT_TAG develop
+    GIT_SHALLOW TRUE
+)
+FetchContent_MakeAvailable(sol2)
+
+# Core library (UI-independent)
+add_library(lumacs_core STATIC
+    src/buffer.cpp
+    src/editor_core.cpp
+    src/lua_api.cpp
+)
+
+target_include_directories(lumacs_core PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_link_libraries(lumacs_core PUBLIC
+    sol2::sol2
+    ${LUA_LIBRARIES}
+)
+
+# TUI executable (FTXUI-based)
+add_executable(lumacs
+    src/main.cpp
+)
+
+target_link_libraries(lumacs PRIVATE
+    lumacs_core
+    ftxui::screen
+    ftxui::dom
+    ftxui::component
+)
+
+# Enable testing
+enable_testing()
+add_subdirectory(tests EXCLUDE_FROM_ALL)

+ 149 - 0
LUA_API.md

@@ -0,0 +1,149 @@
+# Lumacs Lua API Documentation
+
+Lumacs provides a powerful Lua scripting API for configuration and extensibility, inspired by Emacs but using Lua instead of Elisp.
+
+## Configuration File
+
+Lumacs loads `init.lua` from one of these locations (in order):
+1. `./init.lua` (current directory)
+2. `~/.config/lumacs/init.lua`
+3. `~/.lumacs/init.lua`
+
+## Global Objects
+
+### `editor` (EditorCore)
+The main editor instance.
+
+**Properties:**
+- `editor:buffer()` - Returns the current Buffer
+- `editor:cursor()` - Returns current Position
+- `editor:set_cursor(pos)` - Set cursor position
+
+**Methods:**
+- `editor:move_up()` - Move cursor up
+- `editor:move_down()` - Move cursor down
+- `editor:move_left()` - Move cursor left
+- `editor:move_right()` - Move cursor right
+- `editor:move_to_line_start()` - Move to start of line
+- `editor:move_to_line_end()` - Move to end of line
+- `editor:load_file(path)` - Load a file
+- `editor:quit()` - Quit the editor
+
+### `lumacs` (module)
+Module namespace.
+
+**Classes:**
+- `lumacs.Position(line, column)` - Create a cursor position
+
+## Buffer Object
+
+Returned by `editor:buffer()`.
+
+**Properties:**
+- `buffer:name()` - Get buffer name
+- `buffer:line_count()` - Get number of lines
+- `buffer:is_modified()` - Check if modified
+- `buffer:content()` - Get entire content as string
+
+**Methods:**
+- `buffer:line(index)` - Get line by index (0-based)
+- `buffer:insert(pos, text)` - Insert text at position
+- `buffer:insert_char(pos, char)` - Insert single character
+- `buffer:insert_newline(pos)` - Insert newline
+- `buffer:erase_char(pos)` - Delete character (backspace)
+- `buffer:save()` - Save buffer to file
+
+## Position Object
+
+Represents a cursor position.
+
+**Constructor:**
+```lua
+local pos = lumacs.Position(line, column)
+```
+
+**Fields:**
+- `pos.line` - Line number (0-based)
+- `pos.column` - Column number (0-based)
+
+## Global Functions
+
+### `bind_key(key, callback)`
+Bind a key to a Lua function.
+
+**Key names:**
+- Single characters: `"a"`, `"b"`, `"1"`, etc.
+- Special keys: `"Escape"`, `"ArrowUp"`, `"ArrowDown"`, `"ArrowLeft"`, `"ArrowRight"`, `"Home"`, `"End"`
+- Control keys: `"C-a"`, `"C-s"`, `"C-c"`, etc.
+
+**Example:**
+```lua
+bind_key("C-s", function()
+    editor:buffer():save()
+    message("Buffer saved!")
+end)
+```
+
+### `message(text)`
+Display a message to the user (appears in stderr).
+
+```lua
+message("Hello from Lua!")
+```
+
+### `print(...)`
+Print to stderr (stdout is used by TUI).
+
+```lua
+print("Debug info:", value)
+```
+
+## Example Configuration
+
+```lua
+-- Emacs-style navigation
+bind_key("C-n", function() editor:move_down() end)
+bind_key("C-p", function() editor:move_up() end)
+bind_key("C-f", function() editor:move_right() end)
+bind_key("C-b", function() editor:move_left() end)
+bind_key("C-a", function() editor:move_to_line_start() end)
+bind_key("C-e", function() editor:move_to_line_end() end)
+
+-- Save buffer
+bind_key("C-s", function()
+    if editor:buffer():save() then
+        message("Saved!")
+    else
+        message("Failed to save")
+    end
+end)
+
+-- Insert timestamp
+bind_key("C-t", function()
+    local timestamp = os.date("%Y-%m-%d %H:%M:%S")
+    editor:buffer():insert(editor:cursor(), timestamp)
+end)
+
+-- Custom functions
+function goto_line(n)
+    editor:set_cursor(lumacs.Position(n - 1, 0))
+end
+
+message("Lumacs configured!")
+```
+
+## Future Features
+
+Coming soon:
+- **Command buffer** - Execute Lua code interactively (like M-x eval in Emacs)
+- **Multiple buffers** - Switch between open files
+- **Window splits** - Split panes with different buffers
+- **Hooks** - Run code on events (file open, save, etc.)
+- **More buffer operations** - Search, replace, undo/redo
+
+## Tips
+
+- Use `print()` for debugging (output goes to stderr)
+- Check the example `init.lua` for more ideas
+- Lua has full access to the editor state
+- Key bindings override default bindings

+ 84 - 0
README.md

@@ -0,0 +1,84 @@
+# Lumacs
+
+A modern text editor in C++, inspired by Emacs but with better scripting via Lua.
+
+## Features (Planned)
+
+- Modern C++20 codebase with RAII principles
+- Terminal UI using FTXUI
+- Lua scripting with sol2
+- Window splits and buffer management
+- Extensible architecture
+
+## Tech Stack
+
+- **Core**: C++20 with modern idioms
+- **TUI**: FTXUI for terminal rendering
+- **Scripting**: Lua 5.4 + sol2 bindings
+- **Build**: CMake 3.20+
+- **Text Storage**: `std::vector<std::string>` (MVP), rope structure planned
+
+## Building
+
+### Standard Build
+
+```bash
+mkdir build && cd build
+cmake ..
+cmake --build .
+./lumacs [file]
+```
+
+### With Nix
+
+```bash
+nix-shell
+mkdir build && cd build
+cmake -G Ninja ..
+ninja
+./lumacs [file]
+```
+
+### Build Options
+
+- Debug build: `cmake -DCMAKE_BUILD_TYPE=Debug ..`
+- Release build: `cmake -DCMAKE_BUILD_TYPE=Release ..` (default)
+
+## Project Structure
+
+```
+lumacs/
+├── include/lumacs/    # Public headers
+│   └── buffer.hpp     # Text buffer interface
+├── src/               # Implementation
+│   ├── main.cpp       # Entry point
+│   └── buffer.cpp     # Buffer implementation
+├── tests/             # Unit tests (TODO)
+├── CMakeLists.txt     # Build configuration
+└── shell.nix          # Nix development environment
+```
+
+## Current Status
+
+**MVP Phase**: Basic infrastructure in place
+- [x] Project structure and build system
+- [x] Buffer implementation with basic operations
+- [x] Minimal FTXUI integration
+- [x] Lua runtime initialized
+- [ ] Keyboard input handling
+- [ ] Window splits
+- [ ] Lua configuration loading
+- [ ] Basic editing commands
+
+## Development
+
+The project uses modern C++ practices:
+- RAII for resource management
+- Smart pointers for ownership
+- `std::optional` for fallible operations
+- Range-based operations
+- C++20 features (spaceship operator, etc.)
+
+## License
+
+TBD

+ 82 - 0
init.lua

@@ -0,0 +1,82 @@
+-- Lumacs Configuration File
+-- This file is executed on startup and allows you to customize keybindings,
+-- create commands, and extend the editor with Lua.
+
+print("Loading init.lua...")
+
+-- Example: Custom keybindings
+-- Syntax: bind_key("key", function() ... end)
+
+-- Emacs-style navigation (Ctrl+N/P for next/previous line)
+bind_key("C-n", function()
+    editor:move_down()
+    message("Moved down")
+end)
+
+bind_key("C-p", function()
+    editor:move_up()
+    message("Moved up")
+end)
+
+bind_key("C-f", function()
+    editor:move_right()
+end)
+
+bind_key("C-b", function()
+    editor:move_left()
+end)
+
+-- Emacs-style line navigation
+bind_key("C-a", function()
+    editor:move_to_line_start()
+end)
+
+bind_key("C-e", function()
+    editor:move_to_line_end()
+end)
+
+-- Custom command: Save buffer
+bind_key("C-s", function()
+    local buf = editor:buffer()
+    if buf:save() then
+        message("Buffer saved: " .. buf:name())
+    else
+        message("Failed to save buffer")
+    end
+end)
+
+-- Custom command: Insert timestamp
+bind_key("C-t", function()
+    local cursor_pos = editor:cursor()
+    local timestamp = os.date("%Y-%m-%d %H:%M:%S")
+    editor:buffer():insert(cursor_pos, timestamp)
+    message("Inserted timestamp")
+end)
+
+-- Example: Helper functions you can define
+function goto_line(line_num)
+    local pos = lumacs.Position(line_num - 1, 0)
+    editor:set_cursor(pos)
+    message("Jumped to line " .. line_num)
+end
+
+-- Example: Buffer inspection
+function buffer_info()
+    local buf = editor:buffer()
+    local cursor = editor:cursor()
+
+    message(string.format(
+        "Buffer: %s | Lines: %d | Cursor: %d,%d | Modified: %s",
+        buf:name(),
+        buf:line_count(),
+        cursor.line + 1,
+        cursor.column + 1,
+        buf:is_modified() and "yes" or "no"
+    ))
+end
+
+-- Bind to show buffer info
+bind_key("C-i", buffer_info)
+
+-- Welcome message
+message("Lumacs initialized! Press C-i for buffer info, C-s to save.")

+ 38 - 0
shell.nix

@@ -0,0 +1,38 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+pkgs.mkShell {
+  buildInputs = with pkgs; [
+    # Build tools
+    cmake
+    ninja
+    ccache
+
+    # Compiler
+    clang
+    llvmPackages.libcxx
+
+    # Dependencies
+    lua5_4
+
+    # Development tools
+    clang-tools  # clangd, clang-format, clang-tidy
+    lldb  # Use lldb instead of gdb on macOS
+
+    # Git for FetchContent
+    git
+  ];
+
+  shellHook = ''
+    echo "Lumacs development environment"
+    echo "C++ compiler: $(clang++ --version | head -n1)"
+    echo "CMake: $(cmake --version | head -n1)"
+    echo "Lua: $(lua -v)"
+
+    # Set compiler to clang
+    export CC=clang
+    export CXX=clang++
+
+    # Enable ccache
+    export CMAKE_CXX_COMPILER_LAUNCHER=ccache
+  '';
+}

+ 268 - 0
src/buffer.cpp

@@ -0,0 +1,268 @@
+#include "lumacs/buffer.hpp"
+#include <fstream>
+#include <sstream>
+#include <algorithm>
+
+namespace lumacs {
+
+Buffer::Buffer() : name_("*scratch*"), lines_{""}, modified_(false) {}
+
+Buffer::Buffer(std::string name)
+    : name_(std::move(name)), lines_{""}, modified_(false) {}
+
+std::optional<Buffer> Buffer::from_file(const std::filesystem::path& path) {
+    std::ifstream file(path);
+    if (!file.is_open()) {
+        return std::nullopt;
+    }
+
+    Buffer buffer;
+    buffer.file_path_ = path;
+    buffer.name_ = path.filename().string();
+    buffer.lines_.clear();
+    buffer.modified_ = false;
+
+    std::string line;
+    while (std::getline(file, line)) {
+        buffer.lines_.push_back(std::move(line));
+    }
+
+    // Ensure at least one line
+    if (buffer.lines_.empty()) {
+        buffer.lines_.push_back("");
+    }
+
+    return buffer;
+}
+
+// === Content Access ===
+
+size_t Buffer::line_count() const noexcept {
+    return lines_.size();
+}
+
+const std::string& Buffer::line(size_t index) const {
+    return lines_.at(index);
+}
+
+const std::vector<std::string>& Buffer::lines() const noexcept {
+    return lines_;
+}
+
+std::string Buffer::content() const {
+    if (lines_.empty()) {
+        return "";
+    }
+
+    std::ostringstream oss;
+    for (size_t i = 0; i < lines_.size(); ++i) {
+        if (i > 0) {
+            oss << '\n';
+        }
+        oss << lines_[i];
+    }
+    return oss.str();
+}
+
+// === Modification ===
+
+void Buffer::insert(Position pos, std::string_view text) {
+    if (text.empty()) {
+        return;
+    }
+
+    pos = clamp_position(pos);
+
+    // Handle multiline text insertion
+    std::vector<std::string> new_lines;
+    std::string text_copy{text};
+    std::istringstream stream{text_copy};
+    std::string line_text;
+    while (std::getline(stream, line_text)) {
+        new_lines.push_back(std::move(line_text));
+    }
+
+    if (new_lines.empty()) {
+        return;
+    }
+
+    auto& current_line = lines_[pos.line];
+
+    if (new_lines.size() == 1) {
+        // Single line insertion
+        current_line.insert(pos.column, new_lines[0]);
+    } else {
+        // Multiline insertion
+        std::string after = current_line.substr(pos.column);
+        current_line = current_line.substr(0, pos.column) + new_lines[0];
+
+        // Insert middle lines
+        for (size_t i = 1; i < new_lines.size() - 1; ++i) {
+            lines_.insert(lines_.begin() + pos.line + i, new_lines[i]);
+        }
+
+        // Insert last line with remaining text
+        lines_.insert(lines_.begin() + pos.line + new_lines.size() - 1,
+                     new_lines.back() + after);
+    }
+
+    mark_modified();
+}
+
+void Buffer::insert_char(Position pos, char c) {
+    pos = clamp_position(pos);
+    lines_[pos.line].insert(pos.column, 1, c);
+    mark_modified();
+}
+
+void Buffer::insert_newline(Position pos) {
+    pos = clamp_position(pos);
+
+    auto& current_line = lines_[pos.line];
+    std::string after = current_line.substr(pos.column);
+    current_line = current_line.substr(0, pos.column);
+
+    lines_.insert(lines_.begin() + pos.line + 1, std::move(after));
+    mark_modified();
+}
+
+void Buffer::erase(Range range) {
+    if (range.start >= range.end) {
+        return;
+    }
+
+    range.start = clamp_position(range.start);
+    range.end = clamp_position(range.end);
+
+    if (range.start.line == range.end.line) {
+        // Single line deletion
+        auto& line = lines_[range.start.line];
+        line.erase(range.start.column, range.end.column - range.start.column);
+    } else {
+        // Multiline deletion
+        std::string prefix = lines_[range.start.line].substr(0, range.start.column);
+        std::string suffix = lines_[range.end.line].substr(range.end.column);
+
+        // Remove lines in between
+        lines_.erase(lines_.begin() + range.start.line,
+                    lines_.begin() + range.end.line + 1);
+
+        // Insert merged line
+        lines_.insert(lines_.begin() + range.start.line, prefix + suffix);
+    }
+
+    ensure_min_lines();
+    mark_modified();
+}
+
+void Buffer::erase_char(Position pos) {
+    if (pos.column == 0 && pos.line == 0) {
+        return; // Nothing to delete
+    }
+
+    pos = clamp_position(pos);
+
+    if (pos.column > 0) {
+        // Delete character before cursor
+        lines_[pos.line].erase(pos.column - 1, 1);
+    } else if (pos.line > 0) {
+        // Join with previous line
+        std::string current = std::move(lines_[pos.line]);
+        lines_.erase(lines_.begin() + pos.line);
+        lines_[pos.line - 1] += current;
+    }
+
+    mark_modified();
+}
+
+void Buffer::replace(Range range, std::string_view text) {
+    erase(range);
+    insert(range.start, text);
+}
+
+void Buffer::clear() {
+    lines_.clear();
+    lines_.push_back("");
+    mark_modified();
+}
+
+// === File Operations ===
+
+bool Buffer::save() {
+    if (!file_path_) {
+        return false;
+    }
+    return save_as(*file_path_);
+}
+
+bool Buffer::save_as(const std::filesystem::path& path) {
+    std::ofstream file(path);
+    if (!file.is_open()) {
+        return false;
+    }
+
+    for (size_t i = 0; i < lines_.size(); ++i) {
+        if (i > 0) {
+            file << '\n';
+        }
+        file << lines_[i];
+    }
+
+    if (!file.good()) {
+        return false;
+    }
+
+    file_path_ = path;
+    name_ = path.filename().string();
+    modified_ = false;
+    return true;
+}
+
+bool Buffer::is_modified() const noexcept {
+    return modified_;
+}
+
+std::optional<std::filesystem::path> Buffer::file_path() const noexcept {
+    return file_path_;
+}
+
+// === Buffer Properties ===
+
+const std::string& Buffer::name() const noexcept {
+    return name_;
+}
+
+void Buffer::set_name(std::string name) {
+    name_ = std::move(name);
+}
+
+bool Buffer::is_valid_position(Position pos) const noexcept {
+    if (pos.line >= lines_.size()) {
+        return false;
+    }
+    return pos.column <= lines_[pos.line].size();
+}
+
+Position Buffer::clamp_position(Position pos) const noexcept {
+    if (lines_.empty()) {
+        return {0, 0};
+    }
+
+    pos.line = std::min(pos.line, lines_.size() - 1);
+    pos.column = std::min(pos.column, lines_[pos.line].size());
+    return pos;
+}
+
+// === Private Methods ===
+
+void Buffer::ensure_min_lines() {
+    if (lines_.empty()) {
+        lines_.push_back("");
+    }
+}
+
+void Buffer::mark_modified() {
+    modified_ = true;
+}
+
+} // namespace lumacs

+ 151 - 0
src/editor_core.cpp

@@ -0,0 +1,151 @@
+#include "lumacs/editor_core.hpp"
+#include <algorithm>
+
+namespace lumacs {
+
+EditorCore::EditorCore()
+    : buffer_(std::make_unique<Buffer>())
+    , cursor_{0, 0}
+{
+}
+
+// === Buffer Management ===
+
+bool EditorCore::load_file(const std::filesystem::path& path) {
+    auto new_buffer = Buffer::from_file(path);
+    if (!new_buffer) {
+        return false;
+    }
+
+    buffer_ = std::make_unique<Buffer>(std::move(*new_buffer));
+    cursor_ = {0, 0};
+    viewport_.scroll_offset = 0;
+    emit_event(EditorEvent::BufferModified);
+    emit_event(EditorEvent::CursorMoved);
+    emit_event(EditorEvent::ViewportChanged);
+    return true;
+}
+
+void EditorCore::new_buffer(std::string name) {
+    buffer_ = std::make_unique<Buffer>(std::move(name));
+    cursor_ = {0, 0};
+    viewport_.scroll_offset = 0;
+    emit_event(EditorEvent::BufferModified);
+    emit_event(EditorEvent::CursorMoved);
+    emit_event(EditorEvent::ViewportChanged);
+}
+
+// === Cursor Management ===
+
+void EditorCore::set_cursor(Position pos) {
+    cursor_ = buffer_->clamp_position(pos);
+    adjust_scroll();
+    emit_event(EditorEvent::CursorMoved);
+}
+
+// === Cursor Movement ===
+
+void EditorCore::move_up() {
+    if (cursor_.line > 0) {
+        cursor_.line--;
+        clamp_cursor();
+        adjust_scroll();
+        emit_event(EditorEvent::CursorMoved);
+    }
+}
+
+void EditorCore::move_down() {
+    if (cursor_.line < buffer_->line_count() - 1) {
+        cursor_.line++;
+        clamp_cursor();
+        adjust_scroll();
+        emit_event(EditorEvent::CursorMoved);
+    }
+}
+
+void EditorCore::move_left() {
+    if (cursor_.column > 0) {
+        cursor_.column--;
+    } else if (cursor_.line > 0) {
+        // Move to end of previous line
+        cursor_.line--;
+        cursor_.column = buffer_->line(cursor_.line).size();
+        adjust_scroll();
+    }
+    emit_event(EditorEvent::CursorMoved);
+}
+
+void EditorCore::move_right() {
+    const auto& line = buffer_->line(cursor_.line);
+    if (cursor_.column < line.size()) {
+        cursor_.column++;
+    } else if (cursor_.line < buffer_->line_count() - 1) {
+        // Move to start of next line
+        cursor_.line++;
+        cursor_.column = 0;
+        adjust_scroll();
+    }
+    emit_event(EditorEvent::CursorMoved);
+}
+
+void EditorCore::move_to_line_start() {
+    cursor_.column = 0;
+    emit_event(EditorEvent::CursorMoved);
+}
+
+void EditorCore::move_to_line_end() {
+    cursor_.column = buffer_->line(cursor_.line).size();
+    emit_event(EditorEvent::CursorMoved);
+}
+
+// === Viewport Management ===
+
+void EditorCore::set_viewport_size(int width, int height) {
+    viewport_.width = std::max(1, width);
+    viewport_.height = std::max(1, height);
+    adjust_scroll();
+    emit_event(EditorEvent::ViewportChanged);
+}
+
+void EditorCore::adjust_scroll() {
+    // Ensure cursor is visible with a margin from edges
+    viewport_.height = std::max(10, viewport_.height);
+
+    // Scroll down if cursor is below visible area
+    if (static_cast<int>(cursor_.line) >= viewport_.scroll_offset + viewport_.height - SCROLL_MARGIN) {
+        viewport_.scroll_offset = static_cast<int>(cursor_.line) - viewport_.height + SCROLL_MARGIN + 1;
+        emit_event(EditorEvent::ViewportChanged);
+    }
+
+    // Scroll up if cursor is above visible area
+    if (static_cast<int>(cursor_.line) < viewport_.scroll_offset + SCROLL_MARGIN) {
+        viewport_.scroll_offset = (cursor_.line >= static_cast<size_t>(SCROLL_MARGIN))
+                                ? static_cast<int>(cursor_.line) - SCROLL_MARGIN
+                                : 0;
+        emit_event(EditorEvent::ViewportChanged);
+    }
+
+    // Ensure scroll offset is valid
+    viewport_.scroll_offset = std::max(0, viewport_.scroll_offset);
+}
+
+std::pair<size_t, size_t> EditorCore::visible_line_range() const {
+    size_t start = viewport_.scroll_offset;
+    size_t end = std::min(buffer_->line_count(),
+                         static_cast<size_t>(viewport_.scroll_offset + viewport_.height));
+    return {start, end};
+}
+
+// === Private Methods ===
+
+void EditorCore::clamp_cursor() {
+    cursor_ = buffer_->clamp_position(cursor_);
+}
+
+void EditorCore::emit_event(EditorEvent event) {
+    for (const auto& callback : event_callbacks_) {
+        callback(event);
+    }
+}
+
+} // namespace lumacs

+ 160 - 0
src/lua_api.cpp

@@ -0,0 +1,160 @@
+#include "lumacs/lua_api.hpp"
+#include <iostream>
+#include <fstream>
+
+namespace lumacs {
+
+LuaApi::LuaApi(EditorCore& core) : core_(core) {
+    lua_.open_libraries(
+        sol::lib::base,
+        sol::lib::package,
+        sol::lib::string,
+        sol::lib::math,
+        sol::lib::table,
+        sol::lib::io
+    );
+
+    setup_api();
+}
+
+bool LuaApi::load_file(const std::filesystem::path& path) {
+    try {
+        lua_.script_file(path.string());
+        std::cout << "Loaded Lua file: " << path << std::endl;
+        return true;
+    } catch (const sol::error& e) {
+        std::cerr << "Lua error loading " << path << ": " << e.what() << std::endl;
+        return false;
+    }
+}
+
+bool LuaApi::execute(std::string_view code) {
+    try {
+        lua_.script(code);
+        return true;
+    } catch (const sol::error& e) {
+        std::cerr << "Lua error: " << e.what() << std::endl;
+        return false;
+    }
+}
+
+bool LuaApi::load_init_file() {
+    // Get home directory
+    const char* home = getenv("HOME");
+    std::string home_dir = home ? home : ".";
+
+    // Try multiple locations in order
+    std::vector<std::filesystem::path> search_paths = {
+        std::filesystem::current_path() / "init.lua",
+        std::filesystem::path(home_dir) / ".config" / "lumacs" / "init.lua",
+        std::filesystem::path(home_dir) / ".lumacs" / "init.lua",
+    };
+
+    for (const auto& path : search_paths) {
+        if (std::filesystem::exists(path)) {
+            std::cout << "Found init file: " << path << std::endl;
+            return load_file(path);
+        }
+    }
+
+    std::cout << "No init.lua found (searched: ./init.lua, ~/.config/lumacs/init.lua, ~/.lumacs/init.lua)" << std::endl;
+    return false;
+}
+
+void LuaApi::bind_key(std::string key, sol::function callback) {
+    key_bindings_[key] = callback;
+}
+
+bool LuaApi::has_key_binding(const std::string& key) const {
+    return key_bindings_.find(key) != key_bindings_.end();
+}
+
+bool LuaApi::execute_key_binding(const std::string& key) {
+    auto it = key_bindings_.find(key);
+    if (it == key_bindings_.end()) {
+        return false;
+    }
+
+    try {
+        it->second();
+        return true;
+    } catch (const sol::error& e) {
+        std::cerr << "Lua error executing key binding '" << key << "': " << e.what() << std::endl;
+        return false;
+    }
+}
+
+void LuaApi::setup_api() {
+    register_types();
+    register_functions();
+
+    // Set version info
+    lua_["lumacs"] = lua_.create_table_with(
+        "version", "0.1.0",
+        "name", "Lumacs"
+    );
+}
+
+void LuaApi::register_types() {
+    // Position type
+    lua_.new_usertype<Position>("Position",
+        sol::constructors<Position(), Position(size_t, size_t)>(),
+        "line", &Position::line,
+        "column", &Position::column
+    );
+
+    // Buffer type
+    lua_.new_usertype<Buffer>("Buffer",
+        sol::no_constructor,
+        "line_count", &Buffer::line_count,
+        "line", &Buffer::line,
+        "name", &Buffer::name,
+        "is_modified", &Buffer::is_modified,
+        "content", &Buffer::content,
+        "insert", &Buffer::insert,
+        "insert_char", &Buffer::insert_char,
+        "insert_newline", &Buffer::insert_newline,
+        "erase_char", &Buffer::erase_char,
+        "save", &Buffer::save
+    );
+
+    // EditorCore type
+    lua_.new_usertype<EditorCore>("EditorCore",
+        sol::no_constructor,
+        "buffer", sol::property([](EditorCore& e) -> Buffer& { return e.buffer(); }),
+        "cursor", sol::property(&EditorCore::cursor, &EditorCore::set_cursor),
+        "move_up", &EditorCore::move_up,
+        "move_down", &EditorCore::move_down,
+        "move_left", &EditorCore::move_left,
+        "move_right", &EditorCore::move_right,
+        "move_to_line_start", &EditorCore::move_to_line_start,
+        "move_to_line_end", &EditorCore::move_to_line_end,
+        "load_file", &EditorCore::load_file,
+        "quit", &EditorCore::request_quit
+    );
+}
+
+void LuaApi::register_functions() {
+    // Global editor instance
+    lua_["editor"] = std::ref(core_);
+
+    // Key binding function
+    lua_["bind_key"] = [this](std::string key, sol::function callback) {
+        bind_key(std::move(key), std::move(callback));
+    };
+
+    // Print function that goes to stderr (since stdout is used by TUI)
+    lua_["print"] = [](sol::variadic_args args) {
+        for (auto arg : args) {
+            std::cerr << lua_tostring(arg.lua_state(), arg.stack_index()) << "\t";
+        }
+        std::cerr << std::endl;
+    };
+
+    // Message function for user feedback
+    lua_["message"] = [](std::string msg) {
+        std::cerr << "[Lumacs] " << msg << std::endl;
+    };
+}
+
+} // namespace lumacs

+ 299 - 0
src/main.cpp

@@ -0,0 +1,299 @@
+#include "lumacs/editor_core.hpp"
+#include "lumacs/lua_api.hpp"
+#include <ftxui/component/component.hpp>
+#include <ftxui/component/screen_interactive.hpp>
+#include <ftxui/dom/elements.hpp>
+#include <iostream>
+#include <memory>
+
+using namespace ftxui;
+using namespace lumacs;
+
+/// FTXUI-based TUI frontend for Lumacs
+class TuiEditor {
+public:
+    TuiEditor() : lua_api_(core_) {
+        // Listen to editor events
+        core_.on_event([this](EditorEvent event) {
+            handle_editor_event(event);
+        });
+
+        // Load init.lua configuration
+        lua_api_.load_init_file();
+    }
+
+    void load_file(const std::filesystem::path& path) {
+        if (!core_.load_file(path)) {
+            std::cerr << "Failed to load file: " << path << std::endl;
+        }
+    }
+
+    Component create_ui(ScreenInteractive* screen) {
+        screen_ = screen;
+        auto renderer = Renderer([this] { return render(); });
+
+        // Add keyboard event handling
+        auto component = CatchEvent(renderer, [this](Event event) {
+            return handle_input(event);
+        });
+
+        return component;
+    }
+
+private:
+    EditorCore core_;
+    LuaApi lua_api_;
+    ScreenInteractive* screen_ = nullptr;
+    bool should_quit_ = false;
+    std::string message_line_;  // For displaying messages in the UI
+
+    void handle_editor_event(EditorEvent event) {
+        if (event == EditorEvent::Quit) {
+            should_quit_ = true;
+            if (screen_) {
+                screen_->Exit();
+            }
+        }
+    }
+
+    bool handle_input(Event event) {
+        // Convert FTXUI event to string key for Lua bindings
+        std::string key_name;
+
+        // Handle control characters first (they're not is_character())
+        if (!event.input().empty()) {
+            int code = static_cast<int>(static_cast<unsigned char>(event.input()[0]));
+
+            // Control characters (Ctrl+A through Ctrl+Z)
+            if (code > 0 && code < 27 && code != 9 && code != 10 && code != 13) {
+                // Skip tab(9), newline(10), return(13) - handle them specially
+                char letter = 'a' + (code - 1);
+                key_name = "C-" + std::string(1, letter);
+            }
+        }
+
+        // Detect Meta/Alt modifier by checking raw input first
+        // Alt+ArrowUp sends ESC[1;3A (bytes: 27 91 49 59 51 65)
+        std::string input_str = event.input();
+
+        // Check for Alt+Arrow sequences before FTXUI event processing
+        if (input_str.size() >= 6 &&
+            input_str[0] == 27 && input_str[1] == 91 &&
+            input_str[2] == 49 && input_str[3] == 59 && input_str[4] == 51) {
+            // Alt modifier detected (ESC[1;3X pattern)
+            char arrow_key = input_str[5];
+            switch (arrow_key) {
+                case 65: key_name = "M-ArrowUp"; break;     // A
+                case 66: key_name = "M-ArrowDown"; break;   // B
+                case 67: key_name = "M-ArrowRight"; break;  // C
+                case 68: key_name = "M-ArrowLeft"; break;   // D
+            }
+        }
+
+        // Handle special keys
+        if (key_name.empty()) {
+            if (event == Event::Escape) {
+                key_name = "Escape";
+            } else if (event == Event::ArrowUp) {
+                key_name = "ArrowUp";
+            } else if (event == Event::ArrowDown) {
+                key_name = "ArrowDown";
+            } else if (event == Event::ArrowLeft) {
+                key_name = "ArrowLeft";
+            } else if (event == Event::ArrowRight) {
+                key_name = "ArrowRight";
+            } else if (event == Event::Home) {
+                key_name = "Home";
+            } else if (event == Event::End) {
+                key_name = "End";
+            } else if (event == Event::Return) {
+                key_name = "Return";
+            } else if (event == Event::Tab) {
+                key_name = "Tab";
+            } else if (event.is_character()) {
+                // Regular printable characters
+                key_name = event.input();
+            }
+        }
+
+        // Debug: show what key was pressed
+        if (!key_name.empty()) {
+            message_line_ = "Key: " + key_name;
+        }
+
+        // Try Lua key binding first
+        if (!key_name.empty() && lua_api_.execute_key_binding(key_name)) {
+            message_line_ = "Executed: " + key_name;
+            return true;
+        }
+
+        // Fallback to default bindings
+        // Quit commands
+        if (event == Event::Escape || event == Event::Character('Q')) {
+            core_.request_quit();
+            return true;
+        }
+
+        // Ctrl+C to quit
+        if (event.is_character()) {
+            auto input = event.input();
+            if (!input.empty() && input[0] == 3) {  // Ctrl+C
+                core_.request_quit();
+                return true;
+            }
+        }
+
+        // Arrow keys for cursor movement
+        if (event == Event::ArrowUp) {
+            core_.move_up();
+            return true;
+        }
+        if (event == Event::ArrowDown) {
+            core_.move_down();
+            return true;
+        }
+        if (event == Event::ArrowLeft) {
+            core_.move_left();
+            return true;
+        }
+        if (event == Event::ArrowRight) {
+            core_.move_right();
+            return true;
+        }
+
+        // Vim-style movement (h, j, k, l)
+        if (event == Event::Character('h')) {
+            core_.move_left();
+            return true;
+        }
+        if (event == Event::Character('j')) {
+            core_.move_down();
+            return true;
+        }
+        if (event == Event::Character('k')) {
+            core_.move_up();
+            return true;
+        }
+        if (event == Event::Character('l')) {
+            core_.move_right();
+            return true;
+        }
+
+        // Home/End
+        if (event == Event::Home) {
+            core_.move_to_line_start();
+            return true;
+        }
+        if (event == Event::End) {
+            core_.move_to_line_end();
+            return true;
+        }
+
+        return false;
+    }
+
+    Element render() {
+        const auto& buffer = core_.buffer();
+        const auto& cursor = core_.cursor();
+
+        // Get the current terminal dimensions
+        auto dimensions = Terminal::Size();
+        // Account for border (2 lines) + status line (2 lines including separator)
+        int viewport_height = std::max(1, dimensions.dimy - 4);
+        core_.set_viewport_size(dimensions.dimx, viewport_height);
+
+        const auto& buffer_lines = buffer.lines();
+        auto [start_line, end_line] = core_.visible_line_range();
+
+        Elements lines;
+
+        // Render visible lines
+        for (size_t i = start_line; i < end_line; ++i) {
+            const auto& line_text = buffer_lines[i];
+
+            // Build line with cursor highlighting
+            Elements line_elements;
+
+            // Add line number
+            line_elements.push_back(text(std::to_string(i + 1) + " │ "));
+
+            // Render line with cursor on the current line
+            if (i == cursor.line) {
+                // Split line into: before cursor | at cursor | after cursor
+                std::string before = line_text.substr(0, cursor.column);
+                std::string at_cursor;
+                std::string after;
+
+                if (cursor.column < line_text.size()) {
+                    // Cursor is on a character
+                    at_cursor = line_text.substr(cursor.column, 1);
+                    after = line_text.substr(cursor.column + 1);
+                } else {
+                    // Cursor is at end of line (show as space)
+                    at_cursor = " ";
+                }
+
+                line_elements.push_back(text(before));
+                line_elements.push_back(text(at_cursor) | inverted);  // Highlight cursor
+                line_elements.push_back(text(after));
+            } else {
+                // Regular line without cursor
+                line_elements.push_back(text(line_text));
+            }
+
+            lines.push_back(hbox(line_elements));
+        }
+
+        // Fill remaining space if viewport is larger than content
+        while (lines.size() < static_cast<size_t>(viewport_height)) {
+            lines.push_back(text("~"));
+        }
+
+        // Status line
+        std::string status = buffer.name() +
+                           (buffer.is_modified() ? " [+]" : "") +
+                           " | Line " + std::to_string(cursor.line + 1) +
+                           "/" + std::to_string(buffer.line_count()) +
+                           " Col " + std::to_string(cursor.column + 1);
+
+        // Show scroll indicator if there's more content
+        if (buffer_lines.size() > static_cast<size_t>(viewport_height)) {
+            int percent = (cursor.line * 100) / (buffer_lines.size() - 1);
+            status += " " + std::to_string(percent) + "%";
+        }
+
+        // Message line (minibuffer)
+        Elements ui_elements = {
+            vbox(lines) | flex,
+            separator(),
+            text(status) | inverted
+        };
+
+        // Show message line if there's a message
+        if (!message_line_.empty()) {
+            ui_elements.push_back(text(message_line_) | dim);
+        }
+
+        return vbox(ui_elements) | border;
+    }
+
+};
+
+int main(int argc, char* argv[]) {
+    auto screen = ScreenInteractive::Fullscreen();
+    TuiEditor editor;
+
+    // Load file if provided
+    if (argc > 1) {
+        editor.load_file(argv[1]);
+    }
+
+    auto ui = editor.create_ui(&screen);
+
+    std::cout << "Lumacs - A modern text editor" << std::endl;
+    std::cout << "Controls: Arrow keys or hjkl to move, Q or Esc to quit" << std::endl;
+    std::cout << std::endl;
+
+    screen.Loop(ui);
+    return 0;
+}

+ 2 - 0
tests/CMakeLists.txt

@@ -0,0 +1,2 @@
+# Tests will go here
+# For now, just a placeholder