probonopd / Filer

A clean rewrite of the Filer file manager for helloSystem, inspired by John Siracusa's descriptions of "Spatial Orientation"
BSD 2-Clause "Simplified" License
12 stars 1 forks source link

When the icon is moved on the desktop, the placement position keeps jumping #6

Open louies0623 opened 12 months ago

louies0623 commented 12 months ago

PC-1-screen0.webm

probonopd commented 12 months ago

Thanks for testing @louies0623. Yes, this is a known issue. This is what happens:

I don't know yet why this happens and how to solve it.

probonopd commented 11 months ago

Related? Video by @hijarian:

Qt 5.5+ QListView Icon mode behavior bug

probonopd commented 11 months ago

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.

probonopd commented 11 months ago

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()
probonopd commented 2 months ago

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

dfaure-kdab commented 1 month ago

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

probonopd commented 1 month ago

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.

Screencast_20240710.webm

dfaure-kdab commented 1 month ago

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

probonopd commented 1 month ago

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.

probonopd commented 1 month ago

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;

image

#!/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())
probonopd commented 1 month ago

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())
probonopd commented 1 week ago

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()
louies0623 commented 1 week ago

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. images - 2024-08-25T200841 037

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.

louies0623 commented 1 week ago

@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.

https://github.com/helloSystem/Filer/issues/174