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

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

2# PASSWORD_INPUT - Password Input Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Password input widget module. 

8 

9Provides an enhanced password input widget with integrated strength bar 

10and right-side icon for PySide6 applications. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Standard library imports 

19import re 

20from typing import Any 

21 

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 

26 

27from ...types import IconSourceExtended 

28 

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 

33 

34# /////////////////////////////////////////////////////////////// 

35# UTILITY FUNCTIONS 

36# /////////////////////////////////////////////////////////////// 

37 

38 

39def _password_strength(password: str) -> int: 

40 """Calculate password strength score. 

41 

42 Returns a strength score from 0 (weak) to 100 (strong) based on 

43 various criteria like length, character variety, etc. 

44 

45 Args: 

46 password: The password to evaluate. 

47 

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) 

63 

64 

65def _get_strength_color(score: int) -> str: 

66 """Get color based on password strength score. 

67 

68 Args: 

69 score: The password strength score (0-100). 

70 

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 

82 

83 

84def _load_icon_from_source(source: IconSourceExtended) -> QIcon | None: 

85 """Load icon from various sources (ThemeIcon, QIcon, QPixmap, path, URL, etc.). 

86 

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 

94 

95 Args: 

96 source: Icon source (QIcon, path, resource, URL, or SVG). 

97 

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 

110 

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 

129 

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 

134 

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

142 

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

148 

149 # Handle local SVG 

150 elif source.lower().endswith(".svg"): 

151 from PySide6.QtSvg import QSvgRenderer 

152 

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 

163 

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 

171 

172 

173# /////////////////////////////////////////////////////////////// 

174# CLASSES 

175# /////////////////////////////////////////////////////////////// 

176 

177 

178class PasswordInput(QWidget): 

179 """Enhanced password input widget with integrated strength bar. 

180 

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 

189 

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. 

203 

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. 

211 

212 Signals: 

213 strengthChanged(int): Emitted when password strength changes. 

214 iconClicked(): Emitted when the icon is clicked. 

215 """ 

216 

217 strengthChanged = Signal(int) 

218 iconClicked = Signal() 

219 

220 # /////////////////////////////////////////////////////////////// 

221 # INIT 

222 # /////////////////////////////////////////////////////////////// 

223 

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) 

237 

238 # Set widget type for QSS selection 

239 self.setProperty("type", "PasswordInput") 

240 self.setObjectName("PasswordInput") 

241 

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 

254 

255 # Setup UI 

256 self._setup_ui() 

257 

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 

263 

264 # Initialize icon display 

265 self._update_icon() 

266 

267 # ------------------------------------------------ 

268 # PRIVATE METHODS 

269 # ------------------------------------------------ 

270 

271 def _setup_ui(self) -> None: 

272 """Setup the user interface components.""" 

273 # Create layout 

274 self._layout = QVBoxLayout(self) 

275 

276 # Set content margins to show borders 

277 self._layout.setContentsMargins(2, 2, 2, 2) 

278 self._layout.setSpacing(0) 

279 

280 # Create password input 

281 self._password_input = _PasswordLineEdit() 

282 self._password_input.textChanged.connect(self.updateStrength) 

283 

284 # Connect icon click signal 

285 self._password_input.iconClicked.connect(self.togglePassword) 

286 

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) 

295 

296 # Add widgets to layout 

297 self._layout.addWidget(self._password_input) 

298 self._layout.addWidget(self._strength_bar) 

299 

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) 

313 

314 def _update_strength_color(self, score: int) -> None: 

315 """Update strength bar color based on score. 

316 

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 ) 

332 

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

334 # PROPERTIES 

335 # /////////////////////////////////////////////////////////////// 

336 

337 @property 

338 def password(self) -> str: 

339 """Get the password text. 

340 

341 Returns: 

342 The current password text. 

343 """ 

344 return self._password_input.text() 

345 

346 @password.setter 

347 def password(self, value: str) -> None: 

348 """Set the password text. 

349 

350 Args: 

351 value: The new password text. 

352 """ 

353 self._password_input.setText(str(value)) 

354 

355 @property 

356 def show_strength(self) -> bool: 

357 """Get whether the strength bar is shown. 

358 

359 Returns: 

360 True if strength bar is shown, False otherwise. 

361 """ 

362 return self._show_strength 

363 

364 @show_strength.setter 

365 def show_strength(self, value: bool) -> None: 

