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'
|
||||
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
import os
|
||||
from itertools import izip
|
||||
|
||||
from PyQt4.Qt import (
|
||||
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.tweak_book import tprefs
|
||||
|
||||
@ -222,8 +227,212 @@ class ImportForeign(Dialog): # {{{
|
||||
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__':
|
||||
app = QApplication([])
|
||||
d = ImportForeign()
|
||||
d.exec_()
|
||||
print (d.data)
|
||||
QuickOpen.test()
|
||||
|
@ -26,6 +26,9 @@ DEFAULT_LEVEL1 = '/'
|
||||
DEFAULT_LEVEL2 = '-_ 0123456789'
|
||||
DEFAULT_LEVEL3 = '.'
|
||||
|
||||
class PluginFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
class Worker(Thread):
|
||||
|
||||
daemon = True
|
||||
@ -66,6 +69,11 @@ def split(tasks, pool_size):
|
||||
ans.append(section)
|
||||
return ans
|
||||
|
||||
def default_scorer(*args, **kwargs):
|
||||
try:
|
||||
return CScorer(*args, **kwargs)
|
||||
except PluginFailed:
|
||||
return PyScorer(*args, **kwargs)
|
||||
|
||||
class Matcher(object):
|
||||
|
||||
@ -80,7 +88,7 @@ class Matcher(object):
|
||||
self.items = items = tuple(items)
|
||||
tasks = split(items, len(workers))
|
||||
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.sort_keys = None
|
||||
|
||||
@ -201,7 +209,7 @@ class CScorer(object):
|
||||
|
||||
speedup, err = plugins['matcher']
|
||||
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))
|
||||
|
||||
def __call__(self, query):
|
||||
|
Loading…
x
Reference in New Issue
Block a user