Source code for simvx.editor.panels.code_tab
"""Code editor tab integration for the editor's center tab area.
Wraps CodeEditorPanel from simvx.core.ui with optional LSP integration
and file lifecycle management. Multiple files are managed as sub-tabs
within the CodeEditorPanel.
"""
import logging
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Signal
from simvx.core.ui.code_editor_panel import CodeEditorPanel
from simvx.core.ui.core import Control
if TYPE_CHECKING:
from simvx.editor.state import State
log = logging.getLogger(__name__)
[docs]
class CodeEditorTab(Control):
"""Wraps CodeEditorPanel for the editor's center tab area.
Manages multiple open files as sub-tabs, LSP integration, and
file save/load lifecycle.
Usage::
tab = CodeEditorTab(editor_state=state)
tab.open_file("/path/to/script.py")
tab.save_current()
"""
def __init__(self, editor_state: State | None = None, **kwargs):
super().__init__(**kwargs)
if not self.name or self.name == self.__class__.__name__:
self.name = "CodeTab"
self._editor_state = editor_state
self._lsp_client = None
self._lsp_started = False
# Signals
self.file_opened = Signal()
self.file_saved = Signal()
self.file_closed = Signal()
# Create the code editor panel
self._panel = CodeEditorPanel(name="CodePanel")
self.add_child(self._panel)
# Wire panel signals to our signals and editor state
self._panel.file_opened.connect(self._on_file_opened)
self._panel.file_saved.connect(self._on_file_saved)
self._panel.file_closed.connect(self._on_file_closed)
self._panel.active_file_changed.connect(self._on_active_file_changed)
# Wire to editor state file signals if available
if self._editor_state:
self._panel.file_opened.connect(self._editor_state.file_opened.emit)
self._panel.file_saved.connect(self._editor_state.file_saved.emit)
self._panel.file_closed.connect(self._editor_state.file_closed.emit)
self._panel.active_file_changed.connect(self._editor_state.active_file_changed.emit)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
@property
def panel(self) -> CodeEditorPanel:
"""The underlying CodeEditorPanel instance."""
return self._panel
[docs]
def open_file(self, path: str):
"""Open a Python file in the code editor.
If the file is already open, switches to its tab.
"""
self._panel.open_file(path)
[docs]
def save_current(self):
"""Save the current file.
Writes the buffer to disk verbatim. Scene-shape edits made via the
scene panel are reconciled with the source by the editor save path
(``simvx.editor.scene_diff.apply_runtime_diff``); the code tab itself
deliberately does not regenerate the file -- hand edits in this view
must round-trip byte-identically.
"""
path = self._panel.current_path
if not path:
return
self._panel.save_current()
[docs]
def save_all(self):
"""Save all modified files."""
self._panel.save_all()
[docs]
def close_current(self):
"""Close the current file tab."""
self._panel.close_current()
[docs]
@property
def open_paths(self) -> list[str]:
"""Paths of all open files."""
return self._panel.tab_paths
[docs]
@property
def current_path(self) -> str | None:
"""Path of the currently active file."""
return self._panel.current_path
# ------------------------------------------------------------------
# LSP integration
# ------------------------------------------------------------------
[docs]
def start_lsp(self):
"""Start the LSP client for Python completions.
Tries pylsp first, then pyright. No-op if neither is available
or if the LSP client is already running.
"""
if self._lsp_started:
return
self._lsp_started = True
command = self._find_lsp_server()
if not command:
log.info("No LSP server found (pylsp/pyright) -- code completions disabled")
return
try:
from simvx.ide.lsp.client import LSPClient
from simvx.ide.state import State as IDEState
# Create a minimal IDE state for the LSP client
ide_state = IDEState(
file_opened=self.file_opened,
file_closed=self.file_closed,
file_saved=self.file_saved,
)
if self._editor_state and self._editor_state.project_path:
ide_state.project_root = str(self._editor_state.project_path)
self._lsp_client = LSPClient(ide_state, command=command)
self._lsp_client.start()
self._panel.set_lsp_client(self._lsp_client)
log.info("LSP client started with %s", command)
except ImportError:
log.debug("simvx.ide not available -- LSP disabled")
except Exception:
log.exception("Failed to start LSP client")
[docs]
def stop_lsp(self):
"""Stop the LSP client if running."""
if self._lsp_client:
self._lsp_client.stop()
self._lsp_client = None
self._lsp_started = False
self._panel.set_lsp_client(None)
[docs]
def poll_lsp(self, dt: float):
"""Poll the LSP client for messages. Call once per frame."""
if self._lsp_client:
self._lsp_client.poll()
# ------------------------------------------------------------------
# Layout
# ------------------------------------------------------------------
def _update_layout(self):
"""Size the code panel to fill this control."""
if self._panel and self.size is not None:
self._panel.size = self.size
self._panel.position = type(self._panel.position)(0, 0)
[docs]
def process(self, dt: float):
self._update_layout()
self.poll_lsp(dt)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _on_file_opened(self, path: str):
self.file_opened.emit(path)
def _on_file_saved(self, path: str):
self.file_saved.emit(path)
def _on_file_closed(self, path: str):
self.file_closed.emit(path)
def _on_active_file_changed(self, path: str):
pass # Could update status bar or breadcrumb in future
@staticmethod
def _find_lsp_server() -> str | None:
"""Find an available LSP server binary."""
for cmd in ("pylsp", "pyright-langserver"):
if shutil.which(cmd):
return cmd
return None