Coverage for pyPhotoAlbum/merge_dialog.py: 84%
210 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Merge dialog for resolving project conflicts visually
3"""
5from PyQt6.QtWidgets import (
6 QDialog,
7 QVBoxLayout,
8 QHBoxLayout,
9 QPushButton,
10 QLabel,
11 QListWidget,
12 QListWidgetItem,
13 QSplitter,
14 QWidget,
15 QScrollArea,
16 QRadioButton,
17 QButtonGroup,
18 QTextEdit,
19 QComboBox,
20 QGroupBox,
21)
22from PyQt6.QtCore import Qt, QSize, pyqtSignal
23from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
25from typing import Dict, Any, List, Optional
26from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy
27from pyPhotoAlbum.page_renderer import PageRenderer
30class PagePreviewWidget(QWidget):
31 """Widget to render a page preview"""
33 def __init__(self, page_data: Dict[str, Any], parent=None):
34 super().__init__(parent)
35 self.page_data = page_data
36 self.setMinimumSize(200, 280)
37 self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)
39 def paintEvent(self, event):
40 """Render the page preview"""
41 painter = QPainter(self)
42 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
44 # Draw white background
45 painter.fillRect(self.rect(), QColor(255, 255, 255))
47 # Draw border
48 painter.setPen(QPen(QColor(200, 200, 200), 2))
49 painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
51 # Draw placeholder text
52 painter.setPen(QColor(100, 100, 100))
53 font = QFont("Arial", 10)
54 painter.setFont(font)
56 # Page info
57 page_num = self.page_data.get("page_number", "?")
58 element_count = len(self.page_data.get("layout", {}).get("elements", []))
59 last_modified = self.page_data.get("last_modified", "Unknown")
61 # Draw simplified representation
62 y_offset = 20
63 painter.drawText(10, y_offset, f"Page {page_num}")
64 y_offset += 20
65 painter.drawText(10, y_offset, f"Elements: {element_count}")
66 y_offset += 20
68 # Draw element representations
69 elements = self.page_data.get("layout", {}).get("elements", [])
70 for i, elem in enumerate(elements[:5]): # Show first 5 elements
71 elem_type = elem.get("type", "unknown")
72 deleted = elem.get("deleted", False)
74 color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)
75 painter.setBrush(color)
76 painter.setPen(QPen(color.darker(120), 1))
78 # Draw small rectangle representing element
79 x = 10 + (i % 3) * 60
80 y = y_offset + (i // 3) * 60
81 painter.drawRect(x, y, 50, 50)
83 # Draw type label
84 painter.setPen(QColor(0, 0, 0))
85 painter.drawText(x + 5, y + 25, elem_type[:3].upper())
87 # Draw timestamp at bottom
88 painter.setPen(QColor(100, 100, 100))
89 painter.setFont(QFont("Arial", 8))
90 modified_text = last_modified[:19] if last_modified else "No timestamp"
91 painter.drawText(10, self.height() - 10, modified_text)
94class ConflictItemWidget(QWidget):
95 """Widget for displaying and resolving a single conflict"""
97 resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")
99 def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):
100 super().__init__(parent)
101 self.conflict_index = conflict_index
102 self.conflict = conflict
104 self._init_ui()
106 def _init_ui(self):
107 """Initialize the UI"""
108 layout = QVBoxLayout()
110 # Conflict description
111 desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")
112 desc_label.setWordWrap(True)
113 layout.addWidget(desc_label)
115 # Splitter for side-by-side comparison
116 splitter = QSplitter(Qt.Orientation.Horizontal)
118 # Our version
119 our_widget = QGroupBox("Your Version")
120 our_layout = QVBoxLayout()
122 if self.conflict.conflict_type.name.startswith("PAGE"):
123 # Show page preview
124 our_preview = PagePreviewWidget(self.conflict.our_version)
125 our_layout.addWidget(our_preview)
126 elif self.conflict.conflict_type.name.startswith("ELEMENT"):
127 # Show element details
128 our_details = self._create_element_details(self.conflict.our_version)
129 our_layout.addWidget(our_details)
130 else:
131 # Show settings
132 our_details = self._create_settings_details(self.conflict.our_version)
133 our_layout.addWidget(our_details)
135 our_widget.setLayout(our_layout)
136 splitter.addWidget(our_widget)
138 # Their version
139 their_widget = QGroupBox("Other Version")
140 their_layout = QVBoxLayout()
142 if self.conflict.conflict_type.name.startswith("PAGE"):
143 # Show page preview
144 their_preview = PagePreviewWidget(self.conflict.their_version)
145 their_layout.addWidget(their_preview)
146 elif self.conflict.conflict_type.name.startswith("ELEMENT"):
147 # Show element details
148 their_details = self._create_element_details(self.conflict.their_version)
149 their_layout.addWidget(their_details)
150 else:
151 # Show settings
152 their_details = self._create_settings_details(self.conflict.their_version)
153 their_layout.addWidget(their_details)
155 their_widget.setLayout(their_layout)
156 splitter.addWidget(their_widget)
158 layout.addWidget(splitter)
160 # Resolution buttons
161 resolution_layout = QHBoxLayout()
163 self.button_group = QButtonGroup(self)
165 use_ours_btn = QRadioButton("Use Your Version")
166 use_ours_btn.setChecked(True)
167 use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)
168 self.button_group.addButton(use_ours_btn)
169 resolution_layout.addWidget(use_ours_btn)
171 use_theirs_btn = QRadioButton("Use Other Version")
172 use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)
173 self.button_group.addButton(use_theirs_btn)
174 resolution_layout.addWidget(use_theirs_btn)
176 resolution_layout.addStretch()
177 layout.addLayout(resolution_layout)
179 self.setLayout(layout)
181 def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:
182 """Create a text widget showing element details"""
183 details = QTextEdit()
184 details.setReadOnly(True)
185 details.setMaximumHeight(150)
187 elem_type = element_data.get("type", "unknown")
188 position = element_data.get("position", (0, 0))
189 size = element_data.get("size", (0, 0))
190 deleted = element_data.get("deleted", False)
191 last_modified = element_data.get("last_modified", "Unknown")
193 text = f"Type: {elem_type}\n"
194 text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"
195 text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"
196 text += f"Deleted: {deleted}\n"
197 text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"
199 if elem_type == "image":
200 text += f"Image: {element_data.get('image_path', 'N/A')}\n"
201 elif elem_type == "textbox":
202 text += f"Text: {element_data.get('text_content', '')[:50]}...\n"
204 details.setPlainText(text)
205 return details
207 def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:
208 """Create a text widget showing settings details"""
209 details = QTextEdit()
210 details.setReadOnly(True)
211 details.setMaximumHeight(150)
213 text = ""
214 for key, value in settings_data.items():
215 if key != "last_modified":
216 text += f"{key}: {value}\n"
218 last_modified = settings_data.get("last_modified", "Unknown")
219 text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"
221 details.setPlainText(text)
222 return details
224 def _on_resolution_changed(self, choice: str):
225 """Emit signal when resolution choice changes"""
226 self.resolution_changed.emit(self.conflict_index, choice)
228 def get_resolution(self) -> str:
229 """Get the current resolution choice"""
230 for button in self.button_group.buttons():
231 if button.isChecked():
232 if "Your" in button.text():
233 return "ours"
234 else:
235 return "theirs"
236 return "ours" # Default
239class MergeDialog(QDialog):
240 """
241 Dialog for visually resolving merge conflicts between two project versions
242 """
244 def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):
245 super().__init__(parent)
247 self.our_project_data = our_project_data
248 self.their_project_data = their_project_data
249 self.merge_manager = MergeManager()
251 # Detect conflicts
252 self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)
254 # Resolution choices (conflict_index -> "ours" or "theirs")
255 self.resolutions: Dict[int, str] = {}
257 # Initialize default resolutions (all "ours")
258 for i in range(len(self.conflicts)):
259 self.resolutions[i] = "ours"
261 self.setWindowTitle("Merge Projects")
262 self.resize(900, 700)
264 self._init_ui()
266 def _init_ui(self):
267 """Initialize the user interface"""
268 layout = QVBoxLayout()
270 # Header
271 header_label = QLabel(
272 f"<h2>Merge Conflicts Detected</h2>"
273 f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "
274 f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"
275 f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "
276 f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"
277 f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"
278 )
279 header_label.setWordWrap(True)
280 layout.addWidget(header_label)
282 # Auto-resolve strategy
283 strategy_layout = QHBoxLayout()
284 strategy_layout.addWidget(QLabel("Auto-resolve all:"))
286 self.strategy_combo = QComboBox()
287 self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)
288 self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)
289 self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)
290 strategy_layout.addWidget(self.strategy_combo)
292 auto_resolve_btn = QPushButton("Auto-Resolve All")
293 auto_resolve_btn.clicked.connect(self._auto_resolve)
294 strategy_layout.addWidget(auto_resolve_btn)
296 strategy_layout.addStretch()
297 layout.addLayout(strategy_layout)
299 # Scroll area for conflicts
300 scroll = QScrollArea()
301 scroll.setWidgetResizable(True)
302 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
303 scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
305 conflicts_widget = QWidget()
306 conflicts_layout = QVBoxLayout()
308 # Create conflict widgets
309 self.conflict_widgets: List[ConflictItemWidget] = []
310 for i, conflict in enumerate(self.conflicts):
311 conflict_widget = ConflictItemWidget(i, conflict)
312 conflict_widget.resolution_changed.connect(self._on_resolution_changed)
313 self.conflict_widgets.append(conflict_widget)
314 conflicts_layout.addWidget(conflict_widget)
316 conflicts_layout.addStretch()
317 conflicts_widget.setLayout(conflicts_layout)
318 scroll.setWidget(conflicts_widget)
320 layout.addWidget(scroll)
322 # Buttons
323 button_layout = QHBoxLayout()
324 button_layout.addStretch()
326 cancel_button = QPushButton("Cancel")
327 cancel_button.clicked.connect(self.reject)
328 button_layout.addWidget(cancel_button)
330 merge_button = QPushButton("Apply Merge")
331 merge_button.clicked.connect(self.accept)
332 merge_button.setDefault(True)
333 button_layout.addWidget(merge_button)
335 layout.addLayout(button_layout)
337 self.setLayout(layout)
339 def _on_resolution_changed(self, conflict_index: int, choice: str):
340 """Handle resolution choice change"""
341 self.resolutions[conflict_index] = choice
343 def _auto_resolve(self):
344 """Auto-resolve all conflicts based on selected strategy"""
345 strategy = self.strategy_combo.currentData()
346 auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)
348 # Update resolution choices
349 self.resolutions.update(auto_resolutions)
351 # Update UI to reflect auto-resolutions
352 for i, resolution in auto_resolutions.items():
353 if i < len(self.conflict_widgets):
354 # Find the correct radio button and check it
355 for button in self.conflict_widgets[i].button_group.buttons():
356 if resolution == "ours" and "Your" in button.text():
357 button.setChecked(True)
358 elif resolution == "theirs" and "Other" in button.text():
359 button.setChecked(True)
361 def get_merged_project_data(self) -> Dict[str, Any]:
362 """
363 Get the merged project data based on user's conflict resolutions.
365 Returns:
366 Merged project data dictionary
367 """
368 return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)