Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""
|
||
Merge dialog for resolving project conflicts visually
|
||
"""
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QDialog,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QPushButton,
|
||
QLabel,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QSplitter,
|
||
QWidget,
|
||
QScrollArea,
|
||
QRadioButton,
|
||
QButtonGroup,
|
||
QTextEdit,
|
||
QComboBox,
|
||
QGroupBox,
|
||
)
|
||
from PyQt6.QtCore import Qt, QSize, pyqtSignal
|
||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
|
||
|
||
from typing import Dict, Any, List, Optional
|
||
from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy
|
||
from pyPhotoAlbum.page_renderer import PageRenderer
|
||
|
||
|
||
class PagePreviewWidget(QWidget):
|
||
"""Widget to render a page preview"""
|
||
|
||
def __init__(self, page_data: Dict[str, Any], parent=None):
|
||
super().__init__(parent)
|
||
self.page_data = page_data
|
||
self.setMinimumSize(200, 280)
|
||
self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
|
||
|
||
def paintEvent(self, event):
|
||
"""Render the page preview"""
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
# Draw white background
|
||
painter.fillRect(self.rect(), QColor(255, 255, 255))
|
||
|
||
# Draw border
|
||
painter.setPen(QPen(QColor(200, 200, 200), 2))
|
||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
||
|
||
# Draw placeholder text
|
||
painter.setPen(QColor(100, 100, 100))
|
||
font = QFont("Arial", 10)
|
||
painter.setFont(font)
|
||
|
||
# Page info
|
||
page_num = self.page_data.get("page_number", "?")
|
||
element_count = len(self.page_data.get("layout", {}).get("elements", []))
|
||
last_modified = self.page_data.get("last_modified", "Unknown")
|
||
|
||
# Draw simplified representation
|
||
y_offset = 20
|
||
painter.drawText(10, y_offset, f"Page {page_num}")
|
||
y_offset += 20
|
||
painter.drawText(10, y_offset, f"Elements: {element_count}")
|
||
y_offset += 20
|
||
|
||
# Draw element representations
|
||
elements = self.page_data.get("layout", {}).get("elements", [])
|
||
for i, elem in enumerate(elements[:5]): # Show first 5 elements
|
||
elem_type = elem.get("type", "unknown")
|
||
deleted = elem.get("deleted", False)
|
||
|
||
color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)
|
||
painter.setBrush(color)
|
||
painter.setPen(QPen(color.darker(120), 1))
|
||
|
||
# Draw small rectangle representing element
|
||
x = 10 + (i % 3) * 60
|
||
y = y_offset + (i // 3) * 60
|
||
painter.drawRect(x, y, 50, 50)
|
||
|
||
# Draw type label
|
||
painter.setPen(QColor(0, 0, 0))
|
||
painter.drawText(x + 5, y + 25, elem_type[:3].upper())
|
||
|
||
# Draw timestamp at bottom
|
||
painter.setPen(QColor(100, 100, 100))
|
||
painter.setFont(QFont("Arial", 8))
|
||
modified_text = last_modified[:19] if last_modified else "No timestamp"
|
||
painter.drawText(10, self.height() - 10, modified_text)
|
||
|
||
|
||
class ConflictItemWidget(QWidget):
|
||
"""Widget for displaying and resolving a single conflict"""
|
||
|
||
resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")
|
||
|
||
def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):
|
||
super().__init__(parent)
|
||
self.conflict_index = conflict_index
|
||
self.conflict = conflict
|
||
|
||
self._init_ui()
|
||
|
||
def _init_ui(self):
|
||
"""Initialize the UI"""
|
||
layout = QVBoxLayout()
|
||
|
||
# Conflict description
|
||
desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")
|
||
desc_label.setWordWrap(True)
|
||
layout.addWidget(desc_label)
|
||
|
||
# Splitter for side-by-side comparison
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
|
||
# Our version
|
||
our_widget = QGroupBox("Your Version")
|
||
our_layout = QVBoxLayout()
|
||
|
||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||
# Show page preview
|
||
our_preview = PagePreviewWidget(self.conflict.our_version)
|
||
our_layout.addWidget(our_preview)
|
||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||
# Show element details
|
||
our_details = self._create_element_details(self.conflict.our_version)
|
||
our_layout.addWidget(our_details)
|
||
else:
|
||
# Show settings
|
||
our_details = self._create_settings_details(self.conflict.our_version)
|
||
our_layout.addWidget(our_details)
|
||
|
||
our_widget.setLayout(our_layout)
|
||
splitter.addWidget(our_widget)
|
||
|
||
# Their version
|
||
their_widget = QGroupBox("Other Version")
|
||
their_layout = QVBoxLayout()
|
||
|
||
if self.conflict.conflict_type.name.startswith("PAGE"):
|
||
# Show page preview
|
||
their_preview = PagePreviewWidget(self.conflict.their_version)
|
||
their_layout.addWidget(their_preview)
|
||
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
|
||
# Show element details
|
||
their_details = self._create_element_details(self.conflict.their_version)
|
||
their_layout.addWidget(their_details)
|
||
else:
|
||
# Show settings
|
||
their_details = self._create_settings_details(self.conflict.their_version)
|
||
their_layout.addWidget(their_details)
|
||
|
||
their_widget.setLayout(their_layout)
|
||
splitter.addWidget(their_widget)
|
||
|
||
layout.addWidget(splitter)
|
||
|
||
# Resolution buttons
|
||
resolution_layout = QHBoxLayout()
|
||
|
||
self.button_group = QButtonGroup(self)
|
||
|
||
use_ours_btn = QRadioButton("Use Your Version")
|
||
use_ours_btn.setChecked(True)
|
||
use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)
|
||
self.button_group.addButton(use_ours_btn)
|
||
resolution_layout.addWidget(use_ours_btn)
|
||
|
||
use_theirs_btn = QRadioButton("Use Other Version")
|
||
use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)
|
||
self.button_group.addButton(use_theirs_btn)
|
||
resolution_layout.addWidget(use_theirs_btn)
|
||
|
||
resolution_layout.addStretch()
|
||
layout.addLayout(resolution_layout)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:
|
||
"""Create a text widget showing element details"""
|
||
details = QTextEdit()
|
||
details.setReadOnly(True)
|
||
details.setMaximumHeight(150)
|
||
|
||
elem_type = element_data.get("type", "unknown")
|
||
position = element_data.get("position", (0, 0))
|
||
size = element_data.get("size", (0, 0))
|
||
deleted = element_data.get("deleted", False)
|
||
last_modified = element_data.get("last_modified", "Unknown")
|
||
|
||
text = f"Type: {elem_type}\n"
|
||
text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"
|
||
text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"
|
||
text += f"Deleted: {deleted}\n"
|
||
text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"
|
||
|
||
if elem_type == "image":
|
||
text += f"Image: {element_data.get('image_path', 'N/A')}\n"
|
||
elif elem_type == "textbox":
|
||
text += f"Text: {element_data.get('text_content', '')[:50]}...\n"
|
||
|
||
details.setPlainText(text)
|
||
return details
|
||
|
||
def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:
|
||
"""Create a text widget showing settings details"""
|
||
details = QTextEdit()
|
||
details.setReadOnly(True)
|
||
details.setMaximumHeight(150)
|
||
|
||
text = ""
|
||
for key, value in settings_data.items():
|
||
if key != "last_modified":
|
||
text += f"{key}: {value}\n"
|
||
|
||
last_modified = settings_data.get("last_modified", "Unknown")
|
||
text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"
|
||
|
||
details.setPlainText(text)
|
||
return details
|
||
|
||
def _on_resolution_changed(self, choice: str):
|
||
"""Emit signal when resolution choice changes"""
|
||
self.resolution_changed.emit(self.conflict_index, choice)
|
||
|
||
def get_resolution(self) -> str:
|
||
"""Get the current resolution choice"""
|
||
for button in self.button_group.buttons():
|
||
if button.isChecked():
|
||
if "Your" in button.text():
|
||
return "ours"
|
||
else:
|
||
return "theirs"
|
||
return "ours" # Default
|
||
|
||
|
||
class MergeDialog(QDialog):
|
||
"""
|
||
Dialog for visually resolving merge conflicts between two project versions
|
||
"""
|
||
|
||
def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):
|
||
super().__init__(parent)
|
||
|
||
self.our_project_data = our_project_data
|
||
self.their_project_data = their_project_data
|
||
self.merge_manager = MergeManager()
|
||
|
||
# Detect conflicts
|
||
self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)
|
||
|
||
# Resolution choices (conflict_index -> "ours" or "theirs")
|
||
self.resolutions: Dict[int, str] = {}
|
||
|
||
# Initialize default resolutions (all "ours")
|
||
for i in range(len(self.conflicts)):
|
||
self.resolutions[i] = "ours"
|
||
|
||
self.setWindowTitle("Merge Projects")
|
||
self.resize(900, 700)
|
||
|
||
self._init_ui()
|
||
|
||
def _init_ui(self):
|
||
"""Initialize the user interface"""
|
||
layout = QVBoxLayout()
|
||
|
||
# Header
|
||
header_label = QLabel(
|
||
f"<h2>Merge Conflicts Detected</h2>"
|
||
f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "
|
||
f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||
f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "
|
||
f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"
|
||
f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"
|
||
)
|
||
header_label.setWordWrap(True)
|
||
layout.addWidget(header_label)
|
||
|
||
# Auto-resolve strategy
|
||
strategy_layout = QHBoxLayout()
|
||
strategy_layout.addWidget(QLabel("Auto-resolve all:"))
|
||
|
||
self.strategy_combo = QComboBox()
|
||
self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)
|
||
self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)
|
||
self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)
|
||
strategy_layout.addWidget(self.strategy_combo)
|
||
|
||
auto_resolve_btn = QPushButton("Auto-Resolve All")
|
||
auto_resolve_btn.clicked.connect(self._auto_resolve)
|
||
strategy_layout.addWidget(auto_resolve_btn)
|
||
|
||
strategy_layout.addStretch()
|
||
layout.addLayout(strategy_layout)
|
||
|
||
# Scroll area for conflicts
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||
|
||
conflicts_widget = QWidget()
|
||
conflicts_layout = QVBoxLayout()
|
||
|
||
# Create conflict widgets
|
||
self.conflict_widgets: List[ConflictItemWidget] = []
|
||
for i, conflict in enumerate(self.conflicts):
|
||
conflict_widget = ConflictItemWidget(i, conflict)
|
||
conflict_widget.resolution_changed.connect(self._on_resolution_changed)
|
||
self.conflict_widgets.append(conflict_widget)
|
||
conflicts_layout.addWidget(conflict_widget)
|
||
|
||
conflicts_layout.addStretch()
|
||
conflicts_widget.setLayout(conflicts_layout)
|
||
scroll.setWidget(conflicts_widget)
|
||
|
||
layout.addWidget(scroll)
|
||
|
||
# Buttons
|
||
button_layout = QHBoxLayout()
|
||
button_layout.addStretch()
|
||
|
||
cancel_button = QPushButton("Cancel")
|
||
cancel_button.clicked.connect(self.reject)
|
||
button_layout.addWidget(cancel_button)
|
||
|
||
merge_button = QPushButton("Apply Merge")
|
||
merge_button.clicked.connect(self.accept)
|
||
merge_button.setDefault(True)
|
||
button_layout.addWidget(merge_button)
|
||
|
||
layout.addLayout(button_layout)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def _on_resolution_changed(self, conflict_index: int, choice: str):
|
||
"""Handle resolution choice change"""
|
||
self.resolutions[conflict_index] = choice
|
||
|
||
def _auto_resolve(self):
|
||
"""Auto-resolve all conflicts based on selected strategy"""
|
||
strategy = self.strategy_combo.currentData()
|
||
auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)
|
||
|
||
# Update resolution choices
|
||
self.resolutions.update(auto_resolutions)
|
||
|
||
# Update UI to reflect auto-resolutions
|
||
for i, resolution in auto_resolutions.items():
|
||
if i < len(self.conflict_widgets):
|
||
# Find the correct radio button and check it
|
||
for button in self.conflict_widgets[i].button_group.buttons():
|
||
if resolution == "ours" and "Your" in button.text():
|
||
button.setChecked(True)
|
||
elif resolution == "theirs" and "Other" in button.text():
|
||
button.setChecked(True)
|
||
|
||
def get_merged_project_data(self) -> Dict[str, Any]:
|
||
"""
|
||
Get the merged project data based on user's conflict resolutions.
|
||
|
||
Returns:
|
||
Merged project data dictionary
|
||
"""
|
||
return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)
|