Edit book: Allow Ctrl-clicking on class names to jump to the first style rule that matches the tag and class

This commit is contained in:
Kovid Goyal 2021-02-07 12:22:19 +05:30
parent 12393e3650
commit 7b52611fdd
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 200 additions and 10 deletions

View File

@ -821,7 +821,8 @@ Context sensitive help
You can right click on an HTML tag name or a CSS property name to get help for that tag or property.
You can also hold down the :kbd:`Ctrl` key and click on any filename inside a link tag
to open that file in the editor automatically.
to open that file in the editor automatically. Similarly, :kbd:`Ctrl` clicking
a class name will take you to the first style rule that matches the tag and class.
.. _editor_auto_complete:

View File

@ -1102,6 +1102,22 @@ class Boss(QObject):
error_dialog(self.gui, _('Not found'), _(
'No file with the name %s was found in the book') % target, show=True)
def editor_class_clicked(self, class_data):
from calibre.gui2.tweak_book.jump_to_class import find_first_matching_rule, NoMatchingTagFound, NoMatchingRuleFound
ed = self.gui.central.current_editor
name = editor_name(ed)
try:
res = find_first_matching_rule(current_container(), name, ed.get_raw_data(), class_data)
except (NoMatchingTagFound, NoMatchingRuleFound):
res = None
if res is not None and res.file_name and res.rule_address:
editor = self.open_editor_for_name(res.file_name)
if editor:
editor.goto_css_rule(res.rule_address, sourceline_address=res.style_tag_address)
else:
error_dialog(self.gui, _('No matches found'), _('No style rules that match the class {} were found').format(
class_data['class']), show=True)
def save_search(self):
state = self.gui.central.search_panel.state
self.show_saved_searches()
@ -1264,9 +1280,7 @@ class Boss(QObject):
raise
self.apply_container_update_to_gui()
def link_clicked(self, name, anchor, show_anchor_not_found=False):
if not name:
return
def open_editor_for_name(self, name):
if name in editors:
editor = editors[name]
self.gui.central.show_editor(editor)
@ -1284,6 +1298,12 @@ class Boss(QObject):
self.gui, _('Unsupported file format'),
_('Editing files of type %s is not supported') % mt, show=True)
editor = self.edit_file(name, syntax)
return editor
def link_clicked(self, name, anchor, show_anchor_not_found=False):
if not name:
return
editor = self.open_editor_for_name(name)
if anchor and editor is not None:
if editor.go_to_anchor(anchor):
self.gui.preview.pending_go_to_anchor = anchor
@ -1566,6 +1586,8 @@ class Boss(QObject):
editor.word_ignored.connect(self.word_ignored)
if hasattr(editor, 'link_clicked'):
editor.link_clicked.connect(self.editor_link_clicked)
if hasattr(editor, 'class_clicked'):
editor.class_clicked.connect(self.editor_class_clicked)
if getattr(editor, 'syntax', None) == 'html':
editor.smart_highlighting_updated.connect(self.gui.live_css.sync_to_editor)
if hasattr(editor, 'set_request_completion'):

View File

@ -22,7 +22,7 @@ class NullSmarts(object):
def verify_for_spellcheck(self, cursor, highlighter):
return False
def cursor_position_with_sourceline(self, cursor, for_position_sync=True):
def cursor_position_with_sourceline(self, cursor, for_position_sync=True, use_matched_tag=True):
return None, None
def goto_sourceline(self, editor, sourceline, tags, attribute=None):

View File

@ -484,6 +484,7 @@ def create_formats(highlighter, add_css=True):
formats['spell'].setProperty(SPELL_PROPERTY, True)
formats['class_attr'] = syntax_text_char_format(t['Special'])
formats['class_attr'].setProperty(CLASS_ATTRIBUTE_PROPERTY, True)
formats['class_attr'].setToolTip(_('Hold down the Ctrl key and click to open the first matching CSS style rule'))
formats['link'] = syntax_text_char_format(t['Link'])
formats['link'].setProperty(LINK_PROPERTY, True)
formats['link'].setToolTip(_('Hold down the Ctrl key and click to open this link'))

View File

