Coverage for src / ezqt_widgets / widgets / misc / draggable_list.py: 87.35%

337 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 22:46 +0000

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

2# DRAGGABLE_LIST - Draggable List Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Draggable list widget module. 

8 

9Provides a list widget with draggable and reorderable items 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 QMimeData, QPoint, QSize, Qt, Signal 

23from PySide6.QtGui import ( 

24 QDrag, 

25 QDragEnterEvent, 

26 QDragMoveEvent, 

27 QDropEvent, 

28 QMouseEvent, 

29) 

30from PySide6.QtWidgets import ( 

31 QFrame, 

32 QHBoxLayout, 

33 QScrollArea, 

34 QSizePolicy, 

35 QVBoxLayout, 

36 QWidget, 

37) 

38 

39from ...types import IconSourceExtended, WidgetParent 

40 

41# Local imports 

42from ..label.hover_label import HoverLabel 

43 

44# /////////////////////////////////////////////////////////////// 

45# CLASSES 

46# /////////////////////////////////////////////////////////////// 

47 

48 

49class DraggableItem(QFrame): 

50 """Draggable item widget for DraggableList. 

51 

52 This item can be moved by drag & drop and always contains a HoverLabel 

53 for a consistent interface. 

54 

55 Args: 

56 item_id: Unique identifier for the item. 

57 text: Text to display in the item. 

58 parent: Parent widget (default: None). 

59 icon: Icon for the item (default: None, uses default icon). 

60 compact: Whether to display in compact mode (default: False). 

61 **kwargs: Additional keyword arguments passed to HoverLabel. 

62 

63 Signals: 

64 itemClicked(str): Emitted when the item is clicked. 

65 itemRemoved(str): Emitted when the item is removed. 

66 

67 Example: 

68 >>> from ezqt_widgets import DraggableItem 

69 >>> item = DraggableItem(item_id="item-1", text="First item") 

70 >>> item.itemClicked.connect(lambda id: print(f"Clicked: {id}")) 

71 >>> item.itemRemoved.connect(lambda id: print(f"Removed: {id}")) 

72 >>> item.show() 

73 """ 

74 

75 itemClicked = Signal(str) 

76 itemRemoved = Signal(str) 

77 

78 # /////////////////////////////////////////////////////////////// 

79 # INIT 

80 # /////////////////////////////////////////////////////////////// 

81 

82 def __init__( 

83 self, 

84 item_id: str, 

85 text: str, 

86 parent: WidgetParent = None, 

87 icon: IconSourceExtended = None, 

88 compact: bool = False, 

89 **kwargs: Any, 

90 ) -> None: 

91 """Initialize the draggable item.""" 

92 super().__init__(parent) 

93 self.setProperty("type", "DraggableItem") 

94 

95 # Initialize attributes 

96 self._item_id = item_id 

97 self._text = text 

98 self._is_dragging = False 

99 self._drag_start_pos = QPoint() 

100 self._compact = compact 

101 

102 # Configure widget 

103 self.setFrameShape(QFrame.Shape.Box) 

104 self.setLineWidth(1) 

105 self.setMidLineWidth(0) 

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

107 

108 # Height based on compact mode 

109 if self._compact: 

110 self.setMinimumHeight(24) 

111 self.setMaximumHeight(32) 

112 else: 

113 self.setMinimumHeight(40) 

114 self.setMaximumHeight(60) 

115 

116 # Main layout 

117 layout = QHBoxLayout(self) 

118 if self._compact: 

119 layout.setContentsMargins(6, 2, 6, 2) # Reduced margins in compact mode 

120 else: 

121 layout.setContentsMargins(8, 4, 8, 4) # Normal margins 

122 layout.setSpacing(8) 

123 

124 # Default icon for drag & drop if no icon is provided 

125 if icon is None: 125 ↛ 129line 125 didn't jump to line 129 because the condition on line 125 was always true

