Source code for simvx.core.ui.code_editor_panel

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

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

from simvx.core import Signal
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 simvx.ide.State 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.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.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.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] @property def current_editor(self) -> CodeTextEdit | None: """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] @property def current_path(self) -> str | None: """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] @property def open_files(self) -> dict[str, dict]: """The open files dict (path → ``{editor, modified, original_text}``).""" return self._open_files
[docs] @property def tab_paths(self) -> list[str]: """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] @property def modified_files(self) -> list[str]: """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.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.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.current_editor if editor: editor._open_find_bar()
[docs] def show_replace(self): editor = self.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.current_editor) self.completion_requested.emit(path, line, col) def _on_completion_accepted(self, item): editor = self.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.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_rect((x, y), (w, h), colour=theme.bg, filled=True) if not self._tab_paths: scale = 12.0 / 14.0 msg = "No files open" tw = renderer.text_width(msg, scale) renderer.draw_text(msg, (x + (w - tw) / 2, y + h / 2 - 20), colour=theme.text_dim, scale=scale) 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_rect((btn_x, btn_y), (btn_w, btn_h), colour=theme.btn_bg, filled=True) renderer.draw_text(btn_text, (btn_x + 8, btn_y + 5), colour=theme.accent, scale=scale) # 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_rect((btn_x, btn_y), (btn_size, btn_size), colour=(0.25, 0.25, 0.28, 0.8), filled=True) renderer.draw_text("\u2197", (btn_x + 2, btn_y + 1), colour=(0.7, 0.7, 0.7, 0.8), scale=0.4)
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()