development-hub/src/development_hub/workspace.py

630 lines
21 KiB
Python

"""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(
"<h2>Development Hub</h2>"
"<p>Central project orchestration for your development ecosystem.</p>"
"<h3>Getting Started</h3>"
"<ul>"
"<li>Double-click a project to open a terminal</li>"
"<li>Right-click for more actions (Gitea, docs, deploy)</li>"
"<li>Ctrl+N to create a new project</li>"
"</ul>"
"<h3>Keyboard Shortcuts</h3>"
"<ul>"
"<li><b>Ctrl+Shift+T</b> - New terminal tab</li>"
"<li><b>Ctrl+Shift+W</b> - Close current tab</li>"
"<li><b>Ctrl+Shift+D</b> - Split pane horizontal</li>"
"<li><b>Ctrl+Shift+E</b> - Split pane vertical</li>"
"<li><b>Ctrl+Alt+Left/Right</b> - Switch panes</li>"
"<li><b>Ctrl+B</b> - Toggle project panel</li>"
"<li><b>F5</b> - Refresh project list</li>"
"</ul>"
)
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