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