From 4615924ecacd1d57b4f0765ba525ea5eef8b876f Mon Sep 17 00:00:00 2001 From: probonopd Date: Fri, 5 Jul 2024 21:06:10 +0200 Subject: [PATCH] Preview pane; refactor to multiple files --- file_operations.py | 26 ++ main.py | 11 + main_window.py | 334 +++++++++++++++ menus.py | 91 ++++ miller.py | 399 ------------------ requirements.txt | 1 + status_bar.py | 42 ++ toolbar.py | 26 ++ ...scontextmenu.py => windows_context_menu.py | 222 +++++----- windows_integration.py | 74 ++++ windowsmapdrives.py => windows_map_drives.py | 59 ++- windowsproperties.py => windows_properties.py | 91 ++-- 12 files changed, 786 insertions(+), 590 deletions(-) create mode 100644 file_operations.py create mode 100644 main.py create mode 100644 main_window.py create mode 100644 menus.py delete mode 100644 miller.py create mode 100644 status_bar.py create mode 100644 toolbar.py rename windowscontextmenu.py => windows_context_menu.py (95%) create mode 100644 windows_integration.py rename windowsmapdrives.py => windows_map_drives.py (84%) rename windowsproperties.py => windows_properties.py (95%) diff --git a/file_operations.py b/file_operations.py new file mode 100644 index 0000000..5c9f942 --- /dev/null +++ b/file_operations.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +""" +File operations module for Miller Columns File Manager application. + +This module provides functions for file operations such as moving to trash, deleting files, +and emptying the trash. It also includes a helper function to validate directory paths. +""" + +def move_to_trash(window, indexes): + """ + Move the specified indexes to the trash. + """ + print("Move to trash") + +def delete(window, indexes): + """ + Delete the specified indexes. + """ + print("Delete") + +def empty_trash(window): + """ + Empty the trash. + """ + print("Empty trash") diff --git a/main.py b/main.py new file mode 100644 index 0000000..a7d5e7b --- /dev/null +++ b/main.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 + +import sys +from PyQt6.QtWidgets import QApplication +from main_window import MillerColumns + +if __name__ == '__main__': + app = QApplication(sys.argv) + window = MillerColumns() + window.show() + sys.exit(app.exec()) diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..d75ccba --- /dev/null +++ b/main_window.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 + +""" +Main module for Miller Columns File Manager application. + +This module defines the main window (`MillerColumns`) and its functionalities, +including file navigation, status bar updates, etc. +""" + +import sys +import os +from PyQt6.QtWidgets import QApplication, QMainWindow, QHBoxLayout, QVBoxLayout, QListView, QWidget, QAbstractItemView, QMessageBox, QLabel, QTextEdit, QStackedWidget +from PyQt6.QtCore import QSettings, QByteArray, Qt, QDir, QModelIndex, QUrl +from PyQt6.QtGui import QFileSystemModel, QAction, QPixmap +from PyQt6.QtWebEngineWidgets import QWebEngineView # pip install PyQt6-WebEngine +import mimetypes +from file_operations import move_to_trash, delete, empty_trash +from windows_integration import show_context_menu, show_properties +import menus +import toolbar +import status_bar + +class MillerColumns(QMainWindow): + """ + Main application window for Miller Columns File Manager. + """ + def __init__(self): + """ + Initialize the MillerColumns instance. + """ + super().__init__() + self.setWindowTitle("Miller Columns File Manager") + self.resize(1000, 600) # Default size + + self.central_widget = QWidget() + self.setCentralWidget(self.central_widget) + + self.main_layout = QHBoxLayout(self.central_widget) + + self.column_layout = QHBoxLayout() + self.columns = [] + + self.file_model = QFileSystemModel() + self.file_model.setRootPath('') + self.file_model.setOption(QFileSystemModel.Option.DontUseCustomDirectoryIcons, False) # Enable color icons + self.file_model.setFilter(QDir.Filter.AllEntries | QDir.Filter.Hidden | QDir.Filter.System) + # FIXME: . and .. should not be shown in the view, but things like $RECYCLE.BIN should be shown + + home_dir = os.path.expanduser('~') + self.add_column(self.file_model.index(home_dir)) + + self.create_preview_panel() + + self.main_layout.addLayout(self.column_layout) + self.main_layout.addWidget(self.preview_panel) + + self.create_menus() # Create menus directly in the constructor + toolbar.create_toolbar(self) + status_bar.create_status_bar(self) + self.read_settings() + + def create_preview_panel(self): + """ + Create the file preview panel on the right side of the window. + """ + self.preview_panel = QStackedWidget() + self.text_preview = QTextEdit() + self.text_preview.setReadOnly(True) + self.image_preview = QLabel() + self.image_preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.pdf_preview = QWebEngineView() + + self.preview_panel.setFixedWidth(200) + + main_window_palette = self.palette() + background_color = main_window_palette.color(main_window_palette.ColorRole.Window) + background_color_name = background_color.name() + self.preview_panel.setStyleSheet(f"background-color: {background_color_name}") + + self.preview_panel.addWidget(self.text_preview) + self.preview_panel.addWidget(self.image_preview) + self.preview_panel.addWidget(self.pdf_preview) + + def quit_application(self): + app = QApplication.instance() + app.quit() + + def change_path(self): + """ + Change to the directory specified in the path_label. + """ + path = self.path_label.text() + print("Should change path to %s" % path) + if self.is_valid_path(path): + parent_index = self.file_model.index(path) + self._update_view(parent_index) + else: + QMessageBox.critical(self, "Error", f"The path '{path}' does not exist or is not a directory.") + + def is_valid_path(self, path): + """ + Check if the given path is a valid directory or network path. + """ + if os.name == 'nt' and path.startswith('\\\\'): + QMessageBox.information(self, "Network Path", "This is a network path. Please map it first.") + return False + return os.path.exists(path) and os.path.isdir(path) + + def show_about(self): + """ + Show information about the application. + """ + QMessageBox.about(self, "About", "Miller Columns File Manager\nVersion 1.0") + + def _update_view(self, parent_index): + """ + Update the view with the contents of the specified parent_index. + """ + if parent_index.isValid(): + for column_view in self.columns[1:]: + self.column_layout.removeWidget(column_view) + column_view.deleteLater() + self.columns = self.columns[:1] + self.columns[0].setRootIndex(parent_index) + + # Update current directory path if it is a valid directory + if self.file_model.isDir(parent_index): + self.path_label.setText(self.file_model.filePath(parent_index)) + + def go_up(self): + """ + Navigate up one directory level. + """ + if self.columns: + first_view = self.columns[0] + current_index = first_view.rootIndex() + if current_index.isValid(): + parent_index = current_index.parent() + self._update_view(parent_index) + + def go_home(self): + """ + Navigate to the user's home directory. + """ + if self.columns: + first_view = self.columns[0] + current_index = first_view.rootIndex() + if current_index.isValid(): + home_dir = os.path.expanduser('~') + parent_index = self.file_model.index(home_dir) + self._update_view(parent_index) + + def add_column(self, parent_index=None): + """ + Add a new column view displaying the contents of the directory at parent_index. + """ + column_view = QListView() + column_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multiple selections + column_view.setUniformItemSizes(True) + column_view.setAlternatingRowColors(True) # Enable alternating row colors + column_view.setModel(self.file_model) + + if parent_index: + column_view.setRootIndex(parent_index) + + self.column_layout.addWidget(column_view) + self.columns.append(column_view) + + column_view.selectionModel().currentChanged.connect(self.on_selection_changed) + column_view.doubleClicked.connect(self.on_double_clicked) + + # Ensure context menu policy is set + column_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + # Connect the custom context menu + column_view.customContextMenuRequested.connect(lambda pos: self.show_context_menu(pos, column_view)) + + # Connect selection change to update status bar + column_view.selectionModel().selectionChanged.connect(lambda: status_bar.update_status_bar(self)) + + # Allow dragging + column_view.setDragEnabled(True) + + def on_selection_changed(self, current: QModelIndex, previous: QModelIndex): + """ + Handle the selection change in the column view. + """ + column_index = self.get_column_index(current) + if column_index is not None: + # Remove all columns to the right of the current column + while len(self.columns) > column_index + 1: + column_to_remove = self.columns.pop() + self.column_layout.removeWidget(column_to_remove) + column_to_remove.deleteLater() + + # Add a new column if the selected item is a directory + if self.file_model.isDir(current): + self.add_column(current) + + # Update current directory path if it is a valid directory + if self.file_model.isDir(current): + self.path_label.setText(self.file_model.filePath(current)) + + # Update the preview panel with the selected file's content + self.update_preview_panel(current) + + def on_double_clicked(self, index: QModelIndex): + """ + Handle the double-click event on an item in the column view. + """ + file_path = self.file_model.filePath(index) + try: + os.startfile(file_path) + except Exception as e: + QMessageBox.critical(self, "Error", f"{e}") + + def update_preview_panel(self, index: QModelIndex): + """ + Update the preview panel with the content of the selected file. + """ + file_path = self.file_model.filePath(index) + file_size = self.file_model.size(index) + if os.path.isfile(file_path) and file_size < 1024*1024*1: # Limit file size to 1 MB + try: + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type and mime_type.startswith('image'): + pixmap = QPixmap(file_path) + self.image_preview.setPixmap(pixmap) + self.preview_panel.setCurrentWidget(self.image_preview) + elif mime_type == 'application/pdf': + self.pdf_preview.setUrl(QUrl.fromLocalFile(file_path)) + self.preview_panel.setCurrentWidget(self.pdf_preview) + else: + with open(file_path, 'r', errors='ignore') as file: + content = file.read() + self.text_preview.setPlainText(content) + self.preview_panel.setCurrentWidget(self.text_preview) + except Exception as e: + self.text_preview.clear() + self.image_preview.clear() + self.pdf_preview.setUrl(QUrl()) + self.preview_panel.setCurrentWidget(self.text_preview) + else: + self.text_preview.clear() + self.image_preview.clear() + self.pdf_preview.setUrl(QUrl()) + + def get_column_index(self, index: QModelIndex): + """ + Retrieve the index of the column associated with the given QModelIndex. + """ + for i, column in enumerate(self.columns): + if column.selectionModel() == self.sender(): + return i + return None + + def closeEvent(self, event): + """ + Handle the close event of the main window. + """ + self.write_settings() + super().closeEvent(event) + + def read_settings(self): + """ + Read and apply stored application settings. + """ + settings = QSettings("MyCompany", "MillerColumnsFileManager") + geometry = settings.value("geometry", QByteArray()) + if geometry: + self.restoreGeometry(geometry) + + def write_settings(self): + """ + Save current application settings. + """ + settings = QSettings("MyCompany", "MillerColumnsFileManager") + settings.setValue("geometry", self.saveGeometry()) + + def create_menus(self): + """ + Create the main application menu bar and add menus. + """ + menus.create_menus(self) + + def show_context_menu(self, pos, column_view): + """ + Display a context menu at the given position for the specified column view. + """ + show_context_menu(self, pos, column_view) + + def go_trash(self): + """ + Navigate to the trash directory. + """ + if os.name == 'nt': + sys_drive = os.getenv('SystemDrive') + trash_dir = f"{sys_drive}\\$Recycle.Bin" + else: + trash_dir = QDir.homePath() + '/.local/share/Trash/files/' + self._update_view(self.file_model.index(trash_dir)) + + def go_drive(self, drive): + """ + Go to the specified drive. + """ + parent_index = self.file_model.index(drive) + self._update_view(parent_index) + + def add_drive_actions(self): + """ + Add actions for every existing/connected drive letter to the Go menu. + """ + drives = QDir.drives() + go_menu = self.menuBar().addMenu("Go") + + for drive in drives: + drive_path = drive.absolutePath() + drive_action = QAction(drive_path, self) + drive_action.triggered.connect(lambda _, path=drive_path: self._update_view(self.file_model.index(path))) + go_menu.addAction(drive_action) + + def empty_trash(self): + """ + Empty the trash. + """ + trash_dir = QDir.homePath() + '/.local/share/Trash/files/' + # Implementation to empty trash + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = MillerColumns() + window.show() + sys.exit(app.exec()) diff --git a/menus.py b/menus.py new file mode 100644 index 0000000..3d3e547 --- /dev/null +++ b/menus.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 + +""" +Menu creation module for Miller Columns File Manager application. + +This module provides functions to create the main application menu bar and add menus, +including File, Edit, Go, and Help menus with respective actions and event connections. +""" + +import os +from PyQt6.QtWidgets import QMenu +from PyQt6.QtGui import QIcon, QAction + +def create_menus(window): + """ + Create the main application menu bar and add menus. + """ + menubar = window.menuBar() + + # File menu + file_menu = menubar.addMenu("File") + close_action = QAction("Close", window) + close_action.triggered.connect(window.close) + if os.name == 'nt': + import windows_integration + map_drive_action = QAction("Map Network Drive", window) + map_drive_action.triggered.connect(windows_integration.map_network_drive) + unmap_drive_action = QAction("Unmap Network Drive", window) + unmap_drive_action.triggered.connect(windows_integration.unmap_network_drive) + quit_action = QAction("Quit", window) + quit_action.triggered.connect(window.quit_application) + file_menu.addAction(close_action) + file_menu.addSeparator() + if os.name == 'nt': + file_menu.addAction(map_drive_action) + file_menu.addAction(unmap_drive_action) + file_menu.addSeparator() + file_menu.addAction(quit_action) + + # Edit menu + edit_menu = menubar.addMenu("Edit") + window.undo_action = QAction("Undo", window) + window.undo_action.setEnabled(False) + window.cut_action = QAction("Cut", window) + window.cut_action.setEnabled(False) + window.copy_action = QAction("Copy", window) + window.copy_action.setEnabled(False) + window.paste_action = QAction("Paste", window) + window.paste_action.setEnabled(False) + window.move_to_trash_action = QAction("Move to Trash", window) + window.move_to_trash_action.setEnabled(False) + window.delete_action = QAction("Delete", window) + window.delete_action.setEnabled(False) + window.empty_trash_action = QAction("Empty Trash", window) + window.empty_trash_action.setEnabled(False) + window.empty_trash_action.triggered.connect(window.empty_trash) + + edit_menu.addAction(window.undo_action) + edit_menu.addSeparator() + edit_menu.addAction(window.cut_action) + edit_menu.addAction(window.copy_action) + edit_menu.addAction(window.paste_action) + edit_menu.addSeparator() + edit_menu.addAction(window.move_to_trash_action) + edit_menu.addAction(window.delete_action) + edit_menu.addSeparator() + edit_menu.addAction(window.empty_trash_action) + + # Go menu + go_menu = menubar.addMenu("Go") + home_action = QAction("Home", window) + home_action.triggered.connect(window.go_home) + trash_action = QAction("Trash", window) + trash_action.triggered.connect(window.go_trash) + go_menu.addAction(home_action) + go_menu.addAction(trash_action) + go_menu.addSeparator() + + if os.name == 'nt': + from windows_map_drives import get_drive_letters + for drive in get_drive_letters(): + drive_action = QAction(drive, window) + drive_action.triggered.connect(lambda checked, d=drive: window.go_drive(d)) + go_menu.addAction(drive_action) + go_menu.addSeparator() + + # Help menu + help_menu = menubar.addMenu("Help") + about_action = QAction("About", window) + about_action.triggered.connect(window.show_about) + help_menu.addAction(about_action) diff --git a/miller.py b/miller.py deleted file mode 100644 index 4bcec20..0000000 --- a/miller.py +++ /dev/null @@ -1,399 +0,0 @@ -import sys -import os -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QHBoxLayout, QListView, - QWidget, QAbstractItemView, QMenu, QToolBar, - QMessageBox, QLineEdit, QLabel -) -from PyQt6.QtCore import QModelIndex, QSettings, QByteArray, Qt, QSize, QDir -from PyQt6.QtGui import QFileSystemModel, QIcon, QAction - -if os.name == 'nt': - import windowsproperties - import windowscontextmenu - import windowsmapdrives - -class MillerColumns(QMainWindow): - """ - Main application window for Miller Columns File Manager. - """ - def __init__(self): - """ - Initialize the MillerColumns instance. - """ - super().__init__() - self.setWindowTitle("Miller Columns File Manager") - self.resize(800, 600) # Default size - - self.central_widget = QWidget() - self.setCentralWidget(self.central_widget) - - self.layout = QHBoxLayout(self.central_widget) - - self.columns = [] - self.file_model = QFileSystemModel() - self.file_model.setRootPath('') - self.file_model.setOption(QFileSystemModel.Option.DontUseCustomDirectoryIcons, False) # Enable color icons - - self.file_model.setFilter(QDir.Filter.AllEntries | QDir.Filter.Hidden | QDir.Filter.System) - # FIXME: . and .. should not be shown in the view, but things like $RECYCLE.BIN should be shown - - home_dir = os.path.expanduser('~') - self.add_column(self.file_model.index(home_dir)) - - self.create_menus() - self.create_toolbar() - self.create_status_bar() # Create status bar - self.read_settings() - - def create_status_bar(self): - """ - Create and initialize the status bar. - """ - self.statusBar = self.statusBar() - - self.selected_files_label = QLabel() - self.statusBar.addWidget(self.selected_files_label) - - self.selected_files_size_label = QLabel() - self.statusBar.addWidget(self.selected_files_size_label) - - self.update_status_bar() - - def update_status_bar(self): - """ - Update the status bar with current selection information. - """ - selected_indexes = self.columns[-1].selectionModel().selectedIndexes() - num_selected_files = len(selected_indexes) - total_size = sum(self.file_model.size(index) for index in selected_indexes if index.isValid()) - - self.selected_files_label.setText(f"Selected files: {num_selected_files}") - # Depending on the size, display in bytes, KB, MB, or GB - if total_size >= 1024 ** 3: - total_size = f"{total_size / 1024 ** 3:.2f} GB" - elif total_size >= 1024 ** 2: - total_size = f"{total_size / 1024 ** 2:.2f} MB" - elif total_size >= 1024: - total_size = f"{total_size / 1024:.2f} KB" - else: - total_size = f"{total_size} bytes" - self.selected_files_size_label.setText(f"Total size: {total_size}") - - def show_context_menu(self, pos, column_view): - """ - Display a context menu at the given position for the specified column view. - """ - indexes = column_view.selectedIndexes() - - if not indexes: - parent_index = column_view.rootIndex() - if parent_index.isValid(): - file_paths = [self.file_model.filePath(parent_index)] - else: - return - else: - file_paths = [self.file_model.filePath(index) for index in indexes] - - if os.name == 'nt': - try: - windowscontextmenu.show_context_menu(file_paths) - except Exception as e: - QMessageBox.critical(self, "Error", f"{e}") - return - else: - QMessageBox.critical(self, "Error", "Context menu not supported on this platform.") - - def show_properties(self, index: QModelIndex): - """ - Show properties for the file or directory specified by the QModelIndex. - """ - if index.isValid(): - file_path = self.file_model.filePath(index) - if os.name == 'nt': - windowsproperties.get_file_properties(file_path) - else: - print("show_properties not implemented for this platform") - - def create_menus(self): - """ - Create the main application menu bar and add menus. - """ - # Create a menubar - menubar = self.menuBar() - - # File menu - file_menu = menubar.addMenu("File") - close_action = QAction("Close", self) - close_action.triggered.connect(self.close) - map_drive_action = QAction("Map Network Drive", self) - map_drive_action.triggered.connect(self.map_network_drive) - unmap_drive_action = QAction("Unmap Network Drive", self) - unmap_drive_action.triggered.connect(self.unmap_network_drive) - quit_action = QAction("Quit", self) - quit_action.triggered.connect(QApplication.instance().quit) - file_menu.addAction(close_action) - file_menu.addSeparator() - if os.name == 'nt': - file_menu.addAction(map_drive_action) - file_menu.addAction(unmap_drive_action) - file_menu.addSeparator() - file_menu.addAction(quit_action) - - # Edit menu - edit_menu = menubar.addMenu("Edit") - self.undo_action = QAction("Undo", self) - self.undo_action.setEnabled(False) - self.cut_action = QAction("Cut", self) - self.cut_action.setEnabled(False) - self.copy_action = QAction("Copy", self) - self.copy_action.setEnabled(False) - self.paste_action = QAction("Paste", self) - self.paste_action.setEnabled(False) - self.move_to_trash_action = QAction("Move to Trash", self) - self.move_to_trash_action.setEnabled(False) - self.delete_action = QAction("Delete", self) - self.delete_action.setEnabled(False) - self.empty_trash_action = QAction("Empty Trash", self) - self.empty_trash_action.setEnabled(False) - self.empty_trash_action.triggered.connect(self.empty_trash) - - edit_menu.addAction(self.undo_action) - edit_menu.addSeparator() - 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.move_to_trash_action) - edit_menu.addAction(self.delete_action) - edit_menu.addSeparator() - edit_menu.addAction(self.empty_trash_action) - - # Help menu - help_menu = menubar.addMenu("Help") - about_action = QAction("About", self) - about_action.triggered.connect(self.show_about) - help_menu.addAction(about_action) - - def map_network_drive(self): - """ - Open a dialog to map a network drive. - """ - if os.name == 'nt': - network_drive_manager = windowsmapdrives.NetworkDriveManager() - map_dialog = windowsmapdrives.MapDriveDialog(network_drive_manager) - map_dialog.exec() - - def unmap_network_drive(self): - """ - Open a dialog to unmap a network drive. - """ - if os.name == 'nt': - network_drive_manager = windowsmapdrives.NetworkDriveManager() - unmap_dialog = windowsmapdrives.UnmapDriveDialog(network_drive_manager) - unmap_dialog.exec() - - def move_to_trash(self, indexes): - """ - Move the specified indexes to the trash. - """ - print("Move to trash") - - def delete(self, indexes): - """ - Delete the specified indexes. - """ - print("Delete") - - def empty_trash(self): - """ - Empty the trash. - """ - print("Empty trash") - - def create_toolbar(self): - """ - Create the main application toolbar. - """ - # Create a toolbar - toolbar = QToolBar("Navigation") - self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) - - up_action = QAction(QIcon.fromTheme("go-up"), "Up", self) - up_action.triggered.connect(self.go_up) - toolbar.addAction(up_action) - - home_action = QAction(QIcon.fromTheme("home"), "Home", self) - home_action.triggered.connect(self.go_home) - toolbar.addAction(home_action) - - # Add a QLineEdit to show current directory path - self.path_label = QLineEdit() - self.path_label.setReadOnly(False) # Make it editable - self.path_label.setPlaceholderText("Enter Directory Path") - self.path_label.returnPressed.connect(self.change_path) # Connect the returnPressed signal to change_path method - toolbar.addWidget(self.path_label) - - def change_path(self): - """ - Change to the directory specified in the path_label. - """ - path = self.path_label.text() - print("Should change path to %s" % path) - if self.is_valid_path(path): - parent_index = self.file_model.index(path) - self._update_view(parent_index) - else: - QMessageBox.critical(self, "Error", f"The path '{path}' does not exist or is not a directory.") - - def is_valid_path(self, path): - """ - Check if the given path is a valid directory or network path. - """ - if os.name == 'nt' and path.startswith('\\\\'): - QMessageBox.information(self, "Network Path", "This is a network path. Please map it first.") - return False - return os.path.exists(path) and os.path.isdir(path) - - def show_about(self): - """ - Show information about the application. - """ - QMessageBox.about(self, "About", "Miller Columns File Manager\nhttps://github.com/probonopd/Miller") - - def _update_view(self, parent_index): - """ - Update the view with the contents of the specified parent_index. - """ - if parent_index.isValid(): - for column_view in self.columns[1:]: - self.layout.removeWidget(column_view) - column_view.deleteLater() - self.columns = self.columns[:1] - self.columns[0].setRootIndex(parent_index) - - # Update current directory path if it is a valid directory - if self.file_model.isDir(parent_index): - self.path_label.setText(self.file_model.filePath(parent_index)) - - def go_up(self): - """ - Navigate up one directory level. - """ - if self.columns: - first_view = self.columns[0] - current_index = first_view.rootIndex() - if current_index.isValid(): - parent_index = current_index.parent() - self._update_view(parent_index) - - def go_home(self): - """ - Navigate to the user's home directory. - """ - if self.columns: - first_view = self.columns[0] - current_index = first_view.rootIndex() - if current_index.isValid(): - home_dir = os.path.expanduser('~') - parent_index = self.file_model.index(home_dir) - self._update_view(parent_index) - - def add_column(self, parent_index=None): - """ - Add a new column view displaying the contents of the directory at parent_index. - """ - column_view = QListView() - column_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multiple selections - column_view.setUniformItemSizes(True) - column_view.setAlternatingRowColors(True) # Enable alternating row colors - column_view.setModel(self.file_model) - - if parent_index: - column_view.setRootIndex(parent_index) - - self.layout.addWidget(column_view) - self.columns.append(column_view) - - column_view.selectionModel().currentChanged.connect(self.on_selection_changed) - column_view.doubleClicked.connect(self.on_double_clicked) - - # Ensure context menu policy is set - column_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - - # Connect the custom context menu - column_view.customContextMenuRequested.connect(lambda pos: self.show_context_menu(pos, column_view)) - - # Connect selection change to update status bar - column_view.selectionModel().selectionChanged.connect(self.update_status_bar) - - # Allow dragging - column_view.setDragEnabled(True) - - def on_selection_changed(self, current: QModelIndex, previous: QModelIndex): - """ - Handle the selection change in the column view. - """ - column_index = self.get_column_index(current) - if column_index is not None: - # Remove all columns to the right of the current column - while len(self.columns) > column_index + 1: - column_to_remove = self.columns.pop() - self.layout.removeWidget(column_to_remove) - column_to_remove.deleteLater() - - # Add a new column if the selected item is a directory - if self.file_model.isDir(current): - self.add_column(current) - - # Update current directory path if it is a valid directory - if self.file_model.isDir(current): - self.path_label.setText(self.file_model.filePath(current)) - - def on_double_clicked(self, index: QModelIndex): - """ - Handle the double-click event on an item in the column view. - """ - file_path = self.file_model.filePath(index) - try: - os.startfile(file_path) - except Exception as e: - QMessageBox.critical(self, "Error", f"{e}") - - def get_column_index(self, index: QModelIndex): - """ - Retrieve the index of the column associated with the given QModelIndex. - """ - for i, column in enumerate(self.columns): - if column.selectionModel() == self.sender(): - return i - return None - - def closeEvent(self, event): - """ - Handle the close event of the main window. - """ - self.write_settings() - super().closeEvent(event) - - def read_settings(self): - """ - Read and apply stored application settings. - """ - settings = QSettings("MyCompany", "MillerColumnsFileManager") - geometry = settings.value("geometry", QByteArray()) - if geometry: - self.restoreGeometry(geometry) - - def write_settings(self): - """ - Save current application settings. - """ - settings = QSettings("MyCompany", "MillerColumnsFileManager") - settings.setValue("geometry", self.saveGeometry()) - -if __name__ == '__main__': - app = QApplication(sys.argv) - window = MillerColumns() - window.show() - sys.exit(app.exec()) diff --git a/requirements.txt b/requirements.txt index ef3389f..a095ac4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PyQt6>=6.7.0,<7.0.0 +PyQt6-WebEngine>=6.7.0,<7.0.0 pywin32 diff --git a/status_bar.py b/status_bar.py new file mode 100644 index 0000000..8685361 --- /dev/null +++ b/status_bar.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +""" +status_bar.py + +This module defines the status bar functionality for the Miller Columns File Manager application. +""" + +from PyQt6.QtWidgets import QLabel + +def create_status_bar(window): + """ + Create and initialize the status bar. + """ + window.statusBar = window.statusBar() + + window.selected_files_label = QLabel() + window.statusBar.addWidget(window.selected_files_label) + + window.selected_files_size_label = QLabel() + window.statusBar.addWidget(window.selected_files_size_label) + + update_status_bar(window) + +def update_status_bar(window): + """ + Update the status bar with current selection information. + """ + selected_indexes = window.columns[-1].selectionModel().selectedIndexes() + num_selected_files = len(selected_indexes) + total_size = sum(window.file_model.size(index) for index in selected_indexes if index.isValid()) + + window.selected_files_label.setText(f"Selected files: {num_selected_files}") + if total_size >= 1024 ** 3: + total_size = f"{total_size / 1024 ** 3:.2f} GB" + elif total_size >= 1024 ** 2: + total_size = f"{total_size / 1024 ** 2:.2f} MB" + elif total_size >= 1024: + total_size = f"{total_size / 1024:.2f} KB" + else: + total_size = f"{total_size} bytes" + window.selected_files_size_label.setText(f"Total size: {total_size}") diff --git a/toolbar.py b/toolbar.py new file mode 100644 index 0000000..f377688 --- /dev/null +++ b/toolbar.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +from PyQt6.QtWidgets import QToolBar, QLineEdit +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtCore import Qt + +def create_toolbar(window): + """ + Create the main application toolbar. + """ + toolbar = QToolBar("Navigation") + window.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) + + up_action = QAction(QIcon.fromTheme("go-up"), "Up", window) + up_action.triggered.connect(window.go_up) + toolbar.addAction(up_action) + + home_action = QAction(QIcon.fromTheme("home"), "Home", window) + home_action.triggered.connect(window.go_home) + toolbar.addAction(home_action) + + window.path_label = QLineEdit() + window.path_label.setReadOnly(False) + window.path_label.setPlaceholderText("Enter Directory Path") + window.path_label.returnPressed.connect(window.change_path) + toolbar.addWidget(window.path_label) diff --git a/windowscontextmenu.py b/windows_context_menu.py similarity index 95% rename from windowscontextmenu.py rename to windows_context_menu.py index c6c06c4..ea713fb 100644 --- a/windowscontextmenu.py +++ b/windows_context_menu.py @@ -1,111 +1,111 @@ -#!/usr/bin/env python3 - -""" -Script for interacting with Windows context menus using PyQt6 and win32com. - -This script provides a function to show a context menu for a given file or folder path. -The context menu is constructed in Qt and is populated with the available verbs. -When a verb is selected, it is executed using the win32com library. -""" - -import os -from pathlib import Path -from typing import Sequence - -from PyQt6.QtGui import QAction, QIcon, QCursor -from PyQt6.QtWidgets import QApplication, QMenu, QMessageBox - -import win32com.client - - -def _safe_path_parse(file_path: os.PathLike | str) -> Path: - """Safely parse a file path to a Path object.""" - return Path(file_path) - - -def show_context_menu(paths: Sequence[os.PathLike | str]): - """ - Show the appropriate context menu. - - Args: - paths (Sequence[os.PathLike | str]): The paths for which to show the context menu. - """ - if isinstance(paths, (str, os.PathLike)): - paths = [_safe_path_parse(paths)] - elif isinstance(paths, list): - paths = [_safe_path_parse(p) for p in paths] - else: - return - - menu = QMenu() - - shell = win32com.client.Dispatch("Shell.Application") - items = [shell.NameSpace(str(p.parent)).ParseName(p.name) for p in paths] - - print(f"Paths: {paths}") - - # Populate context menu with verbs - # FIXME: Handle multiple items; currently only the first item is used. This might mean that we need to get the Verbs in a different way? - # TODO: Check if https://github.com/NickHugi/PyKotor/blob/master/Libraries/Utility/src/utility/system/windows_context_menu.py handles multiple items better - # May need to take a look at SHMultiFileProperties and https://stackoverflow.com/a/34551988/1839209. - verbs = items[0].Verbs() - for verb in verbs: - if verb.Name: - app = QApplication.instance() - action = QAction(verb.Name, app) - action.triggered.connect(lambda _, v=verb: execute_verb(v)) - menu.addAction(action) - else: - menu.addSeparator() - - menu.exec(QCursor.pos()) - - -def execute_verb(verb): - """ - Execute the specified verb. - - Args: - verb: The verb to execute. - """ - try: - print(f"Executing verb: {verb.Name}") - verb.DoIt() - except Exception as e: - show_error_message(f"An error occurred while executing the action: {e}") - -def show_error_message(message): - """ - Display an error message. - - Args: - message (str): The error message to display. - """ - app = QApplication.instance() - if app is None: - app = QApplication([]) - msg_box = QMessageBox() - msg_box.setIcon(QMessageBox.Icon.Critical) - msg_box.setText("An error occurred.") - msg_box.setInformativeText(message) - msg_box.setWindowTitle("Error") - msg_box.exec() - - -if __name__ == "__main__": - app = QApplication([]) - - # Example with multiple file paths - multiple_files = [ - r"C:\Windows\System32\notepad.exe", - r"C:\Windows\System32\calc.exe", - ] - show_context_menu(multiple_files) - - # Example with a folder path - folderpath = r"C:\Windows\System32" - show_context_menu(folderpath) - - # Example with a file path - filepath = r"C:\Windows\System32\notepad.exe" - show_context_menu(filepath) +#!/usr/bin/env python3 + +""" +Script for interacting with Windows context menus using PyQt6 and win32com. + +This script provides a function to show a context menu for a given file or folder path. +The context menu is constructed in Qt and is populated with the available verbs. +When a verb is selected, it is executed using the win32com library. +""" + +import os +from pathlib import Path +from typing import Sequence + +from PyQt6.QtGui import QAction, QIcon, QCursor +from PyQt6.QtWidgets import QApplication, QMenu, QMessageBox + +import win32com.client + + +def _safe_path_parse(file_path: os.PathLike | str) -> Path: + """Safely parse a file path to a Path object.""" + return Path(file_path) + + +def show_context_menu(paths: Sequence[os.PathLike | str]): + """ + Show the appropriate context menu. + + Args: + paths (Sequence[os.PathLike | str]): The paths for which to show the context menu. + """ + if isinstance(paths, (str, os.PathLike)): + paths = [_safe_path_parse(paths)] + elif isinstance(paths, list): + paths = [_safe_path_parse(p) for p in paths] + else: + return + + menu = QMenu() + + shell = win32com.client.Dispatch("Shell.Application") + items = [shell.NameSpace(str(p.parent)).ParseName(p.name) for p in paths] + + print(f"Paths: {paths}") + + # Populate context menu with verbs + # FIXME: Handle multiple items; currently only the first item is used. This might mean that we need to get the Verbs in a different way? + # TODO: Check if https://github.com/NickHugi/PyKotor/blob/master/Libraries/Utility/src/utility/system/windows_context_menu.py handles multiple items better + # May need to take a look at SHMultiFileProperties and https://stackoverflow.com/a/34551988/1839209. + verbs = items[0].Verbs() + for verb in verbs: + if verb.Name: + app = QApplication.instance() + action = QAction(verb.Name, app) + action.triggered.connect(lambda _, v=verb: execute_verb(v)) + menu.addAction(action) + else: + menu.addSeparator() + + menu.exec(QCursor.pos()) + + +def execute_verb(verb): + """ + Execute the specified verb. + + Args: + verb: The verb to execute. + """ + try: + print(f"Executing verb: {verb.Name}") + verb.DoIt() + except Exception as e: + show_error_message(f"An error occurred while executing the action: {e}") + +def show_error_message(message): + """ + Display an error message. + + Args: + message (str): The error message to display. + """ + app = QApplication.instance() + if app is None: + app = QApplication([]) + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Icon.Critical) + msg_box.setText("An error occurred.") + msg_box.setInformativeText(message) + msg_box.setWindowTitle("Error") + msg_box.exec() + + +if __name__ == "__main__": + app = QApplication([]) + + # Example with multiple file paths + multiple_files = [ + r"C:\Windows\System32\notepad.exe", + r"C:\Windows\System32\calc.exe", + ] + show_context_menu(multiple_files) + + # Example with a folder path + folderpath = r"C:\Windows\System32" + show_context_menu(folderpath) + + # Example with a file path + filepath = r"C:\Windows\System32\notepad.exe" + show_context_menu(filepath) \ No newline at end of file diff --git a/windows_integration.py b/windows_integration.py new file mode 100644 index 0000000..fb6c3a5 --- /dev/null +++ b/windows_integration.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +""" +Windows integration module for Miller Columns File Manager application. + +This module contains functions for platform-specific functionalities on Windows, +including displaying context menus and retrieving file properties. +""" + +import os +from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtCore import QModelIndex + +import windows_map_drives + +def show_context_menu(window, pos, column_view): + """ + Display a context menu at the given position for the specified column view. + """ + indexes = column_view.selectedIndexes() + + if not indexes: + parent_index = column_view.rootIndex() + if parent_index.isValid(): + file_paths = [window.file_model.filePath(parent_index)] + else: + return + else: + file_paths = [window.file_model.filePath(index) for index in indexes] + + if os.name == 'nt': + try: + import windows_context_menu + windows_context_menu.show_context_menu(file_paths) + except Exception as e: + QMessageBox.critical(window, "Error", f"{e}") + return + else: + QMessageBox.critical(window, "Error", "Context menu not supported on this platform.") + +def show_properties(window, index: QModelIndex): + """ + Show properties for the file or directory specified by the QModelIndex. + """ + if index.isValid(): + file_path = window.file_model.filePath(index) + if os.name == 'nt': + import windows_properties + windows_properties.get_file_properties(file_path) + else: + print("show_properties not implemented for this platform") + +def map_network_drive(window): + """ + Map a network drive. + """ + if os.name == 'nt': + network_drive_manager = windows_map_drives.NetworkDriveManager() + map_dialog = windows_map_drives.MapDriveDialog(network_drive_manager) + map_dialog.exec() + + else: + QMessageBox.critical(window, "Error", "Network drive mapping not supported on this platform.") + +def unmap_network_drive(window): + """ + Unmap a network drive. + """ + if os.name == 'nt': + network_drive_manager = windows_map_drives.NetworkDriveManager() + unmap_dialog = windows_map_drives.UnmapDriveDialog(network_drive_manager) + unmap_dialog.exec() + else: + QMessageBox.critical(window, "Error", "Network drive unmapping not supported on this platform.") \ No newline at end of file diff --git a/windowsmapdrives.py b/windows_map_drives.py similarity index 84% rename from windowsmapdrives.py rename to windows_map_drives.py index 6c09547..d176fa1 100644 --- a/windowsmapdrives.py +++ b/windows_map_drives.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 +import os import sys import string import subprocess from PyQt6.QtWidgets import QApplication, QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QMessageBox, QComboBox -import ctypes -from ctypes import wintypes -from string import ascii_uppercase + class NetworkDriveManager: """ @@ -44,6 +43,7 @@ def unmap_drive(self, drive_letter): :param drive_letter: The drive letter to unmap (e.g., 'Z:') """ + drive_letter = drive_letter[0] + ":" try: subprocess.check_call(['net', 'use', '/del', drive_letter]) QMessageBox.information(None, "Success", f"Drive {drive_letter} unmapped successfully") @@ -56,39 +56,12 @@ def get_available_drive_letters(self): :return: List of available drive letters """ - used_drives = set() - try: - result = subprocess.run(['net', 'use'], capture_output=True, text=True, encoding='cp437') - lines = result.stdout.strip().split('\n') - for line in lines[1:]: - parts = line.split() - if len(parts) >= 2 and parts[1].strip(':') in string.ascii_uppercase: - drive_letter = parts[1].strip(':').upper() - used_drives.add(drive_letter) - except subprocess.CalledProcessError as e: - QMessageBox.critical(None, "Error", f"Failed to retrieve used drive letters\n{e}") - + used_drives = get_drive_letters() + used_drives = [d[0] for d in used_drives] # Remove all but the first character of the drive letter + print("Used drives: ", used_drives) available_drives = [d for d in string.ascii_uppercase if d not in used_drives] return available_drives - def get_mapped_network_drives(self): - """ - Get a list of mapped network drives. - - :return: List of tuples (drive_letter, network_path) - """ - drives = [] - kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - - for letter in ascii_uppercase: - root_path = f"{letter}:\\" - drive_type = kernel32.GetDriveTypeW(root_path) - - if drive_type not in [2, 3]: # Exclude DRIVE_REMOVABLE (2) and DRIVE_FIXED (3) - drives.append(root_path) - - return drives - class MapDriveDialog(QDialog): """ @@ -175,7 +148,7 @@ def __init__(self, network_drive_manager): self.layout = QVBoxLayout() self.drive_letter_combo = QComboBox() - mapped_drives = self.network_drive_manager.get_mapped_network_drives() + mapped_drives = get_drive_letters() self.drive_letter_combo.addItems([drive[0] for drive in mapped_drives]) self.drive_letter_combo.setPlaceholderText("Select Drive Letter") self.layout.addWidget(self.drive_letter_combo) @@ -218,6 +191,22 @@ def unmap_drive(self): self.network_drive_manager.unmap_drive(drive_letter) self.close() +def get_drive_letters(): + """ + Get a list of drive letters for all connected drives. + """ + if os.name == 'nt': + import string + from ctypes import windll + drives = [] + bitmask = windll.kernel32.GetLogicalDrives() + for letter in string.ascii_uppercase: + if bitmask & 1: + drives.append(f"{letter}:\\") + bitmask >>= 1 + return drives + else: + return [] if __name__ == '__main__': app = QApplication(sys.argv) @@ -230,4 +219,4 @@ def unmap_drive(self): map_dialog.show() unmap_dialog.show() - sys.exit(app.exec()) + sys.exit(app.exec()) \ No newline at end of file diff --git a/windowsproperties.py b/windows_properties.py similarity index 95% rename from windowsproperties.py rename to windows_properties.py index 02441a3..c583387 100644 --- a/windowsproperties.py +++ b/windows_properties.py @@ -1,45 +1,46 @@ -import os -import win32com.client # Make sure to install pywin32 - -def get_file_properties(file_path): - """ - Retrieves properties of a file specified by file_path using Shell.Application. - - Args: - file_path (str): The path to the file whose properties are to be retrieved. - - Returns: - dict: A dictionary mapping property indices to tuples of (attribute_name, attribute_value). - """ - print(f"Getting properties for {file_path}") - file_path = file_path.replace("/", "\\") - properties = {} - - try: - shell = win32com.client.Dispatch("Shell.Application") - namespace = shell.NameSpace(os.path.dirname(file_path)) - item = namespace.ParseName(os.path.basename(file_path)) - - # Invoke the system properties dialog - item.InvokeVerb("properties") - - # Fetching up to 100 because that's where typical file properties are - for i in range(100): - attr_name = namespace.GetDetailsOf(None, i) - if attr_name: - attr_value = namespace.GetDetailsOf(item, i) - properties[i] = (attr_name, attr_value) - - except AttributeError as e: - print(f"AttributeError: {e}") - except pywintypes.com_error as e: - print(f"COM Error: {e}") - except Exception as e: - print(f"An unexpected error occurred: {e}") - - return properties - -if __name__ == "__main__": - PATH = r"C:\Windows\System32\notepad.exe" - properties = get_file_properties(PATH) - print(properties) +import os +import win32com.client # Make sure to install pywin32 +import pywintypes + +def get_file_properties(file_path): + """ + Retrieves properties of a file specified by file_path using Shell.Application. + + Args: + file_path (str): The path to the file whose properties are to be retrieved. + + Returns: + dict: A dictionary mapping property indices to tuples of (attribute_name, attribute_value). + """ + print(f"Getting properties for {file_path}") + file_path = file_path.replace("/", "\\") + properties = {} + + try: + shell = win32com.client.Dispatch("Shell.Application") + namespace = shell.NameSpace(os.path.dirname(file_path)) + item = namespace.ParseName(os.path.basename(file_path)) + + # Invoke the system properties dialog + item.InvokeVerb("properties") + + # Fetching up to 100 because that's where typical file properties are + for i in range(100): + attr_name = namespace.GetDetailsOf(None, i) + if attr_name: + attr_value = namespace.GetDetailsOf(item, i) + properties[i] = (attr_name, attr_value) + + except AttributeError as e: + print(f"AttributeError: {e}") + except pywintypes.com_error as e: + print(f"COM Error: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + + return properties + +if __name__ == "__main__": + PATH = r"C:\Windows\System32\notepad.exe" + properties = get_file_properties(PATH) + print(properties) \ No newline at end of file