Coverage for src / ezqt_widgets / widgets / label / clickable_tag_label.py: 97.87%

123 statements  

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

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

2# CLICKABLE_TAG_LABEL - Clickable Tag Label Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Clickable tag label widget module. 

8 

9Provides a tag-like clickable label with toggleable state for PySide6 

10applications. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Standard library imports 

19from typing import Any 

20 

21# Third-party imports 

22from PySide6.QtCore import QSize, Qt, Signal 

23from PySide6.QtGui import QFont, QKeyEvent, QMouseEvent 

24from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QSizePolicy 

25 

26# Local imports 

27from ...types import WidgetParent 

28 

29# /////////////////////////////////////////////////////////////// 

30# CLASSES 

31# /////////////////////////////////////////////////////////////// 

32 

33 

34class ClickableTagLabel(QFrame): 

35 """Tag-like clickable label with toggleable state. 

36 

37 Features: 

38 - Clickable tag with enabled/disabled state 

39 - Emits signals on click and state change 

40 - Customizable text, font, min width/height 

41 - Customizable status color (traditional name or hex) 

42 - QSS-friendly (type/class/status properties) 

43 - Automatic minimum size calculation 

44 - Keyboard focus and accessibility 

45 

46 Args: 

47 name: Text to display in the tag (default: ""). 

48 enabled: Initial state (default: False). 

49 status_color: Color when selected (default: "#0078d4"). 

50 min_width: Minimum width (default: None, auto-calculated). 

51 min_height: Minimum height (default: None, auto-calculated). 

52 parent: Parent widget (default: None). 

53 *args: Additional arguments passed to QFrame. 

54 **kwargs: Additional keyword arguments passed to QFrame. 

55 

56 Signals: 

57 clicked(): Emitted when the tag is clicked. 

58 toggleKeyword(str): Emitted with the tag name when toggled. 

59 stateChanged(bool): Emitted when the enabled state changes. 

60 

61 Example: 

62 >>> from ezqt_widgets import ClickableTagLabel 

63 >>> tag = ClickableTagLabel(name="Python", enabled=False, status_color="#0078d4") 

64 >>> tag.stateChanged.connect(lambda state: print(f"Active: {state}")) 

65 >>> tag.toggleKeyword.connect(lambda kw: print(f"Toggled: {kw}")) 

66 >>> tag.show() 

67 """ 

68 

69 clicked = Signal() 

70 toggleKeyword = Signal(str) 

71 stateChanged = Signal(bool) 

72 

73 # /////////////////////////////////////////////////////////////// 

74 # INIT 

75 # /////////////////////////////////////////////////////////////// 

76 

77 def __init__( 

78 self, 

79 name: str = "", 

80 enabled: bool = False, 

81 status_color: str = "#0078d4", 

82 min_width: int | None = None, 

83 min_height: int | None = None, 

84 parent: WidgetParent = None, 

85 *args: Any, 

86 **kwargs: Any, 

87 ) -> None: 

88 """Initialize the clickable tag label.""" 

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

90 

91 self.setProperty("type", "ClickableTagLabel") 

92 

93 # Initialize properties 

94 self._name: str = name 

95 self._enabled: bool = enabled 

96 self._status_color: str = status_color 

97 self._min_width: int | None = min_width 

98 self._min_height: int | None = min_height 

99 

100 # Setup UI 

101 self._setup_ui() 

102 self._update_display() 

103 

104 # ------------------------------------------------ 

105 # PRIVATE METHODS 

106 # ------------------------------------------------ 

107 

108 def _setup_ui(self) -> None: 

109 """Setup the user interface components.""" 

110 self.setFrameShape(QFrame.Shape.NoFrame) 

111 self.setFrameShadow(QFrame.Shadow.Raised) 

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

113 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

114 self.setCursor(Qt.CursorShape.PointingHandCursor) 

115 self.setContentsMargins(4, 0, 4, 0) 

116 self.setFixedHeight(20) 

117 

118 self._layout = QHBoxLayout(self) 

119 self._layout.setObjectName("status_HLayout") 

120 self._layout.setAlignment(Qt.AlignmentFlag.AlignLeft) 

121 self._layout.setContentsMargins(0, 0, 0, 0) 

122 self._layout.setSpacing(12) 

123 

124 self._label = QLabel() 

125 self._label.setObjectName("tag") 

126 self._label.setFont(QFont("Segoe UI", 8)) 

127 self._label.setLineWidth(0) 

128 self._label.setSizePolicy( 

129 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred 

130 ) 

131 self._label.setAlignment(Qt.AlignmentFlag.AlignHCenter) 

132 

133 self._layout.addWidget(self._label, 0, Qt.AlignmentFlag.AlignTop) 

134 

135 if self._min_width: 

136 self.setMinimumWidth(self._min_width) 

137 if self._min_height: 

138 self.setMinimumHeight(self._min_height) 

139 

140 def _update_display(self) -> None: 

141 """Update the display based on current state.""" 

142 self._label.setText(self._name) 

143 self.setObjectName(self._name) 

144 

145 if self._enabled: 

146 self.setProperty("status", "selected") 

147 self._label.setStyleSheet( 

148 f"color: {self._status_color}; background-color: transparent; border: none;" 

149 ) 

150 else: 

151 self.setProperty("status", "unselected") 

