1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3
4__license__ = 'GPL v3'
5__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
6
7from collections import defaultdict, deque
8
9from qt.core import QTextCursor, QTextBlockUserData, QTextLayout, QTimer
10
11from ..themes import highlight_to_char_format
12from calibre.gui2.tweak_book.widgets import BusyCursor
13from calibre.utils.icu import utf16_length
14from polyglot.builtins import iteritems
15
16
17def run_loop(user_data, state_map, formats, text):
18    state = user_data.state
19    i = 0
20    fix_offsets = utf16_length(text) != len(text)
21    seen_states = defaultdict(set)
22    while i < len(text):
23        orig_i = i
24        seen_states[i].add(state.parse)
25        fmt = state_map[state.parse](state, text, i, formats, user_data)
26        for num, f in fmt:
27            if num > 0:
28                if fix_offsets:
29                    # We need to map offsets/lengths from UCS-4 to UTF-16 in
30                    # which non-BMP characters are two code points wide
31                    yield utf16_length(text[:i]), utf16_length(text[i:i+num]), f
32                else:
33                    yield i, num, f
34                i += num
35        if orig_i == i and state.parse in seen_states[i]:
36            # Something went wrong in the syntax highlighter
37            print('Syntax highlighter returned a zero length format, parse state:', state.parse)
38            break
39
40
41class SimpleState:
42
43    __slots__ = ('parse',)
44
45    def __init__(self):
46        self.parse = 0
47
48    def copy(self):
49        s = SimpleState()
50        s.parse = self.parse
51        return s
52
53
54class SimpleUserData(QTextBlockUserData):
55
56    def __init__(self):
57        QTextBlockUserData.__init__(self)
58        self.state = SimpleState()
59        self.doc_name = None
60
61    def clear(self, state=None, doc_name=None):
62        self.state = SimpleState() if state is None else state
63        self.doc_name = doc_name
64
65
66class SyntaxHighlighter:
67
68    create_formats_func = lambda highlighter: {}
69    spell_attributes = ()
70    tag_ok_for_spell = lambda x: False
71    user_data_factory = SimpleUserData
72
73    def __init__(self):
74        self.doc = None
75        self.doc_name = None
76        self.requests = deque()
77        self.ignore_requests = False
78
79    @property
80    def has_requests(self):
81        return bool(self.requests)
82
83    def apply_theme(self, theme):
84        self.theme = {k:highlight_to_char_format(v) for k, v in iteritems(theme)}
85        self.create_formats()
86        self.rehighlight()
87
88    def create_formats(self):
89        self.formats = self.create_formats_func()
90
91    def set_document(self, doc, doc_name=None):
92        old_doc = self.doc
93        if old_doc is not None:
94            old_doc.contentsChange.disconnect(self.reformat_blocks)
95            c = QTextCursor(old_doc)
96            c.beginEditBlock()
97            blk = old_doc.begin()
98            while blk.isValid():
99                blk.layout().clearAdditionalFormats()
100                blk = blk.next()
101            c.endEditBlock()
102        self.doc = self.doc_name = None
103        if doc is not None:
104            self.doc = doc
105            self.doc_name = doc_name
106            doc.contentsChange.connect(self.reformat_blocks)
107            self.rehighlight()
108
109    def rehighlight(self):
110        doc = self.doc
111        if doc is None:
112            return
113        lb = doc.lastBlock()
114        with BusyCursor():
115            self.reformat_blocks(0, 0, lb.position() + lb.length())
116
117    def get_user_data(self, block):
118        ud = block.userData()
119        new_data = False
120        if ud is None:
121            ud = self.user_data_factory()
122            block.setUserData(ud)
123            new_data = True
124        return ud, new_data
125
126    def reformat_blocks(self, position, removed, added):
127        doc = self.doc
128        if doc is None or self.ignore_requests or not hasattr(self, 'state_map'):
129            return
130
131        block = doc.findBlock(position)
132        if not block.isValid():
133            return
134        start_cursor = QTextCursor(block)
135        last_block = doc.findBlock(position + added + (1 if removed > 0 else 0))
136        if not last_block.isValid():
137            last_block = doc.lastBlock()
138        end_cursor = QTextCursor(last_block)
139        end_cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
140        self.requests.append((start_cursor, end_cursor))
141        QTimer.singleShot(0, self.do_one_block)
142
143    def do_one_block(self):
144        try:
145            start_cursor, end_cursor = self.requests[0]
146        except IndexError:
147            return
148        self.ignore_requests = True
149        try:
150            block = start_cursor.block()
151            if not block.isValid():
152                self.requests.popleft()
153                return
154            formats, force_next_highlight = self.parse_single_block(block)
155            self.apply_format_changes(block, formats)
156            try:
157                self.doc.markContentsDirty(block.position(), block.length())
158            except AttributeError:
159                self.requests.clear()
160                return
161            ok = start_cursor.movePosition(QTextCursor.MoveOperation.NextBlock)
162            if not ok:
163                self.requests.popleft()
164                return
165            next_block = start_cursor.block()
166            if next_block.position() > end_cursor.position():
167                if force_next_highlight:
168                    end_cursor.setPosition(next_block.position() + 1)
169                else:
170                    self.requests.popleft()
171                return
172        finally:
173            self.ignore_requests = False
174            QTimer.singleShot(0, self.do_one_block)
175
176    def join(self):
177        ''' Blocks until all pending highlighting requests are handled '''
178        doc = self.doc
179        if doc is None:
180            self.requests.clear()
181            return
182        self.ignore_requests = True
183        try:
184            while self.requests:
185                start_cursor, end_cursor = self.requests.popleft()
186                block = start_cursor.block()
187                last_block = end_cursor.block()
188                if not last_block.isValid():
189                    last_block = doc.lastBlock()
190                end_pos = last_block.position() + last_block.length()
191                force_next_highlight = False
192                while block.isValid() and (force_next_highlight or block.position() < end_pos):
193                    formats, force_next_highlight = self.parse_single_block(block)
194                    self.apply_format_changes(block, formats)
195                    doc.markContentsDirty(block.position(), block.length())
196                    block = block.next()
197        finally:
198            self.ignore_requests = False
199
200    @property
201    def is_working(self):
202        return bool(self.requests)
203
204    def parse_single_block(self, block):
205        ud, is_new_ud = self.get_user_data(block)
206        orig_state = ud.state
207        pblock = block.previous()
208        if pblock.isValid():
209            start_state = pblock.userData()
210            if start_state is None:
211                start_state = self.user_data_factory().state
212            else:
213                start_state = start_state.state.copy()
214        else:
215            start_state = self.user_data_factory().state
216        ud.clear(state=start_state, doc_name=self.doc_name)  # Ensure no stale user data lingers
217        formats = []
218        for i, num, fmt in run_loop(ud, self.state_map, self.formats, str(block.text())):
219            if fmt is not None:
220                r = QTextLayout.FormatRange()
221                r.start, r.length, r.format = i, num, fmt
222                formats.append(r)
223        force_next_highlight = is_new_ud or ud.state != orig_state
224        return formats, force_next_highlight
225
226    def reformat_block(self, block):
227        if block.isValid():
228            self.reformat_blocks(block.position(), 0, 1)
229
230    def apply_format_changes(self, block, formats):
231        layout = block.layout()
232        preedit_start = layout.preeditAreaPosition()
233        preedit_length = len(layout.preeditAreaText())
234        if preedit_length != 0 and preedit_start != 0:
235            for r in formats:
236                # Adjust range by pre-edit text, if any
237                if r.start >= preedit_start:
238                    r.start += preedit_length
239                elif r.start + r.length >= preedit_start:
240                    r.length += preedit_length
241        layout.setAdditionalFormats(formats)
242