Source code for simvx.core.ui.code_editor_panel

"""Unified multi-tab code editor panel with optional IDE feature integration."""


from __future__ import annotations

import logging
import os
from collections.abc import Callable
from pathlib import Path

from simvx.core import Signal
from simvx.core.math.types import Vec2
from simvx.core.ui.autocomplete import AutocompletePopup
from simvx.core.ui.code_edit import CodeTextEdit
from simvx.core.ui.core import Control
from simvx.core.ui.tabs import TabContainer
from simvx.core.ui.theme import get_theme

log = logging.getLogger(__name__)

_SEVERITY_TYPES = {1: "error", 2: "warning", 3: "info", 4: "info"}


[docs] class CodeEditorPanel(Control): """Multi-tab code editor with optional IDE feature integration. Works standalone as a basic code editor. IDE features (diagnostics, breakpoints, LSP, autocomplete) are injected via callbacks and signals -- no IDEState dependency. """ def __init__(self, **kwargs): super().__init__(**kwargs) if not self.name or self.name == self.__class__.__name__: self.name = "Code" # --- Signals --- self.file_opened = Signal() self.file_saved = Signal() self.file_closed = Signal() self.active_file_changed = Signal() self.close_requested = Signal() self.file_changed_externally = Signal() self.cursor_moved = Signal() self.gutter_clicked = Signal() self.completion_requested = Signal() self.pop_out_requested = Signal() # --- Optional IDE callbacks (injected externally) --- self.on_get_diagnostics: Callable[[str], list] | None = None self.on_get_breakpoints: Callable[[str], set[int]] | None = None self.on_toggle_breakpoint: Callable[[str, int], None] | None = None self.on_status_message: Callable[[str], None] | None = None # --- Tab container --- self._tabs = TabContainer() self._tabs.tab_height = 28.0 self._tabs.font_size = 12.0 self._tabs.show_close_buttons = True self._tabs.tab_close_requested.connect(self._on_tab_close_requested) self._tabs.tab_changed.connect(self._on_tab_changed) self.add_child(self._tabs) # --- File tracking --- self._open_files: dict[str, dict] = {} self._tab_paths: list[str] = [] # --- Autocomplete --- self._autocomplete = AutocompletePopup() self._autocomplete.accepted.connect(self._on_completion_accepted) self.add_child(self._autocomplete) self._completion_prefix: str = "" # --- LSP debounce --- self._lsp_change_timer: float = 0.0 self._lsp_pending_path: str = "" self._lsp_client = None # --- Default editor properties (set by IDE config wiring) --- self.default_show_fold_gutter: bool = True self.default_show_indent_guides: bool = True # --- Deferred focus --- self._pending_focus = None # --- File watcher --- self._file_watch_timer: float = 0.0 self._file_mtimes: dict[str, float] = {} # --- Cursor tracking --- self._cursor_line: int = 0 self._cursor_col: int = 0 # ====================================================================== # Public API # ======================================================================
[docs] def open_file(self, path: str): """Open a file in a new tab, or switch to its existing tab.""" resolved = str(Path(path).resolve()) if resolved in self._open_files: idx = self._tab_paths.index(resolved) self._tabs.current_tab = idx self._tabs._update_layout() self.active_file_changed.emit(resolved) self._open_files[resolved]["editor"].set_focus() return try: text = Path(resolved).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as e: log.error("Failed to open %s: %s", resolved, e) self._emit_status(f"Failed to open {Path(resolved).name}: {e}") return editor = CodeTextEdit(text=text, name=Path(resolved).name) editor.show_line_numbers = True editor._show_fold_gutter = self.default_show_fold_gutter editor.show_indent_guides = self.default_show_indent_guides editor.text_changed.connect(lambda _t, p=resolved: self._on_text_changed(p)) editor.gutter_clicked.connect(lambda line, p=resolved: self._on_gutter_clicked(p, line)) editor.completion_requested.connect( lambda e=editor, p=resolved: self._request_completion(p, e._cursor_line, e._cursor_col) ) self._open_files[resolved] = {"editor": editor, "modified": False, "original_text": text} self._tab_paths.append(resolved) self._tabs.add_child(editor) self._tabs.current_tab = len(self._tab_paths) - 1 self._tabs._update_layout() self.active_file_changed.emit(resolved) self.file_opened.emit(resolved) editor.set_focus() try: self._file_mtimes[resolved] = os.stat(resolved).st_mtime except OSError: pass self._apply_diagnostics(resolved) self._apply_breakpoints(resolved)
[docs] def save_current(self): """Save the active tab's file to disk.""" path = self.get_current_path() if path: self.save_file(path)
[docs] def save_file(self, path: str): """Save a specific file to disk.""" info = self._open_files.get(path) if not info: return editor: CodeTextEdit = info["editor"] try: Path(path).write_text(editor.text, encoding="utf-8") except OSError as e: log.error("Failed to save %s: %s", path, e) self._emit_status(f"Failed to save {Path(path).name}: {e}") return info["modified"] = False info["original_text"] = editor.text editor.name = Path(path).name try: self._file_mtimes[path] = os.stat(path).st_mtime except OSError: pass self.file_saved.emit(path) self._emit_status(f"Saved {Path(path).name}")
[docs] def save_all(self): """Save all modified files.""" for path, info in self._open_files.items(): if info["modified"]: self.save_file(path)
[docs] def close_current(self): """Close the active tab.""" path = self.get_current_path() if path: self.close_file(path)
[docs] def close_file(self, path: str): """Close a specific file's tab.""" resolved = path if path.startswith("untitled") else str(Path(path).resolve()) if resolved not in self._open_files: return info = self._open_files.pop(resolved) idx = self._tab_paths.index(resolved) self._tab_paths.remove(resolved) self._tabs.remove_child(info["editor"]) if self._tab_paths: new_idx = min(idx, len(self._tab_paths) - 1) self._tabs.current_tab = new_idx self._tabs._update_layout() self.active_file_changed.emit(self._tab_paths[new_idx]) self._pending_focus = self._open_files[self._tab_paths[new_idx]]["editor"] else: self._tabs.current_tab = 0 self.active_file_changed.emit("") self._file_mtimes.pop(resolved, None) self.file_closed.emit(resolved)
[docs] def new_file(self): """Create a new empty untitled tab.""" count = sum(1 for p in self._tab_paths if p.startswith("untitled")) name = f"untitled-{count + 1}" editor = CodeTextEdit(text="", name=name) editor.show_line_numbers = True editor._show_fold_gutter = self.default_show_fold_gutter editor.show_indent_guides = self.default_show_indent_guides editor.text_changed.connect(lambda _t, p=name: self._on_text_changed(p)) editor.completion_requested.connect( lambda e=editor, p=name: self._request_completion(p, e._cursor_line, e._cursor_col) ) self._open_files[name] = {"editor": editor, "modified": False, "original_text": ""} self._tab_paths.append(name) self._tabs.add_child(editor) self._tabs.current_tab = len(self._tab_paths) - 1 self._tabs._update_layout() self.active_file_changed.emit(name) editor.set_focus()
[docs] def goto_line(self, line: int, col: int = 0): """Navigate to a line (and optionally column) in the active editor.""" editor = self.get_current_editor() if editor: editor._cursor_line = max(0, min(line, len(editor._lines) - 1)) editor._cursor_col = max(0, col) editor._ensure_cursor_visible() editor._cursor_blink = 0.0
[docs] def get_current_editor(self) -> CodeTextEdit | None: """Return the active tab's CodeTextEdit, or None.""" if not self._tab_paths: return None idx = self._tabs.current_tab if 0 <= idx < len(self._tab_paths): return self._open_files[self._tab_paths[idx]]["editor"] return None
[docs] def get_current_path(self) -> str | None: """Return the active tab's file path, or None.""" idx = self._tabs.current_tab if 0 <= idx < len(self._tab_paths): return self._tab_paths[idx] return None
[docs] def get_open_files(self) -> dict[str, dict]: """Return the open files dict.""" return self._open_files
[docs] def get_tab_paths(self) -> list[str]: """Return ordered list of open file paths matching tab indices.""" return self._tab_paths
[docs] def is_file_modified(self, path: str) -> bool: """Check if a file has unsaved modifications.""" info = self._open_files.get(path) return info["modified"] if info else False
[docs] def get_file_text(self, path: str) -> str: """Get the current text content of an open file.""" info = self._open_files.get(path) return info["editor"].text if info else ""
[docs] def get_modified_files(self) -> list[str]: """Return list of paths with unsaved modifications.""" return [p for p, info in self._open_files.items() if info["modified"]]
[docs] def rename_file(self, old_path: str, new_path: str): """Update internal tracking when a file is saved to a new path.""" if old_path not in self._open_files: return info = self._open_files.pop(old_path) idx = self._tab_paths.index(old_path) self._tab_paths[idx] = new_path self._open_files[new_path] = info info["editor"].name = Path(new_path).name self._file_mtimes.pop(old_path, None)
[docs] def reload_file(self, path: str): """Reload a file's content from disk.""" if path not in self._open_files: return try: text = Path(path).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return info = self._open_files[path] info["editor"].text = text info["original_text"] = text info["modified"] = False info["editor"].name = Path(path).name try: self._file_mtimes[path] = os.stat(path).st_mtime except OSError: pass
[docs] def set_all_editors_property(self, attr: str, value): """Set a property on all open CodeTextEdit widgets.""" for info in self._open_files.values(): setattr(info["editor"], attr, value)
# --- IDE integration methods ---
[docs] def set_lsp_client(self, client): """Inject an LSP client for change notifications.""" self._lsp_client = client
[docs] def refresh_diagnostics(self, path: str): """Reapply diagnostic markers for a file (call after diagnostics change).""" resolved = str(Path(path).resolve()) self._apply_diagnostics(resolved)
[docs] def refresh_breakpoints(self, path: str): """Reapply breakpoint markers for a file.""" resolved = str(Path(path).resolve()) self._apply_breakpoints(resolved)
[docs] def show_completions(self, items: list, prefix: str | None = None): """Display autocomplete popup with completion results.""" if not items: return editor = self.get_current_editor() if not editor: return if prefix is None: prefix = self._completion_prefix x, y, w, h = self.get_global_rect() lh = float(editor.font_size) * 1.4 gutter_w = 50.0 char_w = float(editor.font_size) * 0.6 popup_x = x + gutter_w + editor._cursor_col * char_w popup_y = y + 28.0 + (editor._cursor_line - editor._scroll_y + 1) * lh self._autocomplete.show(items, popup_x, popup_y) if prefix: self._autocomplete.update_filter(prefix)
# --- Editor delegation --- def _delegate(self, method: str): editor = self.get_current_editor() if editor and hasattr(editor, method): getattr(editor, method)()
[docs] def undo(self): self._delegate("undo")
[docs] def redo(self): self._delegate("redo")
[docs] def cut(self): self._delegate("cut")
[docs] def copy(self): self._delegate("copy")
[docs] def paste(self): self._delegate("paste")
[docs] def select_all(self): self._delegate("select_all")
[docs] def toggle_comment(self): self._delegate("toggle_comment")
[docs] def delete_line(self): self._delegate("delete_line")
[docs] def select_next_occurrence(self): self._delegate("select_next_occurrence")
[docs] def show_find(self): editor = self.get_current_editor() if editor: editor._open_find_bar()
[docs] def show_replace(self): editor = self.get_current_editor() if editor: editor._open_find_bar() editor._replace_active = True
# ====================================================================== # Internal # ====================================================================== def _emit_status(self, msg: str): if self.on_status_message: self.on_status_message(msg) def _on_gutter_clicked(self, path: str, line: int): if self.on_toggle_breakpoint: self.on_toggle_breakpoint(path, line) self.gutter_clicked.emit(path, line) def _on_tab_close_requested(self, index: int): if 0 <= index < len(self._tab_paths): path = self._tab_paths[index] if self.close_requested._callbacks: self.close_requested.emit(path) else: self.close_file(path) def _on_tab_changed(self, idx: int): if 0 <= idx < len(self._tab_paths): path = self._tab_paths[idx] self.active_file_changed.emit(path) editor = self._open_files[path]["editor"] self.cursor_moved.emit(editor._cursor_line, editor._cursor_col) def _on_text_changed(self, path: str): info = self._open_files.get(path) if not info: return editor: CodeTextEdit = info["editor"] modified = editor.text != info["original_text"] info["modified"] = modified editor.name = f"*{Path(path).name}" if modified else Path(path).name # Report cursor self._cursor_line = editor._cursor_line self._cursor_col = editor._cursor_col self.cursor_moved.emit(editor._cursor_line, editor._cursor_col) # LSP debounce if not path.startswith("untitled"): self._lsp_pending_path = path self._lsp_change_timer = 0.3 # Trigger autocomplete after dot line = editor._lines[editor._cursor_line] if editor._cursor_line < len(editor._lines) else "" col = editor._cursor_col if col > 0 and line[col - 1:col] == ".": self._lsp_change_timer = 0 self._lsp_pending_path = "" self._lsp_send_change(path) self._request_completion(path, editor._cursor_line, col) elif self._autocomplete and self._autocomplete.is_visible: word = self._word_before_cursor(editor) if word: self._autocomplete.update_filter(word) else: self._autocomplete.hide() def _apply_diagnostics(self, path: str): info = self._open_files.get(path) if not info or not self.on_get_diagnostics: return editor: CodeTextEdit = info["editor"] editor.clear_markers("error") editor.clear_markers("warning") editor.clear_markers("info") for diag in self.on_get_diagnostics(path): marker_type = _SEVERITY_TYPES.get(diag.severity, "info") editor.add_marker(diag.line, diag.col_start, diag.col_end, type=marker_type, tooltip=diag.message) def _apply_breakpoints(self, path: str): info = self._open_files.get(path) if not info or not self.on_get_breakpoints: return editor: CodeTextEdit = info["editor"] editor.clear_markers("breakpoint") for bp_line in self.on_get_breakpoints(path): editor.add_marker(bp_line, 0, 999, type="breakpoint", tooltip="Breakpoint") def _request_completion(self, path: str, line: int, col: int): self._completion_prefix = self._word_before_cursor(self.get_current_editor()) self.completion_requested.emit(path, line, col) def _on_completion_accepted(self, item): editor = self.get_current_editor() if not editor: return prefix_len = len(self._completion_prefix) if self._completion_prefix else 0 insert_text = item.insert_text or item.label if prefix_len > 0 and insert_text.startswith(self._completion_prefix): insert_text = insert_text[prefix_len:] elif prefix_len > 0: line = editor._lines[editor._cursor_line] col = editor._cursor_col editor._lines[editor._cursor_line] = line[:col - prefix_len] + line[col:] editor._cursor_col -= prefix_len line = editor._lines[editor._cursor_line] col = editor._cursor_col editor._lines[editor._cursor_line] = line[:col] + insert_text + line[col:] editor._cursor_col += len(insert_text) editor._ensure_cursor_visible() editor.text_changed.emit(editor.text) @staticmethod def _word_before_cursor(editor) -> str: if not editor: return "" line = editor._lines[editor._cursor_line] if editor._cursor_line < len(editor._lines) else "" col = editor._cursor_col start = col while start > 0 and (line[start - 1].isalnum() or line[start - 1] == "_"): start -= 1 return line[start:col] def _lsp_send_change(self, path: str): info = self._open_files.get(path) if not info: return client = self._lsp_client if not client: root = self.parent while root and root.parent: root = root.parent client = getattr(root, "_lsp_client", None) if client: client.notify_change(path, info["editor"].text) def _poll_file_changes(self): for path, mtime in list(self._file_mtimes.items()): if path not in self._open_files: continue try: current_mtime = os.stat(path).st_mtime except OSError: continue if current_mtime != mtime: self._file_mtimes[path] = current_mtime info = self._open_files[path] if not info["modified"]: self.reload_file(path) else: self.file_changed_externally.emit(path) # ====================================================================== # Layout / Draw # ======================================================================
[docs] def process(self, dt: float): # Deferred focus if self._pending_focus is not None: ctrl = self._pending_focus self._pending_focus = None if self._tree: self._tree._set_focused_control(ctrl) else: ctrl.set_focus() # Layout — only when size changed _, _, w, h = self.get_rect() from .containers import Container Container._place(self._tabs, 0, 0, w, h) # When empty, let clicks pass through to CodeEditorPanel (for New File button) self._tabs.mouse_filter = bool(self._tab_paths) # Cursor sync editor = self.get_current_editor() if editor: line, col = editor._cursor_line, editor._cursor_col if line != self._cursor_line or col != self._cursor_col: self._cursor_line = line self._cursor_col = col self.cursor_moved.emit(line, col) # LSP debounce if self._lsp_change_timer > 0: self._lsp_change_timer -= dt if self._lsp_change_timer <= 0 and self._lsp_pending_path: self._lsp_send_change(self._lsp_pending_path) self._lsp_pending_path = "" # File watcher self._file_watch_timer += dt if self._file_watch_timer >= 1.5: self._file_watch_timer = 0.0 self._poll_file_changes()
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, theme.bg) if not self._tab_paths: scale = 12.0 / 14.0 msg = "No files open" tw = renderer.text_width(msg, scale) renderer.draw_text_coloured(msg, x + (w - tw) / 2, y + h / 2 - 20, scale, theme.text_dim) btn_text = "+ New File" btn_tw = renderer.text_width(btn_text, scale) btn_x = x + (w - btn_tw) / 2 - 8 btn_y = y + h / 2 btn_w = btn_tw + 16 btn_h = 24 self._new_file_btn_rect = (btn_x, btn_y, btn_w, btn_h) renderer.draw_filled_rect(btn_x, btn_y, btn_w, btn_h, theme.btn_bg) renderer.draw_text_coloured(btn_text, btn_x + 8, btn_y + 5, scale, theme.accent) # Pop-out button (small arrow icon in top-right) btn_size = 16.0 btn_x = x + w - btn_size - 6 btn_y = y + 6 renderer.draw_filled_rect(btn_x, btn_y, btn_size, btn_size, (0.25, 0.25, 0.28, 0.8)) renderer.draw_text_coloured("\u2197", btn_x + 2, btn_y + 1, 0.4, (0.7, 0.7, 0.7, 0.8))
def _on_gui_input(self, event): """Handle pop-out button and new-file button clicks.""" if not event.pressed or event.button != 1: return px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] # New File button (empty state) if hasattr(self, "_new_file_btn_rect") and not self._tab_paths: bx, by, bw, bh = self._new_file_btn_rect if bx <= px <= bx + bw and by <= py <= by + bh: self.new_file() return # Pop-out button x, y, w, h = self.get_global_rect() btn_size = 16.0 btn_x = x + w - btn_size - 6 btn_y = y + 6 if btn_x <= px <= btn_x + btn_size and btn_y <= py <= btn_y + btn_size: self.pop_out_requested.emit()