Coverage for src / ezqt_widgets / widgets / button / date_button.py: 91.82%

227 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-31 10:03 +0000

1# /////////////////////////////////////////////////////////////// 

2# DATE_BUTTON - Date Selection Button Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Date button widget module. 

8 

9Provides a button widget with integrated calendar dialog for date selection 

10in PySide6 applications. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19import base64 

20from typing import Any 

21 

22# Third-party imports 

23from PySide6.QtCore import QByteArray, QDate, QSize, Qt, Signal 

24from PySide6.QtGui import QIcon, QMouseEvent, QPainter, QPixmap 

25from PySide6.QtSvg import QSvgRenderer 

26from PySide6.QtWidgets import ( 

27 QCalendarWidget, 

28 QDialog, 

29 QHBoxLayout, 

30 QLabel, 

31 QPushButton, 

32 QSizePolicy, 

33 QToolButton, 

34 QVBoxLayout, 

35) 

36 

37from ...types import SizeType, WidgetParent 

38 

39# Local imports 

40from ..misc.theme_icon import ThemeIcon 

41from ..shared import SVG_CALENDAR 

42 

43# /////////////////////////////////////////////////////////////// 

44# FUNCTIONS 

45# /////////////////////////////////////////////////////////////// 

46 

47 

48def _format_date(date: QDate, format_str: str = "dd/MM/yyyy") -> str: 

49 """Format a QDate object to string. 

50 

51 Args: 

52 date: The date to format. 

53 format_str: Format string (default: "dd/MM/yyyy"). 

54 

55 Returns: 

56 Formatted date string, or empty string if date is invalid. 

57 """ 

58 if not date.isValid(): 

59 return "" 

60 return date.toString(format_str) 

61 

62 

63def _parse_date(date_str: str, format_str: str = "dd/MM/yyyy") -> QDate: 

64 """Parse a date string to QDate object. 

65 

66 Args: 

67 date_str: The date string to parse. 

68 format_str: Format string (default: "dd/MM/yyyy"). 

69 

70 Returns: 

71 Parsed QDate object or invalid QDate if parsing fails. 

72 """ 

73 return QDate.fromString(date_str, format_str) 

74 

75 

76def _get_calendar_icon() -> ThemeIcon: 

77 """Get a default calendar icon built from the shared SVG. 

78 

79 Returns: 

80 Calendar ThemeIcon built from SVG_CALENDAR. 

81 

82 Raises: 

83 ValueError: If SVG rendering fails or ThemeIcon cannot be created. 

84 """ 

85 svg_bytes = base64.b64decode(base64.b64encode(SVG_CALENDAR)) 

86 renderer = QSvgRenderer(QByteArray(svg_bytes)) 

87 if not renderer.isValid(): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 raise ValueError("SVG_CALENDAR could not be rendered.") 

89 

90 pixmap = QPixmap(QSize(16, 16)) 

91 pixmap.fill(Qt.GlobalColor.transparent) 

92 painter = QPainter(pixmap) 

93 renderer.render(painter) 

94 painter.end() 

95 

96 themed_icon = ThemeIcon.from_source(QIcon(pixmap)) 

97 if themed_icon is None: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 raise ValueError( 

99 "ThemeIcon.from_source returned None for a non-None QIcon source." 

100 ) 

101 return themed_icon 

102 

103 

104# /////////////////////////////////////////////////////////////// 

105# CLASSES 

106# /////////////////////////////////////////////////////////////// 

107 

108 

109class DatePickerDialog(QDialog): 

110 """Dialog for date selection with calendar widget. 

111 

112 Provides a modal dialog with a calendar widget for selecting dates. 

113 The dialog emits accepted signal when a date is selected and confirmed. 

114 

115 Args: 

116 parent: The parent widget (default: None). 

117 current_date: The current selected date (default: None). 

118 min_date: The minimum selectable date (default: None). 

119 max_date: The maximum selectable date (default: None). 

120 

121 Example: 

122 >>> from ezqt_widgets import DatePickerDialog 

123 >>> from PySide6.QtCore import QDate 

124 >>> dialog = DatePickerDialog(current_date=QDate.currentDate()) 

125 >>> if dialog.exec(): 

126 ... date = dialog.selected_date() 

127 ... print(date.toString("dd/MM/yyyy")) 

128 """ 

129 

130 def __init__( 

131 self, 

132 parent: WidgetParent = None, 

133 current_date: QDate | None = None, 

134 min_date: QDate | None = None, 

135 max_date: QDate | None = None, 

136 ) -> None: 

137 """Initialize the date picker dialog.""" 

