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