Source code for simvx.ide.embedded

"""Embeddable IDE layout — sidebar + code editor + bottom panels, no menu/status bar.

Used by:
- ``IDERoot`` (standalone IDE) — wraps this with menu bar, status bar, overlays
- ``IDEBridgePlugin`` (editor) — parents this into the editor's Script mode center area
"""


from __future__ import annotations

import logging

from simvx.core import Label, Node, Panel, SplitContainer, TabContainer, Vec2
from simvx.core.ui import CodeEditorPanel

from .config import IDEConfig
from .state import IDEState

log = logging.getLogger(__name__)


[docs] class IDEEmbeddedShell(Node): """Reusable IDE layout without menu bar, status bar, or overlays. Builds: Left sidebar: FileBrowserPanel (60%) + SymbolOutlinePanel (40%) Center: CodeEditorPanel Bottom: TabContainer with Terminal, Output, Problems, Search, Debug """ def __init__(self, state: IDEState, config: IDEConfig, *, name: str = "IDEEmbeddedShell", **kwargs): super().__init__(name=name, **kwargs) self._state = state self._config = config # Public panel references (populated in build()) self.code_panel: CodeEditorPanel | None = None self.bottom_tabs: TabContainer | None = None self.file_browser = None self.symbol_outline = None self.debug_manager = None self._minimap = None # Internal layout nodes self._sidebar_split: SplitContainer | None = None self._sidebar_content: SplitContainer | None = None self._main_split: SplitContainer | None = None
[docs] def build(self, width: float, height: float): """Build the IDE layout at the given size. Call once after adding to tree.""" sidebar_ratio = self._config.sidebar_width / max(width, 1) # Sidebar | main area split self._sidebar_split = SplitContainer(vertical=True, split_ratio=sidebar_ratio, name="IDESidebarSplit") self._sidebar_split.size = Vec2(width, height) self.add_child(self._sidebar_split) # --- Left: file browser + symbol outline --- self._build_sidebar(height) # --- Right: editor + bottom panels --- right_w = width - self._config.sidebar_width bp_ratio = 1.0 - (self._config.bottom_panel_height / max(height, 1)) self._main_split = SplitContainer(vertical=False, split_ratio=bp_ratio, name="IDEMainSplit") self._main_split.size = Vec2(right_w, height) self._sidebar_split.add_child(self._main_split) # Code editor self.code_panel = CodeEditorPanel(name="IDECodeEditor") self.code_panel.on_get_diagnostics = self._state.get_diagnostics self.code_panel.on_get_breakpoints = self._state.get_breakpoints self.code_panel.on_toggle_breakpoint = self._state.toggle_breakpoint self.code_panel.on_status_message = lambda msg: self._state.status_message.emit(msg) self.code_panel.size = Vec2(right_w, height - self._config.bottom_panel_height) self._main_split.add_child(self.code_panel) # Bottom tabs self.bottom_tabs = TabContainer(name="IDEBottomTabs") self.bottom_tabs.size = Vec2(right_w, self._config.bottom_panel_height) self._main_split.add_child(self.bottom_tabs) self._build_bottom_panels() # Force layout cascade self._sidebar_split._update_layout() if self._sidebar_content: self._sidebar_content._update_layout() self._main_split._update_layout() self.bottom_tabs._update_layout() # Minimap — added last so it processes after CodeEditorPanel from .widgets.minimap import Minimap self._minimap = Minimap(name="Minimap") self._minimap.visible = self._config.show_minimap self.add_child(self._minimap) self._minimap.line_clicked.connect(self._on_minimap_click) # Push text to minimap on content/tab changes (not polled per-frame) self.code_panel.active_file_changed.connect(self._sync_minimap_text) self._last_minimap_editor = None
def _build_sidebar(self, height: float): """Build left sidebar: file browser + symbol outline.""" try: from .panels.file_browser import FileBrowserPanel self.file_browser = FileBrowserPanel(state=self._state) except Exception as e: log.error("Failed to load FileBrowserPanel: %s", e) self.file_browser = Panel(name="FileBrowser") self.file_browser.bg_colour = (0.12, 0.12, 0.13, 1.0) try: from .panels.symbol_outline import SymbolOutlinePanel self.symbol_outline = SymbolOutlinePanel(state=self._state) except Exception as e: log.error("Failed to load SymbolOutlinePanel: %s", e) self.symbol_outline = None if self.symbol_outline: self._sidebar_content = SplitContainer(vertical=False, split_ratio=0.6, name="IDESidebarContent") self._sidebar_content.size = Vec2(self._config.sidebar_width, height) self._sidebar_content.add_child(self.file_browser) self._sidebar_content.add_child(self.symbol_outline) self._sidebar_split.add_child(self._sidebar_content) else: self.file_browser.size = Vec2(self._config.sidebar_width, height) self._sidebar_split.add_child(self.file_browser) # Set initial project root if self.file_browser and hasattr(self.file_browser, "set_root"): import os root = self._state.project_root or os.getcwd() self._state.project_root = root self.file_browser.set_root(root) def _build_bottom_panels(self): """Add Terminal, Output, Problems, Search, Debug panels to bottom tabs.""" def _error_placeholder(name: str, err) -> Panel: placeholder = Panel(name=name) placeholder.bg_colour = (0.08, 0.08, 0.08, 1.0) err_label = placeholder.add_child(Label(f"Error: {err}", name="ErrorLabel")) err_label.text_colour = (0.8, 0.3, 0.3, 1.0) err_label.font_size = 11.0 err_label.position = Vec2(8, 4) return placeholder # Terminal try: from .panels.terminal_panel import TerminalPanel self._terminal_panel = TerminalPanel(state=self._state, config=self._config) self.bottom_tabs.add_child(self._terminal_panel) except Exception as e: log.error("Failed to load TerminalPanel: %s", e) self.bottom_tabs.add_child(_error_placeholder("Terminal", e)) # Output try: from .panels.output_panel import IDEOutputPanel self._output_panel = IDEOutputPanel(state=self._state) self.bottom_tabs.add_child(self._output_panel) except Exception as e: log.error("Failed to load IDEOutputPanel: %s", e) self.bottom_tabs.add_child(_error_placeholder("Output", e)) # Problems try: from .panels.problems_panel import ProblemsPanel self._problems_panel = ProblemsPanel(state=self._state) self.bottom_tabs.add_child(self._problems_panel) except Exception as e: log.error("Failed to load ProblemsPanel: %s", e) self.bottom_tabs.add_child(_error_placeholder("Problems", e)) # Search try: from .panels.search_panel import SearchPanel self._search_panel = SearchPanel(state=self._state) self.bottom_tabs.add_child(self._search_panel) except Exception as e: log.error("Failed to load SearchPanel: %s", e) self.bottom_tabs.add_child(_error_placeholder("Search", e)) # Debug try: from .dap.manager import DebugManager from .panels.debug_panel import DebugPanel self.debug_manager = DebugManager(self._state, self._config) if hasattr(self, "_terminal_panel") and self._terminal_panel: self.debug_manager.on_run_in_terminal.connect( lambda cmd, env: self._terminal_panel.run_command(cmd) ) self._debug_panel_widget = DebugPanel(state=self._state, debug_manager=self.debug_manager) self.bottom_tabs.add_child(self._debug_panel_widget) except Exception as e: log.error("Failed to load DebugPanel: %s", e) self.bottom_tabs.add_child(_error_placeholder("Debug", e)) def _on_minimap_click(self, line: int): """Scroll the editor to center on the clicked minimap line.""" if not self.code_panel: return editor = self.code_panel.get_current_editor() if not editor: return editor._cursor_line = max(0, min(line, len(editor._lines) - 1)) editor._cursor_col = 0 editor._cursor_blink = 0.0 # Center the view on the clicked line instead of just ensuring visibility visible = editor._visible_lines() editor._scroll_y = float(max(0, line - visible // 2)) editor._clamp_scroll() editor.queue_redraw() def _sync_minimap_text(self, _path: str = ""): """Push the active editor's lines to the minimap. Connected to ``active_file_changed`` and each editor's ``text_changed``. """ if not self._minimap or not self.code_panel: return editor = self.code_panel.get_current_editor() # Unsubscribe from the previous editor's text_changed if it changed prev = self._last_minimap_editor if prev is not None and prev is not editor and hasattr(prev, "text_changed"): try: prev.text_changed.disconnect(self._on_editor_text_changed) except (ValueError, RuntimeError): pass # Subscribe to the new editor if editor is not None and editor is not prev: editor.text_changed.connect(self._on_editor_text_changed) self._last_minimap_editor = editor if editor: self._minimap.set_lines(editor._lines) def _on_editor_text_changed(self, _text: str): """Invalidate minimap when the active editor's text changes.""" if self._minimap: self._minimap.invalidate()
[docs] def process(self, dt: float): """Sync minimap position and viewport with the active editor each frame.""" if not self._minimap or not self._minimap.visible or not self.code_panel: return # Position minimap over the right edge of the code panel (overlay) cp_gx, cp_gy, cp_w, cp_h = self.code_panel.get_global_rect() minimap_w = 80.0 # Compute the global offset contributed by the minimap's ancestor chain ancestor_x, ancestor_y = 0.0, 0.0 node = self while node is not None: pos = getattr(node, "position", None) if pos is not None and hasattr(pos, "x"): ancestor_x += pos.x ancestor_y += pos.y node = node.parent self._minimap.position = Vec2(cp_gx - ancestor_x + cp_w - minimap_w, cp_gy - ancestor_y) self._minimap.size = Vec2(minimap_w, cp_h) # Sync viewport position (cheap — just two ints) editor = self.code_panel.get_current_editor() if editor: # Lazy sync: if the editor changed without a signal (e.g. minimap # was hidden when the file was opened), push lines now. if editor is not self._last_minimap_editor: self._sync_minimap_text() first = int(editor._scroll_y) lh = editor.font_size * 1.4 visible = int(cp_h / lh) if lh > 0 else 0 self._minimap.set_viewport(first, first + visible)
[docs] def refresh_theme(self): """Re-apply theme colours to all panels that cache them.""" for attr in ("file_browser", "symbol_outline", "_search_panel", "_output_panel", "_terminal_panel", "_debug_panel_widget"): panel = getattr(self, attr, None) if panel and hasattr(panel, "refresh_theme"): panel.refresh_theme()
[docs] def resize(self, width: float, height: float): """Update layout to new dimensions.""" if self._sidebar_split: self._sidebar_split.size = Vec2(width, height) self._sidebar_split._update_layout() if self._sidebar_content: self._sidebar_content._update_layout() if self._main_split: self._main_split._update_layout() if self.bottom_tabs: self.bottom_tabs._update_layout()