138 super().__init__(parent) 

139 

140 # /////////////////////////////////////////////////////////////// 

141 # INIT 

142 # /////////////////////////////////////////////////////////////// 

143 

144 self._selected_date: QDate | None = current_date 

145 self._min_date: QDate | None = min_date 

146 self._max_date: QDate | None = max_date 

147 

148 # /////////////////////////////////////////////////////////////// 

149 # SETUP UI 

150 # /////////////////////////////////////////////////////////////// 

151 

152 self._setup_ui() 

153 

154 # Set current date if provided 

155 if current_date and current_date.isValid(): 

156 self._calendar.setSelectedDate(current_date) 

157 

158 # ------------------------------------------------ 

159 # PRIVATE METHODS 

160 # ------------------------------------------------ 

161 

162 def _setup_ui(self) -> None: 

163 """Setup the user interface.""" 

164 self.setWindowTitle("Select a date") 

165 self.setModal(True) 

166 self.setFixedSize(300, 250) 

167 

168 layout = QVBoxLayout(self) 

169 layout.setContentsMargins(10, 10, 10, 10) 

170 layout.setSpacing(10) 

171 

172 self._calendar = QCalendarWidget(self) 

173 self._calendar.clicked.connect(self._on_date_selected) 

174 

175 # Apply date range constraints if provided 

176 if self._min_date and self._min_date.isValid(): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 self._calendar.setMinimumDate(self._min_date) 

178 if self._max_date and self._max_date.isValid(): 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

179 self._calendar.setMaximumDate(self._max_date) 

180 

181 layout.addWidget(self._calendar) 

182 

183 button_layout = QHBoxLayout() 

184 button_layout.setSpacing(10) 

185 

186 ok_button = QPushButton("OK", self) 

187 ok_button.clicked.connect(self.accept) 

188 cancel_button = QPushButton("Cancel", self) 

189 cancel_button.clicked.connect(self.reject) 

190 

191 button_layout.addStretch() 

192 button_layout.addWidget(cancel_button) 

193 button_layout.addWidget(ok_button) 

194 layout.addLayout(button_layout) 

195 

196 self._calendar.activated.connect(self.accept) 

197 

198 def _on_date_selected(self, date: QDate) -> None: 

199 """Handle date selection from calendar. 

200 

201 Args: 

202 date: The selected date from the calendar. 

203 """ 

204 self._selected_date = date 

205 

206 # ------------------------------------------------ 

207 # PUBLIC METHODS 

208 # ------------------------------------------------ 

209 

210 def selectedDate(self) -> QDate | None: 

211 """Get the selected date. 

212 

213 Returns: 

214 The selected date, or None if no date was selected. 

215 """ 

216 return self._selected_date 

217 

218 

219class DateButton(QToolButton): 

220 """Button widget for date selection with integrated calendar. 

221 

222 Features: 

223 - Displays current selected date 

224 - Opens calendar dialog on click 

225 - Configurable date format 

226 - Placeholder text when no date selected 

227 - Calendar icon with customizable appearance 

228 - Date validation and parsing 

229 - Optional minimum and maximum date constraints 

230 

231 Args: 

232 parent: The parent widget (default: None). 

233 date: Initial date (QDate, date string, or None for current date). 

234 date_format: Format for displaying the date (default: "dd/MM/yyyy"). 

235 placeholder: Text to display when no date is selected 

236 (default: "Select a date"). 

237 show_calendar_icon: Whether to show calendar icon (default: True). 

238 icon_size: Size of the calendar icon (default: QSize(16, 16)). 

239 minimum_date: Minimum selectable date (default: None, no constraint). 

240 maximum_date: Maximum selectable date (default: None, no constraint). 

241 min_width: Minimum width of the button (default: None, auto-calculated). 

242 min_height: Minimum height of the button (default: None, auto-calculated). 

243 *args: Additional arguments passed to QToolButton. 

244 **kwargs: Additional keyword arguments passed to QToolButton. 

245 

246 Signals: 

247 dateChanged(QDate): Emitted when the date changes. 

248 dateSelected(QDate): Emitted when a date is selected from calendar. 

249 

250 Example: 

251 >>> from ezqt_widgets import DateButton 

252 >>> btn = DateButton(date_format="dd/MM/yyyy", placeholder="Pick a date") 

253 >>> btn.dateChanged.connect(lambda d: print(d.toString("dd/MM/yyyy"))) 

254 >>> btn.setToday() 

255 >>> btn.show() 

256 """ 

257 

258 dateChanged = Signal(QDate) 