126 icon = "https://img.icons8.com/?size=100&id=8329&format=png&color=000000" 

127 

128 # Content widget (HoverLabel with removal icon) 

129 icon_size = QSize(16, 16) if self._compact else QSize(20, 20) 

130 icon_padding = 2 if self._compact else 4 

131 

132 self._content_widget = HoverLabel( 

133 text=text, 

134 icon=icon, # Trash icon for removal 

135 icon_size=icon_size, 

136 icon_padding=icon_padding, 

137 **kwargs, 

138 ) 

139 self._content_widget.hoverIconClicked.connect(self._on_remove_clicked) 

140 

141 # Icon color property 

142 self._icon_color = "grey" 

143 # Apply initial color 

144 self._content_widget.icon_color = self._icon_color 

145 

146 # Add widget to layout (takes full width) 

147 layout.addWidget(self._content_widget) 

148 

149 # ------------------------------------------------ 

150 # PRIVATE METHODS 

151 # ------------------------------------------------ 

152 

153 def _on_remove_clicked(self) -> None: 

154 """Handle click on removal icon.""" 

155 self.itemRemoved.emit(self._item_id) 

156 

157 # /////////////////////////////////////////////////////////////// 

158 # PROPERTIES 

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

160 

161 @property 

162 def item_id(self) -> str: 

163 """Get the item identifier. 

164 

165 Returns: 

166 The unique identifier of the item. 

167 """ 

168 return self._item_id 

169 

170 @property 

171 def text(self) -> str: 

172 """Get the item text. 

173 

174 Returns: 

175 The display text of the item. 

176 """ 

177 return self._text 

178 

179 @property 

180 def content_widget(self) -> HoverLabel: 

181 """Get the inner HoverLabel widget. 

182 

183 Returns: 

184 The HoverLabel used for display and interaction. 

185 """ 

186 return self._content_widget 

187 

188 @property 

189 def icon_color(self) -> str: 

190 """Get the icon color of the HoverLabel. 

191 

192 Returns: 

193 The current icon color. 

194 """ 

195 return self._icon_color 

196 

197 @icon_color.setter 

198 def icon_color(self, value: str) -> None: 

199 """Set the icon color of the HoverLabel. 

200 

201 Args: 

202 value: The new icon color. 

203 """ 

204 self._icon_color = value 

205 if self._content_widget: 205 ↛ exitline 205 didn't return from function 'icon_color' because the condition on line 205 was always true

206 self._content_widget.icon_color = value 

207 

208 @property 

209 def compact(self) -> bool: 

210 """Get the compact mode. 

211 

212 Returns: 

213 True if compact mode is enabled, False otherwise. 

214 """ 

215 return self._compact 

216 

217 @compact.setter 

218 def compact(self, value: bool) -> None: 

219 """Set the compact mode and adjust height. 

220 

221 Args: 

222 value: Whether to enable compact mode. 

223 """ 

224 self._compact = value 

225 if self._compact: 225 ↛ 229line 225 didn't jump to line 229 because the condition on line 225 was always true

226 self.setMinimumHeight(24) 

227 self.setMaximumHeight(32) 

228 else: 

229 self.setMinimumHeight(40) 

230 self.setMaximumHeight(60) 

231 self.updateGeometry() # Force layout update 

232 

233 # /////////////////////////////////////////////////////////////// 

234 # EVENT HANDLERS 

235 # /////////////////////////////////////////////////////////////// 

236 

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

238 """Handle mouse press events for drag start. 

239 

240 Args: 

241 event: The mouse event. 

242 """ 

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

244 self._drag_start_pos = event.position().toPoint() 

245 super().mousePressEvent(event) 

246 

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

248 """Handle mouse movement for drag & drop. 

249 

250 Args: 

251 event: The mouse event. 

252 """ 

253 if not (event.buttons() & Qt.MouseButton.LeftButton): 

