UI for the subsequence matcher

This commit is contained in:
Kovid Goyal 2014-03-09 14:42:31 +05:30
parent b4b1c021f7
commit b4e2b9e93f
2 changed files with 223 additions and 6 deletions

View File

@ -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)

View File

@ -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):