152 self._label.setStyleSheet( 

153 "color: rgb(86, 86, 86); background-color: transparent; border: none;" 

154 ) 

155 

156 self.refreshStyle() 

157 self.adjustSize() 

158 

159 # /////////////////////////////////////////////////////////////// 

160 # PROPERTIES 

161 # /////////////////////////////////////////////////////////////// 

162 

163 @property 

164 def name(self) -> str: 

165 """Get the tag text. 

166 

167 Returns: 

168 The current tag text. 

169 """ 

170 return self._name 

171 

172 @name.setter 

173 def name(self, value: str) -> None: 

174 """Set the tag text. 

175 

176 Args: 

177 value: The new tag text. 

178 """ 

179 self._name = str(value) 

180 self._update_display() 

181 self.updateGeometry() 

182 

183 @property 

184 def enabled(self) -> bool: 

185 """Get the enabled state. 

186 

187 Returns: 

188 True if enabled, False otherwise. 

189 """ 

190 return self._enabled 

191 

192 @enabled.setter 

193 def enabled(self, value: bool) -> None: 

194 """Set the enabled state. 

195 

196 Args: 

197 value: The new enabled state. 

198 """ 

199 if value != self._enabled: 199 ↛ exitline 199 didn't return from function 'enabled' because the condition on line 199 was always true

200 self._enabled = bool(value) 

201 self._update_display() 

202 self.stateChanged.emit(self._enabled) 

203 

204 @property 

205 def status_color(self) -> str: 

206 """Get the status color. 

207 

208 Returns: 

209 The current status color. 

210 """ 

211 return self._status_color 

212 

213 @status_color.setter 

214 def status_color(self, value: str) -> None: 

215 """Set the status color. 

216 

217 Args: 

218 value: The new status color. 

219 """ 

220 self._status_color = str(value) 

221 if self._enabled: 

222 self._label.setStyleSheet( 

223 f"color: {value}; background-color: transparent; border: none;" 

224 ) 

225 self.refreshStyle() 

226 

227 @property 

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

229 """Get the minimum width. 

230 

231 Returns: 

232 The minimum width, or None if not set. 

233 """ 

234 return self._min_width 

235 

236 @min_width.setter 

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

238 """Set the minimum width. 

239 

240 Args: 

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

242 """ 

243 self._min_width = value 

244 if value: 

245 self.setMinimumWidth(value) 

246 self.updateGeometry() 

247 

248 @property 

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

250 """Get the minimum height. 

251 

252 Returns: 

253 The minimum height, or None if not set. 

254 """ 

255 return self._min_height 

256 

257 @min_height.setter 

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

259 """Set the minimum height. 

260 

261 Args: 

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

263 """ 

264 self._min_height = value 

265 if value: 

266 self.setMinimumHeight(value) 

267 self.updateGeometry() 

268 

269 # /////////////////////////////////////////////////////////////// 

270 # EVENT HANDLERS 

271 # /////////////////////////////////////////////////////////////// 

272 

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

274 """Handle mouse press events. 

275 

276 Args: 

277 event: The mouse event. 

278 """ 

279 if event.button() == Qt.MouseButton.LeftButton: 

280 self.enabled = not self.enabled 

281 self.clicked.emit() 

282 self.toggleKeyword.emit(self._name) 

283 super().mousePressEvent(event) 

284 

285 def keyPressEvent(self, event: QKeyEvent) -> None: 

286 """Handle key press events. 

287 

288 Args: 

289 event: The key event. 

290 """ 

291 if event.key() in [Qt.Key.Key_Space, Qt.Key.Key_Return, Qt.Key.Key_Enter]: 291 ↛ 296line 291 didn't jump to line 296 because the condition on line 291 was always true

292 self.enabled = not self.enabled 

293 self.clicked.emit() 

294 self.toggleKeyword.emit(self._name) 

295 else: 

296 super().keyPressEvent(event) 

297 

298 # /////////////////////////////////////////////////////////////// 

299 # OVERRIDE METHODS 

300 # /////////////////////////////////////////////////////////////// 

301 

302 def sizeHint(self) -> QSize: 

303 """Return the recommended size for the widget. 

304 

305 Returns: 

306 The recommended size. 

307 """ 

308 return QSize(80, 24) 

309 

310 def minimumSizeHint(self) -> QSize: 

311 """Return the minimum size for the widget. 

312 

313 Returns: 

314 The minimum size hint. 

315 """ 

316 font_metrics = self._label.fontMetrics() 

317 text_width = font_metrics.horizontalAdvance(self._name) 

318 min_width = self._min_width if self._min_width is not None else text_width + 16 

319 min_height = ( 

320 self._min_height 

321 if self._min_height is not None 

322 else max(font_metrics.height() + 8, 20) 

323 ) 

324 

325 return QSize(min_width, min_height) 

326 

327 # /////////////////////////////////////////////////////////////// 

328 # STYLE METHODS 

329 # /////////////////////////////////////////////////////////////// 

330 

331 def refreshStyle(self) -> None: 

332 """Refresh the widget style. 

333 

334 Useful after dynamic stylesheet changes. 

335 """ 

336 self.style().unpolish(self) 

337 self.style().polish(self) 

338 self.update() 

339 

340 

341# /////////////////////////////////////////////////////////////// 

342# PUBLIC API 

343# /////////////////////////////////////////////////////////////// 

344 

345__all__ = ["ClickableTagLabel"]