Source code for simvx.editor.ide_bridge

"""IDE Bridge Plugin -- integrates simvx.ide features into the editor's CodeEditorPanel.

No panel swapping: the editor already has a CodeEditorPanel from core.
The bridge injects IDE capabilities (LSP, diagnostics, breakpoints,
completions) and adds IDE-only panels (Terminal, Output, Problems, Search).

If simvx.ide is not installed, this module raises ImportError at import time,
which EditorShell catches gracefully.
"""


from __future__ import annotations

import logging
import subprocess
import sys
from typing import TYPE_CHECKING

from simvx.ide import IDEConfig, IDEState

if TYPE_CHECKING:
    from simvx.core.ui.code_editor_panel import CodeEditorPanel
    from simvx.core.ui.tabs import TabContainer

    from .state import EditorState

log = logging.getLogger(__name__)


[docs] class IDEBridgePlugin: """Bridges EditorState <-> IDEState when simvx.ide is installed. Instead of swapping panels, injects IDE callbacks into the editor's existing CodeEditorPanel and adds IDE-only bottom panels. Lifecycle:: bridge = IDEBridgePlugin(editor_state, bottom_tabs, code_panel) bridge.activate() # wire signals, inject callbacks, start LSP bridge.poll(dt) # call each frame bridge.deactivate() # cleanup """ def __init__(self, editor_state: EditorState, bottom_tabs: TabContainer, code_panel: CodeEditorPanel | None = None): self._editor_state = editor_state self._bottom_tabs = bottom_tabs self._code_panel = code_panel self._ide_state = IDEState( file_opened=editor_state.file_opened, file_closed=editor_state.file_closed, file_saved=editor_state.file_saved, active_file_changed=editor_state.active_file_changed, ) self._ide_config = IDEConfig() self._ide_config.load() self._connections: list = [] self._injected_panels: list = [] self._lsp_client = None self._lint_runner = None # -- Properties ------------------------------------------------------------ @property def ide_state(self) -> IDEState: return self._ide_state @property def ide_config(self) -> IDEConfig: return self._ide_config # -- Lifecycle -------------------------------------------------------------
[docs] def activate(self): """Wire signals, inject IDE callbacks, start LSP and lint runner.""" self._wire_code_panel() self._wire_signals() self._inject_bottom_panels() self._start_lsp() self._start_lint_runner() self._register_ide_keybindings() log.info("IDE bridge activated")
[docs] def deactivate(self): """Disconnect signals, remove panels, stop LSP.""" for conn in self._connections: conn.disconnect() self._connections.clear() self._unregister_ide_keybindings() self._remove_bottom_panels() if self._lsp_client: self._lsp_client.stop() self._lsp_client = None self._lint_runner = None log.info("IDE bridge deactivated")
[docs] def poll(self, dt: float): """Per-frame update -- poll LSP client for messages.""" if self._lsp_client: self._lsp_client.poll()
# -- Code panel integration ------------------------------------------------ def _wire_code_panel(self): """Inject IDE callbacks into the editor's CodeEditorPanel.""" panel = self._code_panel if not panel: return # Inject diagnostic/breakpoint callbacks panel.on_get_diagnostics = self._ide_state.get_diagnostics panel.on_get_breakpoints = self._ide_state.get_breakpoints panel.on_toggle_breakpoint = self._ide_state.toggle_breakpoint panel.on_status_message = lambda msg: self._ide_state.status_message.emit(msg) # Panel → IDE state c = self._connections c.append(panel.cursor_moved.connect(self._ide_state.set_cursor)) c.append(panel.gutter_clicked.connect(self._ide_state.toggle_breakpoint)) c.append(panel.completion_requested.connect(self._ide_state.completion_requested.emit)) # IDE state → Panel c.append(self._ide_state.diagnostics_updated.connect( lambda p, _d: panel.refresh_diagnostics(p) )) c.append(self._ide_state.breakpoint_toggled.connect( lambda p, _l: panel.refresh_breakpoints(p) )) c.append(self._ide_state.completion_received.connect(panel.show_completions)) c.append(self._ide_state.goto_requested.connect( lambda p, l, col: (panel.open_file(p), panel.goto_line(l, col)) )) # Pop-out button → launch standalone IDE c.append(panel.pop_out_requested.connect(self._on_pop_out)) # -- Signal wiring --------------------------------------------------------- def _wire_signals(self): """Wire signal connections between EditorState and IDEState.""" es = self._editor_state ids = self._ide_state c = self._connections # Sync project path if es.project_path: ids.project_root = str(es.project_path) # LSP document lifecycle c.append(ids.file_opened.connect(self._lsp_notify_open)) c.append(ids.file_closed.connect(self._lsp_notify_close)) c.append(ids.file_saved.connect(self._lsp_notify_save)) c.append(ids.completion_requested.connect(self._lsp_request_completion)) # Lint on save c.append(ids.file_saved.connect(self._on_file_saved_lint)) # -- Bottom panel injection ------------------------------------------------ def _inject_bottom_panels(self): """Add IDE-only panels (Terminal, Output, Problems, Search) to bottom tabs.""" from simvx.ide.panels.output_panel import IDEOutputPanel from simvx.ide.panels.problems_panel import ProblemsPanel from simvx.ide.panels.search_panel import SearchPanel from simvx.ide.panels.terminal_panel import TerminalPanel ids = self._ide_state panels = [ self._make_panel("Terminal", lambda: TerminalPanel(state=ids, config=self._ide_config)), self._make_panel("Output", lambda: IDEOutputPanel(state=ids)), self._make_panel("Problems", lambda: ProblemsPanel(state=ids)), self._make_panel("Search", lambda: SearchPanel(state=ids)), ] for p in panels: self._injected_panels.append(p) self._bottom_tabs.add_child(p) self._bottom_tabs._update_layout() def _make_panel(self, name: str, factory): """Create a panel with error fallback.""" try: return factory() except Exception as e: log.error("Failed to create IDE panel %s: %s", name, e) from simvx.core import Label, Panel from simvx.core.math.types import Vec2 placeholder = Panel(name=name) placeholder.bg_colour = (0.08, 0.08, 0.08, 1.0) err = placeholder.add_child(Label(f"Error: {e}", name="ErrorLabel")) err.text_colour = (0.8, 0.3, 0.3, 1.0) err.font_size = 11.0 err.position = Vec2(8, 4) return placeholder def _remove_bottom_panels(self): """Remove injected IDE panels from bottom tabs.""" for p in self._injected_panels: self._bottom_tabs.remove_child(p) self._injected_panels.clear() self._bottom_tabs._update_layout() # -- Keybinding registration ----------------------------------------------- def _register_ide_keybindings(self): """Register IDE keybindings into the editor's ShortcutManager.""" from simvx.ide.keybindings import DEFAULT_BINDINGS shortcuts = self._editor_state.shortcuts actions = self._build_ide_actions() for binding, action_name in DEFAULT_BINDINGS: callback = actions.get(action_name) if not callback: continue ide_name = f"ide.{action_name}" try: shortcuts.register(ide_name, binding, callback) except Exception: pass def _unregister_ide_keybindings(self): """Remove IDE keybindings from the editor's ShortcutManager.""" from simvx.ide.keybindings import DEFAULT_BINDINGS shortcuts = self._editor_state.shortcuts for _, action_name in DEFAULT_BINDINGS: ide_name = f"ide.{action_name}" try: shortcuts.unregister(ide_name) except KeyError: pass def _build_ide_actions(self) -> dict: """Build action callbacks for IDE keybindings.""" actions: dict[str, object] = {} ep = self._code_panel ids = self._ide_state if ep: actions["file.save"] = lambda: ep.save_current() actions["file.close"] = lambda: ep.close_current() actions["edit.undo"] = lambda: ep.undo() actions["edit.redo"] = lambda: ep.redo() actions["edit.toggle_comment"] = lambda: ep.toggle_comment() actions["edit.format_document"] = lambda: ids.format_requested.emit(ids.active_file) return actions # -- Pop-out --------------------------------------------------------------- def _on_pop_out(self): """Launch standalone IDE with current open files.""" args = [sys.executable, "-m", "simvx.ide"] if self._code_panel: for path in self._code_panel.get_tab_paths(): if not path.startswith("untitled"): args.append(path) try: subprocess.Popen(args) except Exception as e: log.error("Failed to launch IDE: %s", e) # -- LSP ------------------------------------------------------------------- def _start_lsp(self): """Start LSP client using IDE config settings.""" if not self._ide_config.lsp_enabled: return try: from simvx.ide.lsp.client import LSPClient env = self._ide_config.get_env(self._ide_state.project_root) self._lsp_client = LSPClient( self._ide_state, command=self._ide_config.lsp_command, args=self._ide_config.lsp_args, env=env, ) self._lsp_client.server_started.connect(self._on_lsp_ready) self._lsp_client.server_stopped.connect(self._on_lsp_stopped) self._lsp_client.start() # Inject into code panel if self._code_panel: self._code_panel.set_lsp_client(self._lsp_client) except Exception as e: log.error("Failed to start LSP client: %s", e) def _on_lsp_ready(self): log.info("IDE bridge: LSP ready (%s)", self._ide_config.lsp_command) def _on_lsp_stopped(self): log.info("IDE bridge: LSP stopped") def _lsp_notify_open(self, path: str): if not self._lsp_client or not path or path.startswith("untitled"): return if self._code_panel: text = self._code_panel.get_file_text(path) if text: self._lsp_client.notify_open(path, text) def _lsp_notify_close(self, path: str): if self._lsp_client and path and not path.startswith("untitled"): self._lsp_client.notify_close(path) def _lsp_notify_save(self, path: str): if not self._lsp_client or not path or path.startswith("untitled"): return if self._code_panel: text = self._code_panel.get_file_text(path) self._lsp_client.notify_save(path, text) def _lsp_request_completion(self, path: str, line: int, col: int): if self._lsp_client and path and not path.startswith("untitled"): self._lsp_client.request_completion(path, line, col) # -- Lint runner ----------------------------------------------------------- def _start_lint_runner(self): """Start lint runner for format-on-save and lint-on-save.""" try: from simvx.ide.lint.runner import LintRunner self._lint_runner = LintRunner(self._ide_state, self._ide_config) except Exception as e: log.error("Failed to start lint runner: %s", e) def _on_file_saved_lint(self, path: str): """Run format/lint on save if configured.""" if not self._lint_runner or not path.endswith(".py"): return if self._ide_config.format_on_save: self._lint_runner.format_on_save(path) if self._ide_config.lint_on_save: self._lint_runner.lint_on_save(path)