Open louies0623 opened 12 months ago
Thanks for testing @louies0623. Yes, this is a known issue. This is what happens:
~/Desktop
). In this case, the new coordinates are not accepted and the icon jumps backI don't know yet why this happens and how to solve it.
Related? Video by @hijarian:
I can reproduce the issue with a minimal Python example:
#!/usr/bin/env python3
import sys
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QStandardItem, QStandardItemModel, QIcon
from PyQt5.QtWidgets import QApplication, QMainWindow, QListView
def main():
app = QApplication(sys.argv)
main_window = QMainWindow()
main_window.setWindowTitle("List View Example")
main_window.setGeometry(100, 100, 800, 400)
list_view = QListView()
list_view.setViewMode(QListView.IconMode)
list_view.setGridSize(QSize(200, 70))
list_view.setLayoutDirection(Qt.RightToLeft) # This seems to be the culprit
# list_view.setFlow(QListView.TopToBottom)
list_view.setMovement(QListView.Free)
list_view.setSelectionMode(QListView.ExtendedSelection)
list_view.setResizeMode(QListView.Fixed)
list_view.setDragEnabled(True)
list_view.setAcceptDrops(True)
list_view.setDragDropMode(QListView.InternalMove)
model = QStandardItemModel()
list_view.setModel(model)
icon_names = ["applications-internet", "applications-multimedia", "applications-office",
"applications-system", "applications-utilities"]
for i, icon_name in enumerate(icon_names):
item = QStandardItem(f"Item {i + 1}")
item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled)
item.setIcon(QIcon.fromTheme(icon_name))
model.setItem(i, 0, item)
main_window.setCentralWidget(list_view)
main_window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
It seems like list_view.setLayoutDirection(Qt.RightToLeft)
is causing the problem.
TODO: Check if this has the issue also when running e.g., on Linux.
Here is a bit more elaborate test program.
#!/usr/bin/env python3
import sys
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QStandardItem, QStandardItemModel, QIcon
from PyQt5.QtWidgets import QApplication, QMainWindow, QListView, QActionGroup
def print_items(list_view):
"""
Print the coordinates of the items in the list view.
"""
print("Item coordinates: ")
for i in range(list_view.model().rowCount()):
item = list_view.model().item(i)
print(f"{i}: {item.text()} {item.icon().name()} has {list_view.visualRect(item.index())}")
def main():
app = QApplication(sys.argv)
main_window = QMainWindow()
main_window.setWindowTitle("List View Example")
main_window.setGeometry(100, 100, 800, 400)
menu_bar = main_window.menuBar()
file_menu = menu_bar.addMenu("&File")
file_menu.addAction("Close", main_window.close, "Ctrl+W")
file_menu.addSeparator()
file_menu.addAction("Quit", app.quit, "Ctrl+Q")
# Direction menu, with "Left to Right" and "Right to Left" actions, out of which only one can be checked at a time
direction_menu = menu_bar.addMenu("&Direction")
direction_group = QActionGroup(main_window)
direction_group.setExclusive(True)
left_to_right_action = direction_menu.addAction("Left to Right")
left_to_right_action.setCheckable(True)
left_to_right_action.triggered.connect(lambda: list_view.setLayoutDirection(Qt.LeftToRight))
direction_group.addAction(left_to_right_action)
right_to_left_action = direction_menu.addAction("Right to Left")
right_to_left_action.setCheckable(True)
right_to_left_action.setChecked(True)
right_to_left_action.triggered.connect(lambda: list_view.setLayoutDirection(Qt.RightToLeft))
direction_group.addAction(right_to_left_action)
# Flow menu, with "Horizontal" and "Vertical" actions, out of which only one can be checked at a time
flow_menu = menu_bar.addMenu("&Flow")
flow_group = QActionGroup(main_window)
flow_group.setExclusive(True)
horizontal_action = flow_menu.addAction("Horizontal")
horizontal_action.setCheckable(True)
horizontal_action.triggered.connect(lambda: list_view.setFlow(QListView.LeftToRight))
flow_group.addAction(horizontal_action)
vertical_action = flow_menu.addAction("Vertical")
vertical_action.setCheckable(True)
vertical_action.setChecked(True)
vertical_action.triggered.connect(lambda: list_view.setFlow(QListView.TopToBottom))
flow_group.addAction(vertical_action)
# Movement menu, with "Free" and "Snap" actions, out of which only one can be checked at a time
movement_menu = menu_bar.addMenu("&Movement")
movement_group = QActionGroup(main_window)
movement_group.setExclusive(True)
free_action = movement_menu.addAction("QListView.Free")
free_action.setCheckable(True)
free_action.setChecked(True)
free_action.triggered.connect(lambda: list_view.setMovement(QListView.Free))
movement_group.addAction(free_action)
snap_action = movement_menu.addAction("QListView.Snap")
snap_action.setCheckable(True)
snap_action.triggered.connect(lambda: list_view.setMovement(QListView.Snap))
movement_group.addAction(snap_action)
# Resize menu, with "Adjust" and "Fixed" actions, out of which only one can be checked at a time
resize_menu = menu_bar.addMenu("&Resize")
resize_group = QActionGroup(main_window)
resize_group.setExclusive(True)
adjust_action = resize_menu.addAction("QListView.Adjust")
adjust_action.setCheckable(True)
adjust_action.triggered.connect(lambda: list_view.setResizeMode(QListView.Adjust))
resize_group.addAction(adjust_action)
fixed_action = resize_menu.addAction("QListView.Fixed")
fixed_action.setCheckable(True)
fixed_action.setChecked(True)
fixed_action.triggered.connect(lambda: list_view.setResizeMode(QListView.Fixed))
resize_group.addAction(fixed_action)
resize_menu.addSeparator()
# Trigger layout now
layout_action = resize_menu.addAction("scheduleDelayedItemsLayout")
layout_action.triggered.connect(lambda: list_view.scheduleDelayedItemsLayout())
# Mode menu, with "QListView.InternalMove" and the other modes, out of which only one can be checked at a time
mode_menu = menu_bar.addMenu("&Mode")
mode_group = QActionGroup(main_window)
mode_group.setExclusive(True)
internal_move_action = mode_menu.addAction("QListView.InternalMove")
internal_move_action.setCheckable(True)
internal_move_action.setChecked(True)
internal_move_action.triggered.connect(lambda: list_view.setDragDropMode(QListView.InternalMove))
mode_group.addAction(internal_move_action)
no_drag_drop_action = mode_menu.addAction("QListView.NoDragDrop")
no_drag_drop_action.setCheckable(True)
no_drag_drop_action.triggered.connect(lambda: list_view.setDragDropMode(QListView.NoDragDrop))
mode_group.addAction(no_drag_drop_action)
drag_only_action = mode_menu.addAction("QListView.DragOnly")
drag_only_action.setCheckable(True)
drag_only_action.triggered.connect(lambda: list_view.setDragDropMode(QListView.DragOnly))
mode_group.addAction(drag_only_action)
drop_only_action = mode_menu.addAction("QListView.DropOnly")
drop_only_action.setCheckable(True)
drop_only_action.triggered.connect(lambda: list_view.setDragDropMode(QListView.DropOnly))
mode_group.addAction(drop_only_action)
drag_drop_action = mode_menu.addAction("QListView.DragDrop (Problematic)")
drag_drop_action.setCheckable(True)
drag_drop_action.triggered.connect(lambda: list_view.setDragDropMode(QListView.DragDrop))
mode_group.addAction(drag_drop_action)
about_menu = menu_bar.addMenu("&About")
about_menu.addAction("About Qt", app.aboutQt, "Ctrl+?")
list_view = QListView()
list_view.setViewMode(QListView.IconMode)
list_view.setGridSize(QSize(200, 70))
# Set the layout direction to top to bottom and right to left
# list_view.setFlow(QListView.TopToBottom)
list_view.setLayoutDirection(Qt.RightToLeft) # THIS SEEMS TO BE THE CULPRIT?
# Free movement of items
list_view.setMovement(QListView.Free)
# Allow multiple selections
list_view.setSelectionMode(QListView.ExtendedSelection)
# When the window size changes, the items are automatically rearranged;
# this results in icons moved to custom positions being reset to their
# original positions.
# list_view.setResizeMode(QListView.Adjust)
list_view.setResizeMode(QListView.Fixed)
# Allow drag and drop. NOTE: Can move items also without the following,
# but with the following it seems less buggy (albeit still buggy).
list_view.setDragEnabled(True)
list_view.setAcceptDrops(True)
list_view.setDragDropMode(QListView.InternalMove)
# Create a QStandardItemModel to manage items
model = QStandardItemModel()
list_view.setModel(model)
# Add items to the model with system icons
icon_names = ["applications-internet", "applications-multimedia", "applications-office",
"applications-system", "applications-utilities",
"applications-internet", "applications-multimedia", "applications-office",
"applications-system", "applications-utilities",
"applications-internet", "applications-multimedia", "applications-office",
"applications-system", "applications-utilities"
] # Replace with actual icon names
for i, icon_name in enumerate(icon_names):
item = QStandardItem(f"Item {i + 1} with a long name")
item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled)
# Set the item's icon using the QIcon class and the icon name
item.setIcon(QIcon.fromTheme(icon_name))
model.setItem(i, 0, item) # Set the item at a specific row
main_window.setCentralWidget(list_view)
# When application quits, the coordinates of the items are printed
app.aboutToQuit.connect(lambda: print_items(list_view))
# After a drop event has been handled, the coordinates of the items are printed
original_drop_event = list_view.dropEvent
def drop_event(event):
original_drop_event(event)
print_items(list_view)
list_view.dropEvent = drop_event
main_window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Even on Windows 11 with PyQt6_Qt6-6.7.1-py3-none-win_amd64
the issue persists; as soon as list_view.setLayoutDirection(Qt.LayoutDirection.RightToLeft)
is used then the icons cannot be moved to certain locations.
Additionally on Windows the icon disappears when being dragged.
#!/usr/bin/env python3
import sys
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QStandardItem, QStandardItemModel, QIcon
from PyQt6.QtWidgets import QApplication, QMainWindow, QListView
def main():
app = QApplication(sys.argv)
main_window = QMainWindow()
main_window.setWindowTitle("List View Example")
main_window.setGeometry(100, 100, 800, 400)
list_view = QListView()
list_view.setViewMode(QListView.ViewMode.IconMode)
list_view.setGridSize(QSize(200, 70))
list_view.setLayoutDirection(Qt.LayoutDirection.RightToLeft) # Remove or comment this line
list_view.setMovement(QListView.Movement.Free)
list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection)
list_view.setResizeMode(QListView.ResizeMode.Fixed)
list_view.setDragEnabled(True)
list_view.setAcceptDrops(True)
list_view.setDragDropMode(QListView.DragDropMode.InternalMove)
model = QStandardItemModel()
list_view.setModel(model)
icon_names = ["applications-internet", "applications-internet", "applications-internet",
"applications-internet", "applications-internet"]
for i, icon_name in enumerate(icon_names):
item = QStandardItem(f"Item {i + 1}")
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled)
item.setIcon(QIcon.fromTheme(icon_name))
model.setItem(i, 0, item)
main_window.setCentralWidget(list_view)
main_window.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
At this point I suspect that it is a bug in Qt 5 and 6.
https://github.com/probonopd/Filer/assets/2480569/4892e852-46c6-4abc-afc5-074f14bd99dc
I can't help with PyQt6 or Windows, but I tested a C++ version of the last testcase posted here (translated from python to C++ by ChatGPT, please check) and it appears to work fine for me on Linux with Qt 6.7.2. http://www.kdab.com/~dfaure/2024/probonopd-filer.cpp
Thank you very much @dfaure-kdab. Using libqt6gui6 6.7.0-0xneon+22.04+jammy+release+build99
on KDE neon user edition and your C code, I still get strange behavior: Depending on which coordinates I move the icons to, the icon gets either moved there (as intended) or duplicated (unintended). As a result, moving around icons results in having many more icons at the end than in the beginning. I am not pressing any modifier keys on the keyboard.
I will need to find a way to get 6.7.2 running and retest then.
Does this still happen if you add
listView->setDefaultDropAction(Qt::MoveAction);
?
While I couldn't reproduce your bug, I was seeing a "+" sign during the DnD operation, and this fixes that, at least. It might also fix the copying that you're seeing ;)
In the Python version on Windows, doing this still prevents me from moving icons to certain positions. Will re-test with C++ on Linux soon.
Maybe we need to drop QListView altogether and implement it in a completely custom way. Something like this which I have tested on Windows 11 so far;
#!/usr/bin/env python3
import sys
import os
import json
from PyQt6.QtCore import Qt, QPoint, QSize, QDir, QRect, QMimeData, QUrl
from PyQt6.QtGui import QFontMetrics, QPainter, QPen, QAction, QDrag
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QScrollArea, QLabel, QSizePolicy, QFileIconProvider, QMenuBar, QHBoxLayout
class SpatialFiler(QWidget):
def __init__(self, path=None):
super().__init__()
self.path = path if path else QDir.homePath()
self.setWindowTitle(f"Spatial Filer - {self.path}")
self.setGeometry(100, 100, 800, 600)
self.setAcceptDrops(True)
# There might be a file .DS_Spatial in the directory that contains the window position and size. If it exists, read the settings from it.
# Example file content:
# {"position": {"x": 499, "y": 242}, "size": {"width": 800, "height": 600}, "items": [{"name": "known_hosts", "x": 110, "y": 0}, {"name": "known_hosts.old", "x": 220, "y": 0}]}
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.path.exists(settings_file):
with open(settings_file, "r") as file:
try:
settings = json.load(file)
# Check if there is a position for the window in the settings file; if yes, set the window position
if "position" in settings:
self.move(settings["position"]["x"], settings["position"]["y"])
# Check if there is a size for the window in the settings file; if yes, set the window size
if "size" in settings:
self.resize(settings["size"]["width"], settings["size"]["height"])
# Check if the window is out of the screen; if yes, move it to the top-left corner
if self.x() < 0 or self.y() < 0:
self.move(0, 0)
except json.JSONDecodeError as e:
print(f"Error reading settings file: {e}")
# Create the menu bar
self.menu_bar = QMenuBar(self)
self.layout = QVBoxLayout(self)
self.layout.setMenuBar(self.menu_bar)
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.container = QWidget()
self.scroll_area.setWidget(self.container)
self.layout.addWidget(self.scroll_area)
self.layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.layout)
# Initialize menu bar items
self.init_menu_bar()
# Initialize other components
self.files = []
self.vertical_spacing = 5
self.line_height = 80
self.horizontal_spacing = 10
self.start_x = 0
self.start_y = 0
self.populate_items()
self.dragging = False
self.last_pos = QPoint(0, 0)
self.selected_files = []
self.selection_rect = QRect(0, 0, 0, 0)
self.is_selecting = False
def init_menu_bar(self):
# File Menu
file_menu = self.menu_bar.addMenu("File")
self.open_action = QAction("Open", self)
self.open_action.triggered.connect(self.open_selected_items)
self.open_action.setEnabled(False)
close_action = QAction("Close", self)
close_action.triggered.connect(self.close)
file_menu.addAction(self.open_action)
file_menu.addSeparator()
file_menu.addAction(close_action)
# Edit Menu
edit_menu = self.menu_bar.addMenu("Edit")
self.cut_action = QAction("Cut", self)
self.copy_action = QAction("Copy", self)
self.paste_action = QAction("Paste", self)
self.delete_action = QAction("Delete", self)
edit_menu.addAction(self.cut_action)
edit_menu.addAction(self.copy_action)
edit_menu.addAction(self.paste_action)
edit_menu.addSeparator()
edit_menu.addAction(self.delete_action)
for action in [self.cut_action, self.copy_action, self.paste_action, self.delete_action]:
action.setEnabled(False)
# Help Menu
help_menu = self.menu_bar.addMenu("Help")
about_action = QAction("About", self)
help_action = QAction("Help", self)
help_menu.addAction(about_action)
help_menu.addAction(help_action)
def populate_items(self):
# print(f"Listing directory contents of: {self.path}")
try:
entries = os.listdir(self.path)
if not entries:
print("No items found.")
else:
for entry in entries:
if entry == app.desktop_settings_file:
continue
# ~/Desktop is a special case; we don't want to show it
if self.path == QDir.homePath() and entry == "Desktop":
continue
entry_path = os.path.join(self.path, entry)
is_directory = os.path.isdir(entry_path)
icon_provider = QFileIconProvider()
icon = icon_provider.icon(self.get_file_info(entry_path)).pixmap(48, 48)
self.add_file(entry, entry_path, icon, is_directory)
except Exception as e:
print(f"Error accessing directory: {e}")
def get_file_info(self, path):
"""Get file info to use with QFileIconProvider"""
from PyQt6.QtCore import QFileInfo
return QFileInfo(path)
def calculate_max_width(self):
return max(item.width() for item in self.files) if self.files else 150
def add_file(self, name, path, icon, is_directory):
position = QPoint(self.start_x + len(self.files) % 5 * (self.calculate_max_width() + self.horizontal_spacing),
self.start_y + len(self.files) // 5 * (self.line_height + self.vertical_spacing))
# Check whether a position is provided in the .DS_Spatial file; if yes, use it
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.path.exists(settings_file):
with open(settings_file, "r") as file:
try:
settings = json.load(file)
for item in settings["items"]:
if item["name"] == name:
position = QPoint(item["x"], item["y"])
except json.JSONDecodeError as e:
print(f"Error reading settings file: {e}")
item = Item(name, icon, path, is_directory, position, self.container)
item.move(position)
item.show()
self.files.append(item)
self.update_container_size()
def update_container_size(self):
max_x = max(item.x() + item.width() for item in self.files) + 10
max_y = max(item.y() + item.height() for item in self.files) + 10
self.container.setMinimumSize(QSize(max_x, max_y))
def mousePressEvent(self, event):
scroll_pos = QPoint(self.scroll_area.horizontalScrollBar().value(),
self.scroll_area.verticalScrollBar().value())
adjusted_pos = event.pos() + scroll_pos
if event.button() == Qt.MouseButton.LeftButton:
clicked_widget = None
for item in self.files:
if (item.x() <= adjusted_pos.x() <= item.x() + item.width()) and \
(item.y() <= adjusted_pos.y() <= item.y() + item.height()):
clicked_widget = item
break
if clicked_widget:
if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
if clicked_widget in self.selected_files:
self.selected_files.remove(clicked_widget)
clicked_widget.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
else:
self.selected_files.append(clicked_widget)
clicked_widget.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
else:
if clicked_widget not in self.selected_files:
self.selected_files = [clicked_widget]
for f in self.files:
if f != clicked_widget:
f.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
clicked_widget.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
self.dragging = True
self.last_pos = adjusted_pos
self.update_menu_state()
return
self.is_selecting = True
self.selection_rect = QRect(adjusted_pos.x(), adjusted_pos.y(), 0, 0)
self.update()
self.selected_files = []
for item in self.files:
item.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
self.update_menu_state()
def mouseMoveEvent(self, event):
scroll_pos = QPoint(self.scroll_area.horizontalScrollBar().value(),
self.scroll_area.verticalScrollBar().value())
adjusted_pos = event.pos() + scroll_pos
if self.dragging:
print("Dragging")
# Let Qt drag the selected items
# Set mime data
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(f.path) for f in self.selected_files])
drag = QDrag(self)
drag.setMimeData(mime_data)
drag.setPixmap(self.selected_files[0].icon_label.pixmap())
# TODO: Make it so that the icon doesn't jump to be at the top left corner of the mouse cursor
# FIXME: Instead of hardcoding the hot spot to be half the icon size, it should be the position of the mouse cursor relative to the item
drag.setHotSpot(QPoint(int(48/2), int(48/2)))
drag.exec()
elif self.is_selecting:
self.selection_rect = QRect(min(self.selection_rect.x(), adjusted_pos.x()),
min(self.selection_rect.y(), adjusted_pos.y()),
abs(adjusted_pos.x() - self.selection_rect.x()),
abs(adjusted_pos.y() - self.selection_rect.y()))
self.update()
for item in self.files:
if (self.selection_rect.x() <= item.x() + item.width() and
item.x() <= self.selection_rect.x() + self.selection_rect.width() and
self.selection_rect.y() <= item.y() + item.height() and
item.y() <= self.selection_rect.y() + self.selection_rect.height()):
if item not in self.selected_files:
self.selected_files.append(item)
item.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
else:
if item in self.selected_files:
self.selected_files.remove(item)
item.setStyleSheet("border: 1px dotted lightgrey; background-color: white;")
def mouseReleaseEvent(self, event):
if self.dragging:
self.dragging = False
self.update_container_size()
elif self.is_selecting:
self.is_selecting = False
self.selection_rect = QRect(0, 0, 0, 0)
self.update()
def open_selected_items(self):
for item in self.selected_files:
item.open(None)
def update_menu_state(self):
# Enable/disable menu actions based on the selection
has_selection = bool(self.selected_files)
self.open_action.setEnabled(has_selection)
self.cut_action.setEnabled(has_selection)
self.copy_action.setEnabled(has_selection)
self.paste_action.setEnabled(has_selection)
self.delete_action.setEnabled(has_selection)
def paintEvent(self, event):
if self.is_selecting:
painter = QPainter(self)
painter.setPen(QPen(Qt.GlobalColor.black, 2))
painter.drawRect(self.selection_rect)
def closeEvent(self, event):
# Remove the window from the dictionary of open windows
if self.path in app.open_windows:
del app.open_windows[self.path]
# Store window position and size in .DS_Spatial JSON file in the directory of the window
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.access(self.path, os.W_OK):
settings = {}
settings["position"] = {"x": self.pos().x(), "y": self.pos().y()}
# Determine the screen this window is on
for screen in QApplication.screens():
if screen.geometry().contains(QApplication.activeWindow().frameGeometry()):
settings["screen"] = {"x": screen.geometry().x(), "y": screen.geometry().y(), "width": screen.geometry().width(), "height": screen.geometry().height()}
break
settings["size"] = {"width": self.width(), "height": self.height()}
settings["items"] = []
for item in self.files:
if item.name != app.desktop_settings_file:
settings["items"].append({"name": item.name, "x": item.pos().x(), "y": item.pos().y()})
with open(settings_file, "w") as file:
json.dump(settings, file, indent=4)
event.accept()
def dragEnterEvent(self, event):
print("Drag enter event")
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dragMoveEvent(self, event):
# print("Drag move event")
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dropEvent(self, event):
print("Drop event")
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
for url in urls:
path = url.toLocalFile()
# NOTE: normpath needs to be used to avoid issues with different path separators like / and \ on Windows
print("Dropped file:", os.path.normpath(path))
print("Dropped in window for path:", os.path.normpath((self.path)))
# Check if the file is already in the directory; if yes, just move its position
if os.path.normpath(os.path.dirname(path)) == os.path.normpath(self.path):
print("File was moved within the same directory")
for item in self.files:
if os.path.normpath(item.path) == os.path.normpath(path):
drop_position = event.position()
print("Moving to coordinates", drop_position.x(), drop_position.y())
# Somehow these coordinates are not correct; they are always too deep in the window, so we need to adjust them
# FIXME: The -20 is trial and error; it should be calculated based on something
# Apparently, QDropEvent's pos() method gives the position of the mouse cursor at the time of the drop event.
# That is not what we want. We want the position of the item that is being dropped, not the mouse cursor.
# Do we need mapToGlobal() or mapFromGlobal()? Or do we need to do something differently in the startDrag event first, like adding all selected item locations to the drag event?
pixmap_height = item.icon_label.pixmap().height()
drop_position = QPoint(int(drop_position.x() - 20), int(drop_position.y() - pixmap_height))
# Half an icon height to the top and to the left
# FIXME: Instead of hardcoding the hot spot to be half the icon size, it should be corrected based on the position of the mouse cursor relative to the item at the time of the drag event
drop_position = QPoint(drop_position.x() - int(48/2), drop_position.y() - int(48/2))
# Take into consideration the scroll position
drop_position += QPoint(self.scroll_area.horizontalScrollBar().value(), self.scroll_area.verticalScrollBar().value())
# If the Alt modifier key is pressed, move to something that is a multiple of 24 - this is kind of a grid
if event.modifiers() == Qt.KeyboardModifier.AltModifier:
drop_position = QPoint(int(drop_position.x() / 48) * 48, int(drop_position.y() / 48) * 48)
item.move(drop_position)
break
else:
print("Not implemented yet: dropEvent for items from other directories")
event.accept()
else:
event.ignore()
class Item(QWidget):
def __init__(self, name, icon, path, is_directory, position, parent=None):
super().__init__(parent)
self.name = name
self.path = path
self.is_directory = is_directory
self.position = position
# Set icon size and padding
self.icon_size = 48
padding = 10 # Padding around icon and text
# Calculate the text width
font_metrics = QFontMetrics(self.font())
text_width = font_metrics.horizontalAdvance(name)
# Determine the widget width
widget_width = max(self.icon_size, text_width) + padding * 2
# Set the fixed size for the widget, including some padding above and below the content
self.setFixedSize(widget_width, self.icon_size + font_metrics.height() + padding * 2)
# Layout setup
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0) # Space between icon and text
self.layout.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
# Icon label setup
self.icon_label = QLabel(self)
self.icon_label.setFixedSize(self.icon_size, self.icon_size)
self.icon_label.setPixmap(icon)
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignHCenter)
# Text label setup
self.text_label = QLabel(self.name, self)
self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.text_label, alignment=Qt.AlignmentFlag.AlignHCenter)
# Ensure the widget's size policy does not expand
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
# Double-click event to open the item
self.mouseDoubleClickEvent = self.open
def open(self, event):
if self.is_directory:
existing_window = app.open_windows.get(self.path)
if existing_window:
existing_window.raise_()
existing_window.activateWindow()
else:
new_window = SpatialFiler(self.path)
new_window.show()
app.open_windows[self.path] = new_window
else:
if sys.platform == "win32":
os.startfile(self.path)
else:
os.system(f"xdg-open \"{self.path}\"")
if __name__ == "__main__":
app = QApplication(sys.argv)
app.open_windows = {}
app.desktop_settings_file = ".DS_Spatial"
spatial_Filer = SpatialFiler()
spatial_Filer.show()
sys.exit(app.exec())
Here is an actually smooth version, but it doesn't use Qt's drag-and-drop mechanisms. Compare how much better moving items in the windows feels here. This is the behavior I'd actually like to achieve, but using Qt's drag-and-drop:
#!/usr/bin/env python3
import sys
import os
import json
from PyQt6.QtCore import Qt, QPoint, QSize, QDir, QRect
from PyQt6.QtGui import QFontMetrics, QPainter, QPen, QAction
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QScrollArea, QLabel, QSizePolicy, QFileIconProvider, QMenuBar
class SpatialFiler(QWidget):
def __init__(self, path=None):
super().__init__()
self.path = path if path else QDir.homePath()
self.setWindowTitle(f"Spatial Filer - {self.path}")
self.setGeometry(100, 100, 800, 600)
# There might be a file .DS_Spatial in the directory that contains the window position and size. If it exists, read the settings from it.
# Example file content:
# {"position": {"x": 499, "y": 242}, "size": {"width": 800, "height": 600}, "items": [{"name": "known_hosts", "x": 110, "y": 0}, {"name": "known_hosts.old", "x": 220, "y": 0}]}
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.path.exists(settings_file):
with open(settings_file, "r") as file:
settings = json.load(file)
# Check if there is a position for the window in the settings file; if yes, set the window position
if "position" in settings:
self.move(settings["position"]["x"], settings["position"]["y"])
# Check if there is a size for the window in the settings file; if yes, set the window size
if "size" in settings:
self.resize(settings["size"]["width"], settings["size"]["height"])
# Check if the window is out of the screen; if yes, move it to the top-left corner
if self.x() < 0 or self.y() < 0:
self.move(0, 0)
# Create the menu bar
self.menu_bar = QMenuBar(self)
self.layout = QVBoxLayout(self)
self.layout.setMenuBar(self.menu_bar)
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.container = QWidget()
self.scroll_area.setWidget(self.container)
self.layout.addWidget(self.scroll_area)
self.layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self.layout)
# Initialize menu bar items
self.init_menu_bar()
# Initialize other components
self.files = []
self.vertical_spacing = 5
self.line_height = 80
self.horizontal_spacing = 10
self.start_x = 0
self.start_y = 0
self.populate_items()
self.dragging = False
self.last_pos = QPoint(0, 0)
self.selected_files = []
self.selection_rect = QRect(0, 0, 0, 0)
self.is_selecting = False
def init_menu_bar(self):
# File Menu
file_menu = self.menu_bar.addMenu("File")
self.open_action = QAction("Open", self)
self.open_action.triggered.connect(self.open_selected_items)
self.open_action.setEnabled(False)
close_action = QAction("Close", self)
close_action.triggered.connect(self.close)
file_menu.addAction(self.open_action)
file_menu.addSeparator()
file_menu.addAction(close_action)
# Edit Menu
edit_menu = self.menu_bar.addMenu("Edit")
self.cut_action = QAction("Cut", self)
self.copy_action = QAction("Copy", self)
self.paste_action = QAction("Paste", self)
self.delete_action = QAction("Delete", self)
edit_menu.addAction(self.cut_action)
edit_menu.addAction(self.copy_action)
edit_menu.addAction(self.paste_action)
edit_menu.addSeparator()
edit_menu.addAction(self.delete_action)
for action in [self.cut_action, self.copy_action, self.paste_action, self.delete_action]:
action.setEnabled(False)
# Help Menu
help_menu = self.menu_bar.addMenu("Help")
about_action = QAction("About", self)
help_action = QAction("Help", self)
help_menu.addAction(about_action)
help_menu.addAction(help_action)
def populate_items(self):
print(f"Listing directory contents of: {self.path}")
try:
entries = os.listdir(self.path)
if not entries:
print("No items found.")
else:
for entry in entries:
entry_path = os.path.join(self.path, entry)
is_directory = os.path.isdir(entry_path)
icon_provider = QFileIconProvider()
icon = icon_provider.icon(self.get_file_info(entry_path)).pixmap(48, 48)
self.add_file(entry, entry_path, icon, is_directory)
except Exception as e:
print(f"Error accessing directory: {e}")
def get_file_info(self, path):
"""Get file info to use with QFileIconProvider"""
from PyQt6.QtCore import QFileInfo
return QFileInfo(path)
def calculate_max_width(self):
return max(item.width() for item in self.files) if self.files else 150
def add_file(self, name, path, icon, is_directory):
position = QPoint(self.start_x + len(self.files) % 5 * (self.calculate_max_width() + self.horizontal_spacing),
self.start_y + len(self.files) // 5 * (self.line_height + self.vertical_spacing))
# Check whether a position is provided in the .DS_Spatial file; if yes, use it
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.path.exists(settings_file):
with open(settings_file, "r") as file:
settings = json.load(file)
for item in settings["items"]:
if item["name"] == name:
position = QPoint(item["x"], item["y"])
item = Item(name, icon, path, is_directory, position, self.container)
item.move(position)
item.show()
self.files.append(item)
self.update_container_size()
def update_container_size(self):
max_x = max(item.x() + item.width() for item in self.files) + 10
max_y = max(item.y() + item.height() for item in self.files) + 10
self.container.setMinimumSize(QSize(max_x, max_y))
def mousePressEvent(self, event):
scroll_pos = QPoint(self.scroll_area.horizontalScrollBar().value(),
self.scroll_area.verticalScrollBar().value())
adjusted_pos = event.pos() + scroll_pos
if event.button() == Qt.MouseButton.LeftButton:
clicked_widget = None
for item in self.files:
if (item.x() <= adjusted_pos.x() <= item.x() + item.width()) and \
(item.y() <= adjusted_pos.y() <= item.y() + item.height()):
clicked_widget = item
break
if clicked_widget:
if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
if clicked_widget in self.selected_files:
self.selected_files.remove(clicked_widget)
clicked_widget.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
else:
self.selected_files.append(clicked_widget)
clicked_widget.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
else:
if clicked_widget not in self.selected_files:
self.selected_files = [clicked_widget]
for f in self.files:
if f != clicked_widget:
f.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
clicked_widget.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
self.dragging = True
self.last_pos = adjusted_pos
self.update_menu_state()
return
self.is_selecting = True
self.selection_rect = QRect(adjusted_pos.x(), adjusted_pos.y(), 0, 0)
self.update()
self.selected_files = []
for item in self.files:
item.setStyleSheet("border: 1px dotted lightgrey; background-color: transparent;")
self.update_menu_state()
def mouseMoveEvent(self, event):
scroll_pos = QPoint(self.scroll_area.horizontalScrollBar().value(),
self.scroll_area.verticalScrollBar().value())
adjusted_pos = event.pos() + scroll_pos
if self.dragging:
offset = adjusted_pos - self.last_pos
for item in self.selected_files:
new_position = item.pos() + offset
item.move(new_position)
self.last_pos = adjusted_pos
self.update()
elif self.is_selecting:
self.selection_rect = QRect(min(self.selection_rect.x(), adjusted_pos.x()),
min(self.selection_rect.y(), adjusted_pos.y()),
abs(adjusted_pos.x() - self.selection_rect.x()),
abs(adjusted_pos.y() - self.selection_rect.y()))
self.update()
for item in self.files:
if (self.selection_rect.x() <= item.x() + item.width() and
item.x() <= self.selection_rect.x() + self.selection_rect.width() and
self.selection_rect.y() <= item.y() + item.height() and
item.y() <= self.selection_rect.y() + self.selection_rect.height()):
if item not in self.selected_files:
self.selected_files.append(item)
item.setStyleSheet("border: 1px dotted blue; background-color: lightblue;")
else:
if item in self.selected_files:
self.selected_files.remove(item)
item.setStyleSheet("border: 1px dotted lightgrey; background-color: white;")
def mouseReleaseEvent(self, event):
if self.dragging:
self.dragging = False
self.update_container_size()
elif self.is_selecting:
self.is_selecting = False
self.selection_rect = QRect(0, 0, 0, 0)
self.update()
def open_selected_items(self):
for item in self.selected_files:
item.open(None)
def update_menu_state(self):
# Enable/disable menu actions based on the selection
has_selection = bool(self.selected_files)
self.open_action.setEnabled(has_selection)
self.cut_action.setEnabled(has_selection)
self.copy_action.setEnabled(has_selection)
self.paste_action.setEnabled(has_selection)
self.delete_action.setEnabled(has_selection)
def paintEvent(self, event):
if self.is_selecting:
painter = QPainter(self)
painter.setPen(QPen(Qt.GlobalColor.black, 2))
painter.drawRect(self.selection_rect)
def closeEvent(self, event):
# Remove the window from the dictionary of open windows
if self.path in app.open_windows:
del app.open_windows[self.path]
# Store window position and size in .DS_Spatial JSON file in the directory of the window
settings_file = os.path.join(self.path, app.desktop_settings_file)
if os.access(self.path, os.W_OK):
settings = {}
settings["position"] = {"x": self.pos().x(), "y": self.pos().y()}
settings["size"] = {"width": self.width(), "height": self.height()}
settings["items"] = []
for item in self.files:
if item.name != app.desktop_settings_file:
settings["items"].append({"name": item.name, "x": item.pos().x(), "y": item.pos().y()})
with open(settings_file, "w") as file:
json.dump(settings, file)
event.accept()
class Item(QWidget):
def __init__(self, name, icon, path, is_directory, position, parent=None):
super().__init__(parent)
self.name = name
self.path = path
self.is_directory = is_directory
self.position = position
# Set icon size and padding
self.icon_size = 48
padding = 10 # Padding around icon and text
# Calculate the text width
font_metrics = QFontMetrics(self.font())
text_width = font_metrics.horizontalAdvance(name)
# Determine the widget width
widget_width = max(self.icon_size, text_width) + padding * 2
# Set the fixed size for the widget, including some padding above and below the content
self.setFixedSize(widget_width, self.icon_size + font_metrics.height() + padding * 2)
# Layout setup
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0) # Space between icon and text
self.layout.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
# Icon label setup
self.icon_label = QLabel(self)
self.icon_label.setFixedSize(self.icon_size, self.icon_size)
self.icon_label.setPixmap(icon)
self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignHCenter)
# Text label setup
self.text_label = QLabel(self.name, self)
self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout.addWidget(self.text_label, alignment=Qt.AlignmentFlag.AlignHCenter)
# Ensure the widget's size policy does not expand
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
# Double-click event to open the item
self.mouseDoubleClickEvent = self.open
def open(self, event):
if self.is_directory:
existing_window = app.open_windows.get(self.path)
if existing_window:
existing_window.raise_()
existing_window.activateWindow()
else:
new_window = SpatialFiler(self.path)
new_window.show()
app.open_windows[self.path] = new_window
else:
if sys.platform == "win32":
os.startfile(self.path)
else:
os.system(f"xdg-open \"{self.path}\"")
if __name__ == "__main__":
app = QApplication(sys.argv)
app.open_windows = {}
app.desktop_settings_file = ".DS_Spatial"
spatial_Filer = SpatialFiler()
spatial_Filer.show()
sys.exit(app.exec())
Possibly related?
(The below does NOT do the trick...)
#!/usr/bin/env python3
import sys
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QStandardItem, QStandardItemModel, QIcon, QDropEvent
from PyQt6.QtWidgets import QApplication, QMainWindow, QListView
class CustomListView(QListView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def dropEvent(self, event: QDropEvent) -> None:
super().dropEvent(event) # Call the base class dropEvent
self.relayout() # Call relayout after a drop event
def relayout(self):
self.setUpdatesEnabled(True) # Re-enable updates
self.clearSelection() # Clear the current selection
self.scrollToTop() # Optionally scroll to the top
self.update() # Trigger a relayout of the QListView
def main():
app = QApplication(sys.argv)
main_window = QMainWindow()
main_window.setWindowTitle("List View Example")
main_window.setGeometry(100, 100, 800, 400)
list_view = CustomListView() # Use the custom QListView
list_view.setViewMode(QListView.ViewMode.IconMode)
list_view.setGridSize(QSize(200, 70))
# list_view.setLayoutDirection(Qt.LayoutDirection.RightToLeft) # Remove or comment this line
list_view.setMovement(QListView.Movement.Free)
list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection)
list_view.setResizeMode(QListView.ResizeMode.Fixed)
list_view.setDragEnabled(True)
list_view.setAcceptDrops(True)
list_view.setDragDropMode(QListView.DragDropMode.InternalMove)
model = QStandardItemModel()
list_view.setModel(model)
icon_names = ["applications-internet", "applications-internet", "applications-internet",
"applications-internet", "applications-internet"]
# Clear current selection and update layout before adding items
list_view.setUpdatesEnabled(False) # Prevent updates while modifying the model
model.removeRows(0, model.rowCount()) # Clear existing items if needed
for i, icon_name in enumerate(icon_names):
item = QStandardItem(f"Item {i + 1}")
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled)
item.setIcon(QIcon.fromTheme(icon_name))
model.setItem(i, 0, item)
# Use a QTimer to delay the initial relayout
QTimer.singleShot(0, lambda: list_view.relayout())
main_window.setCentralWidget(list_view)
main_window.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
https://ubuntu-mate.community/t/proper-alignment-of-desktop-symbols/9153 I wish I could help, but I'm not very capable, but I'm thinking this discussion could give me an idea, what if we use a coarse grid cross to align and position, and do free placement on the fine cross lines? As shown in the picture.
I think that Qt list view is just for displaying lists, so it will have a fixed "snag grid" arrangement. But if there is a position pin behind the icon to help pin it to a specific free position, it may need to be developed separately, because most people think that this is fine, and developers will not specially develop free arrangement of positions.
@probonopd Have you noticed that it is normal for your video to move the icon up and down, but not normally when moving left and right? I am wondering if it is related to a reported problem, that is, the selection detection box is too wide for the computer to interpret. Thought you were going to run him rather than move it.
PC-1-screen0.webm