mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
UI for the subsequence matcher
This commit is contained in:
parent
b4b1c021f7
commit
b4e2b9e93f
@ -6,10 +6,15 @@ from __future__ import (unicode_literals, division, absolute_import,
|
|||||||
__license__ = 'GPL v3'
|
__license__ = 'GPL v3'
|
||||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
import os
|
||||||
|
from itertools import izip
|
||||||
|
|
||||||
from PyQt4.Qt import (
|
from PyQt4.Qt import (
|
||||||
QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout,
|
QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout,
|
||||||
QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt)
|
QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget,
|
||||||
|
QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal)
|
||||||
|
|
||||||
|
from calibre import prepare_string_for_xml
|
||||||
from calibre.gui2 import error_dialog, choose_files, choose_save_file
|
from calibre.gui2 import error_dialog, choose_files, choose_save_file
|
||||||
from calibre.gui2.tweak_book import tprefs
|
from calibre.gui2.tweak_book import tprefs
|
||||||
|
|
||||||
@ -222,8 +227,212 @@ class ImportForeign(Dialog): # {{{
|
|||||||
return src, dest
|
return src, dest
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
# Quick Open {{{
|
||||||
|
|
||||||
|
class Results(QWidget):
|
||||||
|
|
||||||
|
EMPH = "color:magenta; font-weight:bold"
|
||||||
|
MARGIN = 4
|
||||||
|
|
||||||
|
item_selected = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
QWidget.__init__(self, parent=parent)
|
||||||
|
|
||||||
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
self.results = ()
|
||||||
|
self.current_result = -1
|
||||||
|
self.max_result = -1
|
||||||
|
self.mouse_hover_result = -1
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
self.setFocusPolicy(Qt.NoFocus)
|
||||||
|
|
||||||
|
def item_from_y(self, y):
|
||||||
|
if not self.results:
|
||||||
|
return
|
||||||
|
delta = self.results[0][0].size().height() + self.MARGIN
|
||||||
|
maxy = self.height()
|
||||||
|
pos = 0
|
||||||
|
for i, r in enumerate(self.results):
|
||||||
|
bottom = pos + delta
|
||||||
|
if pos <= y < bottom:
|
||||||
|
return i
|
||||||
|
break
|
||||||
|
pos = bottom
|
||||||
|
if pos > min(y, maxy):
|
||||||
|
break
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, ev):
|
||||||
|
y = ev.pos().y()
|
||||||
|
prev = self.mouse_hover_result
|
||||||
|
self.mouse_hover_result = self.item_from_y(y)
|
||||||
|
if prev != self.mouse_hover_result:
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mousePressEvent(self, ev):
|
||||||
|
if ev.button() == 1:
|
||||||
|
i = self.item_from_y(ev.pos().y())
|
||||||
|
if i != -1:
|
||||||
|
ev.accept()
|
||||||
|
self.current_result = i
|
||||||
|
self.update()
|
||||||
|
self.item_selected.emit()
|
||||||
|
return
|
||||||
|
return QWidget.mousePressEvent(self, ev)
|
||||||
|
|
||||||
|
def change_current(self, delta=1):
|
||||||
|
if not self.results:
|
||||||
|
return
|
||||||
|
nc = self.current_result + delta
|
||||||
|
if 0 <= nc <= self.max_result:
|
||||||
|
self.current_result = nc
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def __call__(self, results):
|
||||||
|
if results:
|
||||||
|
self.current_result = 0
|
||||||
|
prefixes = [QStaticText('<b>%s</b>' % os.path.basename(x)) for x in results]
|
||||||
|
self.maxwidth = max([x.size().width() for x in prefixes])
|
||||||
|
divider = QStaticText('\xa0→ \xa0')
|
||||||
|
divider.setTextFormat(Qt.PlainText)
|
||||||
|
self.results = tuple((prefix, divider, self.make_text(text, positions), text)
|
||||||
|
for prefix, (text, positions) in izip(prefixes, results.iteritems()))
|
||||||
|
else:
|
||||||
|
self.results = ()
|
||||||
|
self.current_result = -1
|
||||||
|
self.max_result = min(10, len(self.results) - 1)
|
||||||
|
self.mouse_hover_result = -1
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def make_text(self, text, positions):
|
||||||
|
positions = sorted(set(positions) - {-1}, reverse=True)
|
||||||
|
text = prepare_string_for_xml(text)
|
||||||
|
for p in positions:
|
||||||
|
text = '%s<span style="%s">%s</span>%s' % (text[:p], self.EMPH, text[p], text[p+1:])
|
||||||
|
text = QStaticText(text)
|
||||||
|
text.setTextFormat(Qt.RichText)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def paintEvent(self, ev):
|
||||||
|
offset = QPoint(0, 0)
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setClipRect(ev.rect())
|
||||||
|
bottom = self.rect().bottom()
|
||||||
|
|
||||||
|
if self.results:
|
||||||
|
for i, (prefix, divider, full, text) in enumerate(self.results):
|
||||||
|
size = prefix.size()
|
||||||
|
if offset.y() + size.height() > bottom:
|
||||||
|
break
|
||||||
|
self.max_result = i
|
||||||
|
offset.setX(0)
|
||||||
|
if i in (self.current_result, self.mouse_hover_result):
|
||||||
|
p.save()
|
||||||
|
if i != self.current_result:
|
||||||
|
p.setPen(Qt.DotLine)
|
||||||
|
p.drawLine(offset, QPoint(self.width(), offset.y()))
|
||||||
|
p.restore()
|
||||||
|
offset.setY(offset.y() + self.MARGIN // 2)
|
||||||
|
p.drawStaticText(offset, prefix)
|
||||||
|
offset.setX(self.maxwidth + 5)
|
||||||
|
p.drawStaticText(offset, divider)
|
||||||
|
offset.setX(offset.x() + divider.size().width())
|
||||||
|
p.drawStaticText(offset, full)
|
||||||
|
offset.setY(offset.y() + size.height() + self.MARGIN // 2)
|
||||||
|
if i in (self.current_result, self.mouse_hover_result):
|
||||||
|
offset.setX(0)
|
||||||
|
p.save()
|
||||||
|
if i != self.current_result:
|
||||||
|
p.setPen(Qt.DotLine)
|
||||||
|
p.drawLine(offset, QPoint(self.width(), offset.y()))
|
||||||
|
p.restore()
|
||||||
|
offset.setY(offset.y())
|
||||||
|
else:
|
||||||
|
p.drawText(self.rect(), Qt.AlignCenter, _('No results found'))
|
||||||
|
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_result(self):
|
||||||
|
try:
|
||||||
|
return self.results[self.current_result][-1]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class QuickOpen(Dialog):
|
||||||
|
|
||||||
|
def __init__(self, items, parent=None):
|
||||||
|
from calibre.utils.matcher import Matcher
|
||||||
|
self.matcher = Matcher(items)
|
||||||
|
self.matches = ()
|
||||||
|
self.selected_result = None
|
||||||
|
Dialog.__init__(self, _('Choose file to edit'), 'quick-open', parent=parent)
|
||||||
|
|
||||||
|
def sizeHint(self):
|
||||||
|
ans = Dialog.sizeHint(self)
|
||||||
|
ans.setWidth(800)
|
||||||
|
ans.setHeight(max(600, ans.height()))
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
self.l = l = QVBoxLayout(self)
|
||||||
|
self.setLayout(l)
|
||||||
|
|
||||||
|
self.text = t = QLineEdit(self)
|
||||||
|
t.textEdited.connect(self.update_matches)
|
||||||
|
l.addWidget(t, alignment=Qt.AlignTop)
|
||||||
|
|
||||||
|
example = '<pre>{0}i{1}mages/{0}c{1}hapter1/{0}s{1}cene{0}3{1}.jpg</pre>'.format(
|
||||||
|
'<span style="%s">' % Results.EMPH, '</span>')
|
||||||
|
chars = '<pre style="%s">ics3</pre>' % Results.EMPH
|
||||||
|
|
||||||
|
self.help_label = hl = QLabel(_(
|
||||||
|
'''<p>Quickly choose a file by typing in just a few characters from the file name into the field above.
|
||||||
|
For example, if want to choose the file:
|
||||||
|
{example}
|
||||||
|
Simply type in the characters:
|
||||||
|
{chars}
|
||||||
|
and press Enter.''').format(example=example, chars=chars))
|
||||||
|
hl.setMargin(50), hl.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
||||||
|
l.addWidget(hl)
|
||||||
|
self.results = Results(self)
|
||||||
|
self.results.setVisible(False)
|
||||||
|
self.results.item_selected.connect(self.accept)
|
||||||
|
l.addWidget(self.results)
|
||||||
|
|
||||||
|
l.addWidget(self.bb, alignment=Qt.AlignBottom)
|
||||||
|
|
||||||
|
def update_matches(self, text):
|
||||||
|
text = unicode(text).strip()
|
||||||
|
self.help_label.setVisible(False)
|
||||||
|
self.results.setVisible(True)
|
||||||
|
matches = self.matcher(text)
|
||||||
|
self.results(matches)
|
||||||
|
self.matches = tuple(matches)
|
||||||
|
|
||||||
|
def keyPressEvent(self, ev):
|
||||||
|
if ev.key() in (Qt.Key_Up, Qt.Key_Down):
|
||||||
|
ev.accept()
|
||||||
|
self.results.change_current(delta=-1 if ev.key() == Qt.Key_Up else 1)
|
||||||
|
return
|
||||||
|
return Dialog.keyPressEvent(self, ev)
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
self.selected_result = self.results.selected_result
|
||||||
|
return Dialog.accept(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def test(cls):
|
||||||
|
import os
|
||||||
|
from calibre.utils.matcher import get_items_from_dir
|
||||||
|
items = get_items_from_dir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), lambda x:not x.endswith('.pyc'))
|
||||||
|
d = cls(items)
|
||||||
|
d.exec_()
|
||||||
|
print (d.selected_result)
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
d = ImportForeign()
|
QuickOpen.test()
|
||||||
d.exec_()
|
|
||||||
print (d.data)
|
|
||||||
|
@ -26,6 +26,9 @@ DEFAULT_LEVEL1 = '/'
|
|||||||
DEFAULT_LEVEL2 = '-_ 0123456789'
|
DEFAULT_LEVEL2 = '-_ 0123456789'
|
||||||
DEFAULT_LEVEL3 = '.'
|
DEFAULT_LEVEL3 = '.'
|
||||||
|
|
||||||
|
class PluginFailed(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
class Worker(Thread):
|
class Worker(Thread):
|
||||||
|
|
||||||
daemon = True
|
daemon = True
|
||||||
@ -66,6 +69,11 @@ def split(tasks, pool_size):
|
|||||||
ans.append(section)
|
ans.append(section)
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
|
def default_scorer(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return CScorer(*args, **kwargs)
|
||||||
|
except PluginFailed:
|
||||||
|
return PyScorer(*args, **kwargs)
|
||||||
|
|
||||||
class Matcher(object):
|
class Matcher(object):
|
||||||
|
|
||||||
@ -80,7 +88,7 @@ class Matcher(object):
|
|||||||
self.items = items = tuple(items)
|
self.items = items = tuple(items)
|
||||||
tasks = split(items, len(workers))
|
tasks = split(items, len(workers))
|
||||||
self.task_maps = [{j:i for j, (i, _) in enumerate(task)} for task in tasks]
|
self.task_maps = [{j:i for j, (i, _) in enumerate(task)} for task in tasks]
|
||||||
scorer = scorer or CScorer
|
scorer = scorer or default_scorer
|
||||||
self.scorers = [scorer(tuple(map(itemgetter(1), task_items))) for task_items in tasks]
|
self.scorers = [scorer(tuple(map(itemgetter(1), task_items))) for task_items in tasks]
|
||||||
self.sort_keys = None
|
self.sort_keys = None
|
||||||
|
|
||||||
@ -201,7 +209,7 @@ class CScorer(object):
|
|||||||
|
|
||||||
speedup, err = plugins['matcher']
|
speedup, err = plugins['matcher']
|
||||||
if speedup is None:
|
if speedup is None:
|
||||||
raise RuntimeError('Failed to load the matcher plugin with error: %s' % err)
|
raise PluginFailed('Failed to load the matcher plugin with error: %s' % err)
|
||||||
self.m = speedup.Matcher(items, primary_collator().capsule, unicode(level1), unicode(level2), unicode(level3))
|
self.m = speedup.Matcher(items, primary_collator().capsule, unicode(level1), unicode(level2), unicode(level3))
|
||||||
|
|
||||||
def __call__(self, query):
|
def __call__(self, query):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user