Files
linuxcncAllWasm/lib/python/qtvcp/widgets/file_manager.py
2026-08-20 06:17:39 +08:00

750 lines
28 KiB
Python

#!/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())