"""Workspace widget for managing splittable pane layouts with tabs.""" from pathlib import Path from weakref import ref from PyQt6.QtCore import Qt, pyqtSignal, QEvent, QTimer from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QSplitter, QTabWidget, QLabel, QFrame, QApplication, ) from development_hub.terminal_widget import TerminalWidget # Styles for focused/unfocused panes PANE_FOCUSED_STYLE = """ PaneWidget { border: 2px solid #4a9eff; border-radius: 3px; } """ PANE_UNFOCUSED_STYLE = """ PaneWidget { border: 2px solid transparent; border-radius: 3px; } """ class PaneWidget(QFrame): """A pane containing a tab widget with terminals.""" clicked = pyqtSignal(object) # Emits self when clicked empty = pyqtSignal(object) # Emits self when last tab closed def __init__(self, parent=None): super().__init__(parent) self._is_focused = False self._setup_ui() def _setup_ui(self): """Set up the pane UI.""" layout = QVBoxLayout(self) layout.setContentsMargins(2, 2, 2, 2) layout.setSpacing(0) self.tab_widget = QTabWidget() self.tab_widget.setTabsClosable(True) self.tab_widget.setMovable(True) self.tab_widget.tabCloseRequested.connect(self._close_tab) layout.addWidget(self.tab_widget) # Set initial unfocused style self.setStyleSheet(PANE_UNFOCUSED_STYLE) def set_focused(self, focused: bool): """Set the focus state and update visual styling.""" self._is_focused = focused if focused: self.setStyleSheet(PANE_FOCUSED_STYLE) else: self.setStyleSheet(PANE_UNFOCUSED_STYLE) def is_focused(self) -> bool: """Check if this pane is focused.""" return self._is_focused def add_welcome_tab(self): """Add a welcome tab with instructions.""" welcome = QWidget() layout = QVBoxLayout(welcome) layout.setContentsMargins(40, 40, 40, 40) label = QLabel( "

Development Hub

" "

Central project orchestration for your development ecosystem.

" "

Getting Started

" "" "

Keyboard Shortcuts