259 dateSelected = Signal(QDate) 

260 

261 # /////////////////////////////////////////////////////////////// 

262 # INIT 

263 # /////////////////////////////////////////////////////////////// 

264 

265 def __init__( 

266 self, 

267 parent: WidgetParent = None, 

268 date: QDate | str | None = None, 

269 date_format: str = "dd/MM/yyyy", 

270 placeholder: str = "Select a date", 

271 show_calendar_icon: bool = True, 

272 icon_size: SizeType = QSize(16, 16), 

273 minimum_date: QDate | None = None, 

274 maximum_date: QDate | None = None, 

275 min_width: int | None = None, 

276 min_height: int | None = None, 

277 *args: Any, 

278 **kwargs: Any, 

279 ) -> None: 

280 """Initialize the date button.""" 

281 super().__init__(parent, *args, **kwargs) 

282 self.setProperty("type", "DateButton") 

283 

284 # Initialize properties 

285 self._date_format: str = date_format 

286 self._placeholder: str = placeholder 

287 self._show_calendar_icon: bool = show_calendar_icon 

288 self._icon_size: QSize = ( 

289 QSize(*icon_size) 

290 if isinstance(icon_size, (tuple, list)) 

291 else QSize(icon_size) 

292 ) 

293 self._minimum_date: QDate | None = minimum_date 

294 self._maximum_date: QDate | None = maximum_date 

295 self._min_width: int | None = min_width 

296 self._min_height: int | None = min_height 

297 self._current_date: QDate = QDate() 

298 self._calendar_icon: ThemeIcon = _get_calendar_icon() 

299 

300 # Setup UI components 

301 self._date_label = QLabel() 

302 self._icon_label = QLabel() 

303 

304 # Configure labels 

305 self._date_label.setAlignment( 

306 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter 

307 ) 

308 self._date_label.setStyleSheet("background-color: transparent;") 

309 

310 # Setup layout 

311 layout = QHBoxLayout(self) 

312 layout.setContentsMargins(8, 2, 8, 2) 

313 layout.setSpacing(8) 

314 layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 

315 layout.addWidget(self._date_label) 

316 layout.addStretch() # Push icon to the right 

317 layout.addWidget(self._icon_label) 

318 

319 # Configure size policy 

320 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

321 

322 # Set initial values 

323 if date: 

324 self.date = date 

325 else: 

326 self.date = QDate.currentDate() 

327 

328 self.show_calendar_icon = show_calendar_icon 

329 self._update_display() 

330 

331 # /////////////////////////////////////////////////////////////// 

332 # PROPERTIES 

333 # /////////////////////////////////////////////////////////////// 

334 

335 @property 

336 def date(self) -> QDate: 

337 """Get or set the selected date. 

338 

339 Returns: 

340 The current selected date. 

341 """ 

342 return self._current_date 

343 

344 @date.setter 

345 def date(self, value: QDate | str | None) -> None: 

346 """Set the date from QDate, string, or None. 

347 

348 Dates outside the [minimum_date, maximum_date] range are silently 

349 rejected (the current date is left unchanged). 

350 

351 Args: 

352 value: The date to set (QDate, string, or None). 

353 """ 

354 if isinstance(value, str): 

355 new_date = _parse_date(value, self._date_format) 

356 elif isinstance(value, QDate): 

357 new_date = value 

358 elif value is None: 358 ↛ 362line 358 didn't jump to line 362 because the condition on line 358 was always true

359 new_date = QDate() 

360 

361 # Silently reject dates outside the configured range 

362 if new_date.isValid(): 

363 if ( 

364 self._minimum_date 

365 and self._minimum_date.isValid() 

366 and new_date < self._minimum_date 

367 ): 

368 return 

369 if ( 

370 self._maximum_date 

371 and self._maximum_date.isValid() 

372 and new_date > self._maximum_date 

373 ): 

374 return 

375 

376 if new_date != self._current_date: 376 ↛ exitline 376 didn't return from function 'date' because the condition on line 376 was always true

377 self._current_date = new_date 

378 self._update_display() 

379 self.dateChanged.emit(self._current_date) 

380 

381 @property 

382 def date_string(self) -> str: 

383 """Get or set the date as formatted string. 

384 

385 Returns: 

386 The formatted date string. 

387 """ 

388 return _format_date(self._current_date, self._date_format) 

389 

390 @date_string.setter 

391 def date_string(self, value: str) -> None: 

392 """Set the date from a formatted string. 

393 

394 Args: 

395 value: The formatted date string. 

396 """ 

397 self.date = value 

398 

