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

1""" 

2Merge dialog for resolving project conflicts visually 

3""" 

4 

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 

24 

25from typing import Dict, Any, List, Optional 

26from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy 

27from pyPhotoAlbum.page_renderer import PageRenderer 

28 

29 

30class PagePreviewWidget(QWidget): 

31 """Widget to render a page preview""" 

32 

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) 

38 

39 def paintEvent(self, event): 

40 """Render the page preview""" 

41 painter = QPainter(self) 

42 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

43 

44 # Draw white background 

45 painter.fillRect(self.rect(), QColor(255, 255, 255)) 

46 

47 # Draw border 

48 painter.setPen(QPen(QColor(200, 200, 200), 2)) 

49 painter.drawRect(self.rect().adjusted(1, 1, -1, -1)) 

50 

51 # Draw placeholder text 

52 painter.setPen(QColor(100, 100, 100)) 

53 font = QFont("Arial", 10) 

54 painter.setFont(font) 

55 

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") 

60 

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 

67 

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) 

73 

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)) 

77 

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) 

82 

83 # Draw type label 

84 painter.setPen(QColor(0, 0, 0)) 

85 painter.drawText(x + 5, y + 25, elem_type[:3].upper()) 

86 

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) 

92 

93 

94class ConflictItemWidget(QWidget): 

95 """Widget for displaying and resolving a single conflict""" 

96 

97 resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs") 

98 

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 

103 

104 self._init_ui() 

105 

106 def _init_ui(self): 

107 """Initialize the UI""" 

108 layout = QVBoxLayout() 

109 

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) 

114 

115 # Splitter for side-by-side comparison 

116 splitter = QSplitter(Qt.Orientation.Horizontal) 

117 

118 # Our version 

119 our_widget = QGroupBox("Your Version") 

120 our_layout = QVBoxLayout() 

121 

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) 

134 

135 our_widget.setLayout(our_layout) 

136 splitter.addWidget(our_widget) 

137 

138 # Their version 

139 their_widget = QGroupBox("Other Version") 

140 their_layout = QVBoxLayout() 

141 

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) 

154 

155 their_widget.setLayout(their_layout) 

156 splitter.addWidget(their_widget) 

157 

158 layout.addWidget(splitter) 

159 

160 # Resolution buttons 

161 resolution_layout = QHBoxLayout() 

162 

163 self.button_group = QButtonGroup(self) 

164 

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) 

170 

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) 

175 

176 resolution_layout.addStretch() 

177 layout.addLayout(resolution_layout) 

178 

179 self.setLayout(layout) 

180 

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) 

186 

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") 

192 

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" 

198 

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" 

203 

204 details.setPlainText(text) 

205 return details 

206 

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) 

212 

213 text = "" 

214 for key, value in settings_data.items(): 

215 if key != "last_modified": 

216 text += f"{key}: {value}\n" 

217 

218 last_modified = settings_data.get("last_modified", "Unknown") 

219 text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}" 

220 

221 details.setPlainText(text) 

222 return details 

223 

224 def _on_resolution_changed(self, choice: str): 

225 """Emit signal when resolution choice changes""" 

226 self.resolution_changed.emit(self.conflict_index, choice) 

227 

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 

237 

238 

239class MergeDialog(QDialog): 

240 """ 

241 Dialog for visually resolving merge conflicts between two project versions 

242 """ 

243 

244 def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None): 

245 super().__init__(parent) 

246 

247 self.our_project_data = our_project_data 

248 self.their_project_data = their_project_data 

249 self.merge_manager = MergeManager() 

250 

251 # Detect conflicts 

252 self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data) 

253 

254 # Resolution choices (conflict_index -> "ours" or "theirs") 

255 self.resolutions: Dict[int, str] = {} 

256 

257 # Initialize default resolutions (all "ours") 

258 for i in range(len(self.conflicts)): 

259 self.resolutions[i] = "ours" 

260 

261 self.setWindowTitle("Merge Projects") 

262 self.resize(900, 700) 

263 

264 self._init_ui() 

265 

266 def _init_ui(self): 

267 """Initialize the user interface""" 

268 layout = QVBoxLayout() 

269 

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) 

281 

282 # Auto-resolve strategy 

283 strategy_layout = QHBoxLayout() 

284 strategy_layout.addWidget(QLabel("Auto-resolve all:")) 

285 

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) 

291 

292 auto_resolve_btn = QPushButton("Auto-Resolve All") 

293 auto_resolve_btn.clicked.connect(self._auto_resolve) 

294 strategy_layout.addWidget(auto_resolve_btn) 

295 

296 strategy_layout.addStretch() 

297 layout.addLayout(strategy_layout) 

298 

299 # Scroll area for conflicts 

300 scroll = QScrollArea() 

301 scroll.setWidgetResizable(True) 

302 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

303 scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

304 

305 conflicts_widget = QWidget() 

306 conflicts_layout = QVBoxLayout() 

307 

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) 

315 

316 conflicts_layout.addStretch() 

317 conflicts_widget.setLayout(conflicts_layout) 

318 scroll.setWidget(conflicts_widget) 

319 

320 layout.addWidget(scroll) 

321 

322 # Buttons 

323 button_layout = QHBoxLayout() 

324 button_layout.addStretch() 

325 

326 cancel_button = QPushButton("Cancel") 

327 cancel_button.clicked.connect(self.reject) 

328 button_layout.addWidget(cancel_button) 

329 

330 merge_button = QPushButton("Apply Merge") 

331 merge_button.clicked.connect(self.accept) 

332 merge_button.setDefault(True) 

333 button_layout.addWidget(merge_button) 

334 

335 layout.addLayout(button_layout) 

336 

337 self.setLayout(layout) 

338 

339 def _on_resolution_changed(self, conflict_index: int, choice: str): 

340 """Handle resolution choice change""" 

341 self.resolutions[conflict_index] = choice 

342 

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) 

347 

348 # Update resolution choices 

349 self.resolutions.update(auto_resolutions) 

350 

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) 

360 

361 def get_merged_project_data(self) -> Dict[str, Any]: 

362 """ 

363 Get the merged project data based on user's conflict resolutions. 

364 

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)