@ -24,8 +24,8 @@ from calibre.gui2.tweak_book import (
)
from calibre.gui2.tweak_book.completion.popup import CompletionPopup
from calibre.gui2.tweak_book.editor import (
LINK_PROPERTY, SPELL_LOCALE_PROPERTY, SPELL_PROPERTY, SYNTAX_PROPERTY,
store_locale
CLASS_ATTRIBUTE_PROPERTY, LINK_PROPERTY, SPELL_LOCALE_PROPERTY, SPELL_PROPERTY,
SYNTAX_PROPERTY, store_locale
)
from calibre.gui2.tweak_book.editor.smarts import NullSmarts
from calibre.gui2.tweak_book.editor.snippets import SnippetManager
@ -93,6 +93,7 @@ class LineNumbers(QWidget): # {{{
class TextEdit(PlainTextEdit):
link_clicked = pyqtSignal(object)
class_clicked = pyqtSignal(object)
smart_highlighting_updated = pyqtSignal()
def __init__(self, parent=None, expected_geometry=(100, 50)):
@ -764,6 +765,16 @@ class TextEdit(PlainTextEdit):
if r is not None and r.format.property(LINK_PROPERTY):
return self.text_for_range(c.block(), r)
def class_for_position(self, pos):
c = self.cursorForPosition(pos)
r = self.syntax_range_for_cursor(c)
if r is not None and r.format.property(CLASS_ATTRIBUTE_PROPERTY):
c.select(QTextCursor.SelectionType.WordUnderCursor)
class_name = c.selectedText()
if class_name:
tags = self.current_tag(for_position_sync=False, cursor=c)
return {'class': class_name, 'sourceline_address': tags}
def mousePressEvent(self, ev):
if self.completion_popup.isVisible() and not self.completion_popup.rect().contains(ev.pos()):
# For some reason using eventFilter for this does not work, so we
@ -775,6 +786,11 @@ class TextEdit(PlainTextEdit):
ev.accept()
self.link_clicked.emit(url)
return
class_data = self.class_for_position(ev.pos())
if class_data is not None:
ev.accept()
self.class_clicked.emit(class_data)
return
return PlainTextEdit.mousePressEvent(self, ev)
def get_range_inside_tag(self):
@ -962,8 +978,12 @@ version="1.1" width="100%%" height="100%%" viewBox="0 0 {w} {h}" preserveAspectR
if hasattr(self.smarts, 'rename_block_tag'):
self.smarts.rename_block_tag(self, new_name)
def current_tag(self, for_position_sync=True):
return self.smarts.cursor_position_with_sourceline(self.textCursor(), for_position_sync=for_position_sync)
def current_tag(self, for_position_sync=True, cursor=None):
use_matched_tag = False
if cursor is None:
use_matched_tag = True
cursor = self.textCursor()
return self.smarts.cursor_position_with_sourceline(cursor, for_position_sync=for_position_sync, use_matched_tag=use_matched_tag)
def goto_sourceline(self, sourceline, tags, attribute=None):
return self.smarts.goto_sourceline(self, sourceline, tags, attribute=attribute)

View File

@ -138,6 +138,7 @@ class Editor(QMainWindow):
cursor_position_changed = pyqtSignal()
word_ignored = pyqtSignal(object, object)
link_clicked = pyqtSignal(object)
class_clicked = pyqtSignal(object)
smart_highlighting_updated = pyqtSignal()
def __init__(self, syntax, parent=None):
@ -161,6 +162,7 @@ class Editor(QMainWindow):
self.editor.copyAvailable.connect(self._copy_available)
self.editor.cursorPositionChanged.connect(self._cursor_position_changed)
self.editor.link_clicked.connect(self.link_clicked)
self.editor.class_clicked.connect(self.class_clicked)
self.editor.smart_highlighting_updated.connect(self.smart_highlighting_updated)
@property
@ -425,7 +427,7 @@ class Editor(QMainWindow):
self.restore_state()
def break_cycles(self):
for x in ('modification_state_changed', 'word_ignored', 'link_clicked', 'smart_highlighting_updated'):
for x in ('modification_state_changed', 'word_ignored', 'link_clicked', 'class_clicked', 'smart_highlighting_updated'):
try:
getattr(self, x).disconnect()
except TypeError:
@ -441,6 +443,7 @@ class Editor(QMainWindow):
self.editor.copyAvailable.disconnect()
self.editor.cursorPositionChanged.disconnect()
self.editor.link_clicked.disconnect()
self.editor.class_clicked.disconnect()
self.editor.smart_highlighting_updated.disconnect()
self.editor.setPlainText('')
self.editor.smarts = None

