diff --git a/src/calibre/gui2/tweak_book/editor/syntax/base.py b/src/calibre/gui2/tweak_book/editor/syntax/base.py index f0649a7d58..00fe98efbf 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/base.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/base.py @@ -6,44 +6,53 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal ' -from PyQt4.Qt import (QSyntaxHighlighter, QApplication, QCursor, Qt) +import weakref + +from PyQt4.Qt import ( + QTextCursor, pyqtSlot, QTextBlockUserData, QTextLayout) from ..themes import highlight_to_char_format +from calibre.gui2.tweak_book.widgets import BusyCursor -class SimpleState(object): - - def __init__(self, value): - self.parse = value - - @property - def value(self): - return self.parse - -def run_loop(state, state_map, formats, text): +def run_loop(user_data, state_map, formats, text): + state = user_data.state i = 0 while i < len(text): - fmt = state_map[state.parse](state, text, i, formats) + fmt = state_map[state.parse](state, text, i, formats, user_data) for num, f in fmt: yield i, num, f i += num -class SyntaxHighlighter(QSyntaxHighlighter): +class SimpleState(object): + + __slots__ = ('parse',) + + def __init__(self): + self.parse = 0 + + def copy(self): + s = SimpleState() + s.parse = self.parse + return s + +class SimpleUserData(QTextBlockUserData): + + def __init__(self): + QTextBlockUserData.__init__(self) + self.state = SimpleState() + + def clear(self, state=None): + self.state = SimpleState() if state is None else state + +class SyntaxHighlighter(object): - state_map = {0:lambda state, text, i, formats:[(len(text), None)]} create_formats_func = lambda highlighter: {} spell_attributes = () tag_ok_for_spell = lambda x: False + user_data_factory = SimpleUserData - def __init__(self, *args, **kwargs): - QSyntaxHighlighter.__init__(self, *args, **kwargs) - - def create_state(self, num): - return SimpleState(max(0, num)) - - def rehighlight(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QSyntaxHighlighter.rehighlight(self) - QApplication.restoreOverrideCursor() + def __init__(self): + self.document_ref = lambda : None def apply_theme(self, theme): self.theme = {k:highlight_to_char_format(v) for k, v in theme.iteritems()} @@ -53,20 +62,94 @@ class SyntaxHighlighter(QSyntaxHighlighter): def create_formats(self): self.formats = self.create_formats_func() - def highlightBlock(self, text): - try: - state = self.previousBlockState() - self.setCurrentBlockUserData(None) # Ensure that any stale user data is discarded - state = self.create_state(state) - state.get_user_data, state.set_user_data = self.currentBlockUserData, self.setCurrentBlockUserData - for i, num, fmt in run_loop(state, self.state_map, self.formats, unicode(text)): - if fmt is not None: - self.setFormat(i, num, fmt) - self.setCurrentBlockState(state.value) - except: - import traceback - traceback.print_exc() - finally: - # Disabled as it causes crashes - pass # QApplication.processEvents() # Try to keep the editor responsive to user input + def set_document(self, doc): + old_doc = self.document_ref() + if old_doc is not None: + old_doc.contentsChange.disconnect(self.reformat_blocks) + c = QTextCursor(old_doc) + c.beginEditBlock() + blk = old_doc.begin() + while blk.isValid(): + blk.layout().clearAdditionalFormats() + blk = blk.next() + c.endEditBlock() + if doc is not None: + self.document_ref = weakref.ref(doc) + doc.contentsChange.connect(self.reformat_blocks) + self.rehighlight() + else: + self.document_ref = lambda : None + + def rehighlight(self): + doc = self.document_ref() + if doc is None: + return + lb = doc.lastBlock() + with BusyCursor(): + self.reformat_blocks(0, 0, lb.position() + lb.length()) + + def get_user_data(self, block): + ud = block.userData() + new_data = False + if ud is None: + ud = self.user_data_factory() + block.setUserData(ud) + new_data = True + return ud, new_data + + @pyqtSlot(int, int, int) + def reformat_blocks(self, position, removed, added): + doc = self.document_ref() + if doc is None: + return + last_block = doc.findBlock(position + added + (1 if removed > 0 else 0)) + if not last_block.isValid(): + last_block = doc.lastBlock() + end_pos = last_block.position() + last_block.length() + force_next_highlight = False + + doc.contentsChange.disconnect(self.reformat_blocks) + try: + block = doc.findBlock(position) + while block.isValid() and (block.position() < end_pos or force_next_highlight): + ud, new_ud = self.get_user_data(block) + orig_state = ud.state + pblock = block.previous() + if pblock.isValid(): + start_state = pblock.userData() + if start_state is None: + start_state = self.user_data_factory().state + else: + start_state = start_state.state.copy() + else: + start_state = self.user_data_factory().state + ud.clear(state=start_state) # Ensure no stale user data lingers + formats = [] + for i, num, fmt in run_loop(ud, self.state_map, self.formats, unicode(block.text())): + if fmt is not None: + formats.append((i, num, fmt)) + self.apply_format_changes(doc, block, formats) + force_next_highlight = new_ud or ud.state != orig_state + block = block.next() + finally: + doc.contentsChange.connect(self.reformat_blocks) + + def apply_format_changes(self, doc, block, formats): + layout = block.layout() + preedit_start = layout.preeditAreaPosition() + preedit_length = layout.preeditAreaText().length() + ranges = [] + R = QTextLayout.FormatRange + for i, num, fmt in formats: + # Adjust range by pre-edit text, if any + if preedit_start != 0: + if i >= preedit_start: + i += preedit_length + elif i + num >= preedit_start: + num += preedit_length + r = R() + r.start, r.length, r.format = i, num, fmt + ranges.append(r) + layout.setAdditionalFormats(ranges) + doc.markContentsDirty(block.position(), block.length()) diff --git a/src/calibre/gui2/tweak_book/editor/syntax/css.py b/src/calibre/gui2/tweak_book/editor/syntax/css.py index 04c9182b70..7e19790516 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/css.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/css.py @@ -8,6 +8,8 @@ __copyright__ = '2013, Kovid Goyal ' import re +from PyQt4.Qt import QTextBlockUserData + from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter @@ -118,41 +120,63 @@ content_tokens = [(re.compile(k), v, n) for k, v, n in [ ]] -class State(object): +NORMAL = 0 +IN_COMMENT_NORMAL = 1 +IN_SQS = 2 +IN_DQS = 3 +IN_CONTENT = 4 +IN_COMMENT_CONTENT = 5 - NORMAL = 0 - IN_COMMENT_NORMAL = 1 - IN_SQS = 2 - IN_DQS = 3 - IN_CONTENT = 4 - IN_COMMENT_CONTENT = 5 +class CSSState(object): - def __init__(self, num): - self.parse = num & 0b1111 - self.blocks = num >> 4 + __slots__ = ('parse', 'blocks') - @property - def value(self): - return ((self.parse & 0b1111) | (max(0, self.blocks) << 4)) + def __init__(self): + self.parse = NORMAL + self.blocks = 0 + def copy(self): + s = CSSState() + s.parse, s.blocks = self.parse, self.blocks + return s -def normal(state, text, i, formats): + def __eq__(self, other): + return self.parse == getattr(other, 'parse', -1) and \ + self.blocks == getattr(other, 'blocks', -1) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "CSSState(parse=%s, blocks=%s)" % (self.parse, self.blocks) + __str__ = __repr__ + +class CSSUserData(QTextBlockUserData): + + def __init__(self): + QTextBlockUserData.__init__(self) + self.state = CSSState() + + def clear(self, state=None): + self.state = CSSState() if state is None else state + +def normal(state, text, i, formats, user_data): ' The normal state (outside content blocks {})' m = space_pat.match(text, i) if m is not None: return [(len(m.group()), None)] cdo = cdo_pat.match(text, i) if cdo is not None: - state.parse = State.IN_COMMENT_NORMAL + state.parse = IN_COMMENT_NORMAL return [(len(cdo.group()), formats['comment'])] if text[i] == '"': - state.parse = State.IN_DQS + state.parse = IN_DQS return [(1, formats['string'])] if text[i] == "'": - state.parse = State.IN_SQS + state.parse = IN_SQS return [(1, formats['string'])] if text[i] == '{': - state.parse = State.IN_CONTENT + state.parse = IN_CONTENT state.blocks += 1 return [(1, formats['bracket'])] for token, fmt, name in sheet_tokens: @@ -162,24 +186,24 @@ def normal(state, text, i, formats): return [(len(text) - i, formats['unknown-normal'])] -def content(state, text, i, formats): +def content(state, text, i, formats, user_data): ' Inside content blocks ' m = space_pat.match(text, i) if m is not None: return [(len(m.group()), None)] cdo = cdo_pat.match(text, i) if cdo is not None: - state.parse = State.IN_COMMENT_CONTENT + state.parse = IN_COMMENT_CONTENT return [(len(cdo.group()), formats['comment'])] if text[i] == '"': - state.parse = State.IN_DQS + state.parse = IN_DQS return [(1, formats['string'])] if text[i] == "'": - state.parse = State.IN_SQS + state.parse = IN_SQS return [(1, formats['string'])] if text[i] == '}': state.blocks -= 1 - state.parse = State.NORMAL if state.blocks < 1 else State.IN_CONTENT + state.parse = NORMAL if state.blocks < 1 else IN_CONTENT return [(1, formats['bracket'])] if text[i] == '{': state.blocks += 1 @@ -191,34 +215,34 @@ def content(state, text, i, formats): return [(len(text) - i, formats['unknown-normal'])] -def comment(state, text, i, formats): +def comment(state, text, i, formats, user_data): ' Inside a comment ' pos = text.find('*/', i) if pos == -1: return [(len(text), formats['comment'])] - state.parse = State.NORMAL if state.parse == State.IN_COMMENT_NORMAL else State.IN_CONTENT + state.parse = NORMAL if state.parse == IN_COMMENT_NORMAL else IN_CONTENT return [(pos - i + 2, formats['comment'])] -def in_string(state, text, i, formats): +def in_string(state, text, i, formats, user_data): 'Inside a string' - q = '"' if state.parse == State.IN_DQS else "'" + q = '"' if state.parse == IN_DQS else "'" pos = text.find(q, i) if pos == -1: if text[-1] == '\\': # Multi-line string return [(len(text) - i, formats['string'])] - state.parse = (State.NORMAL if state.blocks < 1 else State.IN_CONTENT) + state.parse = (NORMAL if state.blocks < 1 else IN_CONTENT) return [(len(text) - i, formats['unterminated-string'])] - state.parse = (State.NORMAL if state.blocks < 1 else State.IN_CONTENT) + state.parse = (NORMAL if state.blocks < 1 else IN_CONTENT) return [(pos - i + len(q), formats['string'])] state_map = { - State.NORMAL:normal, - State.IN_COMMENT_NORMAL: comment, - State.IN_COMMENT_CONTENT: comment, - State.IN_SQS: in_string, - State.IN_DQS: in_string, - State.IN_CONTENT: content, + NORMAL:normal, + IN_COMMENT_NORMAL: comment, + IN_COMMENT_CONTENT: comment, + IN_SQS: in_string, + IN_DQS: in_string, + IN_CONTENT: content, } def create_formats(highlighter): @@ -252,9 +276,8 @@ class CSSHighlighter(SyntaxHighlighter): state_map = state_map create_formats_func = create_formats + user_data_factory = CSSUserData - def create_state(self, num): - return State(max(0, num)) if __name__ == '__main__': from calibre.gui2.tweak_book.editor.widget import launch_editor diff --git a/src/calibre/gui2/tweak_book/editor/syntax/html.py b/src/calibre/gui2/tweak_book/editor/syntax/html.py index 9583090132..2419e59721 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/html.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/html.py @@ -15,7 +15,8 @@ from PyQt4.Qt import QFont, QTextBlockUserData from calibre.ebooks.oeb.polish.spell import html_spell_tags, xml_spell_tags from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter, run_loop -from calibre.gui2.tweak_book.editor.syntax.css import create_formats as create_css_formats, state_map as css_state_map, State as CSSState +from calibre.gui2.tweak_book.editor.syntax.css import ( + create_formats as create_css_formats, state_map as css_state_map, CSSState, CSSUserData) from html5lib.constants import cdataElements, rcdataElements @@ -51,41 +52,33 @@ Attr = namedtuple('Attr', 'offset type data') class Tag(object): - __slots__ = ('name', 'bold', 'italic', 'lang', 'hash') + __slots__ = ('name', 'bold', 'italic', 'lang') def __init__(self, name, bold=None, italic=None): self.name = name self.bold = name in bold_tags if bold is None else bold self.italic = name in italic_tags if italic is None else italic self.lang = None - self.hash = 0 - - def __hash__(self): - return self.hash def __eq__(self, other): return self.name == getattr(other, 'name', None) and self.lang == getattr(other, 'lang', False) def copy(self): ans = Tag(self.name, self.bold, self.italic) - ans.lang, ans.hash = self.lang, self.hash + ans.lang = self.lang return ans - def update_hash(self): - self.hash = hash((self.name, self.lang)) - class State(object): - __slots__ = ('tag_being_defined', 'tags', 'is_bold', 'is_italic', - 'current_lang', 'parse', 'get_user_data', 'set_user_data', - 'css_formats', 'stack', 'sub_parser_state', 'default_lang', - 'attribute_name',) + __slots__ = ( + 'tag_being_defined', 'tags', 'is_bold', 'is_italic', 'current_lang', + 'parse', 'css_formats', 'sub_parser_state', 'default_lang', 'attribute_name',) def __init__(self): self.tags = [] self.is_bold = self.is_italic = False - self.tag_being_defined = self.current_lang = self.get_user_data = self.set_user_data = \ - self.css_formats = self.stack = self.sub_parser_state = self.default_lang = self.attribute_name = None + self.tag_being_defined = self.current_lang = self.css_formats = \ + self.sub_parser_state = self.default_lang = self.attribute_name = None self.parse = NORMAL def copy(self): @@ -95,17 +88,10 @@ class State(object): self.tags = [x.copy() for x in self.tags] if self.tag_being_defined is not None: self.tag_being_defined = self.tag_being_defined.copy() + if self.sub_parser_state is not None: + ans.sub_parser_state = self.sub_parser_state.copy() return ans - @property - def value(self): - if self.tag_being_defined is not None: - self.tag_being_defined.update_hash() - return self.stack.index_for(self) - - def __hash__(self): - return hash((self.parse, self.sub_parser_state, self.tag_being_defined, self.attribute_name, tuple(self.tags))) - def __eq__(self, other): return ( self.parse == getattr(other, 'parse', -1) and @@ -115,6 +101,9 @@ class State(object): self.tags == getattr(other, 'tags', None) ) + def __ne__(self, other): + return not self.__eq__(other) + def open_tag(self, name): self.tag_being_defined = Tag(name) @@ -128,7 +117,7 @@ class State(object): return # No matching open tag found, ignore the closing tag # Remove all tags upto the matching open tag self.tags = self.tags[:-len(removed_tags)] - self.sub_parser_state = 0 + self.sub_parser_state = None # Check if we should still be bold or italic if self.is_bold: self.is_bold = False @@ -154,71 +143,41 @@ class State(object): if self.tag_being_defined is None: return t, self.tag_being_defined = self.tag_being_defined, None - t.update_hash() self.tags.append(t) self.is_bold = self.is_bold or t.bold self.is_italic = self.is_italic or t.italic self.current_lang = t.lang or self.current_lang if t.name in cdata_tags: self.parse = CSS if t.name == 'style' else CDATA - self.sub_parser_state = 0 + self.sub_parser_state = None def __repr__(self): return '' % ( '->'.join(x.name for x in self.tags), self.is_bold, self.is_italic, self.current_lang) __str__ = __repr__ -class Stack(object): - - ''' Maintain an efficient bi-directional mapping between states and index - numbers. Ensures that if state1 == state2 then their corresponding index - numbers are the same and vice versa. This is need so that the state number - passed to Qt does not change unless the underlying state has actually - changed. ''' - - def __init__(self): - self.index_map = [] - self.state_map = {} - - def index_for(self, state): - ans = self.state_map.get(state, None) - if ans is None: - self.state_map[state] = ans = len(self.index_map) - self.index_map.append(state) - return ans - - def state_for(self, index): - try: - return self.index_map[index] - except IndexError: - return None - class HTMLUserData(QTextBlockUserData): def __init__(self): QTextBlockUserData.__init__(self) self.tags = [] self.attributes = [] + self.state = State() + self.css_user_data = None -def add_tag_data(state, tag): - ud = q = state.get_user_data() - if ud is None: - ud = HTMLUserData() - ud.tags.append(tag) - if q is None: - state.set_user_data(ud) + def clear(self, state=None): + self.tags, self.attributes = [], [] + self.state = State() if state is None else state + +def add_tag_data(user_data, tag): + user_data.tags.append(tag) ATTR_NAME, ATTR_VALUE, ATTR_START, ATTR_END = object(), object(), object(), object() -def add_attr_data(state, data_type, data, offset): - ud = q = state.get_user_data() - if ud is None: - ud = HTMLUserData() - ud.attributes.append(Attr(offset, data_type, data)) - if q is None: - state.set_user_data(ud) +def add_attr_data(user_data, data_type, data, offset): + user_data.attributes.append(Attr(offset, data_type, data)) -def css(state, text, i, formats): +def css(state, text, i, formats, user_data): ' Inside a