Add Development Hub PyQt6 GUI application
Features: - Project list with discovery from build-public-docs.sh - PTY-based terminal with pyte emulation - Pane-based workspace with horizontal/vertical splits - Tab management within each pane - Drag-drop support (cd to dirs, run executables, insert paths) - New Project dialog with Ramble voice integration - Settings dialog with JSON persistence - Session persistence (restore layout on restart) - Context menu: open terminal, editor, gitea, docs, deploy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
be7b848640
commit
d570681d86
|
|
@ -0,0 +1,30 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "development-hub"
|
||||
version = "0.1.0"
|
||||
description = "Central project orchestration GUI for multi-project development"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyQt6>=6.5.0",
|
||||
"pyte>=0.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-qt>=4.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
development-hub = "development_hub.app:main"
|
||||
|
||||
[project.gui-scripts]
|
||||
development-hub-gui = "development_hub.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Development Hub - Central project orchestration GUI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Entry point for running Development Hub as a module."""
|
||||
|
||||
import sys
|
||||
from development_hub.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""Application entry point for Development Hub."""
|
||||
|
||||
import sys
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from development_hub.main_window import MainWindow
|
||||
from development_hub.styles import DARK_THEME
|
||||
|
||||
|
||||
class DevelopmentHubApp(QApplication):
|
||||
"""Main application class for Development Hub."""
|
||||
|
||||
def __init__(self, argv: list[str]):
|
||||
super().__init__(argv)
|
||||
self.setApplicationName("Development Hub")
|
||||
self.setApplicationVersion("0.1.0")
|
||||
self.setStyle("Fusion") # Consistent cross-platform style
|
||||
self.setStyleSheet(DARK_THEME)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the Development Hub application.
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success).
|
||||
"""
|
||||
app = DevelopmentHubApp(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
"""Dialogs for Development Hub."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QProcess
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QTextEdit,
|
||||
QPushButton,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QMessageBox,
|
||||
QGroupBox,
|
||||
)
|
||||
|
||||
from development_hub.settings import Settings
|
||||
|
||||
|
||||
class RambleThread(QThread):
|
||||
"""Thread to run Ramble subprocess."""
|
||||
|
||||
finished = pyqtSignal(dict) # Emits parsed result
|
||||
error = pyqtSignal(str) # Emits error message
|
||||
|
||||
def __init__(self, prompt: str, fields: list[str], criteria: dict[str, str] = None):
|
||||
super().__init__()
|
||||
self.prompt = prompt
|
||||
self.fields = fields
|
||||
self.criteria = criteria or {}
|
||||
|
||||
def run(self):
|
||||
"""Run Ramble and parse output."""
|
||||
# Check if ramble is available
|
||||
ramble_path = shutil.which("ramble")
|
||||
if not ramble_path:
|
||||
self.error.emit("Ramble is not installed or not in PATH.\nInstall with: pip install ramble")
|
||||
return
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
ramble_path,
|
||||
"--prompt", self.prompt,
|
||||
"--fields", *self.fields,
|
||||
]
|
||||
|
||||
if self.criteria:
|
||||
cmd.extend(["--criteria", json.dumps(self.criteria)])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# User cancelled or error
|
||||
if "cancelled" in result.stderr.lower():
|
||||
self.finished.emit({})
|
||||
else:
|
||||
self.error.emit(f"Ramble error: {result.stderr}")
|
||||
return
|
||||
|
||||
# Parse JSON output
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
self.finished.emit(data)
|
||||
except json.JSONDecodeError:
|
||||
# Ramble might output the dialog result differently
|
||||
# Try to find JSON in the output
|
||||
self.finished.emit({})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.error.emit("Ramble timed out after 5 minutes")
|
||||
except Exception as e:
|
||||
self.error.emit(f"Error running Ramble: {e}")
|
||||
|
||||
|
||||
class NewProjectThread(QThread):
|
||||
"""Thread to run new-project script."""
|
||||
|
||||
output = pyqtSignal(str)
|
||||
finished = pyqtSignal(bool, str) # success, message
|
||||
|
||||
def __init__(self, name: str, title: str, tagline: str, deploy: bool):
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.title = title
|
||||
self.tagline = tagline
|
||||
self.deploy = deploy
|
||||
|
||||
def run(self):
|
||||
"""Run the new-project script."""
|
||||
script = Path.home() / "PycharmProjects" / "development-hub" / "bin" / "new-project"
|
||||
|
||||
if not script.exists():
|
||||
self.finished.emit(False, f"Script not found: {script}")
|
||||
return
|
||||
|
||||
cmd = [
|
||||
str(script),
|
||||
self.name,
|
||||
"--title", self.title,
|
||||
"--tagline", self.tagline,
|
||||
]
|
||||
|
||||
if self.deploy:
|
||||
cmd.append("--deploy")
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
# Stream output
|
||||
for line in process.stdout:
|
||||
self.output.emit(line.rstrip())
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
self.finished.emit(True, f"Project '{self.name}' created successfully!")
|
||||
else:
|
||||
self.finished.emit(False, f"Project creation failed with code {process.returncode}")
|
||||
|
||||
except Exception as e:
|
||||
self.finished.emit(False, f"Error: {e}")
|
||||
|
||||
|
||||
class NewProjectDialog(QDialog):
|
||||
"""Dialog for creating a new project."""
|
||||
|
||||
project_created = pyqtSignal(str) # Emits project name
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("New Project")
|
||||
self.setMinimumWidth(500)
|
||||
self.settings = Settings()
|
||||
|
||||
self._ramble_thread = None
|
||||
self._create_thread = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Form fields
|
||||
form_group = QGroupBox("Project Details")
|
||||
form_layout = QFormLayout(form_group)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("my-project (lowercase, hyphens)")
|
||||
self.name_input.textChanged.connect(self._validate)
|
||||
form_layout.addRow("Name:", self.name_input)
|
||||
|
||||
self.title_input = QLineEdit()
|
||||
self.title_input.setPlaceholderText("My Project")
|
||||
form_layout.addRow("Title:", self.title_input)
|
||||
|
||||
self.tagline_input = QLineEdit()
|
||||
self.tagline_input.setPlaceholderText("A short description of the project")
|
||||
form_layout.addRow("Tagline:", self.tagline_input)
|
||||
|
||||
layout.addWidget(form_group)
|
||||
|
||||
# Ramble button
|
||||
ramble_layout = QHBoxLayout()
|
||||
ramble_layout.addStretch()
|
||||
|
||||
self.ramble_btn = QPushButton("🎤 Ramble...")
|
||||
self.ramble_btn.setToolTip("Describe your project idea and let AI fill in the fields")
|
||||
self.ramble_btn.clicked.connect(self._start_ramble)
|
||||
ramble_layout.addWidget(self.ramble_btn)
|
||||
|
||||
layout.addLayout(ramble_layout)
|
||||
|
||||
# Output area (hidden initially)
|
||||
self.output_group = QGroupBox("Output")
|
||||
output_layout = QVBoxLayout(self.output_group)
|
||||
|
||||
self.output_text = QTextEdit()
|
||||
self.output_text.setReadOnly(True)
|
||||
self.output_text.setMaximumHeight(150)
|
||||
output_layout.addWidget(self.output_text)
|
||||
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate
|
||||
self.progress_bar.hide()
|
||||
output_layout.addWidget(self.progress_bar)
|
||||
|
||||
self.output_group.hide()
|
||||
layout.addWidget(self.output_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
self.create_btn = QPushButton("Create Project")
|
||||
self.create_btn.setDefault(True)
|
||||
self.create_btn.setEnabled(False)
|
||||
self.create_btn.clicked.connect(self._create_project)
|
||||
button_layout.addWidget(self.create_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _validate(self):
|
||||
"""Validate form and enable/disable create button."""
|
||||
name = self.name_input.text().strip()
|
||||
# Basic validation: name must be non-empty and valid
|
||||
valid = bool(name) and name.replace("-", "").replace("_", "").isalnum()
|
||||
self.create_btn.setEnabled(valid)
|
||||
|
||||
def _start_ramble(self):
|
||||
"""Launch Ramble to fill in fields."""
|
||||
self.ramble_btn.setEnabled(False)
|
||||
self.ramble_btn.setText("🎤 Running Ramble...")
|
||||
|
||||
self._ramble_thread = RambleThread(
|
||||
prompt="Describe the project you want to create. What does it do? What problem does it solve?",
|
||||
fields=["Name", "Title", "Tagline"],
|
||||
criteria={
|
||||
"Name": "lowercase, use hyphens instead of spaces, no special characters",
|
||||
"Title": "Title case, human readable",
|
||||
"Tagline": "One sentence description",
|
||||
}
|
||||
)
|
||||
self._ramble_thread.finished.connect(self._on_ramble_finished)
|
||||
self._ramble_thread.error.connect(self._on_ramble_error)
|
||||
self._ramble_thread.start()
|
||||
|
||||
def _on_ramble_finished(self, result: dict):
|
||||
"""Handle Ramble completion."""
|
||||
self.ramble_btn.setEnabled(True)
|
||||
self.ramble_btn.setText("🎤 Ramble...")
|
||||
|
||||
if result and "fields" in result:
|
||||
fields = result["fields"]
|
||||
if "Name" in fields:
|
||||
self.name_input.setText(fields["Name"])
|
||||
if "Title" in fields:
|
||||
self.title_input.setText(fields["Title"])
|
||||
if "Tagline" in fields:
|
||||
self.tagline_input.setText(fields["Tagline"])
|
||||
|
||||
def _on_ramble_error(self, error: str):
|
||||
"""Handle Ramble error."""
|
||||
self.ramble_btn.setEnabled(True)
|
||||
self.ramble_btn.setText("🎤 Ramble...")
|
||||
QMessageBox.warning(self, "Ramble Error", error)
|
||||
|
||||
def _create_project(self):
|
||||
"""Start project creation."""
|
||||
name = self.name_input.text().strip()
|
||||
title = self.title_input.text().strip() or name.replace("-", " ").title()
|
||||
tagline = self.tagline_input.text().strip() or f"The {title} project"
|
||||
|
||||
# Show output area
|
||||
self.output_group.show()
|
||||
self.output_text.clear()
|
||||
self.progress_bar.show()
|
||||
|
||||
# Disable inputs
|
||||
self.name_input.setEnabled(False)
|
||||
self.title_input.setEnabled(False)
|
||||
self.tagline_input.setEnabled(False)
|
||||
self.ramble_btn.setEnabled(False)
|
||||
self.create_btn.setEnabled(False)
|
||||
|
||||
# Start creation thread
|
||||
self._create_thread = NewProjectThread(
|
||||
name=name,
|
||||
title=title,
|
||||
tagline=tagline,
|
||||
deploy=self.settings.deploy_docs_after_creation,
|
||||
)
|
||||
self._create_thread.output.connect(self._on_create_output)
|
||||
self._create_thread.finished.connect(self._on_create_finished)
|
||||
self._create_thread.start()
|
||||
|
||||
def _on_create_output(self, line: str):
|
||||
"""Handle output from creation script."""
|
||||
self.output_text.append(line)
|
||||
# Auto-scroll to bottom
|
||||
scrollbar = self.output_text.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _on_create_finished(self, success: bool, message: str):
|
||||
"""Handle project creation completion."""
|
||||
self.progress_bar.hide()
|
||||
|
||||
if success:
|
||||
self.output_text.append(f"\n✓ {message}")
|
||||
self.project_created.emit(self.name_input.text().strip())
|
||||
|
||||
# Change cancel to close
|
||||
self.cancel_btn.setText("Close")
|
||||
self.create_btn.hide()
|
||||
else:
|
||||
self.output_text.append(f"\n✗ {message}")
|
||||
|
||||
# Re-enable inputs for retry
|
||||
self.name_input.setEnabled(True)
|
||||
self.title_input.setEnabled(True)
|
||||
self.tagline_input.setEnabled(True)
|
||||
self.ramble_btn.setEnabled(True)
|
||||
self.create_btn.setEnabled(True)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Dialog for application settings."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Settings")
|
||||
self.setMinimumWidth(400)
|
||||
self.settings = Settings()
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the dialog UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Project creation settings
|
||||
project_group = QGroupBox("Project Creation")
|
||||
project_layout = QVBoxLayout(project_group)
|
||||
|
||||
from PyQt6.QtWidgets import QCheckBox
|
||||
self.deploy_checkbox = QCheckBox("Deploy docs after creating new project")
|
||||
self.deploy_checkbox.setChecked(self.settings.deploy_docs_after_creation)
|
||||
project_layout.addWidget(self.deploy_checkbox)
|
||||
|
||||
layout.addWidget(project_group)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
|
||||
save_btn = QPushButton("Save")
|
||||
save_btn.setDefault(True)
|
||||
save_btn.clicked.connect(self._save)
|
||||
button_layout.addWidget(save_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _save(self):
|
||||
"""Save settings and close."""
|
||||
self.settings.deploy_docs_after_creation = self.deploy_checkbox.isChecked()
|
||||
self.accept()
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
"""Main window for Development Hub."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
from PyQt6.QtWidgets import (
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from development_hub.project_discovery import Project
|
||||
from development_hub.project_list import ProjectListWidget
|
||||
from development_hub.workspace import WorkspaceManager
|
||||
from development_hub.dialogs import NewProjectDialog, SettingsDialog
|
||||
from development_hub.settings import Settings
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with project list and workspace."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Development Hub")
|
||||
self.resize(1200, 800)
|
||||
self.settings = Settings()
|
||||
|
||||
self._setup_ui()
|
||||
self._setup_menus()
|
||||
self._setup_status_bar()
|
||||
self._restore_session()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the main UI layout."""
|
||||
# Central widget with splitter
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Main splitter: project list | workspace
|
||||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
layout.addWidget(self.main_splitter)
|
||||
|
||||
# Left: Project list
|
||||
self.project_list = ProjectListWidget()
|
||||
self.project_list.setFixedWidth(200)
|
||||
self.project_list.open_terminal_requested.connect(self._open_terminal)
|
||||
self.main_splitter.addWidget(self.project_list)
|
||||
|
||||
# Right: Workspace manager with splittable panes
|
||||
self.workspace = WorkspaceManager()
|
||||
self.workspace.pane_count_changed.connect(self._update_status)
|
||||
self.main_splitter.addWidget(self.workspace)
|
||||
|
||||
# Set splitter proportions
|
||||
self.main_splitter.setSizes([200, 1000])
|
||||
|
||||
def _setup_menus(self):
|
||||
"""Set up the menu bar."""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# File menu
|
||||
file_menu = menubar.addMenu("&File")
|
||||
|
||||
new_project = QAction("&New Project...", self)
|
||||
new_project.setShortcut(QKeySequence("Ctrl+N"))
|
||||
new_project.triggered.connect(self._new_project)
|
||||
file_menu.addAction(new_project)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
settings_action = QAction("&Settings...", self)
|
||||
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
||||
settings_action.triggered.connect(self._show_settings)
|
||||
file_menu.addAction(settings_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
exit_action = QAction("E&xit", self)
|
||||
exit_action.setShortcut(QKeySequence("Ctrl+Q"))
|
||||
exit_action.triggered.connect(self.close)
|
||||
file_menu.addAction(exit_action)
|
||||
|
||||
# Project menu
|
||||
project_menu = menubar.addMenu("&Project")
|
||||
|
||||
refresh = QAction("&Refresh List", self)
|
||||
refresh.setShortcut(QKeySequence("F5"))
|
||||
refresh.triggered.connect(self.project_list.refresh)
|
||||
project_menu.addAction(refresh)
|
||||
|
||||
# View menu
|
||||
view_menu = menubar.addMenu("&View")
|
||||
|
||||
toggle_projects = QAction("Toggle &Project Panel", self)
|
||||
toggle_projects.setShortcut(QKeySequence("Ctrl+B"))
|
||||
toggle_projects.triggered.connect(self._toggle_project_panel)
|
||||
view_menu.addAction(toggle_projects)
|
||||
|
||||
view_menu.addSeparator()
|
||||
|
||||
split_h = QAction("Split &Horizontal", self)
|
||||
split_h.setShortcut(QKeySequence("Ctrl+Shift+D"))
|
||||
split_h.triggered.connect(self._split_horizontal)
|
||||
view_menu.addAction(split_h)
|
||||
|
||||
split_v = QAction("Split &Vertical", self)
|
||||
split_v.setShortcut(QKeySequence("Ctrl+Shift+E"))
|
||||
split_v.triggered.connect(self._split_vertical)
|
||||
view_menu.addAction(split_v)
|
||||
|
||||
view_menu.addSeparator()
|
||||
|
||||
close_pane = QAction("Close &Pane", self)
|
||||
close_pane.setShortcut(QKeySequence("Ctrl+Shift+P"))
|
||||
close_pane.triggered.connect(self._close_active_pane)
|
||||
view_menu.addAction(close_pane)
|
||||
|
||||
view_menu.addSeparator()
|
||||
|
||||
next_pane = QAction("&Next Pane", self)
|
||||
next_pane.setShortcut(QKeySequence("Ctrl+Alt+Right"))
|
||||
next_pane.triggered.connect(self.workspace.focus_next_pane)
|
||||
view_menu.addAction(next_pane)
|
||||
|
||||
prev_pane = QAction("P&revious Pane", self)
|
||||
prev_pane.setShortcut(QKeySequence("Ctrl+Alt+Left"))
|
||||
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
||||
view_menu.addAction(prev_pane)
|
||||
|
||||
# Terminal menu
|
||||
terminal_menu = menubar.addMenu("&Terminal")
|
||||
|
||||
new_tab = QAction("New &Tab", self)
|
||||
new_tab.setShortcut(QKeySequence("Ctrl+Shift+T"))
|
||||
new_tab.triggered.connect(self._new_terminal_tab)
|
||||
terminal_menu.addAction(new_tab)
|
||||
|
||||
close_tab = QAction("&Close Tab", self)
|
||||
close_tab.setShortcut(QKeySequence("Ctrl+Shift+W"))
|
||||
close_tab.triggered.connect(self._close_current_tab)
|
||||
terminal_menu.addAction(close_tab)
|
||||
|
||||
# Help menu
|
||||
help_menu = menubar.addMenu("&Help")
|
||||
|
||||
about = QAction("&About", self)
|
||||
about.triggered.connect(self._show_about)
|
||||
help_menu.addAction(about)
|
||||
|
||||
def _setup_status_bar(self):
|
||||
"""Set up the status bar."""
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self, *args):
|
||||
"""Update status bar text."""
|
||||
project_count = self.project_list.list_widget.count()
|
||||
pane_count = len(self.workspace.find_panes())
|
||||
tab_count = self.workspace.total_tab_count()
|
||||
|
||||
pane_str = f"{pane_count} pane{'s' if pane_count != 1 else ''}"
|
||||
tab_str = f"{tab_count} tab{'s' if tab_count != 1 else ''}"
|
||||
|
||||
self.status_bar.showMessage(
|
||||
f"{project_count} projects | {pane_str} | {tab_str}"
|
||||
)
|
||||
|
||||
def _open_terminal(self, project: Project):
|
||||
"""Open a terminal tab for a project in the active pane."""
|
||||
self.workspace.add_terminal(project.path, project.title)
|
||||
self._update_status()
|
||||
|
||||
def _new_terminal_tab(self):
|
||||
"""Create a new terminal tab at home directory in the active pane."""
|
||||
# Count existing terminal tabs for naming
|
||||
panes = self.workspace.find_panes()
|
||||
terminal_count = 0
|
||||
for pane in panes:
|
||||
for i in range(pane.tab_widget.count()):
|
||||
if pane.tab_widget.tabText(i).startswith("Terminal"):
|
||||
terminal_count += 1
|
||||
|
||||
tab_name = f"Terminal {terminal_count + 1}" if terminal_count > 0 else "Terminal"
|
||||
self.workspace.add_terminal(Path.home(), tab_name)
|
||||
self._update_status()
|
||||
|
||||
def _close_current_tab(self):
|
||||
"""Close the current tab in the active pane."""
|
||||
self.workspace.close_current_tab()
|
||||
self._update_status()
|
||||
|
||||
def _toggle_project_panel(self):
|
||||
"""Toggle visibility of the project list panel."""
|
||||
if self.project_list.isVisible():
|
||||
self.project_list.hide()
|
||||
else:
|
||||
self.project_list.show()
|
||||
|
||||
def _split_horizontal(self):
|
||||
"""Split the active pane horizontally (creates left/right panes)."""
|
||||
self.workspace.split_horizontal()
|
||||
|
||||
def _split_vertical(self):
|
||||
"""Split the active pane vertically (creates top/bottom panes)."""
|
||||
self.workspace.split_vertical()
|
||||
|
||||
def _close_active_pane(self):
|
||||
"""Close the currently active pane."""
|
||||
self.workspace.close_active_pane()
|
||||
self._update_status()
|
||||
|
||||
def _new_project(self):
|
||||
"""Show new project dialog."""
|
||||
dialog = NewProjectDialog(self)
|
||||
dialog.project_created.connect(self._on_project_created)
|
||||
dialog.exec()
|
||||
|
||||
def _on_project_created(self, project_name: str):
|
||||
"""Handle new project creation."""
|
||||
# Refresh the project list to show the new project
|
||||
self.project_list.refresh()
|
||||
self._update_status()
|
||||
|
||||
def _show_settings(self):
|
||||
"""Show settings dialog."""
|
||||
dialog = SettingsDialog(self)
|
||||
dialog.exec()
|
||||
|
||||
def _show_about(self):
|
||||
"""Show about dialog."""
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
QMessageBox.about(
|
||||
self,
|
||||
"About Development Hub",
|
||||
"<h3>Development Hub</h3>"
|
||||
"<p>Version 0.1.0</p>"
|
||||
"<p>Central project orchestration for multi-project development.</p>"
|
||||
"<p>Part of Rob's development ecosystem.</p>"
|
||||
"<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>"
|
||||
)
|
||||
|
||||
def _restore_session(self):
|
||||
"""Restore the previous session."""
|
||||
session = self.settings.load_session()
|
||||
if session:
|
||||
self.workspace.restore_session(session)
|
||||
self._update_status()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close - save session and terminate all terminals."""
|
||||
# Save session state
|
||||
session = self.workspace.get_session_state()
|
||||
self.settings.save_session(session)
|
||||
|
||||
# Terminate all terminals
|
||||
self.workspace.terminate_all()
|
||||
super().closeEvent(event)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"""Discover projects from the build-public-docs.sh configuration."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""Represents a project in the development ecosystem."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
tagline: str
|
||||
owner: str
|
||||
repo: str
|
||||
dirname: str
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Local path to project directory."""
|
||||
return Path.home() / "PycharmProjects" / self.dirname
|
||||
|
||||
@property
|
||||
def gitea_url(self) -> str:
|
||||
"""URL to Gitea repository."""
|
||||
return f"https://gitea.brrd.tech/{self.owner}/{self.repo}"
|
||||
|
||||
@property
|
||||
def docs_url(self) -> str:
|
||||
"""URL to public documentation."""
|
||||
return f"https://pages.brrd.tech/{self.owner}/{self.repo}/"
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
"""Check if project directory exists locally."""
|
||||
return self.path.exists()
|
||||
|
||||
|
||||
def discover_projects() -> list[Project]:
|
||||
"""Parse PROJECT_CONFIG from build-public-docs.sh.
|
||||
|
||||
Returns:
|
||||
List of Project objects discovered from the build script.
|
||||
"""
|
||||
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
|
||||
|
||||
if not build_script.exists():
|
||||
return []
|
||||
|
||||
projects = []
|
||||
# Pattern: PROJECT_CONFIG["key"]="Title|Tagline|Owner|Repo|DirName"
|
||||
pattern = r'PROJECT_CONFIG\["([^"]+)"\]="([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^"]+)"'
|
||||
|
||||
for match in re.finditer(pattern, build_script.read_text()):
|
||||
key, title, tagline, owner, repo, dirname = match.groups()
|
||||
projects.append(Project(
|
||||
key=key,
|
||||
title=title,
|
||||
tagline=tagline,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
dirname=dirname,
|
||||
))
|
||||
|
||||
# Sort by title for consistent display
|
||||
projects.sort(key=lambda p: p.title.lower())
|
||||
|
||||
return projects
|
||||
|
||||
|
||||
def get_project_by_key(key: str) -> Project | None:
|
||||
"""Get a specific project by its key.
|
||||
|
||||
Args:
|
||||
key: The project key (e.g., 'cmdforge', 'ramble')
|
||||
|
||||
Returns:
|
||||
Project object if found, None otherwise.
|
||||
"""
|
||||
for project in discover_projects():
|
||||
if project.key == key:
|
||||
return project
|
||||
return None
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
"""Project list widget showing all discovered projects."""
|
||||
|
||||
import subprocess
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import (
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from development_hub.project_discovery import Project, discover_projects
|
||||
|
||||
|
||||
class ProjectListWidget(QWidget):
|
||||
"""Widget displaying the list of projects with context menu actions."""
|
||||
|
||||
project_selected = pyqtSignal(Project)
|
||||
open_terminal_requested = pyqtSignal(Project)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._projects: list[Project] = []
|
||||
self._setup_ui()
|
||||
self.refresh()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QLabel("PROJECTS")
|
||||
header.setObjectName("header")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Project list
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.list_widget.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.list_widget.itemClicked.connect(self._on_item_clicked)
|
||||
self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
|
||||
layout.addWidget(self.list_widget)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the project list from the build configuration."""
|
||||
self.list_widget.clear()
|
||||
self._projects = discover_projects()
|
||||
|
||||
for project in self._projects:
|
||||
item = QListWidgetItem(project.title)
|
||||
item.setData(Qt.ItemDataRole.UserRole, project)
|
||||
|
||||
# Add tooltip with project info
|
||||
tooltip = f"{project.tagline}\n\nPath: {project.path}"
|
||||
if not project.exists:
|
||||
tooltip += "\n\n(Directory not found)"
|
||||
item.setForeground(Qt.GlobalColor.darkGray)
|
||||
item.setToolTip(tooltip)
|
||||
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _get_selected_project(self) -> Project | None:
|
||||
"""Get the currently selected project."""
|
||||
item = self.list_widget.currentItem()
|
||||
if item:
|
||||
return item.data(Qt.ItemDataRole.UserRole)
|
||||
return None
|
||||
|
||||
def _on_item_clicked(self, item: QListWidgetItem):
|
||||
"""Handle single click on project."""
|
||||
project = item.data(Qt.ItemDataRole.UserRole)
|
||||
self.project_selected.emit(project)
|
||||
|
||||
def _on_item_double_clicked(self, item: QListWidgetItem):
|
||||
"""Handle double-click - open terminal at project."""
|
||||
project = item.data(Qt.ItemDataRole.UserRole)
|
||||
if project.exists:
|
||||
self.open_terminal_requested.emit(project)
|
||||
|
||||
def _show_context_menu(self, position):
|
||||
"""Show context menu for project actions."""
|
||||
item = self.list_widget.itemAt(position)
|
||||
if not item:
|
||||
return
|
||||
|
||||
project = item.data(Qt.ItemDataRole.UserRole)
|
||||
menu = QMenu(self)
|
||||
|
||||
# Open Terminal
|
||||
open_terminal = QAction("Open Terminal", self)
|
||||
open_terminal.triggered.connect(lambda: self.open_terminal_requested.emit(project))
|
||||
open_terminal.setEnabled(project.exists)
|
||||
menu.addAction(open_terminal)
|
||||
|
||||
# Open in Editor
|
||||
open_editor = QAction("Open in Editor", self)
|
||||
open_editor.triggered.connect(lambda: self._open_in_editor(project))
|
||||
open_editor.setEnabled(project.exists)
|
||||
menu.addAction(open_editor)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View on Gitea
|
||||
view_gitea = QAction("View on Gitea", self)
|
||||
view_gitea.triggered.connect(lambda: webbrowser.open(project.gitea_url))
|
||||
menu.addAction(view_gitea)
|
||||
|
||||
# View Documentation
|
||||
view_docs = QAction("View Documentation", self)
|
||||
view_docs.triggered.connect(lambda: webbrowser.open(project.docs_url))
|
||||
menu.addAction(view_docs)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Deploy Docs
|
||||
deploy_docs = QAction("Deploy Docs", self)
|
||||
deploy_docs.triggered.connect(lambda: self._deploy_docs(project))
|
||||
menu.addAction(deploy_docs)
|
||||
|
||||
menu.exec(self.list_widget.mapToGlobal(position))
|
||||
|
||||
def _open_in_editor(self, project: Project):
|
||||
"""Open project in the default editor."""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# Try common editors in order of preference
|
||||
editors = [
|
||||
os.environ.get("EDITOR"),
|
||||
"pycharm",
|
||||
"code", # VS Code
|
||||
"subl", # Sublime Text
|
||||
"gedit",
|
||||
"xdg-open",
|
||||
]
|
||||
|
||||
for editor in editors:
|
||||
if editor and shutil.which(editor):
|
||||
subprocess.Popen([editor, str(project.path)])
|
||||
return
|
||||
|
||||
def _deploy_docs(self, project: Project):
|
||||
"""Deploy documentation for project."""
|
||||
build_script = Path.home() / "PycharmProjects/project-docs/scripts/build-public-docs.sh"
|
||||
if build_script.exists():
|
||||
subprocess.Popen([str(build_script), project.key, "--deploy"])
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""Settings management for Development Hub."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings with file persistence."""
|
||||
|
||||
_instance = None
|
||||
_settings_file = Path.home() / ".config" / "development-hub" / "settings.json"
|
||||
_session_file = Path.home() / ".config" / "development-hub" / "session.json"
|
||||
|
||||
# Default settings
|
||||
DEFAULTS = {
|
||||
"deploy_docs_after_creation": True,
|
||||
"default_project_path": str(Path.home() / "PycharmProjects"),
|
||||
}
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton pattern."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._settings = {}
|
||||
cls._instance._load()
|
||||
return cls._instance
|
||||
|
||||
def _load(self):
|
||||
"""Load settings from file."""
|
||||
self._settings = self.DEFAULTS.copy()
|
||||
|
||||
if self._settings_file.exists():
|
||||
try:
|
||||
with open(self._settings_file) as f:
|
||||
saved = json.load(f)
|
||||
self._settings.update(saved)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
def _save(self):
|
||||
"""Save settings to file."""
|
||||
self._settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._settings_file, "w") as f:
|
||||
json.dump(self._settings, f, indent=2)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""Get a setting value."""
|
||||
return self._settings.get(key, default)
|
||||
|
||||
def set(self, key: str, value):
|
||||
"""Set a setting value and save."""
|
||||
self._settings[key] = value
|
||||
self._save()
|
||||
|
||||
@property
|
||||
def deploy_docs_after_creation(self) -> bool:
|
||||
"""Whether to deploy docs after creating a new project."""
|
||||
return self.get("deploy_docs_after_creation", True)
|
||||
|
||||
@deploy_docs_after_creation.setter
|
||||
def deploy_docs_after_creation(self, value: bool):
|
||||
self.set("deploy_docs_after_creation", value)
|
||||
|
||||
@property
|
||||
def default_project_path(self) -> Path:
|
||||
"""Default path for new projects."""
|
||||
return Path(self.get("default_project_path", str(Path.home() / "PycharmProjects")))
|
||||
|
||||
@default_project_path.setter
|
||||
def default_project_path(self, value: Path):
|
||||
self.set("default_project_path", str(value))
|
||||
|
||||
def save_session(self, state: dict):
|
||||
"""Save session state to file."""
|
||||
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with open(self._session_file, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def load_session(self) -> dict:
|
||||
"""Load session state from file."""
|
||||
if self._session_file.exists():
|
||||
try:
|
||||
with open(self._session_file) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"""Dark theme stylesheet for Development Hub."""
|
||||
|
||||
DARK_THEME = """
|
||||
QMainWindow {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QMenuBar {
|
||||
background-color: #252526;
|
||||
color: #cccccc;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenuBar::item {
|
||||
padding: 4px 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QMenuBar::item:selected {
|
||||
background-color: #094771;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
QMenu {
|
||||
background-color: #252526;
|
||||
border: 1px solid #3c3c3c;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QMenu::item {
|
||||
padding: 6px 24px 6px 12px;
|
||||
}
|
||||
|
||||
QMenu::item:selected {
|
||||
background-color: #094771;
|
||||
}
|
||||
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background-color: #3c3c3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
QListWidget {
|
||||
background-color: #252526;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
QListWidget::item {
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
QListWidget::item:hover {
|
||||
background-color: #2a2d2e;
|
||||
}
|
||||
|
||||
QListWidget::item:selected {
|
||||
background-color: #094771;
|
||||
}
|
||||
|
||||
QSplitter::handle {
|
||||
background-color: #3c3c3c;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #3c3c3c;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #2d2d2d;
|
||||
color: #969696;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-right: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-bottom: 2px solid #007acc;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
QStatusBar {
|
||||
background-color: #007acc;
|
||||
color: #ffffff;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
QLabel#header {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #858585;
|
||||
padding: 8px 12px 4px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
QScrollBar:vertical {
|
||||
background-color: #1e1e1e;
|
||||
width: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #424242;
|
||||
min-height: 20px;
|
||||
border-radius: 3px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical,
|
||||
QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background-color: #1e1e1e;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #424242;
|
||||
min-width: 20px;
|
||||
border-radius: 3px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal,
|
||||
QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #0e639c;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #1177bb;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #094771;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #3c3c3c;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #3c3c3c;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px;
|
||||
padding: 6px 8px;
|
||||
color: #cccccc;
|
||||
selection-background-color: #094771;
|
||||
}
|
||||
|
||||
QLineEdit:focus {
|
||||
border-color: #007acc;
|
||||
}
|
||||
|
||||
QTextEdit, QPlainTextEdit {
|
||||
background-color: #1e1e1e;
|
||||
border: none;
|
||||
color: #d4d4d4;
|
||||
selection-background-color: #264f78;
|
||||
}
|
||||
|
||||
QToolTip {
|
||||
background-color: #252526;
|
||||
color: #cccccc;
|
||||
border: 1px solid #3c3c3c;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
"""
|
||||
|
|
@ -0,0 +1,786 @@
|
|||
"""PTY-based terminal widget with full terminal emulation using pyte."""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
from pathlib import Path
|
||||
|
||||
import pyte
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt6.QtGui import (
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QColor,
|
||||
QPainter,
|
||||
QKeyEvent,
|
||||
QPalette,
|
||||
QTextCharFormat,
|
||||
QDragEnterEvent,
|
||||
QDropEvent,
|
||||
)
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollBar, QAbstractScrollArea
|
||||
|
||||
|
||||
class PtyReaderThread(QThread):
|
||||
"""Thread that reads from PTY master and emits output."""
|
||||
|
||||
output_ready = pyqtSignal(bytes)
|
||||
finished_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, master_fd: int):
|
||||
super().__init__()
|
||||
self.master_fd = master_fd
|
||||
self._running = True
|
||||
|
||||
def run(self):
|
||||
"""Read from PTY and emit output."""
|
||||
while self._running:
|
||||
try:
|
||||
readable, _, _ = select.select([self.master_fd], [], [], 0.1)
|
||||
if readable:
|
||||
try:
|
||||
data = os.read(self.master_fd, 4096)
|
||||
if data:
|
||||
self.output_ready.emit(data)
|
||||
else:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
break
|
||||
|
||||
self.finished_signal.emit()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the reader thread."""
|
||||
self._running = False
|
||||
|
||||
|
||||
# Color mapping from pyte to Qt
|
||||
PYTE_COLORS = {
|
||||
"black": QColor("#1e1e1e"),
|
||||
"red": QColor("#f44747"),
|
||||
"green": QColor("#6a9955"),
|
||||
"brown": QColor("#dcdcaa"), # yellow
|
||||
"blue": QColor("#569cd6"),
|
||||
"magenta": QColor("#c586c0"),
|
||||
"cyan": QColor("#4ec9b0"),
|
||||
"white": QColor("#d4d4d4"),
|
||||
"default": QColor("#d4d4d4"),
|
||||
# Bright colors
|
||||
"brightblack": QColor("#808080"),
|
||||
"brightred": QColor("#f14c4c"),
|
||||
"brightgreen": QColor("#73c991"),
|
||||
"brightbrown": QColor("#e2e210"),
|
||||
"brightblue": QColor("#3794ff"),
|
||||
"brightmagenta": QColor("#d670d6"),
|
||||
"brightcyan": QColor("#29b8db"),
|
||||
"brightwhite": QColor("#ffffff"),
|
||||
}
|
||||
|
||||
BG_COLORS = {
|
||||
"black": QColor("#1e1e1e"),
|
||||
"red": QColor("#5a1d1d"),
|
||||
"green": QColor("#1d3d1d"),
|
||||
"brown": QColor("#3d3d1d"),
|
||||
"blue": QColor("#1d1d5a"),
|
||||
"magenta": QColor("#3d1d3d"),
|
||||
"cyan": QColor("#1d3d3d"),
|
||||
"white": QColor("#3d3d3d"),
|
||||
"default": QColor("#1e1e1e"),
|
||||
}
|
||||
|
||||
|
||||
class TerminalDisplay(QWidget):
|
||||
"""Terminal display widget that renders a pyte screen."""
|
||||
|
||||
key_pressed = pyqtSignal(bytes)
|
||||
cursor_position_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, rows: int = 24, cols: int = 80, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create pyte screen and stream
|
||||
self.screen = pyte.Screen(cols, rows)
|
||||
self.stream = pyte.Stream(self.screen)
|
||||
|
||||
# Set up display
|
||||
self._setup_display()
|
||||
|
||||
# Cursor state (no blinking for performance)
|
||||
self._cursor_visible = True
|
||||
|
||||
# Enable focus
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
# Enable drag-drop
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
# Dirty flag for efficient updates
|
||||
self._dirty = True
|
||||
|
||||
# Selection state
|
||||
self._selection_start = None # (row, col)
|
||||
self._selection_end = None # (row, col)
|
||||
self._selecting = False
|
||||
|
||||
def _setup_display(self):
|
||||
"""Configure the terminal display."""
|
||||
# Use monospace font - smaller size
|
||||
self.font = QFont("Monospace", 10)
|
||||
self.font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
self.font.setFixedPitch(True)
|
||||
self.setFont(self.font)
|
||||
|
||||
# Calculate character dimensions
|
||||
fm = QFontMetrics(self.font)
|
||||
self.char_width = fm.horizontalAdvance("M")
|
||||
self.char_height = fm.lineSpacing()
|
||||
|
||||
# Set background
|
||||
palette = self.palette()
|
||||
palette.setColor(QPalette.ColorRole.Window, QColor("#1e1e1e"))
|
||||
self.setPalette(palette)
|
||||
self.setAutoFillBackground(True)
|
||||
|
||||
def resize_screen(self, rows: int, cols: int):
|
||||
"""Resize the pyte screen."""
|
||||
self.screen.resize(rows, cols)
|
||||
self._dirty = True
|
||||
self.update()
|
||||
|
||||
def feed(self, data: bytes):
|
||||
"""Feed data to the terminal emulator."""
|
||||
try:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
except UnicodeDecodeError:
|
||||
text = data.decode("latin-1", errors="replace")
|
||||
|
||||
# Check for cursor position query (DSR) before feeding to pyte
|
||||
if '\x1b[6n' in text:
|
||||
self.cursor_position_requested.emit()
|
||||
text = text.replace('\x1b[6n', '')
|
||||
|
||||
if text:
|
||||
self.stream.feed(text)
|
||||
self._dirty = True
|
||||
self.update()
|
||||
|
||||
# Debug: print non-default attributes (disabled)
|
||||
# self._debug_screen()
|
||||
|
||||
def _debug_screen(self):
|
||||
"""Debug: Print cells with non-default attributes."""
|
||||
for row in range(min(5, self.screen.lines)): # Just first 5 rows
|
||||
for col in range(min(80, self.screen.columns)):
|
||||
char = self.screen.buffer[row][col]
|
||||
if char.reverse or char.bg != "default" or (char.fg != "default" and char.data.strip()):
|
||||
print(f"[{row},{col}] '{char.data}' fg={char.fg} bg={char.bg} rev={char.reverse} bold={char.bold}")
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Render the terminal screen."""
|
||||
painter = QPainter(self)
|
||||
painter.setFont(self.font)
|
||||
|
||||
# Fill background
|
||||
default_bg_color = QColor("#1e1e1e")
|
||||
painter.fillRect(self.rect(), default_bg_color)
|
||||
|
||||
fm = QFontMetrics(self.font)
|
||||
ascent = fm.ascent()
|
||||
|
||||
# Draw each row
|
||||
for row in range(min(self.screen.lines, self.height() // self.char_height + 1)):
|
||||
y_base = row * self.char_height
|
||||
y_text = y_base + ascent
|
||||
|
||||
col = 0
|
||||
while col < self.screen.columns:
|
||||
char = self.screen.buffer[row][col]
|
||||
x = col * self.char_width
|
||||
|
||||
# Get colors
|
||||
fg_color = self._get_fg_color(char)
|
||||
bg_color = self._get_bg_color(char)
|
||||
|
||||
# Draw background if not default (compare RGB values)
|
||||
if bg_color.rgb() != default_bg_color.rgb():
|
||||
painter.fillRect(x, y_base, self.char_width, self.char_height, bg_color)
|
||||
|
||||
# Draw character
|
||||
if char.data and char.data != " ":
|
||||
painter.setPen(fg_color)
|
||||
painter.drawText(x, y_text, char.data)
|
||||
|
||||
col += 1
|
||||
|
||||
|
||||
# Draw selection
|
||||
self._draw_selection(painter)
|
||||
|
||||
# Draw cursor (block cursor)
|
||||
if self._cursor_visible and self.hasFocus():
|
||||
cx = self.screen.cursor.x
|
||||
cy = self.screen.cursor.y
|
||||
if 0 <= cx < self.screen.columns and 0 <= cy < self.screen.lines:
|
||||
cursor_x = cx * self.char_width
|
||||
cursor_y = cy * self.char_height
|
||||
painter.fillRect(
|
||||
cursor_x, cursor_y,
|
||||
self.char_width, self.char_height,
|
||||
QColor(200, 200, 200, 128)
|
||||
)
|
||||
|
||||
def _color_to_qcolor(self, color, default_fg=True) -> QColor:
|
||||
"""Convert a pyte color to QColor.
|
||||
|
||||
Args:
|
||||
color: The color value from pyte (string, int, or tuple)
|
||||
default_fg: If True, return default foreground; else default background
|
||||
"""
|
||||
default = QColor("#d4d4d4") if default_fg else QColor("#1e1e1e")
|
||||
|
||||
if color == "default" or color is None:
|
||||
return default
|
||||
|
||||
# Handle string color names or hex colors
|
||||
if isinstance(color, str):
|
||||
# Check if it's a hex color (6 hex digits) - pyte returns 256/truecolor as hex
|
||||
if len(color) == 6 and all(c in '0123456789abcdefABCDEF' for c in color):
|
||||
return QColor(f"#{color}")
|
||||
|
||||
# For named colors, use our palettes
|
||||
if default_fg:
|
||||
return PYTE_COLORS.get(color, default)
|
||||
else:
|
||||
return BG_COLORS.get(color, default)
|
||||
|
||||
# Handle 256 color palette
|
||||
if isinstance(color, int):
|
||||
if color < 16:
|
||||
# Standard 16 colors
|
||||
fg_colors = [
|
||||
QColor("#1e1e1e"), QColor("#f44747"), QColor("#6a9955"), QColor("#dcdcaa"),
|
||||
QColor("#569cd6"), QColor("#c586c0"), QColor("#4ec9b0"), QColor("#d4d4d4"),
|
||||
QColor("#808080"), QColor("#f14c4c"), QColor("#73c991"), QColor("#e2e210"),
|
||||
QColor("#3794ff"), QColor("#d670d6"), QColor("#29b8db"), QColor("#ffffff"),
|
||||
]
|
||||
bg_colors = [
|
||||
QColor("#1e1e1e"), QColor("#5a1d1d"), QColor("#1d3d1d"), QColor("#3d3d1d"),
|
||||
QColor("#1d1d5a"), QColor("#3d1d3d"), QColor("#1d3d3d"), QColor("#4a4a4a"),
|
||||
QColor("#4d4d4d"), QColor("#8b3d3d"), QColor("#3d6b3d"), QColor("#6b6b3d"),
|
||||
QColor("#3d3d8b"), QColor("#6b3d6b"), QColor("#3d6b6b"), QColor("#6a6a6a"),
|
||||
]
|
||||
return fg_colors[color] if default_fg else bg_colors[color]
|
||||
elif color < 232:
|
||||
# 216 color cube
|
||||
c = color - 16
|
||||
r = (c // 36) * 51
|
||||
g = ((c // 6) % 6) * 51
|
||||
b = (c % 6) * 51
|
||||
return QColor(r, g, b)
|
||||
else:
|
||||
# Grayscale (24 shades)
|
||||
gray = (color - 232) * 10 + 8
|
||||
return QColor(gray, gray, gray)
|
||||
|
||||
# Handle RGB tuple (truecolor)
|
||||
if isinstance(color, tuple) and len(color) == 3:
|
||||
return QColor(color[0], color[1], color[2])
|
||||
|
||||
return default
|
||||
|
||||
def _get_fg_color(self, char) -> QColor:
|
||||
"""Get foreground color for a character."""
|
||||
if char.reverse:
|
||||
# Reverse video: swap fg/bg
|
||||
if char.bg == "default":
|
||||
return QColor("#1e1e1e")
|
||||
return self._color_to_qcolor(char.bg, default_fg=True)
|
||||
|
||||
if char.fg == "default":
|
||||
color = QColor("#d4d4d4")
|
||||
else:
|
||||
color = self._color_to_qcolor(char.fg, default_fg=True)
|
||||
|
||||
# Brighten if bold
|
||||
if char.bold and isinstance(char.fg, str) and char.fg != "default":
|
||||
if not char.fg.startswith("bright"):
|
||||
bright_name = "bright" + char.fg
|
||||
if bright_name in PYTE_COLORS:
|
||||
return PYTE_COLORS[bright_name]
|
||||
|
||||
return color
|
||||
|
||||
def _get_bg_color(self, char) -> QColor:
|
||||
"""Get background color for a character."""
|
||||
if char.reverse:
|
||||
# Reverse video: swap fg/bg, use foreground as background
|
||||
if char.fg == "default":
|
||||
return QColor("#d4d4d4") # Light background for reverse
|
||||
return self._color_to_qcolor(char.fg, default_fg=False)
|
||||
|
||||
if char.bg == "default":
|
||||
return QColor("#1e1e1e")
|
||||
|
||||
return self._color_to_qcolor(char.bg, default_fg=False)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
"""Handle keyboard input and send to PTY."""
|
||||
key = event.key()
|
||||
modifiers = event.modifiers()
|
||||
text = event.text()
|
||||
|
||||
# Handle special key combinations
|
||||
if modifiers == Qt.KeyboardModifier.ControlModifier:
|
||||
if key == Qt.Key.Key_C:
|
||||
self.key_pressed.emit(b'\x03')
|
||||
return
|
||||
elif key == Qt.Key.Key_D:
|
||||
self.key_pressed.emit(b'\x04')
|
||||
return
|
||||
elif key == Qt.Key.Key_Z:
|
||||
self.key_pressed.emit(b'\x1a')
|
||||
return
|
||||
elif key == Qt.Key.Key_L:
|
||||
self.key_pressed.emit(b'\x0c')
|
||||
return
|
||||
elif key == Qt.Key.Key_A:
|
||||
self.key_pressed.emit(b'\x01')
|
||||
return
|
||||
elif key == Qt.Key.Key_E:
|
||||
self.key_pressed.emit(b'\x05')
|
||||
return
|
||||
elif key == Qt.Key.Key_K:
|
||||
self.key_pressed.emit(b'\x0b')
|
||||
return
|
||||
elif key == Qt.Key.Key_U:
|
||||
self.key_pressed.emit(b'\x15')
|
||||
return
|
||||
elif key == Qt.Key.Key_W:
|
||||
self.key_pressed.emit(b'\x17')
|
||||
return
|
||||
|
||||
# Arrow keys and special keys
|
||||
if key == Qt.Key.Key_Up:
|
||||
self.key_pressed.emit(b'\x1b[A')
|
||||
return
|
||||
elif key == Qt.Key.Key_Down:
|
||||
self.key_pressed.emit(b'\x1b[B')
|
||||
return
|
||||
elif key == Qt.Key.Key_Right:
|
||||
self.key_pressed.emit(b'\x1b[C')
|
||||
return
|
||||
elif key == Qt.Key.Key_Left:
|
||||
self.key_pressed.emit(b'\x1b[D')
|
||||
return
|
||||
elif key == Qt.Key.Key_Home:
|
||||
self.key_pressed.emit(b'\x1b[H')
|
||||
return
|
||||
elif key == Qt.Key.Key_End:
|
||||
self.key_pressed.emit(b'\x1b[F')
|
||||
return
|
||||
elif key == Qt.Key.Key_Delete:
|
||||
self.key_pressed.emit(b'\x1b[3~')
|
||||
return
|
||||
elif key == Qt.Key.Key_Backspace:
|
||||
self.key_pressed.emit(b'\x7f')
|
||||
return
|
||||
elif key == Qt.Key.Key_Tab:
|
||||
self.key_pressed.emit(b'\t')
|
||||
return
|
||||
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
||||
self.key_pressed.emit(b'\r')
|
||||
return
|
||||
elif key == Qt.Key.Key_Escape:
|
||||
self.key_pressed.emit(b'\x1b')
|
||||
return
|
||||
elif key == Qt.Key.Key_PageUp:
|
||||
self.key_pressed.emit(b'\x1b[5~')
|
||||
return
|
||||
elif key == Qt.Key.Key_PageDown:
|
||||
self.key_pressed.emit(b'\x1b[6~')
|
||||
return
|
||||
elif key == Qt.Key.Key_Insert:
|
||||
self.key_pressed.emit(b'\x1b[2~')
|
||||
return
|
||||
|
||||
# Function keys
|
||||
if Qt.Key.Key_F1 <= key <= Qt.Key.Key_F12:
|
||||
fn = key - Qt.Key.Key_F1 + 1
|
||||
if fn <= 4:
|
||||
self.key_pressed.emit(f'\x1bO{chr(ord("P") + fn - 1)}'.encode())
|
||||
else:
|
||||
codes = {5: 15, 6: 17, 7: 18, 8: 19, 9: 20, 10: 21, 11: 23, 12: 24}
|
||||
self.key_pressed.emit(f'\x1b[{codes.get(fn, 15)}~'.encode())
|
||||
return
|
||||
|
||||
# Regular text
|
||||
if text:
|
||||
self.key_pressed.emit(text.encode('utf-8'))
|
||||
|
||||
def _pos_to_cell(self, pos) -> tuple[int, int]:
|
||||
"""Convert pixel position to (row, col)."""
|
||||
col = max(0, min(pos.x() // self.char_width, self.screen.columns - 1))
|
||||
row = max(0, min(pos.y() // self.char_height, self.screen.lines - 1))
|
||||
return (row, col)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press for selection."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._selection_start = self._pos_to_cell(event.pos())
|
||||
self._selection_end = self._selection_start
|
||||
self._selecting = True
|
||||
self.update()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse move for selection."""
|
||||
if self._selecting:
|
||||
self._selection_end = self._pos_to_cell(event.pos())
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
"""Handle mouse release."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._selecting = False
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Show context menu with copy/paste."""
|
||||
from PyQt6.QtWidgets import QMenu, QApplication
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
copy_action = menu.addAction("Copy")
|
||||
copy_action.triggered.connect(self._copy_selection)
|
||||
copy_action.setEnabled(self._has_selection())
|
||||
|
||||
paste_action = menu.addAction("Paste")
|
||||
paste_action.triggered.connect(self._paste_clipboard)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
clear_action = menu.addAction("Clear Selection")
|
||||
clear_action.triggered.connect(self._clear_selection)
|
||||
|
||||
menu.exec(event.globalPos())
|
||||
|
||||
def _has_selection(self) -> bool:
|
||||
"""Check if there's an active selection."""
|
||||
return (self._selection_start is not None and
|
||||
self._selection_end is not None and
|
||||
self._selection_start != self._selection_end)
|
||||
|
||||
def _get_selection_bounds(self) -> tuple[tuple[int, int], tuple[int, int]]:
|
||||
"""Get normalized selection bounds (start <= end)."""
|
||||
if not self._has_selection():
|
||||
return None, None
|
||||
|
||||
start = self._selection_start
|
||||
end = self._selection_end
|
||||
|
||||
# Normalize so start comes before end
|
||||
if (start[0] > end[0]) or (start[0] == end[0] and start[1] > end[1]):
|
||||
start, end = end, start
|
||||
|
||||
return start, end
|
||||
|
||||
def _copy_selection(self):
|
||||
"""Copy selected text to clipboard."""
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
start, end = self._get_selection_bounds()
|
||||
if start is None:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for row in range(start[0], end[0] + 1):
|
||||
line_start = start[1] if row == start[0] else 0
|
||||
line_end = end[1] + 1 if row == end[0] else self.screen.columns
|
||||
|
||||
line = ""
|
||||
for col in range(line_start, line_end):
|
||||
char = self.screen.buffer[row][col]
|
||||
line += char.data if char.data else " "
|
||||
|
||||
lines.append(line.rstrip())
|
||||
|
||||
text = "\n".join(lines)
|
||||
QApplication.clipboard().setText(text)
|
||||
|
||||
def _paste_clipboard(self):
|
||||
"""Paste clipboard content as keyboard input."""
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
text = QApplication.clipboard().text()
|
||||
if text:
|
||||
self.key_pressed.emit(text.encode('utf-8'))
|
||||
|
||||
def _clear_selection(self):
|
||||
"""Clear the current selection."""
|
||||
self._selection_start = None
|
||||
self._selection_end = None
|
||||
self.update()
|
||||
|
||||
def _draw_selection(self, painter):
|
||||
"""Draw selection highlight."""
|
||||
start, end = self._get_selection_bounds()
|
||||
if start is None:
|
||||
return
|
||||
|
||||
selection_color = QColor(70, 130, 180, 100) # Steel blue, semi-transparent
|
||||
|
||||
for row in range(start[0], end[0] + 1):
|
||||
line_start = start[1] if row == start[0] else 0
|
||||
line_end = end[1] + 1 if row == end[0] else self.screen.columns
|
||||
|
||||
x = line_start * self.char_width
|
||||
y = row * self.char_height
|
||||
width = (line_end - line_start) * self.char_width
|
||||
|
||||
painter.fillRect(x, y, width, self.char_height, selection_color)
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||
"""Accept drag events that contain file URLs."""
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
"""Accept drag move events."""
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event: QDropEvent):
|
||||
"""Handle file/folder drops."""
|
||||
urls = event.mimeData().urls()
|
||||
if not urls:
|
||||
return
|
||||
|
||||
# Get the first dropped item's local path
|
||||
path = urls[0].toLocalFile()
|
||||
if not path:
|
||||
return
|
||||
|
||||
# Determine what to do based on the path type
|
||||
path_obj = Path(path)
|
||||
|
||||
if path_obj.is_dir():
|
||||
# Directory → cd to it
|
||||
self._inject_text(f'cd "{path}"\n')
|
||||
elif path_obj.is_file() and os.access(path, os.X_OK):
|
||||
# Executable file → run it
|
||||
self._inject_text(f'"{path}"\n')
|
||||
else:
|
||||
# Regular file → insert quoted path (no newline, user can add args)
|
||||
self._inject_text(f'"{path}" ')
|
||||
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _inject_text(self, text: str):
|
||||
"""Inject text as if user typed it."""
|
||||
self.key_pressed.emit(text.encode('utf-8'))
|
||||
|
||||
|
||||
class TerminalWidget(QWidget):
|
||||
"""Full terminal widget with PTY support using pyte emulation."""
|
||||
|
||||
closed = pyqtSignal()
|
||||
|
||||
def __init__(self, cwd: Path | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cwd = cwd or Path.home()
|
||||
self.master_fd = None
|
||||
self.slave_fd = None
|
||||
self.process = None
|
||||
self.reader_thread = None
|
||||
|
||||
self._setup_ui()
|
||||
self._start_shell()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the terminal UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Start with reasonable default size
|
||||
self.display = TerminalDisplay(rows=24, cols=80)
|
||||
self.display.key_pressed.connect(self._on_key_pressed)
|
||||
self.display.cursor_position_requested.connect(self._report_cursor_position)
|
||||
self.display.setSizePolicy(
|
||||
self.display.sizePolicy().horizontalPolicy(),
|
||||
self.display.sizePolicy().verticalPolicy()
|
||||
)
|
||||
layout.addWidget(self.display)
|
||||
|
||||
def _start_shell(self):
|
||||
"""Start the shell process with PTY."""
|
||||
# Create PTY pair
|
||||
self.master_fd, self.slave_fd = pty.openpty()
|
||||
|
||||
# Get terminal size
|
||||
rows = self.display.screen.lines
|
||||
cols = self.display.screen.columns
|
||||
|
||||
# Set terminal size
|
||||
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
# Get shell
|
||||
shell = os.environ.get('SHELL', '/bin/bash')
|
||||
|
||||
# Environment
|
||||
env = os.environ.copy()
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['COLORTERM'] = 'truecolor'
|
||||
env['COLUMNS'] = str(cols)
|
||||
env['LINES'] = str(rows)
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
[shell, '-i'],
|
||||
stdin=self.slave_fd,
|
||||
stdout=self.slave_fd,
|
||||
stderr=self.slave_fd,
|
||||
cwd=str(self.cwd),
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
os.close(self.slave_fd)
|
||||
self.slave_fd = None
|
||||
|
||||
# Make master non-blocking
|
||||
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Start reader thread
|
||||
self.reader_thread = PtyReaderThread(self.master_fd)
|
||||
self.reader_thread.output_ready.connect(self._on_output)
|
||||
self.reader_thread.finished_signal.connect(self._on_shell_exit)
|
||||
self.reader_thread.start()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle resize events to update PTY size."""
|
||||
super().resizeEvent(event)
|
||||
QTimer.singleShot(50, self._update_terminal_size)
|
||||
|
||||
def _update_terminal_size(self):
|
||||
"""Update terminal size based on widget size."""
|
||||
if self.master_fd is None:
|
||||
return
|
||||
|
||||
# Use the display's calculated char dimensions
|
||||
char_width = self.display.char_width
|
||||
char_height = self.display.char_height
|
||||
|
||||
if char_width <= 0 or char_height <= 0:
|
||||
return
|
||||
|
||||
width = self.display.width()
|
||||
height = self.display.height()
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return
|
||||
|
||||
cols = max(width // char_width, 20)
|
||||
rows = max(height // char_height, 5)
|
||||
|
||||
# Clamp to reasonable values
|
||||
cols = min(cols, 500)
|
||||
rows = min(rows, 200)
|
||||
|
||||
# Update pyte screen if size changed
|
||||
if rows != self.display.screen.lines or cols != self.display.screen.columns:
|
||||
self.display.resize_screen(rows, cols)
|
||||
|
||||
# Update PTY size
|
||||
try:
|
||||
winsize = struct.pack('HHHH', rows, cols, width, height)
|
||||
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _on_key_pressed(self, data: bytes):
|
||||
"""Handle key press from display."""
|
||||
if self.master_fd is not None:
|
||||
try:
|
||||
os.write(self.master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _report_cursor_position(self):
|
||||
"""Respond to cursor position query (DSR)."""
|
||||
if self.master_fd is None:
|
||||
return
|
||||
|
||||
# Get cursor position from pyte screen
|
||||
row = self.display.screen.cursor.y + 1 # 1-based
|
||||
col = self.display.screen.cursor.x + 1 # 1-based
|
||||
|
||||
# Send CPR (Cursor Position Report): \x1b[row;colR
|
||||
response = f'\x1b[{row};{col}R'.encode('utf-8')
|
||||
try:
|
||||
os.write(self.master_fd, response)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _on_output(self, data: bytes):
|
||||
"""Handle output from PTY."""
|
||||
self.display.feed(data)
|
||||
|
||||
def _on_shell_exit(self):
|
||||
"""Handle shell process exit."""
|
||||
self.display.feed(b'\r\n[Process exited]\r\n')
|
||||
self.closed.emit()
|
||||
|
||||
def inject_text(self, text: str):
|
||||
"""Inject text as if user typed it."""
|
||||
if self.master_fd is not None:
|
||||
try:
|
||||
os.write(self.master_fd, text.encode('utf-8'))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def send_signal(self, sig: int):
|
||||
"""Send a signal to the shell process."""
|
||||
if self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), sig)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def terminate(self):
|
||||
"""Terminate the shell and clean up."""
|
||||
if self.reader_thread:
|
||||
self.reader_thread.stop()
|
||||
self.reader_thread.wait(1000)
|
||||
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.master_fd is not None:
|
||||
try:
|
||||
os.close(self.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self.master_fd = None
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle widget close."""
|
||||
self.terminate()
|
||||
super().closeEvent(event)
|
||||
|
||||
def setFocus(self):
|
||||
"""Set focus to the display widget."""
|
||||
self.display.setFocus()
|
||||
|
|
@ -0,0 +1,626 @@
|
|||
"""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", {}))
|
||||
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
|
||||
Loading…
Reference in New Issue