399 @property 

400 def date_format(self) -> str: 

401 """Get or set the date format. 

402 

403 Returns: 

404 The current date format string. 

405 """ 

406 return self._date_format 

407 

408 @date_format.setter 

409 def date_format(self, value: str) -> None: 

410 """Set the date format. 

411 

412 Args: 

413 value: The new date format string. 

414 """ 

415 self._date_format = str(value) 

416 self._update_display() 

417 

418 @property 

419 def placeholder(self) -> str: 

420 """Get or set the placeholder text. 

421 

422 Returns: 

423 The current placeholder text. 

424 """ 

425 return self._placeholder 

426 

427 @placeholder.setter 

428 def placeholder(self, value: str) -> None: 

429 """Set the placeholder text. 

430 

431 Args: 

432 value: The new placeholder text. 

433 """ 

434 self._placeholder = str(value) 

435 self._update_display() 

436 

437 @property 

438 def show_calendar_icon(self) -> bool: 

439 """Get or set calendar icon visibility. 

440 

441 Returns: 

442 True if calendar icon is visible, False otherwise. 

443 """ 

444 return self._show_calendar_icon 

445 

446 @show_calendar_icon.setter 

447 def show_calendar_icon(self, value: bool) -> None: 

448 """Set calendar icon visibility. 

449 

450 Args: 

451 value: Whether to show the calendar icon. 

452 """ 

453 self._show_calendar_icon = bool(value) 

454 if self._show_calendar_icon: 

455 self._icon_label.show() 

456 self._icon_label.setPixmap(self._calendar_icon.pixmap(self._icon_size)) 

457 self._icon_label.setFixedSize(self._icon_size) 

458 else: 

459 self._icon_label.hide() 

460 

461 @property 

462 def icon_size(self) -> QSize: 

463 """Get or set the icon size. 

464 

465 Returns: 

466 The current icon size. 

467 """ 

468 return self._icon_size 

469 

470 @icon_size.setter 

471 def icon_size(self, value: QSize | tuple[int, int]) -> None: 

472 """Set the icon size. 

473 

474 Args: 

475 value: The new icon size (QSize or tuple). 

476 """ 

477 self._icon_size = ( 

478 QSize(*value) if isinstance(value, (tuple, list)) else QSize(value) 

479 ) 

480 if self._show_calendar_icon: 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true

481 self._icon_label.setPixmap(self._calendar_icon.pixmap(self._icon_size)) 

482 self._icon_label.setFixedSize(self._icon_size) 

483 

484 @property 

485 def minimum_date(self) -> QDate | None: 

486 """Get or set the minimum selectable date. 

487 

488 Returns: 

489 The minimum date, or None if no constraint is set. 

490 """ 

491 return self._minimum_date 

492 

493 @minimum_date.setter 

494 def minimum_date(self, value: QDate | None) -> None: 

495 """Set the minimum selectable date. 

496 

497 Args: 

498 value: The minimum date, or None to remove the constraint. 

499 """ 

500 self._minimum_date = value 

501 

502 @property 

503 def maximum_date(self) -> QDate | None: 

504 """Get or set the maximum selectable date. 

505 

506 Returns: 

507 The maximum date, or None if no constraint is set. 

508 """ 

509 return self._maximum_date 

510 

511 @maximum_date.setter 

512 def maximum_date(self, value: QDate | None) -> None: 

513 """Set the maximum selectable date. 

514 

515 Args: 

516 value: The maximum date, or None to remove the constraint. 

517 """ 

518 self._maximum_date = value 

519 

520 @property 

521 def min_width(self) -> int | None: 

522 """Get or set the minimum width of the button. 

523 

524 Returns: 

525 The minimum width, or None if not set. 

526 """ 

527 return self._min_width 

528 

529 @min_width.setter 

530 def min_width(self, value: int | None) -> None: 

531 """Set the minimum width of the button. 

532 

533 Args: 

534 value: The minimum width, or None to auto-calculate. 

535 """ 

536 self._min_width = value 

537 self.updateGeometry() 

538 

539 @property 

540 def min_height(self) -> int | None: 

541 """Get or set the minimum height of the button. 

542 

543 Returns: 

544 The minimum height, or None if not set. 

545 """ 

546 return self._min_height 

547 

548 @min_height.setter 

549 def min_height(self, value: int | None) -> None: 

550 """Set the minimum height of the button. 

551 

552 Args: 

553 value: The minimum height, or None to auto-calculate. 

554 """ 

555 self._min_height = value 

556 self.updateGeometry() 

557 

558 # ------------------------------------------------ 

