Source code for simvx.ide.embedded

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

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

import logging

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

from .config import Config
from .state import State

log = logging.getLogger(__name__)

[docs] class EmbeddedShell(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: State, config: Config, *, name: str = "EmbeddedShell", **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_session = 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.""" # Resolve the project root *first* so the file browser sees a valid # path at construction time and can spin up its GitStatusProvider. import os if not self._state.project_root: self._state.project_root = os.getcwd() try: from .panels.file_browser import FileBrowserPanel self.file_browser = FileBrowserPanel(state=self._state) except (ImportError, AttributeError, OSError, RuntimeError): log.exception("Failed to load FileBrowserPanel") 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 (ImportError, AttributeError, OSError, RuntimeError): log.exception("Failed to load SymbolOutlinePanel") 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 on the panel (may differ if state was pre-populated). if self.file_browser and hasattr(self.file_browser, "set_root"): self.file_browser.set_root(self._state.project_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 (ImportError, AttributeError, OSError, RuntimeError) as e: log.exception("Failed to load TerminalPanel") self.bottom_tabs.add_child(_error_placeholder("Terminal", e)) # Output try: from .panels.output_panel import OutputPanel self._output_panel = OutputPanel(state=self._state) self.bottom_tabs.add_child(self._output_panel) except (ImportError, AttributeError, OSError, RuntimeError) as e: log.exception("Failed to load OutputPanel") 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 (ImportError, AttributeError, OSError, RuntimeError) as e: log.exception("Failed to load ProblemsPanel") 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 (ImportError, AttributeError, OSError, RuntimeError) as e: log.exception("Failed to load SearchPanel") self.bottom_tabs.add_child(_error_placeholder("Search", e)) # Debug try: from .debug_session import DebugSession from .panels.debug_panel import DebugPanel self.debug_session = DebugSession(self._state, self._config) if hasattr(self, "_terminal_panel") and self._terminal_panel: self.debug_session.on_run_in_terminal.connect( lambda cmd, env: self._terminal_panel.run_command(cmd) ) self._debug_panel_widget = DebugPanel(state=self._state, debug_session=self.debug_session) self.bottom_tabs.add_child(self._debug_panel_widget) except (ImportError, AttributeError, OSError, RuntimeError) as e: log.exception("Failed to load DebugPanel") 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.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.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.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()