#!/usr/bin/env python3 import sys import os import shutil from collections import OrderedDict from qtpy.QtCore import QCoreApplication from qtpy.QtWidgets import (QApplication, QFileSystemModel, QWidget, QVBoxLayout, QHBoxLayout, QListView, QComboBox, QPushButton, QToolButton, QSizePolicy, QMenu, QAction, QLineEdit, QCheckBox, QTableView, QHeaderView, QMessageBox) from qtpy.QtGui import QIcon, QDragEnterEvent, QDropEvent from qtpy.QtCore import (QModelIndex, QDir, Qt, Slot, Signal, Property, QFileInfo, QMimeData, QItemSelectionModel, QItemSelection, QSortFilterProxyModel) from qtvcp.widgets.widget_baseclass import _HalWidgetBase from qtvcp.core import Status, Action, Info from qtvcp import logger # Instantiate the libraries with global reference # STATUS gives us status messages from linuxcnc # ACTION gives commands to linuxcnc # INFO holds INI file details # LOG is for running code logging STATUS = Status() ACTION = Action() INFO = Info() LOG = logger.getLogger(__name__) # Force the log level for this module # LOG.setLevel(logger.DEBUG) # One of DEBUG, INFO, WARNING, ERROR, CRITICAL _translate = QCoreApplication.translate class SharedViewMixin: itemDropped = Signal(list) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() def dragMoveEvent(self, event: QDragEnterEvent): event.acceptProposedAction() def dropEvent(self, event: QDropEvent): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): source_path = url.toLocalFile() proxy_index = self.indexAt(event.pos()) if proxy_index.isValid(): source_index = self.proxy_model.mapToSource(proxy_index) if source_index.isValid(): destination_path = self.source_model.filePath(source_index) if os.path.isdir(destination_path): dest_file = os.path.join(destination_path, os.path.basename(source_path)) else: dest_file = os.path.join(os.path.dirname(destination_path), os.path.basename(source_path)) else: destination_path = self.source_model.rootPath() dest_file = os.path.join(destination_path, os.path.basename(source_path)) # isdir = True if os.path.isdir(source_path) else False self.itemDropped.emit([source_path, dest_file]) event.acceptProposedAction() else: event.ignore() class CustomTableView(QTableView, SharedViewMixin): def __init__(self, proxy_model, parent=None): super().__init__(parent) self.setModel(proxy_model) self.proxy_model = proxy_model self.source_model = proxy_model.sourceModel() self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QTableView.DragDrop) class CustomListView(QListView, SharedViewMixin): def __init__(self, proxy_model, parent=None): super().__init__(parent) self.setModel(proxy_model) self.proxy_model = proxy_model self.source_model = proxy_model.sourceModel() self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QListView.DragDrop) class RestrictedProxyModel(QSortFilterProxyModel): def __init__(self, parent=None): super().__init__(parent) self.base_path = '/home' self.restricted = False def filterAcceptsRow(self, source_row, source_parent): if not self.restricted: return True model = self.sourceModel() index = model.index(source_row, 0, source_parent) if not index.isValid(): return False # Only allow paths inside the base path file_path = QDir(model.filePath(index)).canonicalPath() return file_path.startswith(self.base_path) def set_rootpath(self, path): self.base_path = path def get_rootpath(self): return self.base_path def set_restricted(self, state: bool): self.restricted = state self.invalidateFilter() def lessThan(self, left, right): # name if left.column() == 0: left_data = self.sourceModel().fileInfo(left) right_data = self.sourceModel().fileInfo(right) if left_data.isDir() and not right_data.isDir(): return True if not left_data.isDir() and right_data.isDir(): return False return left_data.fileName().lower() < right_data.fileName().lower() # size if left.column() == 1: left_data = self.sourceModel().size(left) right_data = self.sourceModel().size(right) return left_data < right_data # last modified if left.column() == 3: left_data = self.sourceModel().fileInfo(left) right_data = self.sourceModel().fileInfo(right) return left_data.lastModified() < right_data.lastModified() # default return False class FileManager(QWidget, _HalWidgetBase): def __init__(self, parent=None): super(FileManager, self).__init__(parent) self.title = _translate('FileManager','Qtvcp File System View') self._last = 0 self._doubleClick = False self._showListView = False if INFO.PROGRAM_PREFIX is not None: self.user_path = os.path.expanduser(INFO.PROGRAM_PREFIX) else: self.user_path = (os.path.join(os.path.expanduser('~'), 'linuxcnc/nc_files')) user = os.path.split(os.path.expanduser('~') )[-1] # check for Ubuntu/Mint path first media = os.path.join('/media', user) if os.path.exists(media): self.media_path = media else: self.media_path = '/media' temp = [('User', self.user_path), ('Media', self.media_path)] self._jumpList = OrderedDict(temp) self.currentPath = None self.currentFolder = None self.jump_delete = [] self.PREFS_ = None self.initUI() def initUI(self): self.setWindowTitle(self.title) self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)) line_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) button_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) box_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) self.textLine = QLineEdit() self.textLine.setToolTip('Current Directory/selected File') self.textLine.setSizePolicy(line_policy) self.textLine.setMinimumHeight(40) self.pasteButton = QPushButton() self.pasteButton.setSizePolicy(button_policy) self.pasteButton.setMinimumSize(80, 40) self.pasteButton.setEnabled(False) self.pasteButton.setText('Paste') self.pasteButton.setToolTip('Copy file from copy path to current directory/file') self.pasteButton.clicked.connect(self.paste) self.pasteButton.hide() pasteBox = QHBoxLayout() pasteBox.addWidget(self.textLine) pasteBox.addWidget(self.pasteButton) self.copyLine = QLineEdit() self.copyLine.setSizePolicy(line_policy) self.copyLine.setToolTip('File path to copy from when pasting') self.copyLine.setMinimumHeight(40) self.copyLine.setReadOnly(True) self.copyButton = QPushButton() self.copyButton.setSizePolicy(button_policy) self.copyButton.setMinimumSize(80, 40) self.copyButton.setText('Copy') self.copyButton.setToolTip('Record current file as copy path') self.copyButton.clicked.connect(self.recordCopyPath) self.copyBox = QHBoxLayout() self.copyBox.addWidget(self.copyButton) self.copyBox.addWidget(self.copyLine) self.copyLine.hide() self.copyButton.hide() self.model = QFileSystemModel() self.model.setRootPath(QDir.currentPath()) self.model.setFilter(QDir.AllDirs | QDir.NoDot | QDir.Files) self.model.setNameFilterDisables(False) self.model.rootPathChanged.connect(self.folderChanged) self.proxy_model = RestrictedProxyModel() self.proxy_model.setSourceModel(self.model) self.list = CustomListView(self.proxy_model) self.list.setModel(self.proxy_model) self.list.resize(640, 480) self.list.setAlternatingRowColors(True) self.list.hide() self.table = CustomTableView(self.proxy_model) self.table.setModel(self.proxy_model) self.table.resize(640, 480) self.table.setAlternatingRowColors(True) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.swapSections(1,3) header.setSortIndicator(1,Qt.AscendingOrder) self.table.setSortingEnabled(True) self.table.setColumnHidden(2, True) # type self.table.verticalHeader().setVisible(False) # row count header self.cb = QComboBox() self.cb.currentIndexChanged.connect(self.filterChanged) self.fillCombobox(INFO.PROGRAM_FILTERS_EXTENSIONS) self.cb.setMinimumSize(200,40) self.cb.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) self.jumpButton = QToolButton() self.jumpButton.setText(_translate('FileManager','User')) self.jumpButton.setSizePolicy(line_policy) self.jumpButton.setMinimumSize(40, 40) self.jumpButton.setMaximumSize(80, 40) self.jumpButton.setToolTip('Jump to User directory.\nLong press for Options.') self.jumpButton.clicked.connect(self.onJumpClicked) self.addButton = QPushButton() self.addButton.setText(_translate('FileManager','Add\nJump')) self.addButton.setSizePolicy(line_policy) self.addButton.setMinimumSize(40, 40) self.addButton.setMaximumSize(80, 40) self.addButton.setToolTip('Add current directory to jump button list') self.addButton.clicked.connect(self.onActionClicked) self.delButton = QPushButton() self.delButton.setText(_translate('FileManager','Del\nJump')) self.delButton.setSizePolicy(line_policy) self.delButton.setMinimumSize(40, 40) self.delButton.setMaximumSize(80, 40) self.delButton.setToolTip('Delete current directory from jump button list') self.delButton.clicked.connect(self.onActionClicked) self.loadButton = QPushButton() self.loadButton.setText(_translate('FileManager','Load')) self.loadButton.setSizePolicy(line_policy) self.loadButton.setMinimumSize(40, 40) self.loadButton.setMaximumSize(80, 40) self.loadButton.setToolTip('Load selected file') self.loadButton.clicked.connect(self._getPathActivated) self.chk_restricted = QCheckBox(_translate('FileManager','Restricted')) self.chk_restricted.setSizePolicy(box_policy) self.chk_restricted.setToolTip('Restrict navigation past root path') self.chk_restricted.stateChanged.connect(lambda state: self.setRestricted(state)) self.copy_control = QCheckBox(_translate('FileManager','Copy Controls')) self.copy_control.setSizePolicy(box_policy) self.copy_control.stateChanged.connect(lambda state: self.showCopyControls(state)) self.checkbox = QHBoxLayout() self.checkbox.addWidget(self.chk_restricted) self.checkbox.addWidget(self.copy_control) self.checkbox.insertStretch(2, stretch=0) self.settingMenu = QMenu(self) self.jumpButton.setMenu(self.settingMenu) hbox = QHBoxLayout() hbox.addWidget(self.jumpButton) hbox.addWidget(self.addButton) hbox.addWidget(self.delButton) hbox.addWidget(self.loadButton) hbox.addWidget(self.cb) self.windowLayout = QVBoxLayout() self.windowLayout.addLayout(pasteBox) self.windowLayout.addLayout(self.copyBox) self.windowLayout.addWidget(self.list) self.windowLayout.addWidget(self.table) self.windowLayout.addLayout(hbox) self.windowLayout.addLayout(self.checkbox) self.setLayout(self.windowLayout) def _hal_init(self): if self.PREFS_: last_path = self.PREFS_.getpref('last_loaded_directory', self.user_path, str, 'BOOK_KEEPING') LOG.debug(f"LAST FILE PATH: {last_path}") if not last_path == '' and os.path.exists(last_path): self.updateDirectoryView(last_path) else: self.updateDirectoryView(self.user_path) # get all the saved jumplist paths temp = self.PREFS_.getall('FILEMANAGER_JUMPLIST') self._jumpList.update(temp) else: LOG.debug(f"LAST FILE PATH: {self.user_path}") self.updateDirectoryView(self.user_path) # install jump paths into toolbutton menu for i in self._jumpList: self.addAction(i) # set recorded columns sort settings self.SETTINGS_.beginGroup(f"FileManager-{self.objectName()}") sect = self.SETTINGS_.value('sortIndicatorSection', type = int) order = self.SETTINGS_.value('sortIndicatorOrder', type = int) self.SETTINGS_.endGroup() if not None in(sect,order): self.table.horizontalHeader().setSortIndicator(sect, Qt.SortOrder(order)) self.connectSelection() # when qtvcp closes this gets called # record jump list paths def _hal_cleanup(self): if self.PREFS_: for opt in self.jump_delete: self.PREFS_.removepref(opt, 'FILEMANAGER_JUMPLIST') for i, key in enumerate(self._jumpList): if i in(0,1): continue self.PREFS_.putpref(key, self._jumpList.get(key), str, 'FILEMANAGER_JUMPLIST') # record sorted columns h = self.table.horizontalHeader() self.SETTINGS_.beginGroup(f"FileManager-{self.objectName()}") self.SETTINGS_.setValue('sortIndicatorSection', h.sortIndicatorSection()) self.SETTINGS_.setValue('sortIndicatorOrder', h.sortIndicatorOrder()) self.SETTINGS_.endGroup() ######################### # callbacks ######################### # add shown text and hidden filter data from the INI def fillCombobox(self, data): for i in data: self.cb.addItem(i[0],i[1]) def folderChanged(self, data): data = os.path.normpath(data) self.currentFolder = data self.textLine.setText(data) def updateDirectoryView(self, path, quiet = False): if os.path.exists(path): self.list.selectionModel().clearSelection() self.list.setRootIndex(QModelIndex()) self.table.selectionModel().clearSelection() self.table.setRootIndex(QModelIndex()) index = self.model.setRootPath(path) if index.isValid(): proxy_index = self.proxy_model.mapFromSource(index) self.list.setRootIndex(proxy_index) self.table.setRootIndex(proxy_index) self.proxy_model.invalidate() else: LOG.debug(f"Set directory view error - no such path {path}") if not quiet: STATUS.emit('error', STATUS.TEMPORARY_MESSAGE, f"File Manager error - No such path: {path}") # retrieve selected filter (it's held as QT.userData) def filterChanged(self, index): userdata = self.cb.itemData(index) self.model.setNameFilters(userdata) def listClicked(self, index): # the signal passes the index of the clicked item source_index = self.proxy_model.mapToSource(index) dir_path = os.path.normpath(self.model.filePath(source_index)) if self.model.fileInfo(source_index).isFile(): self.currentPath = dir_path self.textLine.setText(self.currentPath) return else: self.currentPath = None self.textLine.setText(os.path.abspath(dir_path)) self.updateDirectoryView(dir_path) def onUserClicked(self): self.showUserDir() def onMediaClicked(self): self.showMediaDir() # jump directly to a saved path shown on the button def onJumpClicked(self): data = self.jumpButton.text() if data.upper() == 'MEDIA': self.showMediaDir() elif data.upper() == 'USER': self.showUserDir() elif data in self._jumpList: temp = self._jumpList[data] if temp is not None: self.updateDirectoryView(temp) else: STATUS.emit('error',STATUS.OPERATOR_ERROR, f'file jumppath: {data} not valid') LOG.debug(f'file jumppath: {data} not valid') else: self.jumpButton.setText('User') # jump directly to a saved path from the menu def jumpTriggered(self, data): name = data self.jumpButton.setText(name) if data.upper() == 'MEDIA': self.jumpButton.setToolTip('Jump to Media directory.\nLong press for Options.') self.showMediaDir() elif data.upper() == 'USER': self.jumpButton.setToolTip('Jump to User directory.\nLong press for Options.') self.showUserDir() else: self.jumpButton.setToolTip(f'Jump to directory:\n{self._jumpList.get(name)}') self.updateDirectoryView(self._jumpList.get(name)) # add or remove a jump list path def onActionClicked(self): name = os.path.basename(self.currentFolder) btn = self.sender() if btn == self.addButton: try: self._jumpList[name] = self.currentFolder self.addAction(name) except Exception as e: print(e) elif btn == self.delButton: try: x = self.jumpButton.text() if name in self._jumpList: self.jump_delete.append(name) self._jumpList.pop(name) elif x in self._jumpList: self.jump_delete.append(x) self._jumpList.pop(x) else: return # don't know what to do otherwise self.settingMenu.clear() for key in self._jumpList: self.addAction(key) self.jumpButton.setText('User') except Exception as e: print(e) # get current selection and update the path # then if the path is good load it into linuxcnc # record it in the preference file if available def _getPathActivated(self): if self.list.isVisible(): row = self.list.selectionModel().currentIndex() else: row = self.table.selectionModel().currentIndex() self.listClicked(row) fname = self.currentPath if fname is None: return if fname: self.load(fname) def recordCopyPath(self): data, isFile = self.getCurrentSelected() if isFile: self.copyLine.setText(os.path.normpath(data)) self.pasteButton.setEnabled(True) else: self.copyLine.setText('') self.pasteButton.setEnabled(False) STATUS.emit('error', STATUS.OPERATOR_ERROR, 'Can only copy a file, not a folder') def paste(self): res = self.copyFile(self.copyLine.text(), self.textLine.text()) if res: self.copyLine.setText('') self.pasteButton.setEnabled(False) ######################## # helper functions ######################## def setRestricted(self, state): self.proxy_model.set_restricted(state) self.updateDirectoryView(self.proxy_model.get_rootpath()) def addAction(self, i): action = QAction(QIcon.fromTheme('user-home'), i, self) # weird lambda i=i to work around 'function closure' action.triggered.connect(lambda state, i=i: self.jumpTriggered(i)) self.settingMenu.addAction(action) def showList(self, state=True): if state: self.table.hide() self.list.show() else: self.table.show() self.list.hide() def showTable(self, state=True): self.showList(not state) def showCopyControls(self, state): if state: self.copyLine.show() self.copyButton.show() self.pasteButton.show() else: self.copyLine.hide() self.copyButton.hide() self.pasteButton.hide() def showMediaDir(self, quiet = False): self.proxy_model.set_rootpath(self.media_path) self.jumpButton.setText(_translate('FileManager','Media')) self.updateDirectoryView(self.media_path, quiet) def showUserDir(self, quiet = False): self.proxy_model.set_rootpath(self.user_path) self.jumpButton.setText(_translate('FileManager','User')) self.updateDirectoryView(self.user_path, quiet) def dropCopy(self, data): source, dest = data self.copyChecks(source, dest) def copyFile(self, source, dest): self.copyChecks(source, dest) def overwriteMessage(self, d): title = "File Already Exists" info = f"Overwrite {d}?" buttons = QMessageBox.No | QMessageBox.Yes retval = QMessageBox.warning( self, title, info, buttons) if retval == QMessageBox.No: return False return True # likely class patch candidate def copyChecks(self, s, d): if os.path.isfile(d) or os.path.isdir(d): retval = self.overwriteMessage(d) if retval == False: txt = f'File {d} not copied' mess = {'SHORTTEXT':txt,'TITLE':'FileManager', 'DETAILS':txt} opt = {'LOG':True,'LEVEL':STATUS.WARNING} STATUS.emit('status-message',mess,opt) return False self.copy(s, d) def copy(self, s, d): try: if os.path.isdir(s): shutil.copytree(s, d) else: shutil.copy2(s, d) txt = f' Copied {s} to {d}' mess = {'SHORTTEXT':txt,'TITLE':'FileManager', 'DETAILS':txt} opt = {'LOG':True,'LEVEL':STATUS.DEFAULT} STATUS.emit('status-message',mess,opt) return True except Exception as e: STATUS.emit('error', STATUS.OPERATOR_ERROR, f"Copy file error: {e}") return False @Slot(float) @Slot(int) def scroll(self, data): if data > self._last: self.up() elif data < self._last: self.down() self._last = data # moves the selection up # used with MPG scrolling def up(self): self.select_row('up') # moves the selection down # used with MPG scrolling def down(self): self.select_row('down') def select_row(self, style='down'): style = style.lower() if self.list.isVisible(): i = self.list.rootIndex() selectionModel = self.list.selectionModel() else: i = self.table.rootIndex() selectionModel = self.table.selectionModel() row = selectionModel.currentIndex().row() self.rows = self.model.rowCount(i) if style == 'last': row = self.rows elif style == 'up': if row > 0: row -= 1 else: row = 0 elif style == 'down': if row < self.rows-1: row += 1 else: row = self.rows-1 else: return top = self.model.index(row, 0, i) selectionModel.setCurrentIndex(top, QItemSelectionModel.Select | QItemSelectionModel.Rows) selection = QItemSelection(top, top) selectionModel.clearSelection() selectionModel.select(selection, QItemSelectionModel.Select) # returns the current highlighted (selected) path as well as # whether it's a file or not. def getCurrentSelected(self): if self.list.isVisible(): selectionModel = self.list.selectionModel() else: selectionModel = self.table.selectionModel() index = self.proxy_model.mapToSource(selectionModel.currentIndex()) dir_path = os.path.normpath(self.model.filePath(index)) if self.model.fileInfo(index).isFile(): return (dir_path, True) elif dir_path == '.': return (self.proxy_model.get_rootpath(), False) else: return (dir_path, False) # This can be class patched to do something else def load(self, fname=None): return # try: # if fname is None: # self._getPathActivated() # return # self.recordBookKeeping() # ACTION.OPEN_PROGRAM(fname) # STATUS.emit('update-machine-log', f'Loaded: {fname}', 'TIME,SUCCESS') # except Exception as e: # LOG.error(f"Load file error: {e}") # STATUS.emit('error', STATUS.NML_ERROR, f"Load file error: {e}") # This can be class patched to do something else def recordBookKeeping(self): fname = self.currentPath if fname is None: return if self.PREFS_: self.PREFS_.putpref('last_loaded_directory', self.model.rootPath(), str, 'BOOK_KEEPING') self.PREFS_.putpref('RecentPath_0', fname, str, 'BOOK_KEEPING') def connectSelection(self): try: self.list.disconnect() self.table.disconnect() self.list.activated.connect(self._getPathActivated) self.table.activated.connect(self._getPathActivated) except: pass self.list.itemDropped.connect(self.dropCopy) self.table.itemDropped.connect(self.dropCopy) # choose double click or single click for folder selection if self._doubleClick: self.list.doubleClicked[QModelIndex].connect(self.listClicked) self.table.doubleClicked[QModelIndex].connect(self.listClicked) else: self.list.clicked[QModelIndex].connect(self.listClicked) self.table.clicked[QModelIndex].connect(self.listClicked) ###################### # Properties ###################### # Double Click folder selection def setDoubleClickSelection(self, state): self._doubleClick = state self.connectSelection() def getDoubleClickSelection(self): return self._doubleClick def resetDoubleClickSelection(self, state): self._doubleClick = False self.connectSelection() doubleClickSelection = Property(bool, getDoubleClickSelection, setDoubleClickSelection, resetDoubleClickSelection) # list/table view selection def setShowListView(self, state): self._showListView = state self.showList(state) def getShowListView(self): return self._showListView def resetShowListView(self, state): self._showListView = False self.showList(False) showListView = Property(bool, getShowListView, setShowListView, resetShowListView) if __name__ == "__main__": import sys app = QApplication(sys.argv) gui = FileManager() gui.show() gui.PREFS_ = True gui.connectSelection() gui.onUserClicked() # gui.onMediaClicked() gui.setRestricted(True) sys.exit(app.exec())