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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Draggable list widget module.
9Provides a list widget with draggable and reorderable items for PySide6
10applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19from typing import Any
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)
39from ...types import IconSourceExtended, WidgetParent
41# Local imports
42from ..label.hover_label import HoverLabel
44# ///////////////////////////////////////////////////////////////
45# CLASSES
46# ///////////////////////////////////////////////////////////////
49class DraggableItem(QFrame):
50 """Draggable item widget for DraggableList.
52 This item can be moved by drag & drop and always contains a HoverLabel
53 for a consistent interface.
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.
63 Signals:
64 itemClicked(str): Emitted when the item is clicked.
65 itemRemoved(str): Emitted when the item is removed.
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 """
75 itemClicked = Signal(str)
76 itemRemoved = Signal(str)
78 # ///////////////////////////////////////////////////////////////
79 # INIT
80 # ///////////////////////////////////////////////////////////////
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")
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
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)
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)
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)
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"
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
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)
141 # Icon color property
142 self._icon_color = "grey"
143 # Apply initial color
144 self._content_widget.icon_color = self._icon_color
146 # Add widget to layout (takes full width)
147 layout.addWidget(self._content_widget)
149 # ------------------------------------------------
150 # PRIVATE METHODS
151 # ------------------------------------------------
153 def _on_remove_clicked(self) -> None:
154 """Handle click on removal icon."""
155 self.itemRemoved.emit(self._item_id)
157 # ///////////////////////////////////////////////////////////////
158 # PROPERTIES
159 # ///////////////////////////////////////////////////////////////
161 @property
162 def item_id(self) -> str:
163 """Get the item identifier.
165 Returns:
166 The unique identifier of the item.
167 """
168 return self._item_id
170 @property
171 def text(self) -> str:
172 """Get the item text.
174 Returns:
175 The display text of the item.
176 """
177 return self._text
179 @property
180 def content_widget(self) -> HoverLabel:
181 """Get the inner HoverLabel widget.
183 Returns:
184 The HoverLabel used for display and interaction.
185 """
186 return self._content_widget
188 @property
189 def icon_color(self) -> str:
190 """Get the icon color of the HoverLabel.
192 Returns:
193 The current icon color.
194 """
195 return self._icon_color
197 @icon_color.setter
198 def icon_color(self, value: str) -> None:
199 """Set the icon color of the HoverLabel.
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
208 @property
209 def compact(self) -> bool:
210 """Get the compact mode.
212 Returns:
213 True if compact mode is enabled, False otherwise.
214 """
215 return self._compact
217 @compact.setter
218 def compact(self, value: bool) -> None:
219 """Set the compact mode and adjust height.
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
233 # ///////////////////////////////////////////////////////////////
234 # EVENT HANDLERS
235 # ///////////////////////////////////////////////////////////////
237 def mousePressEvent(self, event: QMouseEvent) -> None:
238 """Handle mouse press events for drag start.
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)
247 def mouseMoveEvent(self, event: QMouseEvent) -> None:
248 """Handle mouse movement for drag & drop.
250 Args:
251 event: The mouse event.
252 """
253 if not (event.buttons() & Qt.MouseButton.LeftButton):
254 return
256 if not self._is_dragging:
257 if (
258 event.position().toPoint() - self._drag_start_pos
259 ).manhattanLength() < 10:
260 return
262 self._is_dragging = True
263 self.setProperty("dragging", True)
264 self.style().unpolish(self)
265 self.style().polish(self)
267 # Create drag
268 drag = QDrag(self)
269 mime_data = QMimeData()
270 mime_data.setText(self._item_id)
271 drag.setMimeData(mime_data)
273 # Execute drag
274 drag.exec(Qt.DropAction.MoveAction)
276 # Cleanup after drag
277 self._is_dragging = False
278 self.setProperty("dragging", False)
279 self.style().unpolish(self)
280 self.style().polish(self)
282 def mouseReleaseEvent(self, event: QMouseEvent) -> None:
283 """Handle mouse release events for drag end.
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)
294 # ///////////////////////////////////////////////////////////////
295 # OVERRIDE METHODS
296 # ///////////////////////////////////////////////////////////////
298 def sizeHint(self) -> QSize:
299 """Get the recommended size for the widget based on content.
301 Returns:
302 The recommended size.
303 """
304 # Get suggested size from HoverLabel
305 content_size = self._content_widget.sizeHint()
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()
313 # Calculate total width
314 total_width = (
315 content_size.width() + layout_margins.left() + layout_margins.right()
316 )
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
332 return QSize(total_width, min(min_height, max_height))
334 def minimumSizeHint(self) -> QSize:
335 """Get the minimum size for the widget.
337 Returns:
338 The minimum size hint.
339 """
340 # Get minimum size from HoverLabel
341 content_min_size = self._content_widget.minimumSizeHint()
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()
349 # Minimum width based on content + margins
350 min_width = (
351 content_min_size.width() + layout_margins.left() + layout_margins.right()
352 )
354 # Minimum height based on compact mode
355 min_height = 24 if self._compact else 40
357 return QSize(min_width, min_height)
359 # ///////////////////////////////////////////////////////////////
360 # STYLE METHODS
361 # ///////////////////////////////////////////////////////////////
363 def refreshStyle(self) -> None:
364 """Refresh the widget's style.
366 Useful after dynamic stylesheet changes.
367 """
368 self.style().unpolish(self)
369 self.style().polish(self)
370 self.update()
373class DraggableList(QWidget):
374 """List widget with reorderable items via drag & drop and removal.
376 This widget allows managing a list of items that users can reorder by
377 drag & drop and remove individually.
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
389 Use cases:
390 - Reorderable task list
391 - Option selector with customizable order
392 - File management interface
393 - Priority-ordered element configuration
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.
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).
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 """
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
436 # ///////////////////////////////////////////////////////////////
437 # INIT
438 # ///////////////////////////////////////////////////////////////
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")
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
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)
473 # Main layout
474 layout = QVBoxLayout(self)
475 layout.setContentsMargins(8, 8, 8, 8)
476 layout.setSpacing(4)
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)
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
496 self._scroll_area.setWidget(self._container_widget)
497 layout.addWidget(self._scroll_area)
499 # Initialize items
500 self._create_items()
502 # ///////////////////////////////////////////////////////////////
503 # PROPERTIES
504 # ///////////////////////////////////////////////////////////////
506 @property
507 def items(self) -> list[str]:
508 """Get the list of items.
510 Returns:
511 A copy of the current items list.
512 """
513 return self._items.copy()
515 @items.setter
516 def items(self, value: list[str]) -> None:
517 """Set the list of items.
519 Args:
520 value: The new items list.
521 """
522 self._items = value.copy()
523 self._create_items()
525 @property
526 def item_count(self) -> int:
527 """Get the number of items in the list.
529 Returns:
530 The number of items (read-only).
531 """
532 return len(self._items)
534 @property
535 def allow_drag_drop(self) -> bool:
536 """Get whether drag & drop is allowed.
538 Returns:
539 True if drag & drop is allowed, False otherwise.
540 """
541 return self._allow_drag_drop
543 @allow_drag_drop.setter
544 def allow_drag_drop(self, value: bool) -> None:
545 """Set whether drag & drop is allowed.
547 Args:
548 value: Whether to allow drag & drop.
549 """
550 self._allow_drag_drop = value
552 @property
553 def allow_remove(self) -> bool:
554 """Get whether item removal is allowed.
556 Returns:
557 True if removal is allowed, False otherwise.
558 """
559 return self._allow_remove
561 @allow_remove.setter
562 def allow_remove(self, value: bool) -> None:
563 """Set whether item removal is allowed.
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
572 @property
573 def icon_color(self) -> str:
574 """Get the icon color of the items.
576 Returns:
577 The current icon color.
578 """
579 return self._icon_color
581 @icon_color.setter
582 def icon_color(self, value: str) -> None:
583 """Set the icon color for all items.
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
592 @property
593 def compact(self) -> bool:
594 """Get the compact mode.
596 Returns:
597 True if compact mode is enabled, False otherwise.
598 """
599 return self._compact
601 @compact.setter
602 def compact(self, value: bool) -> None:
603 """Set the compact mode and update all items.
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
612 @property
613 def min_width(self) -> int:
614 """Get the minimum width of the widget.
616 Returns:
617 The minimum width.
618 """
619 return self._min_width
621 @min_width.setter
622 def min_width(self, value: int) -> None:
623 """Set the minimum width of the widget.
625 Args:
626 value: The new minimum width.
627 """
628 self._min_width = value
629 self.updateGeometry() # Force layout update
631 # ///////////////////////////////////////////////////////////////
632 # PUBLIC METHODS
633 # ///////////////////////////////////////////////////////////////
635 def addItem(self, item_id: str, text: str | None = None) -> None:
636 """Add an item to the list.
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
645 text = text or item_id
646 self._items.append(item_id)
648 # Create widget
649 item_widget = DraggableItem(
650 item_id=item_id, text=text, compact=self._compact, **self._kwargs
651 )
653 # Connect signals
654 item_widget.itemRemoved.connect(self._on_item_removed)
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
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
664 # Emit signal
665 self.itemAdded.emit(item_id, len(self._items) - 1)
666 self.orderChanged.emit(self._items.copy())
668 def removeItem(self, item_id: str) -> bool:
669 """Remove an item from the list.
671 Args:
672 item_id: Identifier of the item to remove.
674 Returns:
675 True if the item was removed, False otherwise.
676 """
677 if item_id not in self._items:
678 return False
680 # Remove from list
681 position = self._items.index(item_id)
682 self._items.remove(item_id)
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]
691 # Emit signals
692 self.itemRemoved.emit(item_id, position)
693 self.orderChanged.emit(self._items.copy())
695 return True
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()
705 # Clear list
706 self._items.clear()
708 # Emit signal
709 self.orderChanged.emit([])
711 def moveItem(self, item_id: str, new_position: int) -> bool:
712 """Move an item to a new position.
714 Args:
715 item_id: Identifier of the item to move.
716 new_position: New position (0-based).
718 Returns:
719 True if the item was moved, False otherwise.
720 """
721 if item_id not in self._items:
722 return False
724 old_position = self._items.index(item_id)
725 if old_position == new_position:
726 return True
728 # Move in list
729 self._items.pop(old_position)
730 self._items.insert(new_position, item_id)
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)
738 # Emit signals
739 self.itemMoved.emit(item_id, old_position, new_position)
740 self.orderChanged.emit(self._items.copy())
742 return True
744 def getItemPosition(self, item_id: str) -> int:
745 """Get the position of an item.
747 Args:
748 item_id: Identifier of the item.
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
758 # ------------------------------------------------
759 # PRIVATE METHODS
760 # ------------------------------------------------
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()
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 )
776 # Connect signals
777 item_widget.itemRemoved.connect(self._on_item_removed)
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
783 # Add to layout
784 self._container_layout.insertWidget(i, item_widget)
785 self._item_widgets[item_id] = item_widget
787 def _on_item_removed(self, item_id: str) -> None:
788 """Handle item removal."""
789 self.removeItem(item_id)
791 def _calculate_drop_position(self, drop_pos: QPoint) -> int:
792 """Calculate drop position based on coordinates.
794 Args:
795 drop_pos: Drop position coordinates.
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)
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
812 return len(self._items) - 1
814 # ///////////////////////////////////////////////////////////////
815 # EVENT HANDLERS
816 # ///////////////////////////////////////////////////////////////
818 def dragEnterEvent(self, event: QDragEnterEvent) -> None:
819 """Handle drag enter events.
821 Args:
822 event: The drag enter event.
823 """
824 if self._allow_drag_drop and event.mimeData().hasText():
825 event.acceptProposedAction()
827 def dragMoveEvent(self, event: QDragMoveEvent) -> None:
828 """Handle drag move events.
830 Args:
831 event: The drag move event.
832 """
833 if self._allow_drag_drop and event.mimeData().hasText():
834 event.acceptProposedAction()
836 def dropEvent(self, event: QDropEvent) -> None:
837 """Handle drop events.
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
845 item_id = event.mimeData().text()
846 if item_id not in self._items:
847 return
849 # Calculate new position
850 drop_pos = event.position().toPoint()
851 new_position = self._calculate_drop_position(drop_pos)
853 # Move item
854 self.moveItem(item_id, new_position)
856 event.acceptProposedAction()
858 # ///////////////////////////////////////////////////////////////
859 # OVERRIDE METHODS
860 # ///////////////////////////////////////////////////////////////
862 def sizeHint(self) -> QSize:
863 """Get the recommended size for the widget based on content.
865 Returns:
866 The recommended size.
867 """
868 # Calculate maximum width of items
869 max_item_width = 0
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
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
882 # Add main widget margins
883 margins = self.contentsMargins()
884 total_width = max_item_width + margins.left() + margins.right()
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)
891 # Add margins and limit to maximum height
892 total_height = min(
893 total_items_height + margins.top() + margins.bottom(), self._max_height
894 )
896 return QSize(total_width, max(200, total_height))
898 def minimumSizeHint(self) -> QSize:
899 """Get the minimum size for the widget.
901 Returns:
902 The minimum size hint.
903 """
904 # Minimum width based on items or configured minimum width
905 min_width = 0
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
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
919 # Add margins
920 margins = self.contentsMargins()
921 total_width = min_width + margins.left() + margins.right()
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()
928 return QSize(total_width, min_height)
930 # ///////////////////////////////////////////////////////////////
931 # STYLE METHODS
932 # ///////////////////////////////////////////////////////////////
934 def refreshStyle(self) -> None:
935 """Refresh the widget's style.
937 Useful after dynamic stylesheet changes.
938 """
939 self.style().unpolish(self)
940 self.style().polish(self)
941 self.update()
944# ///////////////////////////////////////////////////////////////
945# PUBLIC API
946# ///////////////////////////////////////////////////////////////
948__all__ = ["DraggableItem", "DraggableList"]