#include "lumacs/tui_editor.hpp" #include "lumacs/editor_core.hpp" #include "lumacs/lua_api.hpp" #include "lumacs/mode_activator.hpp" #include "lumacs/keybinding.hpp" #include #include #include #include #include #include #include using namespace lumacs; /// ncurses-based TUI frontend for Lumacs class TuiEditor : public IEditorView { public: TuiEditor() : core_(nullptr) {} ~TuiEditor() override { endwin(); // Cleanup ncurses } // IEditorView methods void init() override; void run() override; void handle_editor_event(EditorEvent event) override; void set_core(EditorCore* core) override; private: EditorCore* core_ = nullptr; // Raw pointer to EditorCore, not owned std::unique_ptr mode_activator_; bool should_quit_ = false; std::string message_line_; int height_ = 0, width_ = 0; // For cursor blinking logic bool cursor_visible_ = true; std::chrono::steady_clock::time_point last_cursor_move_time_ = std::chrono::steady_clock::now(); static constexpr std::chrono::milliseconds BLINK_INTERVAL = std::chrono::milliseconds(500); static constexpr std::chrono::milliseconds BLINK_STATIONARY_THRESHOLD = std::chrono::milliseconds(1000); // 1 second // Viewport bounds calculation (avoids code duplication) struct ViewportBounds { int content_width; int content_height; int line_number_width; int minibuffer_lines; }; ViewportBounds calculate_viewport_bounds() const { ViewportBounds bounds; bounds.minibuffer_lines = 1; // Always reserve 1 line for minibuffer/message bounds.content_height = height_ - bounds.minibuffer_lines; bool show_line_numbers = core_->config().get("show_line_numbers", true); bounds.line_number_width = show_line_numbers ? core_->config().get("line_number_width", 6) : 0; bounds.content_width = width_ - bounds.line_number_width; return bounds; } // Private helper method declarations std::string resolve_key(int ch); bool handle_input(int ch); void render(); void render_layout_node(std::shared_ptr node, int x, int y, int width, int height); void render_window(std::shared_ptr window, int x, int y, int width, int height); void render_window_modeline(std::shared_ptr window, int x, int y, int width, bool is_active); void render_message_line(); int get_attributes_for_face(const std::string& face_name); // Hardware cursor position tracking int hw_cursor_x_ = -1; int hw_cursor_y_ = -1; }; // --- TuiEditor Public Method Definitions --- void TuiEditor::init() { // Initialize ncurses initscr(); cbreak(); // Disable line buffering noecho(); // Don't echo pressed keys keypad(stdscr, TRUE); // Enable special keys raw(); // Enable all control characters timeout(50); // Set 50ms timeout for getch() to avoid blocking forever // Color support if (has_colors()) { start_color(); use_default_colors(); } // Get screen dimensions getmaxyx(stdscr, height_, width_); // Initialize theme colors for ncurses if (has_colors() && core_->theme_manager().active_theme()) { core_->theme_manager().active_theme()->initialize_ncurses_colors(); } // Set initial viewport size auto bounds = calculate_viewport_bounds(); core_->set_viewport_size(bounds.content_width, bounds.content_height); spdlog::debug("ncurses editor initialized: {}x{}", width_, height_); } void TuiEditor::run() { should_quit_ = false; // Initial render render(); std::chrono::steady_clock::time_point last_blink_toggle_time = std::chrono::steady_clock::now(); while (!should_quit_) { // Handle screen resize int new_height, new_width; getmaxyx(stdscr, new_height, new_width); if (new_height != height_ || new_width != width_) { height_ = new_height; width_ = new_width; auto bounds = calculate_viewport_bounds(); core_->set_viewport_size(bounds.content_width, bounds.content_height); spdlog::debug("Screen resized to: {}x{}", width_, height_); spdlog::debug("Content area: {}x{}", bounds.content_width, bounds.content_height); // Force cursor to be visible after resize, as it implies movement. last_cursor_move_time_ = std::chrono::steady_clock::now(); cursor_visible_ = true; render(); // Re-render after resize } // Get input (with timeout) int ch = getch(); // Only process input and render if we got actual input (not timeout) if (ch != ERR) { last_cursor_move_time_ = std::chrono::steady_clock::now(); cursor_visible_ = true; // Ensure cursor is visible on input handle_input(ch); render(); last_blink_toggle_time = std::chrono::steady_clock::now(); // Reset blink timer after input } else { // No input (timeout occurred) auto now = std::chrono::steady_clock::now(); if (now - last_cursor_move_time_ > BLINK_STATIONARY_THRESHOLD) { // If stationary for long enough, start/continue blinking if (now - last_blink_toggle_time > BLINK_INTERVAL) { cursor_visible_ = !cursor_visible_; core_->check_and_clear_message(); // Check and clear messages (like for GtkEditor's blink) render(); // Re-render to show/hide cursor last_blink_toggle_time = now; } } else { // Still "moving" recently, keep cursor visible. // If it was just hidden by a blink, make it visible again. if (!cursor_visible_) { cursor_visible_ = true; render(); // Make sure it's visible } } } } } void TuiEditor::handle_editor_event(EditorEvent event) { // Handle UI-specific events first if (event == EditorEvent::Quit) { should_quit_ = true; return; } else if (event == EditorEvent::Message) { message_line_ = core_->last_message(); return; } else if (event == EditorEvent::CursorMoved) { last_cursor_move_time_ = std::chrono::steady_clock::now(); cursor_visible_ = true; render(); return; } else if (event == EditorEvent::TransientMessageCleared) { render(); return; } // Delegate mode activation events to ModeActivator if (mode_activator_) { auto quit_callback = [this]() { should_quit_ = true; }; mode_activator_->handle_mode_event(event, quit_callback); } } void TuiEditor::set_core(EditorCore* core) { core_ = core; if (core_) { mode_activator_ = std::make_unique(*core_); } } // --- TuiEditor Private Helper Method Definitions --- int TuiEditor::get_attributes_for_face(const std::string& face_name) { auto theme = core_->theme_manager().active_theme(); if (theme) { return theme->get_face_attributes_ncurses(face_name); } return 0; // A_NORMAL } /// Convert ncurses key code to our key name format std::string TuiEditor::resolve_key(int ch) { spdlog::trace("NCURSES INPUT: Raw key code: {} (0x{:x})", ch, ch); if (ch == 27) return "Escape"; if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return "Return"; if (ch == '\t') return "Tab"; if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') return "Backspace"; if (ch == KEY_UP) return "ArrowUp"; if (ch == KEY_DOWN) return "ArrowDown"; if (ch == KEY_LEFT) return "ArrowLeft"; if (ch == KEY_RIGHT) return "ArrowRight"; if (ch == KEY_HOME) return "Home"; if (ch == KEY_END) return "End"; if (ch == KEY_PPAGE) return "PageUp"; if (ch == KEY_NPAGE) return "PageDown"; if (ch == ' ') return "Space"; // Control keys if (ch > 0 && ch < 32) { return "C-" + std::string(1, (char)('a' + ch - 1)); } // Regular characters if (ch >= 32 && ch < 127) { return std::string(1, (char)ch); } return ""; } bool TuiEditor::handle_input(int ch) { std::string key_name; // Check for Meta key sequence (Escape + Key) if (ch == 27) { // Set non-blocking read to check for immediate next key timeout(0); int next_ch = getch(); timeout(50); // Restore timeout if (next_ch != ERR) { // Ensure next_ch is a valid printable char or special key we can map // For now, handle simple M-char sequences std::string next_key = resolve_key(next_ch); if (!next_key.empty() && next_key.length() == 1) { // Simple char key_name = "M-" + next_key; } else if (!next_key.empty()) { // Special key with Meta, e.g. M-ArrowUp? // resolve_key returns "ArrowUp", so we get "M-ArrowUp". This is valid. key_name = "M-" + next_key; } else { // Couldn't resolve next key, just treat as Escape then ignore next? // Or treat as Escape sequence. key_name = "Escape"; // We effectively consumed next_ch and ignored it. // Better might be to ungetch, but ncurses ungetch is tricky. // Let's assume if resolve_key fails it was garbage. } } else { key_name = "Escape"; } } else { key_name = resolve_key(ch); } if (key_name.empty()) { spdlog::trace("Empty key name, ignoring input"); return false; } spdlog::trace("Resolved key: {}", key_name); // Handle Minibuffer Input Logic first if (core_->minibuffer_manager().is_active()) { return core_->minibuffer_manager().handle_key_event(key_name); } // Normal mode processing (pass to keybinding system) KeyProcessingResult result = core_->keybinding_manager().process_key(Key::parse(key_name)); if (result.command_result.has_value()) { core_->set_message(result.command_result->message); } if (result.type == KeyResult::Unbound) { // Fallback: Self-insert for printable characters // Check if key is a single character and not a control sequence // The resolve_key function returns "C-x", "M-x", "Esc", "Return", etc. // Printable characters are returned as single chars "a", "1", etc. bool has_ctrl = key_name.find("C-") != std::string::npos; bool has_meta = key_name.find("M-") != std::string::npos; // Special case: "Space" should be treated as a printable character bool is_space = (key_name == "Space"); std::string char_to_insert = is_space ? " " : key_name; if (!has_ctrl && !has_meta && (key_name.length() == 1 || is_space)) { // We can assume it's printable if length is 1 and it's not a special key (which resolve_key handles) core_->command_system().execute("self-insert-command", {char_to_insert}); // --- Macro Recording Logic for Self-Insert --- if (core_->is_recording_macro()) { core_->record_key_sequence(char_to_insert); } // -------------------------------------------- return true; } } return result.type != KeyResult::Unbound; } // process_key is removed as keybinding_manager handles it void TuiEditor::render() { // Reset hardware cursor position hw_cursor_x_ = -1; hw_cursor_y_ = -1; // Clear and update screen info getmaxyx(stdscr, height_, width_); // Set background color from theme auto theme = core_->theme_manager().active_theme(); if (theme) { int bg_color_pair = theme->get_color_pair(ThemeElement::Background); bkgd(bg_color_pair); } clear(); // Calculate content area auto bounds = calculate_viewport_bounds(); // Render the layout tree recursively (now includes per-window modelines) render_layout_node(core_->root_layout(), 0, 0, width_, bounds.content_height); // Global message/command line (last line) render_message_line(); // Set hardware cursor if (core_->minibuffer_manager().is_active()) { // Minibuffer active: render_message_line already moved cursor to input pos curs_set(2); // Ensure visible (high visibility) } else { // Minibuffer inactive: place at buffer cursor if (hw_cursor_x_ != -1 && hw_cursor_y_ != -1 && cursor_visible_) { move(hw_cursor_y_, hw_cursor_x_); curs_set(2); // High visibility } else { curs_set(0); // Hide if off-screen or blinking off } } // Refresh screen refresh(); } void TuiEditor::render_layout_node(std::shared_ptr node, int x, int y, int width, int height) { if (!node) return; if (node->type == LayoutNode::Type::Leaf) { // Render a single window render_window(node->window, x, y, width, height); } else if (node->type == LayoutNode::Type::HorizontalSplit) { // Split horizontally: top and bottom windows int top_height = height / 2; int bottom_height = height - top_height; render_layout_node(node->child1, x, y, width, top_height); render_layout_node(node->child2, x, y + top_height, width, bottom_height); } else if (node->type == LayoutNode::Type::VerticalSplit) { // Split vertically: left and right windows int separator_width = (width > 2) ? 1 : 0; int available_width = width - separator_width; int left_width = available_width / 2; int right_width = available_width - left_width; render_layout_node(node->child1, x, y, left_width, height); // Draw separator if enabled if (separator_width > 0) { int attrs = get_attributes_for_face("window-divider"); attron(attrs); int sep_x = x + left_width; for (int i = 0; i < height; ++i) { mvaddch(y + i, sep_x, ACS_VLINE); } attroff(attrs); } render_layout_node(node->child2, x + left_width + separator_width, y, right_width, height); } } void TuiEditor::render_window(std::shared_ptr window, int x, int y, int width, int height) { if (!window) return; // Check configuration for line numbers and modeline bool show_line_numbers = core_->config().get("show_line_numbers", true); bool show_modeline = core_->config().get("show_modeline", true); int line_number_width = show_line_numbers ? core_->config().get("line_number_width", 6) : 0; int modeline_height = show_modeline ? 1 : 0; // Update window viewport size (reserve space for modeline) int content_width = width - line_number_width; int content_height = height - modeline_height; window->set_viewport_size(content_width, content_height); // Get window data const auto& buffer = window->buffer(); const auto cursor = window->cursor(); auto [start_line, end_line] = window->visible_line_range(); bool is_active = (window == core_->active_window()); spdlog::trace("Render window at {},{} size {}x{} viewport={}-{} cursor=({},{}) active={}", x, y, width, height, start_line, end_line, cursor.line, cursor.column, is_active); // Render buffer lines for (int screen_y = 0; screen_y < content_height && start_line + screen_y < end_line; ++screen_y) { size_t buffer_line_idx = start_line + screen_y; const auto& line_text = buffer.line(buffer_line_idx); // Clear this line move(y + screen_y, x); for (int i = 0; i < width; ++i) addch(' '); // Line number (if enabled) if (show_line_numbers) { mvprintw(y + screen_y, x, "%3zu ", buffer_line_idx + 1); } // Line content with syntax highlighting if (!line_text.empty()) { int max_content_width = content_width - 1; const auto& styles = buffer.get_line_styles(buffer_line_idx); if (styles.empty()) { // No styles, render as plain text std::string display_text = line_text; if ((int)display_text.length() > max_content_width) { display_text = display_text.substr(0, max_content_width - 3) + "..."; } mvprintw(y + screen_y, x + line_number_width, "%s", display_text.c_str()); } else { // Render with syntax highlighting size_t pos = 0; int screen_x = x + line_number_width; for (const auto& styled : styles) { size_t start = styled.range.start.column; size_t end = std::min(styled.range.end.column, line_text.size()); // Render unstyled text before this styled range if (pos < start && pos < line_text.size()) { size_t len = std::min(start - pos, line_text.size() - pos); std::string unstyled = line_text.substr(pos, len); mvprintw(y + screen_y, screen_x, "%s", unstyled.c_str()); screen_x += unstyled.length(); pos = start; } // Render styled text if (pos < end && pos < line_text.size()) { size_t len = std::min(end - pos, line_text.size() - pos); std::string styled_text = line_text.substr(pos, len); // Apply color int attrs = get_attributes_for_face(styled.attr.face_name); attron(attrs); mvprintw(y + screen_y, screen_x, "%s", styled_text.c_str()); attroff(attrs); screen_x += styled_text.length(); pos = end; } } // Render remaining unstyled text if (pos < line_text.size()) { std::string remaining = line_text.substr(pos); if ((int)(screen_x - x - line_number_width + remaining.length()) > max_content_width) { remaining = remaining.substr(0, max_content_width - (screen_x - x - line_number_width) - 3) + "..."; } mvprintw(y + screen_y, screen_x, "%s", remaining.c_str()); } } } // Highlight ISearch match if (core_->minibuffer_manager().is_isearch_active() && core_->minibuffer_manager().get_isearch_match_range().has_value()) { auto match = core_->minibuffer_manager().get_isearch_match_range().value(); // Check if current line is part of the match if (buffer_line_idx >= match.start.line && buffer_line_idx <= match.end.line) { int horizontal_offset = window->viewport().horizontal_offset; size_t hl_start = (buffer_line_idx == match.start.line) ? match.start.column : 0; size_t hl_end = (buffer_line_idx == match.end.line) ? match.end.column : line_text.length(); // Handle case where match extends past end of line (e.g. capturing newline) // Ensure we don't go past line text length for string extraction size_t text_len = line_text.length(); size_t safe_hl_end = std::min(hl_end, text_len); // Adjust for horizontal scroll int draw_start = static_cast(hl_start) - horizontal_offset; int draw_end = static_cast(safe_hl_end) - horizontal_offset; // Clip to viewable area draw_start = std::max(0, draw_start); // Clip draw_end to window width if (draw_end > content_width) draw_end = content_width; if (draw_start < draw_end) { // Extract text to highlight std::string text_to_hl = line_text.substr(draw_start + horizontal_offset, draw_end - draw_start); int screen_x = x + line_number_width + draw_start; int attrs = get_attributes_for_face(core_->minibuffer_manager().is_isearch_failed() ? "isearch-fail" : "isearch"); if (attrs == 0) attrs = A_REVERSE; attron(attrs); mvprintw(y + screen_y, screen_x, "%s", text_to_hl.c_str()); attroff(attrs); } } } // Show cursor if this is the cursor line and this is the active window if (buffer_line_idx == cursor.line && is_active && cursor_visible_) { int cursor_screen_x = x + line_number_width + cursor.column; if (cursor_screen_x < x + width) { char cursor_char = ' '; if (cursor.column < line_text.size()) { cursor_char = line_text[cursor.column]; } // Force A_REVERSE to ensure visible block cursor int attrs = A_REVERSE; attron(attrs); mvaddch(y + screen_y, cursor_screen_x, cursor_char); attroff(attrs); } } // Track hardware cursor position for active window if (buffer_line_idx == cursor.line && is_active) { int cursor_screen_x = x + line_number_width + (int)cursor.column; if (cursor_screen_x < x + width) { hw_cursor_x_ = cursor_screen_x; hw_cursor_y_ = y + screen_y; } } } // Fill remaining lines (for empty lines below buffer) - no tildes size_t displayed_lines = std::min((size_t)content_height, end_line - start_line); for (int screen_y = displayed_lines; screen_y < content_height; ++screen_y) { move(y + screen_y, x); for (int i = 0; i < width; ++i) addch(' '); } // Render modeline for this window if (show_modeline) { render_window_modeline(window, x, y + content_height, width, is_active); } } void TuiEditor::render_window_modeline(std::shared_ptr window, int x, int y, int width, bool is_active) { const auto& buffer = window->buffer(); const auto cursor = window->cursor(); // Choose modeline colors std::string face_name = is_active ? "mode-line" : "mode-line-inactive"; int attrs = get_attributes_for_face(face_name); if (attrs == 0) attrs = is_active ? A_REVERSE : A_DIM; attron(attrs); // Clear the modeline move(y, x); for (int i = 0; i < width; ++i) addch(' '); // Create modeline content std::string modeline; // Buffer name and modification status modeline += buffer.name(); if (buffer.is_modified()) modeline += " [+] "; // Cursor position modeline += " | " + std::to_string(cursor.line + 1) + ":" + std::to_string(cursor.column + 1); // Major mode (if available) // TODO: Add major mode support when available // Right-aligned content (percentage through file) std::string right_side; if (buffer.line_count() > 0) { int percentage = (cursor.line * 100) / (buffer.line_count() - 1); right_side = " " + std::to_string(percentage) + "%"; } // Truncate modeline if too long int available_width = width - right_side.length(); if ((int)modeline.length() > available_width) { modeline = modeline.substr(0, available_width - 3) + "..."; } // Render left side mvprintw(y, x, "%s", modeline.c_str()); // Render right side if (!right_side.empty()) { mvprintw(y, x + width - right_side.length(), "%s", right_side.c_str()); } // Turn off modeline attributes attroff(attrs); } void TuiEditor::render_message_line() { int msg_y = height_ - 1; int attrs = get_attributes_for_face("minibuffer-prompt"); attron(attrs); move(msg_y, 0); clrtoeol(); if (core_->minibuffer_manager().is_active()) { std::string prompt_part = core_->minibuffer_manager().get_prompt(); std::string input_part = core_->minibuffer_manager().get_input_buffer(); std::string display_text = prompt_part + input_part; mvprintw(msg_y, 0, "%s", display_text.c_str()); // Display completion candidates below the input line (conceptually, but physically above if at bottom) auto candidates = core_->minibuffer_manager().get_completion_candidates(); if (!candidates.empty() && msg_y > 0) { // Move up one line to display completions above the current minibuffer line move(msg_y - 1, 0); clrtoeol(); // Clear the line first to remove artifacts from underlying window std::string completion_display; for (size_t i = 0; i < candidates.size() && static_cast(completion_display.length()) < width_ - 5; ++i) { if (!completion_display.empty()) completion_display += " "; completion_display += candidates[i].display_text; } if (static_cast(completion_display.length()) >= width_ - 5) { completion_display = completion_display.substr(0, width_ - 8) + "..."; } // Render with a different color/attribute if possible mvprintw(msg_y - 1, 0, "%s", completion_display.c_str()); } // Explicitly place cursor at the correct position in minibuffer // prompt_part is handled by get_prompt(), cursor_position is relative to input_buffer move(msg_y, prompt_part.length() + core_->minibuffer_manager().get_cursor_position()); } else if (core_->keybinding_manager().is_building_sequence()) { // Display partial key sequence (like Emacs "C-x-") std::string key_seq = core_->keybinding_manager().current_sequence_display() + "-"; mvprintw(msg_y, 0, "%s", key_seq.c_str()); } else if (!message_line_.empty()) { // Display transient message with truncation if needed std::string display_msg = message_line_; // Handle multi-line messages: show first line + indicator size_t newline_pos = display_msg.find('\n'); if (newline_pos != std::string::npos) { // Count lines size_t line_count = 1; size_t pos = 0; while ((pos = display_msg.find('\n', pos)) != std::string::npos) { ++line_count; ++pos; } // Show first line with line count indicator display_msg = display_msg.substr(0, newline_pos) + " [+" + std::to_string(line_count - 1) + " lines, C-h e]"; } const std::string suffix = "...[C-h e]"; int available_width = width_ - 1; if (static_cast(display_msg.length()) > available_width) { size_t max_chars = available_width - suffix.length(); display_msg = display_msg.substr(0, max_chars) + suffix; } // Apply color based on severity int msg_attrs = attrs; switch (core_->last_message_severity()) { case MessageSeverity::Warning: msg_attrs |= COLOR_PAIR(3) | A_BOLD; // Yellow break; case MessageSeverity::Error: msg_attrs |= COLOR_PAIR(1) | A_BOLD; // Red break; default: break; } attron(msg_attrs); mvprintw(msg_y, 0, "%s", display_msg.c_str()); attroff(msg_attrs); } attroff(attrs); // After rendering, check if it's time to clear the message. core_->check_and_clear_message(); } namespace lumacs { std::unique_ptr create_tui_editor() { return std::make_unique(); } }