Source code for simvx.ide.state

"""IDE state management -- central signal bus for cross-component communication.

All IDE components connect to these signals rather than referencing each other.
Uses simvx.core.Signal (not Qt signals).
"""


from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path

from simvx.core import Signal
from simvx.core.document import BufferRegistry
from simvx.core.file_state import FileStateMixin

log = logging.getLogger(__name__)


[docs] @dataclass class Diagnostic: """Single diagnostic (error/warning) from LSP or linter.""" path: str line: int col_start: int col_end: int severity: int # 1=Error, 2=Warning, 3=Info, 4=Hint message: str source: str = "" code: str = ""
[docs] class IDEState(FileStateMixin): """Single source of truth for IDE-wide state. Signals: file_opened(path: str) file_closed(path: str) file_saved(path: str) active_file_changed(path: str) cursor_moved(line: int, col: int) diagnostics_updated(path: str, diagnostics: list[Diagnostic]) goto_requested(path: str, line: int, col: int) status_message(message: str) sidebar_toggled(visible: bool) bottom_panel_toggled(visible: bool) run_requested(path: str) debug_started() debug_stopped() breakpoint_toggled(path: str, line: int) completion_requested(path: str, line: int, col: int) completion_received(items: list) hover_received(text: str, line: int, col: int) format_requested(path: str) """ def __init__( self, *, file_opened: Signal | None = None, file_closed: Signal | None = None, file_saved: Signal | None = None, active_file_changed: Signal | None = None, ): # File lifecycle signals -- shared with EditorState when embedded self._init_file_signals( file_opened=file_opened, file_closed=file_closed, file_saved=file_saved, active_file_changed=active_file_changed, ) # Editor events self.cursor_moved = Signal() # LSP / Diagnostics self.diagnostics_updated = Signal() self.completion_requested = Signal() self.completion_received = Signal() self.hover_received = Signal() self.definition_received = Signal() self.references_received = Signal() self.format_requested = Signal() self.rename_edits_received = Signal() # emits dict[str, list[tuple]] self.formatting_edits_received = Signal() # emits (path: str, edits: list[tuple]) # Navigation self.goto_requested = Signal() # Run / Debug self.run_requested = Signal() self.debug_started = Signal() self.debug_stopped = Signal() self.debug_output = Signal() self.debug_state_changed = Signal() self.breakpoint_toggled = Signal() self.bookmark_toggled = Signal() self.exception_occurred = Signal() # UI state self.status_message = Signal() self.sidebar_toggled = Signal() self.bottom_panel_toggled = Signal() # Document management self.buffers = BufferRegistry() # Private state self._project_root: str = "" self._active_file: str = "" self._cursor_line: int = 0 self._cursor_col: int = 0 self._diagnostics: dict[str, list[Diagnostic]] = {} self._breakpoints: dict[str, set[int]] = {} self._bookmarks: dict[str, set[int]] = {} self._cursor_history: list[tuple[str, int, int]] = [] self._history_pos: int = -1 # -- Properties ------------------------------------------------------------ @property def project_root(self) -> str: return self._project_root @project_root.setter def project_root(self, path: str): self._project_root = path @property def active_file(self) -> str: return self._active_file @active_file.setter def active_file(self, path: str): if path != self._active_file: self._active_file = path self.active_file_changed.emit(path) @property def cursor_line(self) -> int: return self._cursor_line @property def cursor_col(self) -> int: return self._cursor_col # -- Cursor ----------------------------------------------------------------
[docs] def set_cursor(self, line: int, col: int): self._cursor_line = line self._cursor_col = col self.cursor_moved.emit(line, col)
# -- Diagnostics -----------------------------------------------------------
[docs] def set_diagnostics(self, path: str, diagnostics: list[Diagnostic]): self._diagnostics[path] = diagnostics self.diagnostics_updated.emit(path, diagnostics)
[docs] def get_diagnostics(self, path: str) -> list[Diagnostic]: return self._diagnostics.get(path, [])
[docs] def get_all_diagnostics(self) -> dict[str, list[Diagnostic]]: return dict(self._diagnostics)
# -- Breakpoints -----------------------------------------------------------
[docs] def toggle_breakpoint(self, path: str, line: int): bp = self._breakpoints.setdefault(path, set()) if line in bp: bp.discard(line) else: bp.add(line) self.breakpoint_toggled.emit(path, line)
[docs] def get_breakpoints(self, path: str) -> set[int]: return self._breakpoints.get(path, set())
[docs] def get_all_breakpoints(self) -> dict[str, set[int]]: return {p: set(lines) for p, lines in self._breakpoints.items() if lines}
# -- Bookmarks -------------------------------------------------------------
[docs] def toggle_bookmark(self, path: str, line: int): bm = self._bookmarks.setdefault(path, set()) if line in bm: bm.discard(line) else: bm.add(line) self.bookmark_toggled.emit(path, line)
[docs] def get_bookmarks(self, path: str) -> set[int]: return self._bookmarks.get(path, set())
[docs] def get_all_bookmarks(self) -> dict[str, set[int]]: return {p: set(lines) for p, lines in self._bookmarks.items() if lines}
# -- Cursor History --------------------------------------------------------
[docs] def push_cursor_history(self, path: str, line: int, col: int = 0): """Push current location onto history stack (for Alt+Left/Right navigation).""" entry = (path, line, col) # Don't push duplicates if self._cursor_history and self._history_pos >= 0: if self._cursor_history[self._history_pos] == entry: return # Truncate forward history when pushing new entry self._cursor_history = self._cursor_history[: self._history_pos + 1] self._cursor_history.append(entry) if len(self._cursor_history) > 100: self._cursor_history = self._cursor_history[-100:] self._history_pos = len(self._cursor_history) - 1
[docs] def history_back(self) -> tuple[str, int, int] | None: """Navigate backward in cursor history. Returns (path, line, col) or None.""" if self._history_pos > 0: self._history_pos -= 1 return self._cursor_history[self._history_pos] return None
[docs] def history_forward(self) -> tuple[str, int, int] | None: """Navigate forward in cursor history. Returns (path, line, col) or None.""" if self._history_pos < len(self._cursor_history) - 1: self._history_pos += 1 return self._cursor_history[self._history_pos] return None
# -- Navigation ------------------------------------------------------------
[docs] def goto(self, path: str, line: int, col: int = 0): self.goto_requested.emit(path, line, col)
# -- Helpers ---------------------------------------------------------------
[docs] def relative_path(self, path: str) -> str: if self._project_root and path.startswith(self._project_root): return str(Path(path).relative_to(self._project_root)) return Path(path).name