254 return 

255 

256 if not self._is_dragging: 

257 if ( 

258 event.position().toPoint() - self._drag_start_pos 

259 ).manhattanLength() < 10: 

260 return 

261 

262 self._is_dragging = True 

263 self.setProperty("dragging", True) 

264 self.style().unpolish(self) 

265 self.style().polish(self) 

266 

267 # Create drag 

268 drag = QDrag(self) 

269 mime_data = QMimeData() 

270 mime_data.setText(self._item_id) 

271 drag.setMimeData(mime_data) 

272 

273 # Execute drag 

274 drag.exec(Qt.DropAction.MoveAction) 

275 

276 # Cleanup after drag 

277 self._is_dragging = False 

278 self.setProperty("dragging", False) 

279 self.style().unpolish(self) 

280 self.style().polish(self) 

281 

282 def mouseReleaseEvent(self, event: QMouseEvent) -> None: 

283 """Handle mouse release events for drag end. 

284 

285 Args: 

286 event: The mouse event. 

287 """ 

288 self._is_dragging = False 

289 self.setProperty("dragging", False) 

290 self.style().unpolish(self) 

291 self.style().polish(self) 

292 super().mouseReleaseEvent(event) 

293 

294 # /////////////////////////////////////////////////////////////// 

295 # OVERRIDE METHODS 

296 # /////////////////////////////////////////////////////////////// 

297 

298 def sizeHint(self) -> QSize: 

299 """Get the recommended size for the widget based on content. 

300 

301 Returns: 

302 The recommended size. 

303 """ 

304 # Get suggested size from HoverLabel 

305 content_size = self._content_widget.sizeHint() 

306 

307 # Add layout margins and padding 

308 layout = self.layout() 

309 if layout is None: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 return QSize(content_size.width(), content_size.height()) 

311 layout_margins = layout.contentsMargins() 

312 

313 # Calculate total width 

314 total_width = ( 

315 content_size.width() + layout_margins.left() + layout_margins.right() 

316 ) 

317 

318 # Calculate total height based on compact mode 

319 if self._compact: 

320 min_height = max( 

321 24, 

322 content_size.height() + layout_margins.top() + layout_margins.bottom(), 

323 ) 

324 max_height = 32 

325 else: 

326 min_height = max( 

327 40, 

328 content_size.height() + layout_margins.top() + layout_margins.bottom(), 

329 ) 

330 max_height = 60 

331 

332 return QSize(total_width, min(min_height, max_height)) 

333 

334 def minimumSizeHint(self) -> QSize: 

335 """Get the minimum size for the widget. 

336 

337 Returns: 

338 The minimum size hint. 

339 """ 

340 # Get minimum size from HoverLabel 

341 content_min_size = self._content_widget.minimumSizeHint() 

342 

343 # Add layout margins 

344 layout = self.layout() 

345 if layout is None: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 return QSize(content_min_size.width(), content_min_size.height()) 

347 layout_margins = layout.contentsMargins() 

348 

349 # Minimum width based on content + margins 

350 min_width = ( 

351 content_min_size.width() + layout_margins.left() + layout_margins.right() 

352 ) 

353 

354 # Minimum height based on compact mode 

355 min_height = 24 if self._compact else 40 

356 

357 return QSize(min_width, min_height) 

358 

359 # /////////////////////////////////////////////////////////////// 

360 # STYLE METHODS 

361 # /////////////////////////////////////////////////////////////// 

362 

363 def refreshStyle(self) -> None: 

364 """Refresh the widget's style. 

365 

366 Useful after dynamic stylesheet changes. 

367 """ 

368 self.style().unpolish(self) 

369 self.style().polish(self) 

370 self.update() 

371 

372 

373class DraggableList(QWidget): 

