"""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 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()