" "" ) label.setWordWrap(True) label.setAlignment(Qt.AlignmentFlag.AlignTop) layout.addWidget(label) layout.addStretch() self.tab_widget.addTab(welcome, "Welcome") def add_terminal(self, cwd: Path, title: str) -> TerminalWidget: """Add a new terminal tab to this pane.""" terminal = TerminalWidget(cwd=cwd) # Use weak reference to avoid preventing garbage collection weak_self = ref(self) def on_closed(): pane = weak_self() if pane is not None: pane._on_terminal_closed(terminal) terminal.closed.connect(on_closed) idx = self.tab_widget.addTab(terminal, title) self.tab_widget.setCurrentIndex(idx) terminal.setFocus() return terminal def _close_tab(self, index: int): """Close tab at index.""" widget = self.tab_widget.widget(index) self.tab_widget.removeTab(index) if isinstance(widget, TerminalWidget): widget.terminate() widget.deleteLater() # Check if pane is now empty if self.tab_widget.count() == 0: self.empty.emit(self) def _on_terminal_closed(self, terminal: TerminalWidget): """Handle terminal process exit.""" # Check if tab_widget still exists if not hasattr(self, 'tab_widget') or self.tab_widget is None: return try: for i in range(self.tab_widget.count()): if self.tab_widget.widget(i) == terminal: current_title = self.tab_widget.tabText(i) if not current_title.endswith(" (exited)"): self.tab_widget.setTabText(i, f"{current_title} (exited)") break except RuntimeError: # Widget was deleted pass def close_current_tab(self): """Close the currently selected tab.""" idx = self.tab_widget.currentIndex() if idx >= 0: self._close_tab(idx) def take_current_tab(self) -> tuple[QWidget, str] | None: """Remove and return the current tab widget and its title.""" idx = self.tab_widget.currentIndex() if idx < 0: return None title = self.tab_widget.tabText(idx) widget = self.tab_widget.widget(idx) self.tab_widget.removeTab(idx) # Check if pane is now empty if self.tab_widget.count() == 0: self.empty.emit(self) return (widget, title) def add_widget_tab(self, widget: QWidget, title: str): """Add an existing widget as a tab.""" idx = self.tab_widget.addTab(widget, title) self.tab_widget.setCurrentIndex(idx) def has_tabs(self) -> bool: """Check if pane has any tabs.""" return self.tab_widget.count() > 0 def tab_count(self) -> int: """Get number of tabs in this pane.""" return self.tab_widget.count() def get_current_terminal(self) -> TerminalWidget | None: """Get the current terminal widget.""" widget = self.tab_widget.currentWidget() if isinstance(widget, TerminalWidget): return widget return None def terminate_all(self): """Terminate all terminals in this pane.""" for i in range(self.tab_widget.count()): widget = self.tab_widget.widget(i) if isinstance(widget, TerminalWidget): widget.terminate() def setFocus(self): """Set focus to the current terminal.""" terminal = self.get_current_terminal() if terminal: terminal.setFocus() def get_session_state(self) -> dict: """Get the session state for persistence.""" tabs = [] for i in range(self.tab_widget.count()): widget = self.tab_widget.widget(i) title = self.tab_widget.tabText(i) if isinstance(widget, TerminalWidget): tabs.append({ "type": "terminal", "title": title, "cwd": str(widget.cwd), }) # Skip welcome tab and other non-terminal widgets return { "tabs": tabs, "current_tab": self.tab_widget.currentIndex(), } def restore_tabs(self, state: dict): """Restore tabs from session state.""" tabs = state.get("tabs", []) current_tab = state.get("current_tab", 0) for tab_info in tabs: if tab_info.get("type") == "terminal": cwd = Path(tab_info.get("cwd", str(Path.home()))) title = tab_info.get("title", "Terminal") self.add_terminal(cwd, title) # Restore current tab selection if 0 <= current_tab < self.tab_widget.count(): self.tab_widget.setCurrentIndex(current_tab) class WorkspaceManager(QWidget): """Manages splittable panes, each with their own tab bar.""" pane_count_changed = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) self._active_pane = None self._setup_ui() # Install application-wide event filter for focus tracking QApplication.instance().installEventFilter(self) def _setup_ui(self): """Set up the workspace with initial pane.""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Create initial pane with welcome tab self._root_widget = self._create_pane() self._root_widget.add_welcome_tab() layout.addWidget(self._root_widget) self._active_pane = self._root_widget self._active_pane.set_focused(True) def _create_pane(self) -> PaneWidget: """Create a new pane widget.""" pane = PaneWidget() pane.empty.connect(self._on_pane_empty) return pane def eventFilter(self, obj, event): """Application-wide event filter to track which pane is clicked.""" if event.type() == QEvent.Type.MouseButtonPress: # Find which pane (if any) contains the clicked widget widget = obj while widget is not None: if isinstance(widget, PaneWidget): self._set_active_pane(widget) break widget = widget.parent() return super().eventFilter(obj, event) def _set_active_pane(self, pane: PaneWidget): """Set the active pane and update styling.""" if pane == self._active_pane: return if self._active_pane is not None: self._active_pane.set_focused(False) self._active_pane = pane pane.set_focused(True) def _on_pane_empty(self, pane: PaneWidget): """Handle pane becoming empty.""" panes = self.find_panes() if len(panes) <= 1: # Last pane - don't remove, just leave empty return self._remove_pane(pane) def _remove_pane(self, pane: PaneWidget): """Remove a pane from the workspace.""" parent = pane.parent() pane.terminate_all() if parent == self: # This is the root - shouldn't remove return if isinstance(parent, QSplitter): # Remove from splitter pane.setParent(None) pane.deleteLater() # If splitter has only one child left, replace splitter with that child if parent.count() == 1: remaining = parent.widget(0) grandparent = parent.parent() if grandparent == self: # Parent splitter is root layout = self.layout() layout.removeWidget(parent) remaining.setParent(None) layout.addWidget(remaining) self._root_widget = remaining parent.deleteLater() elif isinstance(grandparent, QSplitter): # Nested splitter index = grandparent.indexOf(parent) remaining.setParent(None) grandparent.replaceWidget(index, remaining) parent.deleteLater() # Update active pane with proper focus styling panes = self.find_panes() if panes: self._active_pane = panes[0] self._active_pane.set_focused(True) self._active_pane.setFocus() self.pane_count_changed.emit(len(self.find_panes())) def get_active_pane(self) -> PaneWidget | None: """Get the currently active pane.""" if self._active_pane and self._active_pane.isVisible(): return self._active_pane # Fallback to first pane panes = self.find_panes() if panes: self._active_pane = panes[0] return self._active_pane return None def find_panes(self) -> list[PaneWidget]: """Find all pane widgets in the workspace.""" panes = [] self._collect_panes(self._root_widget, panes) return panes def _collect_panes(self, widget, panes: list): """Recursively collect panes from widget tree.""" if isinstance(widget, PaneWidget): panes.append(widget) elif isinstance(widget, QSplitter): for i in range(widget.count()): self._collect_panes(widget.widget(i), panes) def add_terminal(self, cwd: Path, title: str) -> TerminalWidget: """Add a terminal to the active pane.""" pane = self.get_active_pane() if pane: return pane.add_terminal(cwd, title) return None def split_horizontal(self): """Split the active pane horizontally (left/right).""" self._split(Qt.Orientation.Horizontal) def split_vertical(self): """Split the active pane vertically (top/bottom).""" self._split(Qt.Orientation.Vertical) def _split(self, orientation: Qt.Orientation): """Split the active pane with given orientation.""" current_pane = self.get_active_pane() if not current_pane: return # Get current size before splitting if orientation == Qt.Orientation.Horizontal: total_size = current_pane.width() else: total_size = current_pane.height() half_size = max(total_size // 2, 100) # Create new pane new_pane = self._create_pane() # If current pane has 2+ tabs, move the focused tab to new pane tab_to_move = None if current_pane.tab_count() >= 2: tab_to_move = current_pane.take_current_tab() # Find parent of current pane parent = current_pane.parent() splitter_to_equalize = None if parent == self: # Current is the root widget - create splitter as new root layout = self.layout() layout.removeWidget(current_pane) splitter = QSplitter(orientation) splitter.addWidget(current_pane) splitter.addWidget(new_pane) splitter_to_equalize = splitter layout.addWidget(splitter) self._root_widget = splitter elif isinstance(parent, QSplitter): # Current is in a splitter index = parent.indexOf(current_pane) if parent.orientation() == orientation: # Same orientation - just add to existing splitter parent.insertWidget(index + 1, new_pane) splitter_to_equalize = parent else: # Different orientation - need nested splitter new_splitter = QSplitter(orientation) parent.replaceWidget(index, new_splitter) new_splitter.addWidget(current_pane) new_splitter.addWidget(new_pane) splitter_to_equalize = new_splitter # If we moved a tab, add it to the new pane if tab_to_move: widget, title = tab_to_move new_pane.add_widget_tab(widget, title) # Update focus to new pane self._set_active_pane(new_pane) new_pane.setFocus() # Equalize sizes after layout is processed if splitter_to_equalize: QTimer.singleShot(0, lambda s=splitter_to_equalize: self._equalize_splitter(s)) self.pane_count_changed.emit(len(self.find_panes())) def _equalize_splitter(self, splitter: QSplitter): """Set equal sizes for all children in a splitter.""" count = splitter.count() if count == 0: return if splitter.orientation() == Qt.Orientation.Horizontal: total = splitter.width() else: total = splitter.height() size_each = total // count splitter.setSizes([size_each] * count) def close_current_tab(self): """Close the current tab in the active pane.""" pane = self.get_active_pane() if pane: pane.close_current_tab() def close_active_pane(self): """Close the currently active pane.""" pane = self.get_active_pane() if pane: panes = self.find_panes() if len(panes) > 1: self._remove_pane(pane) def total_tab_count(self) -> int: """Get total number of tabs across all panes.""" return sum(pane.tab_count() for pane in self.find_panes()) def terminate_all(self): """Terminate all terminals in all panes.""" for pane in self.find_panes(): pane.terminate_all() def focus_next_pane(self): """Focus the next pane.""" panes = self.find_panes() if len(panes) <= 1: return current = self.get_active_pane() if current in panes: idx = panes.index(current) next_idx = (idx + 1) % len(panes) next_pane = panes[next_idx] self._set_active_pane(next_pane) next_pane.setFocus() def focus_previous_pane(self): """Focus the previous pane.""" panes = self.find_panes() if len(panes) <= 1: return current = self.get_active_pane() if current in panes: idx = panes.index(current) prev_idx = (idx - 1) % len(panes) prev_pane = panes[prev_idx] self._set_active_pane(prev_pane) prev_pane.setFocus() def get_session_state(self) -> dict: """Get the complete session state for persistence.""" return { "layout": self._serialize_widget(self._root_widget), } def _serialize_widget(self, widget) -> dict: """Recursively serialize a widget tree.""" if isinstance(widget, PaneWidget): return { "type": "pane", "state": widget.get_session_state(), } elif isinstance(widget, QSplitter): children = [] for i in range(widget.count()): children.append(self._serialize_widget(widget.widget(i))) return { "type": "splitter", "orientation": "horizontal" if widget.orientation() == Qt.Orientation.Horizontal else "vertical", "sizes": widget.sizes(), "children": children, } return {} def restore_session(self, state: dict): """Restore the workspace from session state.""" layout = state.get("layout") if not layout: return # Check if there are any tabs to restore if not self._has_tabs_in_layout(layout): return # Clear existing layout old_root = self._root_widget self.layout().removeWidget(old_root) # Build new layout new_root = self._deserialize_widget(layout) if new_root: self.layout().addWidget(new_root) self._root_widget = new_root # Clean up old root if isinstance(old_root, PaneWidget): old_root.terminate_all() old_root.deleteLater() # Set first pane as active panes = self.find_panes() if panes: self._active_pane = panes[0] self._active_pane.set_focused(True) def _has_tabs_in_layout(self, layout: dict) -> bool: """Check if a layout has any tabs to restore.""" if layout.get("type") == "pane": tabs = layout.get("state", {}).get("tabs", []) return len(tabs) > 0 elif layout.get("type") == "splitter": for child in layout.get("children", []): if self._has_tabs_in_layout(child): return True return False def _deserialize_widget(self, data: dict) -> QWidget | None: """Recursively deserialize a widget tree.""" widget_type = data.get("type") if widget_type == "pane": pane = self._create_pane() pane.restore_tabs(data.get("state", {})) # If pane is empty after restore, add welcome tab if pane.tab_count() == 0: pane.add_welcome_tab() return pane elif widget_type == "splitter": orientation = ( Qt.Orientation.Horizontal if data.get("orientation") == "horizontal" else Qt.Orientation.Vertical ) splitter = QSplitter(orientation) for child_data in data.get("children", []): child = self._deserialize_widget(child_data) if child: splitter.addWidget(child) # Restore sizes after adding all children sizes = data.get("sizes", []) if sizes and len(sizes) == splitter.count(): QTimer.singleShot(0, lambda s=splitter, sz=sizes: s.setSizes(sz)) return splitter return None