1 """

2 Page Setup Dialog for pyPhotoAlbum

3

4 Encapsulates all UI logic for page setup configuration,

5 separating presentation from business logic.

6 """

7

8 import math

9 from typing import Optional, Dict, Any

10 from PyQt6.QtWidgets import (

11 QDialog,

12 QVBoxLayout,

13 QHBoxLayout,

14 QLabel,

15 QDoubleSpinBox,

16 QSpinBox,

17 QPushButton,

18 QGroupBox,

19 QComboBox,

20 QCheckBox,

21 QRadioButton,

22 QButtonGroup,

23 )

24 from pyPhotoAlbum.project import Project

25

26

27 class PageSetupDialog(QDialog):

28 """

29 Dialog for configuring page settings.

30

31 This dialog handles all UI presentation logic for page setup,

32 including page size, DPI settings, and cover configuration.

33 """

34

35 def __init__(self, parent, project: Project, initial_page_index: int = 0):

36 """

37 Initialize the page setup dialog.

38

39 Args:

40 parent: Parent widget

41 project: Project instance containing pages and settings

42 initial_page_index: Index of page to initially select

43 """

44 super().__init__(parent)

45 self.project = project

46 self.initial_page_index = initial_page_index

47

48 self._setup_ui()

49 self._connect_signals()

50 self._initialize_values()

51

52 def _setup_ui(self):

53 """Create and layout all UI components."""

54 self.setWindowTitle("Page Setup")

55 self.setMinimumWidth(450)

56

57 layout = QVBoxLayout()

58

59 # Page selection group

60 self._page_select_group = self._create_page_selection_group()

61 layout.addWidget(self._page_select_group)

62

63 # Cover settings group

64 self._cover_group = self._create_cover_settings_group()

65 layout.addWidget(self._cover_group)

66

67 # Page size group

68 self._size_group = self._create_page_size_group()

69 layout.addWidget(self._size_group)

70

71 # DPI settings group

72 self._dpi_group = self._create_dpi_settings_group()

73 layout.addWidget(self._dpi_group)

74

75 # Buttons

76 button_layout = self._create_button_layout()

77 layout.addLayout(button_layout)

78

79 self.setLayout(layout)

80

81 def _create_page_selection_group(self) -> QGroupBox:

82 """Create the page selection group."""

83 group = QGroupBox("Select Page")

84 layout = QVBoxLayout()

85

86 # Page combo box

87 self.page_combo = QComboBox()

88 for i, page in enumerate(self.project.pages):

89 page_label = self.project.get_page_display_name(page)

90 if page.is_double_spread and not page.is_cover:

91 page_label += " (Double Spread)"

92 if page.manually_sized:

93 page_label += " *"

94 self.page_combo.addItem(page_label, i)

95 layout.addWidget(self.page_combo)

96

97 # Info label

98 info_label = QLabel("* = Manually sized page")

99 info_label.setStyleSheet("font-size: 9pt; color: gray;")

100 layout.addWidget(info_label)

101

102 group.setLayout(layout)

103 return group

104

105 def _create_cover_settings_group(self) -> QGroupBox:

106 """Create the cover settings group."""

107 group = QGroupBox("Cover Settings")

108 layout = QVBoxLayout()

109

110 # Cover checkbox

111 self.cover_checkbox = QCheckBox("Designate as Cover")

112 self.cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")

113 layout.addWidget(self.cover_checkbox)

114

115 # Paper thickness

116 thickness_layout = QHBoxLayout()

117 thickness_layout.addWidget(QLabel("Paper Thickness:"))

118 self.thickness_spinbox = QDoubleSpinBox()

119 self.thickness_spinbox.setRange(0.05, 1.0)

120 self.thickness_spinbox.setSingleStep(0.05)

121 self.thickness_spinbox.setValue(self.project.paper_thickness_mm)

122 self.thickness_spinbox.setSuffix(" mm")

123 self.thickness_spinbox.setToolTip("Thickness of paper for spine calculation")

124 thickness_layout.addWidget(self.thickness_spinbox)

125 layout.addLayout(thickness_layout)

126

127 # Bleed margin

128 bleed_layout = QHBoxLayout()

129 bleed_layout.addWidget(QLabel("Bleed Margin:"))