366 """Set whether the strength bar is shown. 

367 

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) 

373 

374 @property 

375 def strength_bar_height(self) -> int: 

376 """Get the strength bar height. 

377 

378 Returns: 

379 The current strength bar height in pixels. 

380 """ 

381 return self._strength_bar_height 

382 

383 @strength_bar_height.setter 

384 def strength_bar_height(self, value: int) -> None: 

385 """Set the strength bar height. 

386 

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) 

392 

393 @property 

394 def show_icon(self) -> QIcon | None: 

395 """Get the show password icon. 

396 

397 Returns: 

398 The current show password icon, or None if not set. 

399 """ 

400 return self._show_icon 

401 

402 @show_icon.setter 

403 def show_icon(self, value: IconSourceExtended) -> None: 

404 """Set the show password icon. 

405 

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

413 

414 @property 

415 def hide_icon(self) -> QIcon | None: 

416 """Get the hide password icon. 

417 

418 Returns: 

419 The current hide password icon, or None if not set. 

420 """ 

421 return self._hide_icon 

422 

423 @hide_icon.setter 

424 def hide_icon(self, value: IconSourceExtended) -> None: 

425 """Set the hide password icon. 

426 

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

434 

435 @property 

436 def icon_size(self) -> QSize: 

437 """Get the icon size. 

438 

439 Returns: 

440 The current icon size. 

441 """ 

442 return self._icon_size 

443 

444 @icon_size.setter 

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

446 """Set the icon size. 

447 

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

453 

454 # /////////////////////////////////////////////////////////////// 

455 # PUBLIC METHODS 

456 # /////////////////////////////////////////////////////////////// 

457 

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

466 

467 def updateStrength(self, text: str) -> None: 

468 """Update password strength. 

469 

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) 

478 

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

480 """Update all icons' color for the given theme. 

481 

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

483 icons in sync with the application's color scheme. 

484 

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

493 

494 

495class _PasswordLineEdit(QLineEdit): 

496 """QLineEdit subclass with right-side icon support. 

497 

498 Features: 

499 - Right-side icon with click functionality 

500 - Icon management system 

501 - Signal iconClicked emitted when icon is clicked 

502 

503 Args: 

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

505 """ 

506 

507 iconClicked = Signal() 

508 

509 # /////////////////////////////////////////////////////////////// 

510 # INIT 

511 # /////////////////////////////////////////////////////////////// 

512 

513 def __init__(self, parent=None) -> None: 

514 """Initialize the password line edit.""" 

515 super().__init__(parent) 

516 

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) 

523 

524 # /////////////////////////////////////////////////////////////// 

525 # PUBLIC METHODS 

526 # /////////////////////////////////////////////////////////////// 

527 

528 def setRightIcon(self, icon: QIcon | None, size: QSize | None = None) -> None: 

529 """Set the right-side icon. 

530 

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

541 

542 # /////////////////////////////////////////////////////////////// 

543 # EVENT HANDLERS 

544 # /////////////////////////////////////////////////////////////// 

545 

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

547 """Handle mouse press events for icon clicking. 

548 

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) 

560 

561 def mouseMoveEvent(self, event: QMouseEvent) -> None: 

562 """Handle mouse move events for cursor changes. 

563 

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) 

576 

577 def paintEvent(self, event: QPaintEvent) -> None: 

578 """Custom paint event to draw the right-side icon. 

579 

580 Args: 

581 event: The paint event. 

582 """ 

583 super().paintEvent(event) 

584 

585 if not self._right_icon: 

586 return 

587 

588 # Calculate icon position 

589 icon_x = self.width() - self._icon_size.width() - 8 

590 icon_y = (self.height() - self._icon_size.height()) // 2 

591 

592 self._icon_rect = QRect( 

593 icon_x, icon_y, self._icon_size.width(), self._icon_size.height() 

594 ) 

595 

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

600 

601 # /////////////////////////////////////////////////////////////// 

602 # STYLE METHODS 

603 # /////////////////////////////////////////////////////////////// 

604 

605 def refreshStyle(self) -> None: 

606 """Refresh the widget's style. 

607 

608 Useful after dynamic stylesheet changes. 

609 """ 

610 self.style().unpolish(self) 

611 self.style().polish(self) 

612 self.update() 

613 

614 

615# /////////////////////////////////////////////////////////////// 

616# PUBLIC API 

617# /////////////////////////////////////////////////////////////// 

618 

619__all__ = ["PasswordInput"]