# this project is licensed under the WTFPLv2, see COPYING.txt for details
import os
import re
from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex, QRegExp, Qt, QTimer, QElapsedTimer, QEvent
from PyQt5.QtGui import QStandardItem, QStandardItemModel
from PyQt5.QtWidgets import QVBoxLayout, QLineEdit, QTreeView, QWidget, QFileSystemModel, QApplication
from eye.consts import AbsolutePathRole
from eye.helpers.intent import send_intent
from eye.qt import Slot
from eye.structs import PropDict
from eye.widgets.helpers import WidgetMixin
__all__ = ('FileChooser', 'SubSequenceFileChooser', 'selectFileInChooser')
def commonPrefix(strings):
res = ''
for cs in zip(*strings):
if len(set(cs)) == 1:
res += cs[0]
else:
break
return res
class RootChangerProxy(QSortFilterProxyModel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.srcRoot = QModelIndex()
def setRootSource(self, srcRoot):
self.modelAboutToBeReset.emit() # TODO be a bit more clever
self.srcRoot = srcRoot
self.modelReset.emit()
self.sourceModel().fetchMore(srcRoot)
def mapToSource(self, proxyIdx):
if not proxyIdx.isValid():
return self.srcRoot
srcParent = self.mapToSource(proxyIdx.parent())
return self.sourceModel().index(proxyIdx.row(), proxyIdx.column(), srcParent)
def mapFromSource(self, srcIdx):
if srcIdx == self.srcRoot or not srcIdx.isValid():
return QModelIndex()
proxyParent = self.mapFromSource(srcIdx.parent())
return self.index(srcIdx.row(), srcIdx.column(),proxyParent)
class BaseFileChooser(QWidget, WidgetMixin):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.options = PropDict()
# sub-widgets
layout = QVBoxLayout()
self.setLayout(layout)
self.edit = QLineEdit()
layout.addWidget(self.edit)
self.setFocusProxy(self.edit)
self.view = QTreeView()
layout.addWidget(self.view)
self.edit.installEventFilter(self)
self.setWindowTitle(self.tr('File selector'))
self.add_category('filechooser')
def setModel(self, model):
self.view.setModel(model)
@Slot(str)
def setRoot(self, path):
raise NotImplementedError()
def openFile(self, path):
send_intent(self, 'open_editor', path=path, reason='filechooser')
def eventFilter(self, obj, ev):
if (obj is not self.edit
or ev.type() not in (QEvent.KeyPress, QEvent.KeyRelease)
or ev.key() not in (Qt.Key_Down, Qt.Key_Up, Qt.Key_PageUp, Qt.Key_PageDown)):
return super().eventFilter(obj, ev)
QApplication.sendEvent(self.view, ev)
return True
def walk_files(root, ignore_re=None):
for dp, dirs, files in os.walk(root):
if ignore_re:
dirs[:] = filter(ignore_re.match, dirs)
for f in files:
if ignore_re and ignore_re.match(f):
continue
yield os.path.join(dp, f)
class SubSequenceProxy(QSortFilterProxyModel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.reobj = re.compile('')
self.scores = {}
def setFilter(self, text):
parts = map(QRegExp.escape, text)
pattern = '.*?'.join('(%s)' % part for part in parts)
self.reobj = re.compile(pattern, re.I)
self.cache = {}
self.invalidate()
def filterAcceptsRow(self, row, parent):
if not self.reobj.pattern:
return False
mdl = self.sourceModel()
qidx = mdl.index(row, 0, parent)
text = mdl.data(qidx)
mtc = self.reobj.search(text)
if mtc:
self.scores[text] = self._score_match(mtc, text)
return bool(mtc)
def _score(self, qidx):
text = self.sourceModel().data(qidx)
try:
return self.scores[text]
except KeyError:
pass
mtc = self.reobj.search(text)
self.scores[text] = self._score_match(mtc, text)
return self.scores[text]
def _score_match(self, mtc, text):
n = self.reobj.groups
seq = 0
sub = 0
left = 0
for i in range(1, n + 1):
if i == 1:
left += mtc.start(i)
else:
if mtc.start(i) == mtc.start(i - 1) + 1:
seq += 1
else:
left += mtc.start(i) - mtc.start(i - 1)
if mtc.start(i) == 0:
sub += 1
else:
sub += int(text[mtc.start(i) - 1] in '/.-_')
score = (-seq, -sub, left, text)
return score
def lessThan(self, qidx1, qidx2):
if not self.reobj.groups:
return qidx1.data() < qidx2.data()
return self._score(qidx1) < self._score(qidx2)
[docs]
class SubSequenceFileChooser(BaseFileChooser):
maxSecsPerCrawlBatch = .1
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.mdl = QStandardItemModel()
self.filter = SubSequenceProxy()
self.filter.setSourceModel(self.mdl)
self.view.setModel(self.filter)
self.view.setRootIsDecorated(False)
self.view.setAlternatingRowColors(True)
self.view.sortByColumn(0, Qt.AscendingOrder)
self.view.activated.connect(self._on_activated)
self.edit.textEdited.connect(self._on_text_edited)
self.crawlTimer = QTimer()
# restart the timer manually so an exception breaks the loop and timer
self.crawlTimer.setSingleShot(True)
self.crawlTimer.timeout.connect(self.crawlBatch)
self.crawler = None
@Slot(str)
def _on_text_edited(self, text):
selected = self.view.selectedIndexes()
qidx = None
if len(selected):
qidx = self.filter.mapToSource(selected[0])
self.filter.setFilter(text)
if qidx is not None:
qidx = self.filter.mapFromSource(qidx)
if qidx is None or not qidx.isValid():
qidx = self.filter.index(0, 0)
self.view.setCurrentIndex(qidx)
[docs]
@Slot()
def crawlBatch(self):
start_time = QElapsedTimer()
start_time.start()
prefix_len = len(self.root) + 1 # 1 for the /
for path in self.crawler:
subpath = path[prefix_len:]
qitem = QStandardItem(subpath)
qitem.setData(path, AbsolutePathRole)
qitem.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
self.mdl.appendRow(qitem)
if start_time.hasExpired(self.maxSecsPerCrawlBatch * 1000):
self.crawlTimer.start(0)
break
[docs]
def setRoot(self, root):
self.root = os.path.abspath(root)
self.mdl.clear()
self.mdl.setHorizontalHeaderLabels([self.tr('File')])
self.crawler = walk_files(root)
self.crawlTimer.start(0)
@Slot(QModelIndex)
def _on_activated(self, qidx):
path = self.filter.data(qidx, AbsolutePathRole)
if not os.path.isfile(path): # symlinks?
return
self.openFile(path)
# TODO be able to configure crawling: ignored patterns, depth, etc.
[docs]
class FileChooser(BaseFileChooser):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.options = PropDict()
self.edit.textEdited.connect(self._on_text_edited)
self.view.activated.connect(self._on_activated)
# models
self.rootChanger = RootChangerProxy()
fsModel = QFileSystemModel(self)
self.setModel(fsModel)
self.filter = QSortFilterProxyModel()
self.filter.setSourceModel(self.rootChanger)
self.view.setModel(self.filter)
[docs]
def setModel(self, model):
self.baseModel = model
self.rootChanger.setSourceModel(self.baseModel)
[docs]
@Slot(str)
def setRoot(self, path):
self.root = path
srcIdx = self.baseModel.setRootPath(path)
self.rootChanger.setRootSource(srcIdx)
self.view.setRootIndex(QModelIndex())
@Slot(str)
def _on_text_edited(self, txt):
elems = txt.rsplit('/', 1)
if len(elems) == 2:
dir, base = elems
else:
dir, base = '', elems[0]
path = os.path.join(self.root, dir)
self.rootChanger.setRootSource(self.baseModel.index(path))
self.filter.setFilterRegExp(QRegExp(base, Qt.CaseInsensitive, QRegExp.Wildcard))
if self.options.get('autosuggest'):
names = [self.filter.data(self.filter.index(i, 0)).toString()
for i in range(self.filter.rowCount(QModelIndex()))]
names = [n[len(base):] for n in names]
add = commonPrefix(names)
cursor = self.edit.cursorPosition()
self.edit.setText(self.edit.text()[:cursor] + add)
self.edit.setSelection(cursor, len(self.edit.text()))
@Slot(QModelIndex)
def _on_activated(self, idx):
idx = self.filter.mapToSource(idx)
idx = self.rootChanger.mapToSource(idx)
info = self.baseModel.fileInfo(idx)
if info.isDir():
return
path = info.absoluteFilePath()
self.openFile(path)
[docs]
def selectFileInChooser(path, chooser):
"""Select file/dir item in file chooser
Does nothing if file is not in the tree viewed by file chooser.
"""
if not path.startswith(chooser.root):
return False
model = chooser.view.model()
chain = [model]
while hasattr(model, "sourceModel"):
model = model.sourceModel()
chain.append(model)
qidx = chain[-1].index(path)
if not qidx.isValid():
return False
for model in reversed(chain[:-1]):
qidx = model.mapFromSource(qidx)
chooser.view.setCurrentIndex(qidx)
return True