374 """List widget with reorderable items via drag & drop and removal. 

375 

376 This widget allows managing a list of items that users can reorder by 

377 drag & drop and remove individually. 

378 

379 Features: 

380 - List of items reorderable by drag & drop 

381 - Item removal via HoverLabel (red icon on hover) 

382 - Consistent interface with HoverLabel for all items 

383 - Signals for reordering and removal events 

384 - Smooth and intuitive interface 

385 - Appearance customization 

386 - Automatic item order management 

387 - Integrated removal icon in HoverLabel 

388 

389 Use cases: 

390 - Reorderable task list 

391 - Option selector with customizable order 

392 - File management interface 

393 - Priority-ordered element configuration 

394 

395 Args: 

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

397 items: Initial list of items (default: []). 

398 allow_drag_drop: Allow drag & drop for reordering (default: True). 

399 allow_remove: Allow item removal via HoverLabel (default: True). 

400 max_height: Maximum height of the widget (default: 300). 

401 min_width: Minimum width of the widget (default: 150). 

402 compact: Display items in compact mode (reduced height) (default: False). 

403 *args: Additional arguments passed to item widgets. 

404 **kwargs: Additional keyword arguments passed to item widgets. 

405 

406 Signals: 

407 itemMoved(str, int, int): Emitted when an item is moved 

408 (item_id, old_position, new_position). 

409 itemRemoved(str, int): Emitted when an item is removed 

410 (item_id, position). 

411 itemAdded(str, int): Emitted when an item is added 

412 (item_id, position). 

413 itemClicked(str): Emitted when an item is clicked (item_id). 

414 orderChanged(list): Emitted when the item order changes 

415 (new ordered list). 

416 

417 Example: 

418 >>> draggable_list = DraggableList( 

419 ... items=["Item 1", "Item 2", "Item 3"], 

420 ... icon="https://img.icons8.com/?size=100&id=8329&format=png&color=000000" 

421 ... ) 

422 >>> draggable_list.itemMoved.connect( 

423 ... lambda item_id, old_pos, new_pos: print(f"Moved {item_id} from {old_pos} to {new_pos}") 

424 ... ) 

425 >>> draggable_list.itemRemoved.connect( 

426 ... lambda item_id, pos: print(f"Removed {item_id} at {pos}") 

427 ... ) 

428 """ 

429 

430 itemMoved = Signal(str, int, int) # item_id, old_position, new_position 

431 itemRemoved = Signal(str, int) # item_id, position 

432 itemAdded = Signal(str, int) # item_id, position 

433 itemClicked = Signal(str) # item_id 

434 orderChanged = Signal(list) # new ordered list 

435 

436 # /////////////////////////////////////////////////////////////// 

437 # INIT 

438 # /////////////////////////////////////////////////////////////// 

439 

440 def __init__( 

441 self, 

442 parent: WidgetParent = None, 

443 items: list[str] | None = None, 

444 allow_drag_drop: bool = True, 

445 allow_remove: bool = True, 

446 max_height: int = 300, 

447 min_width: int = 150, 

448 compact: bool = False, 

449 *args: Any, # noqa: ARG002 

450 **kwargs: Any, 

451 ) -> None: 

452 """Initialize the draggable list.""" 

453 super().__init__(parent) 

454 self.setProperty("type", "DraggableList") 

455 

456 # Initialize attributes 

457 self._items: list[str] = items or [] 

458 self._allow_drag_drop: bool = allow_drag_drop 

459 self._allow_remove: bool = allow_remove 

460 self._max_height: int = max_height 

461 self._min_width: int = min_width 

462 self._compact: bool = compact 

463 self._item_widgets: dict[str, DraggableItem] = {} 

464 self._kwargs = kwargs 

465 self._icon_color = "grey" # Default icon color 

466 

467 # Configure widget 

468 self.setAcceptDrops(True) 

469 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

470 self.setMinimumWidth(min_width) 

471 self.setMaximumHeight(max_height) 

472 

473 # Main layout 

474 layout = QVBoxLayout(self) 