130 self.bleed_spinbox = QDoubleSpinBox()

131 self.bleed_spinbox.setRange(0, 10)

132 self.bleed_spinbox.setSingleStep(0.5)

133 self.bleed_spinbox.setValue(self.project.cover_bleed_mm)

134 self.bleed_spinbox.setSuffix(" mm")

135 self.bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")

136 bleed_layout.addWidget(self.bleed_spinbox)

137 layout.addLayout(bleed_layout)

138

139 # Calculated spine width display

140 self.spine_info_label = QLabel()

141 self.spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")

142 self.spine_info_label.setWordWrap(True)

143 layout.addWidget(self.spine_info_label)

144

145 group.setLayout(layout)

146 return group

147

148 def _create_page_size_group(self) -> QGroupBox:

149 """Create the page size group."""

150 group = QGroupBox("Page Size")

151 layout = QVBoxLayout()

152

153 # Width

154 width_layout = QHBoxLayout()

155 width_layout.addWidget(QLabel("Width:"))

156 self.width_spinbox = QDoubleSpinBox()

157 self.width_spinbox.setRange(10, 1000)

158 self.width_spinbox.setSuffix(" mm")

159 width_layout.addWidget(self.width_spinbox)

160 layout.addLayout(width_layout)

161

162 # Height

163 height_layout = QHBoxLayout()

164 height_layout.addWidget(QLabel("Height:"))

165 self.height_spinbox = QDoubleSpinBox()

166 self.height_spinbox.setRange(10, 1000)

167 self.height_spinbox.setSuffix(" mm")

168 height_layout.addWidget(self.height_spinbox)

169 layout.addLayout(height_layout)

170

171 # Apply scope radio buttons

172 scope_label = QLabel("Apply to:")

173 layout.addWidget(scope_label)

174

175 self._apply_scope_group = QButtonGroup(self)

176 self.scope_page_only = QRadioButton("This page only")

177 self.scope_non_manual = QRadioButton("All non-manual pages")

178 self.scope_all_pages = QRadioButton("All pages (override manual sizing)")

179 self.scope_page_only.setChecked(True)

180

181 self._apply_scope_group.addButton(self.scope_page_only, 0)

182 self._apply_scope_group.addButton(self.scope_non_manual, 1)

183 self._apply_scope_group.addButton(self.scope_all_pages, 2)

184

185 layout.addWidget(self.scope_page_only)

186 layout.addWidget(self.scope_non_manual)

187 layout.addWidget(self.scope_all_pages)

188

189 group.setLayout(layout)

190 return group

191

192 def _create_dpi_settings_group(self) -> QGroupBox:

193 """Create the DPI settings group."""

194 group = QGroupBox("DPI Settings")

195 layout = QVBoxLayout()

196

197 # Working DPI

198 working_dpi_layout = QHBoxLayout()

199 working_dpi_layout.addWidget(QLabel("Working DPI:"))

200 self.working_dpi_spinbox = QSpinBox()

201 self.working_dpi_spinbox.setRange(72, 1200)

202 self.working_dpi_spinbox.setValue(self.project.working_dpi)

203 working_dpi_layout.addWidget(self.working_dpi_spinbox)

204 layout.addLayout(working_dpi_layout)

205

206 # Export DPI

207 export_dpi_layout = QHBoxLayout()

208 export_dpi_layout.addWidget(QLabel("Export DPI:"))

209 self.export_dpi_spinbox = QSpinBox()

210 self.export_dpi_spinbox.setRange(72, 1200)

211 self.export_dpi_spinbox.setValue(self.project.export_dpi)

212 export_dpi_layout.addWidget(self.export_dpi_spinbox)

213 layout.addLayout(export_dpi_layout)

214

215 group.setLayout(layout)

216 return group

217

218 def _create_button_layout(self) -> QHBoxLayout:

219 """Create dialog button layout."""

220 layout = QHBoxLayout()

221

222 cancel_btn = QPushButton("Cancel")

223 cancel_btn.clicked.connect(self.reject)

224

225 ok_btn = QPushButton("OK")

226 ok_btn.clicked.connect(self.accept)

227 ok_btn.setDefault(True)

228

229 layout.addStretch()

