diff --git a/manual/edit.rst b/manual/edit.rst index aa8307ec59..187e0c8f02 100644 --- a/manual/edit.rst +++ b/manual/edit.rst @@ -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: diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index 5bbaf9c21b..d18c2b2d01 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -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'): diff --git a/src/calibre/gui2/tweak_book/editor/smarts/__init__.py b/src/calibre/gui2/tweak_book/editor/smarts/__init__.py index 8774aab529..b11c78dd6e 100644 --- a/src/calibre/gui2/tweak_book/editor/smarts/__init__.py +++ b/src/calibre/gui2/tweak_book/editor/smarts/__init__.py @@ -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): diff --git a/src/calibre/gui2/tweak_book/editor/syntax/html.py b/src/calibre/gui2/tweak_book/editor/syntax/html.py index 866a22635e..074d8483de 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/html.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/html.py @@ -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')) diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index 65c9a1c181..deb9fb51c8 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -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) diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 0fb4a97545..a9c2b951cd 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -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 diff --git a/src/calibre/gui2/tweak_book/jump_to_class.py b/src/calibre/gui2/tweak_book/jump_to_class.py new file mode 100644 index 0000000000..59fee96952 --- /dev/null +++ b/src/calibre/gui2/tweak_book/jump_to_class.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2021, Kovid Goyal + +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 + ) + )