475 layout.setContentsMargins(8, 8, 8, 8) 

476 layout.setSpacing(4) 

477 

478 # Scroll area 

479 self._scroll_area = QScrollArea() 

480 self._scroll_area.setWidgetResizable(True) 

481 self._scroll_area.setHorizontalScrollBarPolicy( 

482 Qt.ScrollBarPolicy.ScrollBarAlwaysOff 

483 ) 

484 self._scroll_area.setVerticalScrollBarPolicy( 

485 Qt.ScrollBarPolicy.ScrollBarAsNeeded 

486 ) 

487 self._scroll_area.setFrameShape(QFrame.Shape.NoFrame) 

488 

489 # Container widget for items 

490 self._container_widget = QWidget() 

491 self._container_layout = QVBoxLayout(self._container_widget) 

492 self._container_layout.setContentsMargins(0, 0, 0, 0) 

493 self._container_layout.setSpacing(4) 

494 self._container_layout.addStretch() # Flexible space at the end 

495 

496 self._scroll_area.setWidget(self._container_widget) 

497 layout.addWidget(self._scroll_area) 

498 

499 # Initialize items 

500 self._create_items() 

501 

502 # /////////////////////////////////////////////////////////////// 

503 # PROPERTIES 

504 # /////////////////////////////////////////////////////////////// 

505 

506 @property 

507 def items(self) -> list[str]: 

508 """Get the list of items. 

509 

510 Returns: 

511 A copy of the current items list. 

512 """ 

513 return self._items.copy() 

514 

515 @items.setter 

516 def items(self, value: list[str]) -> None: 

517 """Set the list of items. 

518 

519 Args: 

520 value: The new items list. 

521 """ 

522 self._items = value.copy() 

523 self._create_items() 

524 

525 @property 

526 def item_count(self) -> int: 

527 """Get the number of items in the list. 

528 

529 Returns: 

530 The number of items (read-only). 

531 """ 

532 return len(self._items) 

533 

534 @property 

535 def allow_drag_drop(self) -> bool: 

536 """Get whether drag & drop is allowed. 

537 

538 Returns: 

539 True if drag & drop is allowed, False otherwise. 

540 """ 

541 return self._allow_drag_drop 

542 

543 @allow_drag_drop.setter 

544 def allow_drag_drop(self, value: bool) -> None: 

545 """Set whether drag & drop is allowed. 

546 

547 Args: 

548 value: Whether to allow drag & drop. 

549 """ 

550 self._allow_drag_drop = value 

551 

552 @property 

553 def allow_remove(self) -> bool: 

554 """Get whether item removal is allowed. 

555 

556 Returns: 

557 True if removal is allowed, False otherwise. 

558 """ 

559 return self._allow_remove 

560 

561 @allow_remove.setter 

562 def allow_remove(self, value: bool) -> None: 

563 """Set whether item removal is allowed. 

564 

565 Args: 

566 value: Whether to allow item removal. 

567 """ 

568 self._allow_remove = value 

569 for widget in self._item_widgets.values(): 

570 widget.content_widget.icon_enabled = value 

571 

572 @property 

573 def icon_color(self) -> str: 

574 """Get the icon color of the items. 

575 

576 Returns: 

577 The current icon color. 

578 """ 

579 return self._icon_color 

580 

581 @icon_color.setter 

582 def icon_color(self, value: str) -> None: 

583 """Set the icon color for all items. 

584 

585 Args: 

586 value: The new icon color. 

587 """ 

588 self._icon_color = value 

589 for widget in self._item_widgets.values(): 

590 widget.icon_color = value 

591 

592 @property 

593 def compact(self) -> bool: 

594 """Get the compact mode. 

595 

596 Returns: 

597 True if compact mode is enabled, False otherwise. 

598 """ 

599 return self._compact 

600 

601 @compact.setter 

602 def compact(self, value: bool) -> None: 

