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-04-01 22:46 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 22:46 +0000
1# ///////////////////////////////////////////////////////////////
2# DATE_BUTTON - Date Selection Button Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Date button widget module.
9Provides a button widget with integrated calendar dialog for date selection
10in PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import base64
20from typing import Any
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)
37from ...types import SizeType, WidgetParent
39# Local imports
40from ..misc.theme_icon import ThemeIcon
41from ..shared import SVG_CALENDAR
43# ///////////////////////////////////////////////////////////////
44# FUNCTIONS
45# ///////////////////////////////////////////////////////////////
48def _format_date(date: QDate, format_str: str = "dd/MM/yyyy") -> str:
49 """Format a QDate object to string.
51 Args:
52 date: The date to format.
53 format_str: Format string (default: "dd/MM/yyyy").
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)
63def _parse_date(date_str: str, format_str: str = "dd/MM/yyyy") -> QDate:
64 """Parse a date string to QDate object.
66 Args:
67 date_str: The date string to parse.
68 format_str: Format string (default: "dd/MM/yyyy").
70 Returns:
71 Parsed QDate object or invalid QDate if parsing fails.
72 """
73 return QDate.fromString(date_str, format_str)
76def _get_calendar_icon() -> ThemeIcon:
77 """Get a default calendar icon built from the shared SVG.
79 Returns:
80 Calendar ThemeIcon built from SVG_CALENDAR.
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.")
90 pixmap = QPixmap(QSize(16, 16))
91 pixmap.fill(Qt.GlobalColor.transparent)
92 painter = QPainter(pixmap)
93 renderer.render(painter)
94 painter.end()
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
104# ///////////////////////////////////////////////////////////////
105# CLASSES
106# ///////////////////////////////////////////////////////////////
109class DatePickerDialog(QDialog):
110 """Dialog for date selection with calendar widget.
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.
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).
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 """
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)
140 # ///////////////////////////////////////////////////////////////
141 # INIT
142 # ///////////////////////////////////////////////////////////////
144 self._selected_date: QDate | None = current_date
145 self._min_date: QDate | None = min_date
146 self._max_date: QDate | None = max_date
148 # ///////////////////////////////////////////////////////////////
149 # SETUP UI
150 # ///////////////////////////////////////////////////////////////
152 self._setup_ui()
154 # Set current date if provided
155 if current_date and current_date.isValid():
156 self._calendar.setSelectedDate(current_date)
158 # ------------------------------------------------
159 # PRIVATE METHODS
160 # ------------------------------------------------
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)
168 layout = QVBoxLayout(self)
169 layout.setContentsMargins(10, 10, 10, 10)
170 layout.setSpacing(10)
172 self._calendar = QCalendarWidget(self)
173 self._calendar.clicked.connect(self._on_date_selected)
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)
181 layout.addWidget(self._calendar)
183 button_layout = QHBoxLayout()
184 button_layout.setSpacing(10)
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)
191 button_layout.addStretch()
192 button_layout.addWidget(cancel_button)
193 button_layout.addWidget(ok_button)
194 layout.addLayout(button_layout)
196 self._calendar.activated.connect(self.accept)
198 def _on_date_selected(self, date: QDate) -> None:
199 """Handle date selection from calendar.
201 Args:
202 date: The selected date from the calendar.
203 """
204 self._selected_date = date
206 # ------------------------------------------------
207 # PUBLIC METHODS
208 # ------------------------------------------------
210 def selectedDate(self) -> QDate | None:
211 """Get the selected date.
213 Returns:
214 The selected date, or None if no date was selected.
215 """
216 return self._selected_date
219class DateButton(QToolButton):
220 """Button widget for date selection with integrated calendar.
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
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.
246 Signals:
247 dateChanged(QDate): Emitted when the date changes.
248 dateSelected(QDate): Emitted when a date is selected from calendar.
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 """
258 dateChanged = Signal(QDate)
259 dateSelected = Signal(QDate)
261 # ///////////////////////////////////////////////////////////////
262 # INIT
263 # ///////////////////////////////////////////////////////////////
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")
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()
300 # Setup UI components
301 self._date_label = QLabel()
302 self._icon_label = QLabel()
304 # Configure labels
305 self._date_label.setAlignment(
306 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
307 )
308 self._date_label.setStyleSheet("background-color: transparent;")
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)
319 # Configure size policy
320 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
322 # Set initial values
323 if date:
324 self.date = date
325 else:
326 self.date = QDate.currentDate()
328 self.show_calendar_icon = show_calendar_icon
329 self._update_display()
331 # ///////////////////////////////////////////////////////////////
332 # PROPERTIES
333 # ///////////////////////////////////////////////////////////////
335 @property
336 def date(self) -> QDate:
337 """Get or set the selected date.
339 Returns:
340 The current selected date.
341 """
342 return self._current_date
344 @date.setter
345 def date(self, value: QDate | str | None) -> None:
346 """Set the date from QDate, string, or None.
348 Dates outside the [minimum_date, maximum_date] range are silently
349 rejected (the current date is left unchanged).
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()
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
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)
381 @property
382 def date_string(self) -> str:
383 """Get or set the date as formatted string.
385 Returns:
386 The formatted date string.
387 """
388 return _format_date(self._current_date, self._date_format)
390 @date_string.setter
391 def date_string(self, value: str) -> None:
392 """Set the date from a formatted string.
394 Args:
395 value: The formatted date string.
396 """
397 self.date = value
399 @property
400 def date_format(self) -> str:
401 """Get or set the date format.
403 Returns:
404 The current date format string.
405 """
406 return self._date_format
408 @date_format.setter
409 def date_format(self, value: str) -> None:
410 """Set the date format.
412 Args:
413 value: The new date format string.
414 """
415 self._date_format = str(value)
416 self._update_display()
418 @property
419 def placeholder(self) -> str:
420 """Get or set the placeholder text.
422 Returns:
423 The current placeholder text.
424 """
425 return self._placeholder
427 @placeholder.setter
428 def placeholder(self, value: str) -> None:
429 """Set the placeholder text.
431 Args:
432 value: The new placeholder text.
433 """
434 self._placeholder = str(value)
435 self._update_display()
437 @property
438 def show_calendar_icon(self) -> bool:
439 """Get or set calendar icon visibility.
441 Returns:
442 True if calendar icon is visible, False otherwise.
443 """
444 return self._show_calendar_icon
446 @show_calendar_icon.setter
447 def show_calendar_icon(self, value: bool) -> None:
448 """Set calendar icon visibility.
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()
461 @property
462 def icon_size(self) -> QSize:
463 """Get or set the icon size.
465 Returns:
466 The current icon size.
467 """
468 return self._icon_size
470 @icon_size.setter
471 def icon_size(self, value: QSize | tuple[int, int]) -> None:
472 """Set the icon size.
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)
484 @property
485 def minimum_date(self) -> QDate | None:
486 """Get or set the minimum selectable date.
488 Returns:
489 The minimum date, or None if no constraint is set.
490 """
491 return self._minimum_date
493 @minimum_date.setter
494 def minimum_date(self, value: QDate | None) -> None:
495 """Set the minimum selectable date.
497 Args:
498 value: The minimum date, or None to remove the constraint.
499 """
500 self._minimum_date = value
502 @property
503 def maximum_date(self) -> QDate | None:
504 """Get or set the maximum selectable date.
506 Returns:
507 The maximum date, or None if no constraint is set.
508 """
509 return self._maximum_date
511 @maximum_date.setter
512 def maximum_date(self, value: QDate | None) -> None:
513 """Set the maximum selectable date.
515 Args:
516 value: The maximum date, or None to remove the constraint.
517 """
518 self._maximum_date = value
520 @property
521 def min_width(self) -> int | None:
522 """Get or set the minimum width of the button.
524 Returns:
525 The minimum width, or None if not set.
526 """
527 return self._min_width
529 @min_width.setter
530 def min_width(self, value: int | None) -> None:
531 """Set the minimum width of the button.
533 Args:
534 value: The minimum width, or None to auto-calculate.
535 """
536 self._min_width = value
537 self.updateGeometry()
539 @property
540 def min_height(self) -> int | None:
541 """Get or set the minimum height of the button.
543 Returns:
544 The minimum height, or None if not set.
545 """
546 return self._min_height
548 @min_height.setter
549 def min_height(self, value: int | None) -> None:
550 """Set the minimum height of the button.
552 Args:
553 value: The minimum height, or None to auto-calculate.
554 """
555 self._min_height = value
556 self.updateGeometry()
558 # ------------------------------------------------
559 # PRIVATE METHODS
560 # ------------------------------------------------
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
569 self._date_label.setText(display_text)
571 # ///////////////////////////////////////////////////////////////
572 # PUBLIC METHODS
573 # ///////////////////////////////////////////////////////////////
575 def clearDate(self) -> None:
576 """Clear the selected date."""
577 self.date = None
579 def setToday(self) -> None:
580 """Set the date to today."""
581 self.date = QDate.currentDate()
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)
597 def setTheme(self, theme: str) -> None:
598 """Update the calendar icon color for the given theme.
600 Can be connected directly to a theme-change signal to keep
601 the icon in sync with the application's color scheme.
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))
610 # ///////////////////////////////////////////////////////////////
611 # EVENT HANDLERS
612 # ///////////////////////////////////////////////////////////////
614 def mousePressEvent(self, event: QMouseEvent) -> None:
615 """Handle mouse press events.
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.
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)
630 # ///////////////////////////////////////////////////////////////
631 # OVERRIDE METHODS
632 # ///////////////////////////////////////////////////////////////
634 def sizeHint(self) -> QSize:
635 """Get the recommended size for the button.
637 Returns:
638 The recommended size.
639 """
640 return QSize(150, 30)
642 def minimumSizeHint(self) -> QSize:
643 """Get the minimum size hint for the button.
645 Returns:
646 The minimum size hint.
647 """
648 base_size = super().minimumSizeHint()
650 text_width = self._date_label.fontMetrics().horizontalAdvance(
651 self.date_string if self._current_date.isValid() else self._placeholder
652 )
654 icon_width = self._icon_size.width() if self._show_calendar_icon else 0
656 total_width = text_width + icon_width + 16 + 8 # margins + spacing
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 )
665 return QSize(max(min_width, total_width), min_height)
667 # ///////////////////////////////////////////////////////////////
668 # STYLE METHODS
669 # ///////////////////////////////////////////////////////////////
671 def refreshStyle(self) -> None:
672 """Refresh the widget's style.
674 Useful after dynamic stylesheet changes.
675 """
676 self.style().unpolish(self)
677 self.style().polish(self)
678 self.update()
681# ///////////////////////////////////////////////////////////////
682# PUBLIC API
683# ///////////////////////////////////////////////////////////////
685__all__ = ["DateButton", "DatePickerDialog"]