Coverage for src / ezqt_widgets / widgets / input / password_input.py: 70.70%
240 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# PASSWORD_INPUT - Password Input Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Password input widget module.
9Provides an enhanced password input widget with integrated strength bar
10and right-side icon for PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import re
20from typing import Any
22# Third-party imports
23from PySide6.QtCore import QRect, QSize, Qt, Signal
24from PySide6.QtGui import QIcon, QMouseEvent, QPainter, QPaintEvent, QPixmap
25from PySide6.QtWidgets import QLineEdit, QProgressBar, QVBoxLayout, QWidget
27from ...types import IconSourceExtended
29# Local imports
30from ...utils._network_utils import fetch_url_bytes
31from ..misc.theme_icon import ThemeIcon
32from ..shared import SVG_EYE_CLOSED, SVG_EYE_OPEN
34# ///////////////////////////////////////////////////////////////
35# UTILITY FUNCTIONS
36# ///////////////////////////////////////////////////////////////
39def _password_strength(password: str) -> int:
40 """Calculate password strength score.
42 Returns a strength score from 0 (weak) to 100 (strong) based on
43 various criteria like length, character variety, etc.
45 Args:
46 password: The password to evaluate.
48 Returns:
49 Strength score from 0 to 100.
50 """
51 score = 0
52 if len(password) >= 8:
53 score += 25
54 if re.search(r"[A-Z]", password):
55 score += 15
56 if re.search(r"[a-z]", password):
57 score += 15
58 if re.search(r"\d", password):
59 score += 20
60 if re.search(r"[^A-Za-z0-9]", password):
61 score += 25
62 return min(score, 100)
65def _get_strength_color(score: int) -> str:
66 """Get color based on password strength score.
68 Args:
69 score: The password strength score (0-100).
71 Returns:
72 Hex color code for the strength level.
73 """
74 if score < 30:
75 return "#ff4444" # Red
76 elif score < 60:
77 return "#ffaa00" # Orange
78 elif score < 80:
79 return "#44aa44" # Green
80 else:
81 return "#00aa00" # Dark green
84def _load_icon_from_source(source: IconSourceExtended) -> QIcon | None:
85 """Load icon from various sources (ThemeIcon, QIcon, QPixmap, path, URL, etc.).
87 Supports loading icons from:
88 - ThemeIcon/QIcon objects (returned as-is)
89 - QPixmap objects (wrapped into QIcon)
90 - Local file paths (PNG, JPG, etc.)
91 - Local SVG files
92 - Remote URLs (HTTP/HTTPS)
93 - Remote SVG URLs
95 Args:
96 source: Icon source (QIcon, path, resource, URL, or SVG).
98 Returns:
99 Loaded icon or None if loading failed.
100 """
101 if source is None:
102 return None
103 elif isinstance(source, QPixmap): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return ThemeIcon.from_source(QIcon(source))
105 elif isinstance(source, QIcon):
106 return ThemeIcon.from_source(source)
107 elif isinstance(source, bytes):
108 from PySide6.QtCore import QByteArray
109 from PySide6.QtSvg import QSvgRenderer
111 renderer = QSvgRenderer(QByteArray(source))
112 if renderer.isValid(): 112 ↛ 119line 112 didn't jump to line 119 because the condition on line 112 was always true
113 pixmap = QPixmap(QSize(16, 16))
114 pixmap.fill(Qt.GlobalColor.transparent)
115 painter = QPainter(pixmap)
116 renderer.render(painter)
117 painter.end()
118 return ThemeIcon.from_source(QIcon(pixmap))
119 pixmap = QPixmap()
120 if not pixmap.loadFromData(source):
121 return None
122 return ThemeIcon.from_source(QIcon(pixmap))
123 elif isinstance(source, str): 123 ↛ exitline 123 didn't return from function '_load_icon_from_source' because the condition on line 123 was always true
124 # Handle URL
125 if source.startswith(("http://", "https://")): 125 ↛ 150line 125 didn't jump to line 150 because the condition on line 125 was always true
126 image_data = fetch_url_bytes(source)
127 if not image_data:
128 return None
130 # Handle SVG from URL
131 if source.lower().endswith(".svg"): 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 from PySide6.QtCore import QByteArray
133 from PySide6.QtSvg import QSvgRenderer
135 renderer = QSvgRenderer(QByteArray(image_data))
136 pixmap = QPixmap(QSize(16, 16))
137 pixmap.fill(Qt.GlobalColor.transparent)
138 painter = QPainter(pixmap)
139 renderer.render(painter)
140 painter.end()
141 return ThemeIcon.from_source(QIcon(pixmap))
143 # Handle raster image from URL
144 pixmap = QPixmap()
145 if not pixmap.loadFromData(image_data): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return None
147 return ThemeIcon.from_source(QIcon(pixmap))
149 # Handle local SVG
150 elif source.lower().endswith(".svg"):
151 from PySide6.QtSvg import QSvgRenderer
153 renderer = QSvgRenderer(source)
154 if renderer.isValid():
155 pixmap = QPixmap(QSize(16, 16))
156 pixmap.fill(Qt.GlobalColor.transparent)
157 painter = QPainter(pixmap)
158 renderer.render(painter)
159 painter.end()
160 return ThemeIcon.from_source(QIcon(pixmap))
161 else:
162 return None
164 # Handle local image
165 else:
166 pixmap = QPixmap(source)
167 if not pixmap.isNull():
168 return ThemeIcon.from_source(QIcon(pixmap))
169 else:
170 return None
173# ///////////////////////////////////////////////////////////////
174# CLASSES
175# ///////////////////////////////////////////////////////////////
178class PasswordInput(QWidget):
179 """Enhanced password input widget with integrated strength bar.
181 Features:
182 - QLineEdit in password mode with integrated strength bar
183 - Right-side icon with click functionality
184 - Icon management system (ThemeIcon, QIcon, QPixmap, path, URL, SVG)
185 - Animated strength bar that fills the bottom border
186 - Signal strengthChanged(int) emitted on password change
187 - Color-coded strength indicator
188 - External QSS styling support with CSS variables
190 Args:
191 parent: The parent widget (default: None).
192 show_strength: Whether to show the password strength bar
193 (default: True).
194 strength_bar_height: Height of the strength bar in pixels
195 (default: 3).
196 show_icon: Icon for show password (ThemeIcon, QIcon, QPixmap, str, or None,
197 default: URL to icons8.com).
198 hide_icon: Icon for hide password (ThemeIcon, QIcon, QPixmap, str, or None,
199 default: URL to icons8.com).
200 icon_size: Size of the icon (QSize or tuple, default: QSize(16, 16)).
201 *args: Additional arguments passed to QWidget.
202 **kwargs: Additional keyword arguments passed to QWidget.
204 Properties:
205 password: Get or set the password text.
206 show_strength: Get or set whether to show the strength bar.
207 strength_bar_height: Get or set the strength bar height.
208 show_icon: Get or set the show password icon.
209 hide_icon: Get or set the hide password icon.
210 icon_size: Get or set the icon size.
212 Signals:
213 strengthChanged(int): Emitted when password strength changes.
214 iconClicked(): Emitted when the icon is clicked.
215 """
217 strengthChanged = Signal(int)
218 iconClicked = Signal()
220 # ///////////////////////////////////////////////////////////////
221 # INIT
222 # ///////////////////////////////////////////////////////////////
224 def __init__(
225 self,
226 parent: QWidget | None = None,
227 show_strength: bool = True,
228 strength_bar_height: int = 3,
229 show_icon: IconSourceExtended = SVG_EYE_OPEN,
230 hide_icon: IconSourceExtended = SVG_EYE_CLOSED,
231 icon_size: QSize | tuple[int, int] = QSize(16, 16),
232 *args: Any,
233 **kwargs: Any,
234 ) -> None:
235 """Initialize the password input widget."""
236 super().__init__(parent, *args, **kwargs)
238 # Set widget type for QSS selection
239 self.setProperty("type", "PasswordInput")
240 self.setObjectName("PasswordInput")
242 # Initialize properties
243 self._show_strength: bool = show_strength
244 self._strength_bar_height: int = strength_bar_height
245 self._show_icon: QIcon | None = None
246 self._hide_icon: QIcon | None = None
247 self._show_icon_source: IconSourceExtended = show_icon
248 self._hide_icon_source: IconSourceExtended = hide_icon
249 self._icon_size: QSize = (
250 QSize(*icon_size) if isinstance(icon_size, (tuple, list)) else icon_size
251 )
252 self._current_strength: int = 0
253 self._password_visible: bool = False
255 # Setup UI
256 self._setup_ui()
258 # Set icons
259 if show_icon: 259 ↛ 261line 259 didn't jump to line 261 because the condition on line 259 was always true
260 self.show_icon = show_icon
261 if hide_icon: 261 ↛ 265line 261 didn't jump to line 265 because the condition on line 261 was always true
262 self.hide_icon = hide_icon
264 # Initialize icon display
265 self._update_icon()
267 # ------------------------------------------------
268 # PRIVATE METHODS
269 # ------------------------------------------------
271 def _setup_ui(self) -> None:
272 """Setup the user interface components."""
273 # Create layout
274 self._layout = QVBoxLayout(self)
276 # Set content margins to show borders
277 self._layout.setContentsMargins(2, 2, 2, 2)
278 self._layout.setSpacing(0)
280 # Create password input
281 self._password_input = _PasswordLineEdit()
282 self._password_input.textChanged.connect(self.updateStrength)
284 # Connect icon click signal
285 self._password_input.iconClicked.connect(self.togglePassword)
287 # Create strength bar
288 self._strength_bar = QProgressBar()
289 self._strength_bar.setProperty("type", "PasswordStrengthBar")
290 self._strength_bar.setFixedHeight(self._strength_bar_height)
291 self._strength_bar.setRange(0, 100)
292 self._strength_bar.setValue(0)
293 self._strength_bar.setTextVisible(False)
294 self._strength_bar.setVisible(self._show_strength)
296 # Add widgets to layout
297 self._layout.addWidget(self._password_input)
298 self._layout.addWidget(self._strength_bar)
300 def _update_icon(self) -> None:
301 """Update the icon based on password visibility."""
302 if self._password_visible and self._hide_icon:
303 self._password_input.setRightIcon(self._hide_icon, self._icon_size)
304 elif not self._password_visible and self._show_icon: 304 ↛ 307line 304 didn't jump to line 307 because the condition on line 304 was always true
305 self._password_input.setRightIcon(self._show_icon, self._icon_size)
306 # Handle case where icons are not yet loaded
307 elif not self._password_visible and self._show_icon_source:
308 # Try to load icon from source if not already loaded
309 icon = _load_icon_from_source(self._show_icon_source)
310 if icon:
311 self._show_icon = icon
312 self._password_input.setRightIcon(icon, self._icon_size)
314 def _update_strength_color(self, score: int) -> None:
315 """Update strength bar color based on score.
317 Args:
318 score: The password strength score (0-100).
319 """
320 color = _get_strength_color(score)
321 self._strength_bar.setStyleSheet(
322 f"""
323 QProgressBar {{
324 border: none;
325 background-color: #2d2d2d;
326 }}
327 QProgressBar::chunk {{
328 background-color: {color};
329 }}
330 """
331 )
333 # ///////////////////////////////////////////////////////////////
334 # PROPERTIES
335 # ///////////////////////////////////////////////////////////////
337 @property
338 def password(self) -> str:
339 """Get the password text.
341 Returns:
342 The current password text.
343 """
344 return self._password_input.text()
346 @password.setter
347 def password(self, value: str) -> None:
348 """Set the password text.
350 Args:
351 value: The new password text.
352 """
353 self._password_input.setText(str(value))
355 @property
356 def show_strength(self) -> bool:
357 """Get whether the strength bar is shown.
359 Returns:
360 True if strength bar is shown, False otherwise.
361 """
362 return self._show_strength
364 @show_strength.setter
365 def show_strength(self, value: bool) -> None:
366 """Set whether the strength bar is shown.
368 Args:
369 value: Whether to show the strength bar.
370 """
371 self._show_strength = bool(value)
372 self._strength_bar.setVisible(self._show_strength)
374 @property
375 def strength_bar_height(self) -> int:
376 """Get the strength bar height.
378 Returns:
379 The current strength bar height in pixels.
380 """
381 return self._strength_bar_height
383 @strength_bar_height.setter
384 def strength_bar_height(self, value: int) -> None:
385 """Set the strength bar height.
387 Args:
388 value: The new strength bar height in pixels.
389 """
390 self._strength_bar_height = max(1, int(value))
391 self._strength_bar.setFixedHeight(self._strength_bar_height)
393 @property
394 def show_icon(self) -> QIcon | None:
395 """Get the show password icon.
397 Returns:
398 The current show password icon, or None if not set.
399 """
400 return self._show_icon
402 @show_icon.setter
403 def show_icon(self, value: IconSourceExtended) -> None:
404 """Set the show password icon.
406 Args:
407 value: The icon source (ThemeIcon, QIcon, QPixmap, path, URL, or None).
408 """
409 self._show_icon_source = value
410 self._show_icon = _load_icon_from_source(value)
411 if not self._password_visible: 411 ↛ exitline 411 didn't return from function 'show_icon' because the condition on line 411 was always true
412 self._update_icon()
414 @property
415 def hide_icon(self) -> QIcon | None:
416 """Get the hide password icon.
418 Returns:
419 The current hide password icon, or None if not set.
420 """
421 return self._hide_icon
423 @hide_icon.setter
424 def hide_icon(self, value: IconSourceExtended) -> None:
425 """Set the hide password icon.
427 Args:
428 value: The icon source (ThemeIcon, QIcon, QPixmap, path, URL, or None).
429 """
430 self._hide_icon_source = value
431 self._hide_icon = _load_icon_from_source(value)
432 if self._password_visible: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
433 self._update_icon()
435 @property
436 def icon_size(self) -> QSize:
437 """Get the icon size.
439 Returns:
440 The current icon size.
441 """
442 return self._icon_size
444 @icon_size.setter
445 def icon_size(self, value: QSize | tuple[int, int]) -> None:
446 """Set the icon size.
448 Args:
449 value: The new icon size (QSize or tuple).
450 """
451 self._icon_size = QSize(*value) if isinstance(value, (tuple, list)) else value
452 self._update_icon()
454 # ///////////////////////////////////////////////////////////////
455 # PUBLIC METHODS
456 # ///////////////////////////////////////////////////////////////
458 def togglePassword(self) -> None:
459 """Toggle password visibility."""
460 self._password_visible = not self._password_visible
461 if self._password_visible:
462 self._password_input.setEchoMode(QLineEdit.EchoMode.Normal)
463 else:
464 self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
465 self._update_icon()
467 def updateStrength(self, text: str) -> None:
468 """Update password strength.
470 Args:
471 text: The password text to evaluate.
472 """
473 score = _password_strength(text)
474 self._current_strength = score
475 self._strength_bar.setValue(score)
476 self._update_strength_color(score)
477 self.strengthChanged.emit(score)
479 def setTheme(self, theme: str) -> None:
480 """Update all icons' color for the given theme.
482 Can be connected directly to a theme-change signal to keep
483 icons in sync with the application's color scheme.
485 Args:
486 theme: The new theme (``"dark"`` or ``"light"``).
487 """
488 if isinstance(self._show_icon, ThemeIcon):
489 self._show_icon.setTheme(theme)
490 if isinstance(self._hide_icon, ThemeIcon):
491 self._hide_icon.setTheme(theme)
492 self._update_icon()
495class _PasswordLineEdit(QLineEdit):
496 """QLineEdit subclass with right-side icon support.
498 Features:
499 - Right-side icon with click functionality
500 - Icon management system
501 - Signal iconClicked emitted when icon is clicked
503 Args:
504 parent: The parent widget (default: None).
505 """
507 iconClicked = Signal()
509 # ///////////////////////////////////////////////////////////////
510 # INIT
511 # ///////////////////////////////////////////////////////////////
513 def __init__(self, parent=None) -> None:
514 """Initialize the password line edit."""
515 super().__init__(parent)
517 # Set widget type for QSS selection
518 self.setProperty("type", "PasswordInputField")
519 self.setEchoMode(QLineEdit.EchoMode.Password)
520 self._right_icon: QIcon | None = None
521 self._icon_rect: QRect | None = None
522 self._icon_size: QSize = QSize(16, 16)
524 # ///////////////////////////////////////////////////////////////
525 # PUBLIC METHODS
526 # ///////////////////////////////////////////////////////////////
528 def setRightIcon(self, icon: QIcon | None, size: QSize | None = None) -> None:
529 """Set the right-side icon.
531 Args:
532 icon: The icon to display (QIcon or None).
533 size: The icon size (QSize or None for default).
534 """
535 self._right_icon = icon
536 if size: 536 ↛ 539line 536 didn't jump to line 539 because the condition on line 536 was always true
537 self._icon_size = size
538 else:
539 self._icon_size = QSize(16, 16)
540 self.update()
542 # ///////////////////////////////////////////////////////////////
543 # EVENT HANDLERS
544 # ///////////////////////////////////////////////////////////////
546 def mousePressEvent(self, event: QMouseEvent) -> None:
547 """Handle mouse press events for icon clicking.
549 Args:
550 event: The mouse event.
551 """
552 if (
553 self._right_icon
554 and self._icon_rect
555 and self._icon_rect.contains(event.pos())
556 ):
557 self.iconClicked.emit()
558 else:
559 super().mousePressEvent(event)
561 def mouseMoveEvent(self, event: QMouseEvent) -> None:
562 """Handle mouse move events for cursor changes.
564 Args:
565 event: The mouse event.
566 """
567 if (
568 self._right_icon
569 and self._icon_rect
570 and self._icon_rect.contains(event.pos())
571 ):
572 self.setCursor(Qt.CursorShape.PointingHandCursor)
573 else:
574 self.setCursor(Qt.CursorShape.IBeamCursor)
575 super().mouseMoveEvent(event)
577 def paintEvent(self, event: QPaintEvent) -> None:
578 """Custom paint event to draw the right-side icon.
580 Args:
581 event: The paint event.
582 """
583 super().paintEvent(event)
585 if not self._right_icon:
586 return
588 # Calculate icon position
589 icon_x = self.width() - self._icon_size.width() - 8
590 icon_y = (self.height() - self._icon_size.height()) // 2
592 self._icon_rect = QRect(
593 icon_x, icon_y, self._icon_size.width(), self._icon_size.height()
594 )
596 # Draw icon
597 painter = QPainter(self)
598 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
599 painter.drawPixmap(self._icon_rect, self._right_icon.pixmap(self._icon_size))
601 # ///////////////////////////////////////////////////////////////
602 # STYLE METHODS
603 # ///////////////////////////////////////////////////////////////
605 def refreshStyle(self) -> None:
606 """Refresh the widget's style.
608 Useful after dynamic stylesheet changes.
609 """
610 self.style().unpolish(self)
611 self.style().polish(self)
612 self.update()
615# ///////////////////////////////////////////////////////////////
616# PUBLIC API
617# ///////////////////////////////////////////////////////////////
619__all__ = ["PasswordInput"]