Files
pyPhotoAlbum/pyPhotoAlbum/merge_dialog.py
T
dtourolle f6ed11b0bc
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
black formatting
2025-11-27 23:07:16 +01:00

369 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)