603 """Set the compact mode and update all items. 

604 

605 Args: 

606 value: Whether to enable compact mode. 

607 """ 

608 self._compact = value 

609 for widget in self._item_widgets.values(): 

610 widget.compact = value 

611 

612 @property 

613 def min_width(self) -> int: 

614 """Get the minimum width of the widget. 

615 

616 Returns: 

617 The minimum width. 

618 """ 

619 return self._min_width 

620 

621 @min_width.setter 

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

623 """Set the minimum width of the widget. 

624 

625 Args: 

626 value: The new minimum width. 

627 """ 

628 self._min_width = value 

629 self.updateGeometry() # Force layout update 

630 

631 # /////////////////////////////////////////////////////////////// 

632 # PUBLIC METHODS 

633 # /////////////////////////////////////////////////////////////// 

634 

635 def addItem(self, item_id: str, text: str | None = None) -> None: 

636 """Add an item to the list. 

637 

638 Args: 

639 item_id: Unique identifier for the item. 

640 text: Text to display (uses item_id if None). 

641 """ 

642 if item_id in self._items: 

643 return # Item already present 

644 

645 text = text or item_id 

646 self._items.append(item_id) 

647 

648 # Create widget 

649 item_widget = DraggableItem( 

650 item_id=item_id, text=text, compact=self._compact, **self._kwargs 

651 ) 

652 

653 # Connect signals 

654 item_widget.itemRemoved.connect(self._on_item_removed) 

655 

656 # Hide removal icon if necessary 

657 if not self._allow_remove: 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true

658 item_widget.content_widget.icon_enabled = False 

659 

660 # Add to layout (before stretch) 

661 self._container_layout.insertWidget(len(self._items) - 1, item_widget) 

662 self._item_widgets[item_id] = item_widget 

663 

664 # Emit signal 

665 self.itemAdded.emit(item_id, len(self._items) - 1) 

666 self.orderChanged.emit(self._items.copy()) 

667 

668 def removeItem(self, item_id: str) -> bool: 

669 """Remove an item from the list. 

670 

671 Args: 

672 item_id: Identifier of the item to remove. 

673 

674 Returns: 

675 True if the item was removed, False otherwise. 

676 """ 

677 if item_id not in self._items: 

678 return False 

679 

680 # Remove from list 

681 position = self._items.index(item_id) 

682 self._items.remove(item_id) 

683 

684 # Remove widget 

685 if item_id in self._item_widgets: 685 ↛ 692line 685 didn't jump to line 692 because the condition on line 685 was always true

686 widget = self._item_widgets[item_id] 

687 self._container_layout.removeWidget(widget) 

688 widget.deleteLater() 

689 del self._item_widgets[item_id] 

690 

691 # Emit signals 

692 self.itemRemoved.emit(item_id, position) 

693 self.orderChanged.emit(self._items.copy()) 

694 

695 return True 

696 

697 def clearItems(self) -> None: 

698 """Remove all items from the list.""" 

699 # Clean up widgets 

700 for widget in self._item_widgets.values(): 

701 self._container_layout.removeWidget(widget) 

702 widget.deleteLater() 

703 self._item_widgets.clear() 

704 

705 # Clear list 

706 self._items.clear() 

707 

708 # Emit signal 

709 self.orderChanged.emit([]) 

710 

711 def moveItem(self, item_id: str, new_position: int) -> bool: 

712 """Move an item to a new position. 

713 

714 Args: 

715 item_id: Identifier of the item to move. 

716 new_position: New position (0-based). 

717 

718 Returns: 

719 True if the item was moved, False otherwise. 

720 """ 

721 if item_id not in self._items: 

722 return False 

723 

724 old_position = self._items.index(item_id) 

725 if old_position == new_position: 

726 return True 

727 

728 # Move in list 

729 self._items.pop(old_position) 

730 self._items.insert(new_position, item_id) 

