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