1# This file is part of the Frescobaldi project, http://www.frescobaldi.org/
2#
3# Copyright (c) 2008 - 2014 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21The Highlighter class provides syntax highlighting and more information
22about a document's contents.
23"""
24
25
26
27from PyQt5.QtGui import (
28    QColor, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat,
29    QTextCursor, QTextDocument)
30
31
32import ly.lex
33import ly.colorize
34
35import app
36import cursortools
37import document
38import textformats
39import metainfo
40import plugin
41import variables
42import documentinfo
43
44
45metainfo.define('highlighting', True)
46
47
48def mapping(data):
49    """Return a dictionary mapping token classes from ly.lex to QTextCharFormats.
50
51    The QTextFormats are queried from the specified TextFormatData instance.
52    The returned dictionary is a ly.colorize.Mapping instance.
53
54    """
55    return ly.colorize.Mapper((cls, data.textFormat(mode, style.name))
56                        for mode, styles in ly.colorize.default_mapping()
57                            for style in styles
58                                for cls in style.classes)
59
60
61def highlighter(doc):
62    """Return the Highlighter for this document."""
63    return Highlighter.instance(doc)
64
65
66def highlight_mapping():
67    """Return the global Mapping instance that maps token class to QTextCharFormat."""
68    global _highlight_mapping
69    try:
70        return _highlight_mapping
71    except NameError:
72        _highlight_mapping = mapping(textformats.formatData('editor'))
73        return _highlight_mapping
74
75
76def _reset_highlight_mapping():
77    """Remove the global HighlightFormats instance, so it's recreated next time."""
78    global _highlight_mapping
79    try:
80        del _highlight_mapping
81    except NameError:
82        pass
83
84app.settingsChanged.connect(_reset_highlight_mapping, -100) # before all others
85
86
87class Highlighter(plugin.Plugin, QSyntaxHighlighter):
88    """A QSyntaxHighlighter that can highlight a QTextDocument.
89
90    It can be used for both generic QTextDocuments as well as
91    document.Document objects. In the latter case it automatically:
92    - initializes whether highlighting is enabled from the document's metainfo
93    - picks the mode from the variables if they specify that
94
95    The Highlighter automatically re-reads the highlighting settings if they
96    are changed.
97
98    """
99    def __init__(self, doc):
100        QSyntaxHighlighter.__init__(self, doc)
101        self._fridge = ly.lex.Fridge()
102        app.settingsChanged.connect(self.rehighlight)
103        self._initialState = None
104        self._highlighting = True
105        self._mode = None
106        self.initializeDocument()
107
108    def initializeDocument(self):
109        """This method is always called by the __init__ method.
110
111        The default implementation does nothing for generic QTextDocuments,
112        but for document.EditorDocument instances it connects to some additional
113        signals to keep the mode up-to-date (reading it from the variables if
114        needed) and initializes whether to enable visual highlighting from the
115        document's metainfo.
116
117        """
118        doc = self.document()
119        if hasattr(doc, 'url'):
120            self._highlighting = metainfo.info(doc).highlighting
121            self._mode = documentinfo.mode(doc, False)
122            if doc.__class__ == document.EditorDocument:
123                doc.loaded.connect(self._resetHighlighting)
124                variables.manager(doc).changed.connect(self._variablesChange)
125
126    def _variablesChange(self):
127        """Called whenever the variables have changed. Checks the mode."""
128        mode = documentinfo.mode(self.document(), False)
129        if mode != self._mode:
130            self._mode = mode
131            self.rehighlight()
132
133    def _resetHighlighting(self):
134        """Switch highlighting on or off depending on saved metainfo."""
135        self.setHighlighting(metainfo.info(self.document()).highlighting)
136
137    def highlightBlock(self, text):
138        """Called by Qt when the highlighting of the current line needs updating."""
139        # find the state of the previous line
140        prev = self.previousBlockState()
141        state = self._fridge.thaw(prev)
142        blank = not state and (not text or text.isspace())
143        if not state:
144            state = self.initialState()
145
146        # collect and save the tokens
147        tokens = tuple(state.tokens(text))
148        cursortools.data(self.currentBlock()).tokens = tokens
149
150        # if blank thus far, keep the highlighter coming back
151        # because the parsing state is not yet known; else save the state
152        self.setCurrentBlockState(prev - 1 if blank else self._fridge.freeze(state))
153
154        # apply highlighting if desired
155        if self._highlighting:
156            setFormat = lambda f: self.setFormat(token.pos, len(token), f)
157            mapping = highlight_mapping()
158            for token in tokens:
159                f = mapping[token]
160                if f:
161                    setFormat(f)
162
163    def setHighlighting(self, enable):
164        """Enable or disable highlighting."""
165        changed = enable != self._highlighting
166        self._highlighting = enable
167        if changed:
168            self.rehighlight()
169
170    def isHighlighting(self):
171        """Return whether highlighting is active."""
172        return self._highlighting
173
174    def state(self, block):
175        """Return a thawed ly.lex.State() object at the *end* of the QTextBlock.
176
177        Do not use this method directly. Instead use tokeniter.state() or
178        tokeniter.state_end(), because that assures the highlighter has run
179        at least once.
180
181        """
182        return self._fridge.thaw(block.userState()) or self.initialState()
183
184    def setInitialState(self, state):
185        """Force the initial state. Use None to enable auto-detection."""
186        self._initialState = self._fridge.freeze(state) if state else None
187
188    def initialState(self):
189        """Return the initial State for this document."""
190        if self._initialState is None:
191            mode = self._mode or ly.lex.guessMode(self.document().toPlainText())
192            return ly.lex.state(mode)
193        return self._fridge.thaw(self._initialState)
194
195
196def html_copy(cursor, scheme='editor', number_lines=False):
197    """Return a new QTextDocument with highlighting set as HTML textcharformats.
198
199    The cursor is a cursor of a document.Document instance. If the cursor
200    has a selection, only the selection is put in the new document.
201
202    If number_lines is True, line numbers are added.
203
204    """
205    data = textformats.formatData(scheme)
206    doc = QTextDocument()
207    doc.setDefaultFont(data.font)
208    doc.setPlainText(cursor.document().toPlainText())
209    if metainfo.info(cursor.document()).highlighting:
210        highlight(doc, mapping(data), ly.lex.state(documentinfo.mode(cursor.document())))
211    if cursor.hasSelection():
212        # cut out not selected text
213        start, end = cursor.selectionStart(), cursor.selectionEnd()
214        cur1 = QTextCursor(doc)
215        cur1.setPosition(start, QTextCursor.KeepAnchor)
216        cur2 = QTextCursor(doc)
217        cur2.setPosition(end)
218        cur2.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
219        cur2.removeSelectedText()
220        cur1.removeSelectedText()
221    if number_lines:
222        c = QTextCursor(doc)
223        f = QTextCharFormat()
224        f.setBackground(QColor('#eeeeee'))
225        if cursor.hasSelection():
226            num = cursor.document().findBlock(cursor.selectionStart()).blockNumber() + 1
227            last = cursor.document().findBlock(cursor.selectionEnd())
228        else:
229            num = 1
230            last = cursor.document().lastBlock()
231        lastnum = last.blockNumber() + 1
232        padding = len(format(lastnum))
233        block = doc.firstBlock()
234        while block.isValid():
235            c.setPosition(block.position())
236            c.setCharFormat(f)
237            c.insertText('{0:>{1}d} '.format(num, padding))
238            block = block.next()
239            num += 1
240    return doc
241
242
243def highlight(doc, mapping=None, state=None):
244    """Highlight a generic QTextDocument once.
245
246    mapping is an optional Mapping instance, defaulting to the current
247    configured editor highlighting formats (returned by highlight_mapping()).
248    state is an optional ly.lex.State instance. By default the text type is
249    guessed.
250
251    """
252    if mapping is None:
253        mapping = highlight_mapping()
254    if state is None:
255        state = ly.lex.guessState(doc.toPlainText())
256    cursor = QTextCursor(doc)
257    block = doc.firstBlock()
258    while block.isValid():
259        for token in state.tokens(block.text()):
260            f = mapping[token]
261            if f:
262                cursor.setPosition(block.position() + token.pos)
263                cursor.setPosition(block.position() + token.end, QTextCursor.KeepAnchor)
264                cursor.setCharFormat(f)
265        block = block.next()
266