diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py
index c20520445c..de605790fb 100644
--- a/src/calibre/gui2/tweak_book/boss.py
+++ b/src/calibre/gui2/tweak_book/boss.py
@@ -37,8 +37,8 @@ from calibre.gui2.tweak_book.toc import TOCEditor
from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime
from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook
from calibre.gui2.tweak_book.preferences import Preferences
-from calibre.gui2.tweak_book.widgets import RationalizeFolders, MultiSplit, ImportForeign
-from calibre.gui2.tweak_book.widgets import QuickOpen
+from calibre.gui2.tweak_book.widgets import (
+ RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink)
_diff_dialogs = []
@@ -641,6 +641,11 @@ class Boss(QObject):
chosen_name = chosen_image_is_external[0]
href = current_container().name_to_href(chosen_name, edname)
ed.insert_image(href)
+ elif action[0] == 'insert_hyperlink':
+ self.commit_all_editors_to_container()
+ d = InsertLink(current_container(), edname, parent=self.gui)
+ if d.exec_() == d.Accepted:
+ ed.insert_hyperlink(d.href)
else:
ed.action_triggered(action)
diff --git a/src/calibre/gui2/tweak_book/editor/smart/html.py b/src/calibre/gui2/tweak_book/editor/smart/html.py
index 40d905bcbd..bd4a5b568f 100644
--- a/src/calibre/gui2/tweak_book/editor/smart/html.py
+++ b/src/calibre/gui2/tweak_book/editor/smart/html.py
@@ -12,6 +12,7 @@ from . import NullSmarts
from PyQt4.Qt import QTextEdit
+from calibre import prepare_string_for_xml
from calibre.gui2 import error_dialog
get_offset = itemgetter(0)
@@ -128,6 +129,20 @@ def rename_tag(cursor, opening_tag, closing_tag, new_name, insert=False):
cursor.insertText(text)
cursor.endEditBlock()
+def ensure_not_within_tag_definition(cursor):
+ ''' Ensure the cursor is not inside a tag definition <>. Returns True iff the cursor was moved. '''
+ block, offset = cursor.block(), cursor.positionInBlock()
+ b, boundary = next_tag_boundary(block, offset, forward=False)
+ if b is None:
+ return False
+ if boundary.is_start:
+ # We are inside a tag
+ block, boundary = next_tag_boundary(block, offset)
+ if block is not None:
+ cursor.setPosition(block.position() + boundary.offset + 1)
+ return True
+ return False
+
class HTMLSmarts(NullSmarts):
def get_extra_selections(self, editor):
@@ -180,4 +195,13 @@ class HTMLSmarts(NullSmarts):
return error_dialog(editor, _('No found'), _(
'No suitable block level tag was found to rename'), show=True)
-
+ def insert_hyperlink(self, editor, target):
+ c = editor.textCursor()
+ if c.hasSelection():
+ c.insertText('') # delete any existing selected text
+ ensure_not_within_tag_definition(c)
+ c.insertText('' % prepare_string_for_xml(target, True))
+ p = c.position()
+ c.insertText('')
+ c.setPosition(p) # ensure cursor is positioned inside the newly created tag
+ editor.setTextCursor(c)
diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py
index fb4f39183a..cccb274c97 100644
--- a/src/calibre/gui2/tweak_book/editor/text.py
+++ b/src/calibre/gui2/tweak_book/editor/text.py
@@ -602,6 +602,10 @@ class TextEdit(PlainTextEdit):
c.setPosition(left + len(text), c.KeepAnchor)
self.setTextCursor(c)
+ def insert_hyperlink(self, target):
+ if hasattr(self.smarts, 'insert_hyperlink'):
+ self.smarts.insert_hyperlink(self, target)
+
def keyPressEvent(self, ev):
if ev.key() == Qt.Key_X and ev.modifiers() == Qt.AltModifier:
if self.replace_possible_unicode_sequence():
diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py
index a15514b28a..d4c7de956a 100644
--- a/src/calibre/gui2/tweak_book/editor/widget.py
+++ b/src/calibre/gui2/tweak_book/editor/widget.py
@@ -56,6 +56,9 @@ def register_text_editor_actions(reg, palette):
ac = reg('view-image', _('&Insert image'), ('insert_resource', 'image'), 'insert-image', (), _('Insert an image into the text'))
ac.setToolTip(_('
Insert image
Insert an image into the text'))
+ ac = reg('insert-link', _('Insert &hyperlink'), ('insert_hyperlink',), 'insert-hyperlink', (), _('Insert hyperlink'))
+ ac.setToolTip(_('Insert hyperlink
Insert a hyperlink into the text'))
+
for i, name in enumerate(('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p')):
text = ('&' + name) if name == 'p' else (name[0] + '&' + name[1])
desc = _('Convert the paragraph to <%s>') % name
@@ -141,6 +144,9 @@ class Editor(QMainWindow):
def insert_image(self, href):
self.editor.insert_image(href)
+ def insert_hyperlink(self, href):
+ self.editor.insert_hyperlink(href)
+
def undo(self):
self.editor.undo()
@@ -195,6 +201,8 @@ class Editor(QMainWindow):
b.addAction(actions['pretty-current'])
if self.syntax in {'html', 'css'}:
b.addAction(actions['insert-image'])
+ if self.syntax == 'html':
+ b.addAction(actions['insert-hyperlink'])
if self.syntax == 'html':
self.format_bar = b = self.addToolBar(_('Format text'))
for x in ('bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', 'color', 'background-color'):
diff --git a/src/calibre/gui2/tweak_book/widgets.py b/src/calibre/gui2/tweak_book/widgets.py
index b770ca825d..43793e9bda 100644
--- a/src/calibre/gui2/tweak_book/widgets.py
+++ b/src/calibre/gui2/tweak_book/widgets.py
@@ -12,13 +12,18 @@ from itertools import izip
from PyQt4.Qt import (
QDialog, QDialogButtonBox, QGridLayout, QLabel, QLineEdit, QVBoxLayout,
QFormLayout, QHBoxLayout, QToolButton, QIcon, QApplication, Qt, QWidget,
- QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption)
+ QPoint, QSizePolicy, QPainter, QStaticText, pyqtSignal, QTextOption,
+ QAbstractListModel, QModelIndex, QVariant, QStyledItemDelegate, QStyle,
+ QListView, QTextDocument, QSize)
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, NONE
from calibre.gui2.tweak_book import tprefs
+from calibre.utils.icu import primary_sort_key
from calibre.utils.matcher import get_char, Matcher
+ROOT = QModelIndex()
+
class Dialog(QDialog):
def __init__(self, title, name, parent=None):
@@ -230,6 +235,15 @@ class ImportForeign(Dialog): # {{{
# Quick Open {{{
+def make_highlighted_text(emph, text, positions):
+ positions = sorted(set(positions) - {-1}, reverse=True)
+ text = prepare_string_for_xml(text)
+ for p in positions:
+ ch = get_char(text, p)
+ text = '%s%s%s' % (text[:p], emph, ch, text[p+len(ch):])
+ return text
+
+
class Results(QWidget):
EMPH = "color:magenta; font-weight:bold"
@@ -310,12 +324,7 @@ class Results(QWidget):
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:
- ch = get_char(text, p)
- text = '%s%s%s' % (text[:p], self.EMPH, ch, text[p+len(ch):])
- text = QStaticText(text)
+ text = QStaticText(make_highlighted_text(self.EMPH, text, positions))
text.setTextOption(self.text_option)
text.setTextFormat(Qt.RichText)
return text
@@ -411,7 +420,7 @@ class QuickOpen(Dialog):
text = unicode(text).strip()
self.help_label.setVisible(False)
self.results.setVisible(True)
- matches = self.matcher(text)
+ matches = self.matcher(text, limit=100)
self.results(matches)
self.matches = tuple(matches)
@@ -437,6 +446,193 @@ class QuickOpen(Dialog):
# }}}
+# Filterable names list {{{
+
+class NamesDelegate(QStyledItemDelegate):
+
+ def sizeHint(self, option, index):
+ ans = QStyledItemDelegate.sizeHint(self, option, index)
+ ans.setHeight(ans.height() + 10)
+ return ans
+
+ def paint(self, painter, option, index):
+ QStyledItemDelegate.paint(self, painter, option, index)
+ text, positions = index.data(Qt.UserRole).toPyObject()
+ self.initStyleOption(option, index)
+ painter.save()
+ painter.setFont(option.font)
+ p = option.palette
+ c = p.HighlightedText if option.state & QStyle.State_Selected else p.Text
+ group = (p.Active if option.state & QStyle.State_Active else p.Inactive)
+ c = p.color(group, c)
+ painter.setClipRect(option.rect)
+ if positions is None or -1 in positions:
+ painter.setPen(c)
+ painter.drawText(option.rect, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextSingleLine, text)
+ else:
+ to = QTextOption()
+ to.setWrapMode(to.NoWrap)
+ to.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
+ positions = sorted(set(positions) - {-1}, reverse=True)
+ text = '%s' % make_highlighted_text(Results.EMPH, text, positions)
+ doc = QTextDocument()
+ c = 'rgb(%d, %d, %d)'%c.getRgb()[:3]
+ doc.setDefaultStyleSheet(' body { color: %s }'%c)
+ doc.setHtml(text)
+ doc.setDefaultFont(option.font)
+ doc.setDocumentMargin(0.0)
+ doc.setDefaultTextOption(to)
+ height = doc.size().height()
+ painter.translate(option.rect.left(), option.rect.top() + (max(0, option.rect.height() - height) // 2))
+ doc.drawContents(painter)
+ painter.restore()
+
+class NamesModel(QAbstractListModel):
+
+ filtered = pyqtSignal(object)
+
+ def __init__(self, names, parent=None):
+ self.items = []
+ QAbstractListModel.__init__(self, parent)
+ self.set_names(names)
+
+ def set_names(self, names):
+ self.names = names
+ self.matcher = Matcher(names)
+ self.filter('')
+
+ def rowCount(self, parent=ROOT):
+ return len(self.items)
+
+ def data(self, index, role):
+ if role == Qt.UserRole:
+ return QVariant(self.items[index.row()])
+ if role == Qt.DisplayRole:
+ return QVariant('\xa0' * 20)
+ return NONE
+
+ def filter(self, query):
+ query = unicode(query or '')
+ if not query:
+ self.items = tuple((text, None) for text in self.names)
+ else:
+ self.items = tuple(self.matcher(query).iteritems())
+ self.reset()
+ self.filtered.emit(not bool(query))
+
+def create_filterable_names_list(names, filter_text=None, parent=None):
+ nl = QListView(parent)
+ nl.m = m = NamesModel(names, parent=nl)
+ m.filtered.connect(lambda all_items: nl.scrollTo(m.index(0)))
+ nl.setModel(m)
+ nl.d = NamesDelegate(nl)
+ nl.setItemDelegate(nl.d)
+ f = QLineEdit(parent)
+ f.setPlaceholderText(filter_text or '')
+ f.textEdited.connect(m.filter)
+ return nl, f
+
+# }}}
+
+# Insert Link {{{
+class InsertLink(Dialog):
+
+ def __init__(self, container, source_name, parent=None):
+ self.container = container
+ self.source_name = source_name
+ Dialog.__init__(self, _('Insert Hyperlink'), 'insert-hyperlink', parent=parent)
+ self.anchor_cache = {}
+
+ def sizeHint(self):
+ return QSize(800, 600)
+
+ def setup_ui(self):
+ self.l = l = QVBoxLayout(self)
+ self.setLayout(l)
+
+ self.h = h = QHBoxLayout()
+ l.addLayout(h)
+
+ names = [n for n, linear in self.container.spine_names]
+ fn, f = create_filterable_names_list(names, filter_text=_('Filter files'), parent=self)
+ self.file_names, self.file_names_filter = fn, f
+ fn.selectionModel().selectionChanged.connect(self.selected_file_changed)
+ self.fnl = fnl = QVBoxLayout()
+ self.la1 = la = QLabel(_('Choose a &file to link to:'))
+ la.setBuddy(fn)
+ fnl.addWidget(la), fnl.addWidget(fn), fnl.addWidget(f)
+ h.addLayout(fnl), h.setStretch(0, 2)
+
+ fn, f = create_filterable_names_list([], filter_text=_('Filter locations'), parent=self)
+ self.anchor_names, self.anchor_names_filter = fn, f
+ fn.selectionModel().selectionChanged.connect(self.update_target)
+ fn.doubleClicked.connect(self.accept, type=Qt.QueuedConnection)
+ self.anl = fnl = QVBoxLayout()
+ self.la2 = la = QLabel(_('Choose a &location (anchor) in the file:'))
+ la.setBuddy(fn)
+ fnl.addWidget(la), fnl.addWidget(fn), fnl.addWidget(f)
+ h.addLayout(fnl), h.setStretch(1, 1)
+
+ self.tl = tl = QHBoxLayout()
+ self.la3 = la = QLabel(_('&Target:'))
+ tl.addWidget(la)
+ self.target = t = QLineEdit(self)
+ la.setBuddy(t)
+ tl.addWidget(t)
+ l.addLayout(tl)
+
+ l.addWidget(self.bb)
+
+ def selected_file_changed(self, *args):
+ rows = list(self.file_names.selectionModel().selectedRows())
+ if not rows:
+ self.anchor_names.model().set_names([])
+ else:
+ name, positions = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject()
+ self.populate_anchors(name)
+
+ def populate_anchors(self, name):
+ if name not in self.anchor_cache:
+ from calibre.ebooks.oeb.base import XHTML_NS
+ root = self.container.parsed(name)
+ self.anchor_cache[name] = sorted(
+ (set(root.xpath('//*/@id')) | set(root.xpath('//h:a/@name', namespaces={'h':XHTML_NS}))) - {''}, key=primary_sort_key)
+ self.anchor_names.model().set_names(self.anchor_cache[name])
+ self.update_target()
+
+ def update_target(self):
+ rows = list(self.file_names.selectionModel().selectedRows())
+ if not rows:
+ return
+ name = self.file_names.model().data(rows[0], Qt.UserRole).toPyObject()[0]
+ if name == self.source_name:
+ href = ''
+ else:
+ href = self.container.name_to_href(name, self.source_name)
+ frag = ''
+ rows = list(self.anchor_names.selectionModel().selectedRows())
+ if rows:
+ anchor = self.anchor_names.model().data(rows[0], Qt.UserRole).toPyObject()[0]
+ if anchor:
+ frag = '#' + anchor
+ href += frag
+ self.target.setText(href or '#')
+
+ @property
+ def href(self):
+ return unicode(self.target.text()).strip()
+
+ @classmethod
+ def test(cls):
+ import sys
+ from calibre.ebooks.oeb.polish.container import get_container
+ c = get_container(sys.argv[-1], tweak_mode=True)
+ d = cls(c, next(c.spine_names)[0])
+ if d.exec_() == d.Accepted:
+ print (d.href)
+
+# }}}
+
if __name__ == '__main__':
app = QApplication([])
- QuickOpen.test()
+ InsertLink.test()
diff --git a/src/calibre/utils/matcher.py b/src/calibre/utils/matcher.py
index d4c7c8c162..895d29082a 100644
--- a/src/calibre/utils/matcher.py
+++ b/src/calibre/utils/matcher.py
@@ -92,7 +92,7 @@ class Matcher(object):
self.scorers = [scorer(tuple(map(itemgetter(1), task_items))) for task_items in tasks]
self.sort_keys = None
- def __call__(self, query):
+ def __call__(self, query, limit=None):
query = normalize('NFC', unicode(query))
with wlock:
for i, scorer in enumerate(self.scorers):
@@ -119,6 +119,8 @@ class Matcher(object):
raise Exception('Failed to score items: %s' % error)
items = sorted(((-scores[i], item, positions[i]) for i, item in enumerate(self.items)),
key=itemgetter(0))
+ if limit is not None:
+ del items[limit:]
return OrderedDict(x[1:] for x in filter(itemgetter(0), items))
def get_items_from_dir(basedir, acceptq=lambda x: True):