630 lines
21 KiB
Python
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
|