230 layout.addWidget(cancel_btn)

231 layout.addWidget(ok_btn)

232

233 return layout

234

235 def _connect_signals(self):

236 """Connect widget signals to handlers."""

237 self.page_combo.currentIndexChanged.connect(self._on_page_changed)

238 self.cover_checkbox.stateChanged.connect(self._update_spine_info)

239 self.thickness_spinbox.valueChanged.connect(self._update_spine_info)

240 self.bleed_spinbox.valueChanged.connect(self._update_spine_info)

241

242 def _initialize_values(self):

243 """Initialize dialog values based on current page."""

244 # Set initial page selection

245 if 0 <= self.initial_page_index < len(self.project.pages):

246 self.page_combo.setCurrentIndex(self.initial_page_index)

247

248 # Trigger initial page change to populate values

249 self._on_page_changed(self.initial_page_index)

250

251 def _on_page_changed(self, index: int):

252 """

253 Handle page selection change.

254

255 Args:

256 index: Index of selected page

257 """

258 if index < 0 or index >= len(self.project.pages):

259 return

260

261 selected_page = self.project.pages[index]

262 is_first_page = index == 0

263

264 # Show/hide cover settings based on page selection

265 self._cover_group.setVisible(is_first_page)

266

267 # Update cover checkbox

268 if is_first_page:

269 self.cover_checkbox.setChecked(selected_page.is_cover)

270 self._update_spine_info()

271

272 # Get display width (accounting for double spreads and covers)

273 if selected_page.is_cover:

274 # For covers, show the full calculated width

275 display_width = selected_page.layout.size[0]

276 elif selected_page.is_double_spread:

277 display_width = (

278 selected_page.layout.base_width

279 if hasattr(selected_page.layout, "base_width")

280 else selected_page.layout.size[0] / 2

281 )

282 else:

283 display_width = selected_page.layout.size[0]

284

285 self.width_spinbox.setValue(display_width)

286 self.height_spinbox.setValue(selected_page.layout.size[1])

287

288 # Disable size editing for covers (auto-calculated)

289 is_cover = selected_page.is_cover

290 self.width_spinbox.setEnabled(not is_cover)

291 self.height_spinbox.setEnabled(not is_cover)

292 self.scope_non_manual.setEnabled(not is_cover)

293 self.scope_all_pages.setEnabled(not is_cover)

294 if is_cover:

295 self.scope_page_only.setChecked(True)

296

297 def _update_spine_info(self):

298 """Update the spine information display."""

299 if self.cover_checkbox.isChecked():

300 # Calculate spine width with current settings

301 content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)

302 sheets = math.ceil(content_pages / 4)

303 spine_width = sheets * self.thickness_spinbox.value() * 2

304

305 page_width = self.project.page_size_mm[0]

306 total_width = (page_width * 2) + spine_width + (self.bleed_spinbox.value() * 2)

307

308 self.spine_info_label.setText(

309 f"Cover Layout: Front ({page_width:.0f}mm) + "

310 f"Spine ({spine_width:.2f}mm) + "

311 f"Back ({page_width:.0f}mm) + "

312 f"Bleed ({self.bleed_spinbox.value():.1f}mm × 2)\n"

313 f"Total Width: {total_width:.1f}mm | "

314 f"Content Pages: {content_pages} | Sheets: {sheets}"

315 )

316 else:

317 self.spine_info_label.setText("")

318

319 def get_values(self) -> Dict[str, Any]:

320 """

321 Get dialog values.

322

323 Returns:

324 Dictionary containing all dialog values

325 """

326 selected_index = self.page_combo.currentData()

327 selected_page = self.project.pages[selected_index]

328

329 return {

330 "selected_index": selected_index,

331 "selected_page": selected_page,

332 "is_cover": self.cover_checkbox.isChecked(),

333 "paper_thickness_mm": self.thickness_spinbox.value(),

334 "cover_bleed_mm": self.bleed_spinbox.value(),

335 "width_mm": self.width_spinbox.value(),

336 "height_mm": self.height_spinbox.value(),

337 "working_dpi": self.working_dpi_spinbox.value(),

338 "export_dpi": self.export_dpi_spinbox.value(),

339 "apply_scope": self._apply_scope_group.checkedId(),

340 }