731 

732 # Move widget 

733 if item_id in self._item_widgets: 733 ↛ 739line 733 didn't jump to line 739 because the condition on line 733 was always true

734 widget = self._item_widgets[item_id] 

735 self._container_layout.removeWidget(widget) 

736 self._container_layout.insertWidget(new_position, widget) 

737 

738 # Emit signals 

739 self.itemMoved.emit(item_id, old_position, new_position) 

740 self.orderChanged.emit(self._items.copy()) 

741 

742 return True 

743 

744 def getItemPosition(self, item_id: str) -> int: 

745 """Get the position of an item. 

746 

747 Args: 

748 item_id: Identifier of the item. 

749 

750 Returns: 

751 Position of the item (-1 if not found). 

752 """ 

753 try: 

754 return self._items.index(item_id) 

755 except ValueError: 

756 return -1 

757 

758 # ------------------------------------------------ 

759 # PRIVATE METHODS 

760 # ------------------------------------------------ 

761 

762 def _create_items(self) -> None: 

763 """Create widgets for all items.""" 

764 # Clean up existing widgets 

765 for widget in self._item_widgets.values(): 

766 self._container_layout.removeWidget(widget) 

767 widget.deleteLater() 

768 self._item_widgets.clear() 

769 

770 # Create new widgets 

771 for i, item_id in enumerate(self._items): 

772 item_widget = DraggableItem( 

773 item_id=item_id, text=item_id, compact=self._compact, **self._kwargs 

774 ) 

775 

776 # Connect signals 

777 item_widget.itemRemoved.connect(self._on_item_removed) 

778 

779 # Hide removal icon if necessary 

780 if not self._allow_remove: 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true

781 item_widget.content_widget.icon_enabled = False 

782 

783 # Add to layout 

784 self._container_layout.insertWidget(i, item_widget) 

785 self._item_widgets[item_id] = item_widget 

786 

787 def _on_item_removed(self, item_id: str) -> None: 

788 """Handle item removal.""" 

789 self.removeItem(item_id) 

790 

791 def _calculate_drop_position(self, drop_pos: QPoint) -> int: 

792 """Calculate drop position based on coordinates. 

793 

794 Args: 

795 drop_pos: Drop position coordinates. 

796 

797 Returns: 

798 The calculated drop position index. 

799 """ 

800 # Convert global coordinates to container local coordinates 

801 local_pos = self._container_widget.mapFrom(self, drop_pos) 

802 

803 # Find position in layout 

804 for i in range(self._container_layout.count() - 1): # -1 to exclude stretch 

805 item = self._container_layout.itemAt(i) 

806 widget = item.widget() if item else None 

807 if widget is not None: 807 ↛ 804line 807 didn't jump to line 804 because the condition on line 807 was always true

808 widget_rect = widget.geometry() 

809 if local_pos.y() < widget_rect.center().y(): 

810 return i 

811 

812 return len(self._items) - 1 

813 

814 # /////////////////////////////////////////////////////////////// 

815 # EVENT HANDLERS 

816 # /////////////////////////////////////////////////////////////// 

817 

818 def dragEnterEvent(self, event: QDragEnterEvent) -> None: 

819 """Handle drag enter events. 

820 

821 Args: 

822 event: The drag enter event. 

823 """ 

824 if self._allow_drag_drop and event.mimeData().hasText(): 

825 event.acceptProposedAction() 

826 

827 def dragMoveEvent(self, event: QDragMoveEvent) -> None: 

828 """Handle drag move events. 

829 

830 Args: 

831 event: The drag move event. 

832 """ 

833 if self._allow_drag_drop and event.mimeData().hasText(): 

834 event.acceptProposedAction() 

835 

836 def dropEvent(self, event: QDropEvent) -> None: 

837 """Handle drop events. 

838 

839 Args: 

840 event: The drop event. 

841 """ 