View File

@ -0,0 +1,143 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
from contextlib import suppress
from css_parser.css import CSSRule
from typing import List, NamedTuple, Optional, Tuple
from calibre.ebooks.oeb.parse_utils import barename
from calibre.ebooks.oeb.polish.container import get_container
from calibre.ebooks.oeb.polish.parsing import parse
from css_selectors import Select, SelectorError
class NoMatchingTagFound(KeyError):
pass
class NoMatchingRuleFound(KeyError):
pass
class RuleLocation(NamedTuple):
rule_address: List[int]
file_name: str
style_tag_address: Optional[Tuple[int, List[int]]] = None
def rule_matches_elem(rule, elem, select, class_name):
for selector in rule.selectorList:
if class_name in selector.selectorText:
with suppress(SelectorError):
if elem in select(selector.selectorText):
return True
return False
def find_first_rule_that_matches_elem(
container,
elem,
select,
class_name,
rules,
current_file_name,
recursion_level=0,
rule_address=None
):
# iterate over rules handling @import and @media rules returning a rule
# address for matching rule
if recursion_level > 16:
return None
rule_address = rule_address or []
for i, rule in enumerate(rules):
if rule.type == CSSRule.STYLE_RULE:
if rule_matches_elem(rule, elem, select, class_name):
return RuleLocation(rule_address + [i], current_file_name)
elif rule.type == CSSRule.MEDIA_RULE:
res = find_first_rule_that_matches_elem(
container, elem, select, class_name, rule.cssRules,
current_file_name, recursion_level + 1, rule_address + [i]
)
if res is not None:
return res
elif rule.type == CSSRule.IMPORT_RULE:
if not rule.href:
continue
sname = container.href_to_name(rule.href, current_file_name)
if sname:
try:
sheet = container.parsed(sname)
except Exception:
continue
if not hasattr(sheet, 'cssRules'):
continue
res = find_first_rule_that_matches_elem(
container, elem, select, class_name, sheet.cssRules, sname,
recursion_level + 1
)
if res is not None:
return res
return None
def find_first_matching_rule(
container, html_file_name, raw_html, class_data, lnum_attr='data-lnum'
):
lnum, tags = class_data['sourceline_address']
class_name = class_data['class']
root = parse(
raw_html,
decoder=lambda x: x.decode('utf-8'),
line_numbers=True,
linenumber_attribute=lnum_attr
)
tags_on_line = root.xpath(f'//*[@{lnum_attr}={lnum}]')
barenames = [barename(tag.tag) for tag in tags_on_line]
if barenames[:len(tags)] != tags:
raise NoMatchingTagFound(
f'No tag matching the specification was found in {html_file_name}'
)
target_elem = tags_on_line[len(tags) - 1]
select = Select(root, ignore_inappropriate_pseudo_classes=True)
for tag in root.iter('*'):
tn = barename(tag.tag)
if tn == 'style' and tag.text and tag.get('type', 'text/css') == 'text/css':
try:
sheet = container.parse_css(tag.text)
except Exception:
continue
res = find_first_rule_that_matches_elem(
container, target_elem, select, class_name, sheet.cssRules,
html_file_name
)
if res is not None:
return res._replace(style_tag_address=(int(tag.get(lnum_attr)), ['style']))
elif tn == 'link' and tag.get('href') and tag.get('rel') == 'stylesheet':
sname = container.href_to_name(tag.get('href'), html_file_name)
try:
sheet = container.parsed(sname)
except Exception:
continue
if not hasattr(sheet, 'cssRules'):
continue
res = find_first_rule_that_matches_elem(
container, target_elem, select, class_name, sheet.cssRules, sname
)
if res is not None:
return res
raise NoMatchingRuleFound(
f'No CSS rules that apply to the specified tag in {html_file_name} with the class {class_name} found'
)
def develop():
container = get_container('/t/demo.epub', tweak_mode=True)
fname = 'index_split_002.html'
data = {'class': 'xxx', 'sourceline_address': (13, ['body'])}
print(
find_first_matching_rule(
container, fname,
container.open(fname).read(), data
)
)