559 # PRIVATE METHODS 

560 # ------------------------------------------------ 

561 

562 def _update_display(self) -> None: 

563 """Update the display text.""" 

564 if self._current_date.isValid(): 

565 display_text = _format_date(self._current_date, self._date_format) 

566 else: 

567 display_text = self._placeholder 

568 

569 self._date_label.setText(display_text) 

570 

571 # /////////////////////////////////////////////////////////////// 

572 # PUBLIC METHODS 

573 # /////////////////////////////////////////////////////////////// 

574 

575 def clearDate(self) -> None: 

576 """Clear the selected date.""" 

577 self.date = None 

578 

579 def setToday(self) -> None: 

580 """Set the date to today.""" 

581 self.date = QDate.currentDate() 

582 

583 def openCalendar(self) -> None: 

584 """Open the calendar dialog.""" 

585 dialog = DatePickerDialog( 

586 self, 

587 self._current_date, 

588 min_date=self._minimum_date, 

589 max_date=self._maximum_date, 

590 ) 

591 if dialog.exec() == QDialog.DialogCode.Accepted: 

592 selected_date = dialog.selectedDate() 

593 if selected_date and selected_date.isValid(): 593 ↛ exitline 593 didn't return from function 'openCalendar' because the condition on line 593 was always true

594 self.date = selected_date 

595 self.dateSelected.emit(selected_date) 

596 

597 def setTheme(self, theme: str) -> None: 

598 """Update the calendar icon color for the given theme. 

599 

600 Can be connected directly to a theme-change signal to keep 

601 the icon in sync with the application's color scheme. 

602 

603 Args: 

604 theme: The new theme (``"dark"`` or ``"light"``). 

605 """ 

606 self._calendar_icon.setTheme(theme) 

607 if self._show_calendar_icon: 

608 self._icon_label.setPixmap(self._calendar_icon.pixmap(self._icon_size)) 

609 

610 # /////////////////////////////////////////////////////////////// 

611 # EVENT HANDLERS 

612 # /////////////////////////////////////////////////////////////// 

613 

614 def mousePressEvent(self, event: QMouseEvent) -> None: 

615 """Handle mouse press events. 

616 

617 The left-button press opens the calendar dialog directly. The 

618 ``clicked`` signal is emitted only after the user confirms a date 

619 (inside ``openCalendar``), not unconditionally on press. 

620 

621 Args: 

622 event: The mouse event. 

623 """ 

624 if event.button() == Qt.MouseButton.LeftButton: 624 ↛ 628line 624 didn't jump to line 628 because the condition on line 624 was always true

625 self.openCalendar() 

626 event.accept() # absorb — do not forward to QToolButton 

627 else: 

628 super().mousePressEvent(event) 

629 

630 # /////////////////////////////////////////////////////////////// 

631 # OVERRIDE METHODS 

632 # /////////////////////////////////////////////////////////////// 

633 

634 def sizeHint(self) -> QSize: 

635 """Get the recommended size for the button. 

636 

637 Returns: 

638 The recommended size. 

639 """ 

640 return QSize(150, 30) 

641 

642 def minimumSizeHint(self) -> QSize: 

643 """Get the minimum size hint for the button. 

644 

645 Returns: 

646 The minimum size hint. 

647 """ 

648 base_size = super().minimumSizeHint() 

649 

650 text_width = self._date_label.fontMetrics().horizontalAdvance( 

651 self.date_string if self._current_date.isValid() else self._placeholder 

652 ) 

653 

654 icon_width = self._icon_size.width() if self._show_calendar_icon else 0 

655 

656 total_width = text_width + icon_width + 16 + 8 # margins + spacing 

657 

658 min_width = self._min_width if self._min_width is not None else total_width 

659 min_height = ( 

660 self._min_height 

661 if self._min_height is not None 

662 else max(base_size.height(), 30) 

663 ) 

664 

665 return QSize(max(min_width, total_width), min_height) 

666 

667 # /////////////////////////////////////////////////////////////// 

668 # STYLE METHODS 

669 # /////////////////////////////////////////////////////////////// 

670 

671 def refreshStyle(self) -> None: 

672 """Refresh the widget's style. 

673 

674 Useful after dynamic stylesheet changes. 

675 """ 

676 self.style().unpolish(self) 

677 self.style().polish(self) 

678 self.update() 

679 

680 

681# /////////////////////////////////////////////////////////////// 

682# PUBLIC API 

683# /////////////////////////////////////////////////////////////// 

684 

685__all__ = ["DateButton", "DatePickerDialog"]