842 if not self._allow_drag_drop: 842 ↛ 843line 842 didn't jump to line 843 because the condition on line 842 was never true

843 return 

844 

845 item_id = event.mimeData().text() 

846 if item_id not in self._items: 

847 return 

848 

849 # Calculate new position 

850 drop_pos = event.position().toPoint() 

851 new_position = self._calculate_drop_position(drop_pos) 

852 

853 # Move item 

854 self.moveItem(item_id, new_position) 

855 

856 event.acceptProposedAction() 

857 

858 # /////////////////////////////////////////////////////////////// 

859 # OVERRIDE METHODS 

860 # /////////////////////////////////////////////////////////////// 

861 

862 def sizeHint(self) -> QSize: 

863 """Get the recommended size for the widget based on content. 

864 

865 Returns: 

866 The recommended size. 

867 """ 

868 # Calculate maximum width of items 

869 max_item_width = 0 

870 

871 if self._item_widgets: 871 ↛ 879line 871 didn't jump to line 879 because the condition on line 871 was always true

872 # Get maximum width of existing items 

873 item_widths = [ 

874 widget.sizeHint().width() for widget in self._item_widgets.values() 

875 ] 

876 max_item_width = max(item_widths) if item_widths else 0 

877 

878 # Use minimum width only if necessary 

879 if max_item_width < self._min_width: 879 ↛ 883line 879 didn't jump to line 883 because the condition on line 879 was always true

880 max_item_width = self._min_width 

881 

882 # Add main widget margins 

883 margins = self.contentsMargins() 

884 total_width = max_item_width + margins.left() + margins.right() 

885 

886 # Calculate height based on number of items 

887 item_height = 50 # Approximate item height 

888 spacing = 4 # Spacing between items 

889 total_items_height = len(self._item_widgets) * (item_height + spacing) 

890 

891 # Add margins and limit to maximum height 

892 total_height = min( 

893 total_items_height + margins.top() + margins.bottom(), self._max_height 

894 ) 

895 

896 return QSize(total_width, max(200, total_height)) 

897 

898 def minimumSizeHint(self) -> QSize: 

899 """Get the minimum size for the widget. 

900 

901 Returns: 

902 The minimum size hint. 

903 """ 

904 # Minimum width based on items or configured minimum width 

905 min_width = 0 

906 

907 if self._item_widgets: 907 ↛ 916line 907 didn't jump to line 916 because the condition on line 907 was always true

908 # Get minimum width of existing items 

909 item_min_widths = [ 

910 widget.minimumSizeHint().width() 

911 for widget in self._item_widgets.values() 

912 ] 

913 min_width = max(item_min_widths) if item_min_widths else 0 

914 

915 # Use minimum width only if necessary 

916 if min_width < self._min_width: 916 ↛ 920line 916 didn't jump to line 920 because the condition on line 916 was always true

917 min_width = self._min_width 

918 

919 # Add margins 

920 margins = self.contentsMargins() 

921 total_width = min_width + margins.left() + margins.right() 

922 

923 # Minimum height based on at least one item 

924 item_min_height = 40 # Minimum item height 

925 spacing = 4 # Spacing 

926 min_height = item_min_height + spacing + margins.top() + margins.bottom() 

927 

928 return QSize(total_width, min_height) 

929 

930 # /////////////////////////////////////////////////////////////// 

931 # STYLE METHODS 

932 # /////////////////////////////////////////////////////////////// 

933 

934 def refreshStyle(self) -> None: 

935 """Refresh the widget's style. 

936 

937 Useful after dynamic stylesheet changes. 

938 """ 

939 self.style().unpolish(self) 

940 self.style().polish(self) 

941 self.update() 

942 

943 

944# /////////////////////////////////////////////////////////////// 

945# PUBLIC API 

946# /////////////////////////////////////////////////////////////// 

947 

948__all__ = ["DraggableItem", "DraggableList"]