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