1# -*- coding: utf-8 -*-
2#
3# Picard, the next-generation MusicBrainz tagger
4#
5# Copyright (C) 2006-2007, 2009 Lukáš Lalinský
6# Copyright (C) 2014 m42i
7# Copyright (C) 2020 Laurent Monin
8# Copyright (C) 2020-2021 Philipp Wolfer
9#
10# This program is free software; you can redistribute it and/or
11# modify it under the terms of the GNU General Public License
12# as published by the Free Software Foundation; either version 2
13# of the License, or (at your option) any later version.
14#
15# This program is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with this program; if not, write to the Free Software
22# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23
24import re
25
26from PyQt5 import (
27    QtCore,
28    QtGui,
29)
30from PyQt5.QtCore import Qt
31from PyQt5.QtGui import QTextCursor
32from PyQt5.QtWidgets import (
33    QCompleter,
34    QTextEdit,
35)
36
37from picard.const.sys import IS_MACOS
38from picard.script import script_function_names
39from picard.util.tags import (
40    PRESERVED_TAGS,
41    TAG_NAMES,
42)
43
44from picard.ui import FONT_FAMILY_MONOSPACE
45from picard.ui.theme import theme
46
47
48EXTRA_VARIABLES = (
49    '~absolutetracknumber',
50    '~albumartists_sort',
51    '~albumartists',
52    '~artists_sort',
53    '~datatrack',
54    '~discpregap',
55    '~multiartist',
56    '~musicbrainz_discids',
57    '~performance_attributes',
58    '~pregap',
59    '~primaryreleasetype',
60    '~rating',
61    '~recording_firstreleasedate',
62    '~recordingcomment',
63    '~recordingtitle',
64    '~releasecomment',
65    '~releasecountries',
66    '~releasegroup_firstreleasedate',
67    '~releasegroup',
68    '~releasegroupcomment',
69    '~releaselanguage',
70    '~secondaryreleasetype',
71    '~silence',
72    '~totalalbumtracks',
73    '~video',
74)
75
76
77class TaggerScriptSyntaxHighlighter(QtGui.QSyntaxHighlighter):
78
79    def __init__(self, document):
80        super().__init__(document)
81        syntax_theme = theme.syntax_theme
82        self.func_re = QtCore.QRegExp(r"\$(?!noop)[a-zA-Z][_a-zA-Z0-9]*\(")
83        self.func_fmt = QtGui.QTextCharFormat()
84        self.func_fmt.setFontWeight(QtGui.QFont.Bold)
85        self.func_fmt.setForeground(syntax_theme.func)
86        self.var_re = QtCore.QRegExp(r"%[_a-zA-Z0-9:]*%")
87        self.var_fmt = QtGui.QTextCharFormat()
88        self.var_fmt.setForeground(syntax_theme.var)
89        self.escape_re = QtCore.QRegExp(r"\\.")
90        self.escape_fmt = QtGui.QTextCharFormat()
91        self.escape_fmt.setForeground(syntax_theme.escape)
92        self.special_re = QtCore.QRegExp(r"[^\\][(),]")
93        self.special_fmt = QtGui.QTextCharFormat()
94        self.special_fmt.setForeground(syntax_theme.special)
95        self.bracket_re = QtCore.QRegExp(r"[()]")
96        self.noop_re = QtCore.QRegExp(r"\$noop\(")
97        self.noop_fmt = QtGui.QTextCharFormat()
98        self.noop_fmt.setFontWeight(QtGui.QFont.Bold)
99        self.noop_fmt.setFontItalic(True)
100        self.noop_fmt.setForeground(syntax_theme.noop)
101        self.rules = [
102            (self.func_re, self.func_fmt, 0, -1),
103            (self.var_re, self.var_fmt, 0, 0),
104            (self.escape_re, self.escape_fmt, 0, 0),
105            (self.special_re, self.special_fmt, 1, -1),
106        ]
107
108    def highlightBlock(self, text):
109        self.setCurrentBlockState(0)
110
111        for expr, fmt, a, b in self.rules:
112            index = expr.indexIn(text)
113            while index >= 0:
114                length = expr.matchedLength()
115                self.setFormat(index + a, length + b, fmt)
116                index = expr.indexIn(text, index + length + b)
117
118        # Ignore everything if we're already in a noop function
119        index = self.noop_re.indexIn(text) if self.previousBlockState() <= 0 else 0
120        open_brackets = self.previousBlockState() if self.previousBlockState() > 0 else 0
121        text_length = len(text)
122        while index >= 0:
123            next_index = self.bracket_re.indexIn(text, index)
124
125            # Skip escaped brackets
126            if next_index > 0 and text[next_index - 1] == '\\':
127                next_index += 1
128
129            # Reached end of text?
130            if next_index >= text_length:
131                self.setFormat(index, text_length - index, self.noop_fmt)
132                break
133
134            if next_index > -1 and text[next_index] == '(':
135                open_brackets += 1
136            elif next_index > -1 and text[next_index] == ')':
137                open_brackets -= 1
138
139            if next_index > -1:
140                self.setFormat(index, next_index - index + 1, self.noop_fmt)
141            elif next_index == -1 and open_brackets > 0:
142                self.setFormat(index, text_length - index, self.noop_fmt)
143
144            # Check for next noop operation, necessary for multiple noops in one line
145            if open_brackets == 0:
146                next_index = self.noop_re.indexIn(text, next_index)
147
148            index = next_index + 1 if next_index > -1 and next_index < text_length else -1
149
150        self.setCurrentBlockState(open_brackets)
151
152
153class ScriptCompleter(QCompleter):
154    def __init__(self, parent=None):
155        super().__init__(sorted(self.choices), parent)
156        self.setCompletionMode(QCompleter.UnfilteredPopupCompletion)
157        self.highlighted.connect(self.set_highlighted)
158        self.last_selected = ''
159
160    @property
161    def choices(self):
162        yield from {'$' + name for name in script_function_names()}
163        yield from {'%' + name.replace('~', '_') + '%' for name in self.all_tags}
164
165    @property
166    def all_tags(self):
167        yield from TAG_NAMES.keys()
168        yield from PRESERVED_TAGS
169        yield from EXTRA_VARIABLES
170
171    def set_highlighted(self, text):
172        self.last_selected = text
173
174    def get_selected(self):
175        return self.last_selected
176
177
178class ScriptTextEdit(QTextEdit):
179    autocomplete_trigger_chars = re.compile('[$%A-Za-z0-9_]')
180
181    def __init__(self, parent):
182        super().__init__(parent)
183        self.highlighter = TaggerScriptSyntaxHighlighter(self.document())
184        self.enable_completer()
185        self.setFontFamily(FONT_FAMILY_MONOSPACE)
186
187    def enable_completer(self):
188        self.completer = ScriptCompleter()
189        self.completer.setWidget(self)
190        self.completer.activated.connect(self.insert_completion)
191        self.popup_shown = False
192
193    def insert_completion(self, completion):
194        if not completion:
195            return
196        tc = self.cursor_select_word()
197        if completion.startswith('$'):
198            completion += '('
199        tc.insertText(completion)
200        # Peek at the next character to include it in the replacement
201        if not tc.atEnd():
202            pos = tc.position()
203            tc = self.textCursor()
204            tc.setPosition(pos + 1, QTextCursor.KeepAnchor)
205            first_char = completion[0]
206            next_char = tc.selectedText()
207            if (first_char == '$' and next_char == '(') or (first_char == '%' and next_char == '%'):
208                tc.removeSelectedText()
209            else:
210                tc.setPosition(pos)  # Reset position
211        self.setTextCursor(tc)
212        self.popup_hide()
213
214    def popup_hide(self):
215        self.completer.popup().hide()
216
217    def cursor_select_word(self, full_word=True):
218        tc = self.textCursor()
219        current_position = tc.position()
220        tc.select(QTextCursor.WordUnderCursor)
221        selected_text = tc.selectedText()
222        # Check for start of function or end of variable
223        if current_position > 0 and selected_text and selected_text[0] in ('(', '%'):
224            current_position -= 1
225            tc.setPosition(current_position)
226            tc.select(QTextCursor.WordUnderCursor)
227            selected_text = tc.selectedText()
228        start = tc.selectionStart()
229        end = tc.selectionEnd()
230        if current_position < start or current_position > end:
231            # If the cursor is between words WordUnderCursor will select the
232            # previous word. Reset the selection if the new selection is
233            # outside the old cursor position.
234            tc.setPosition(current_position)
235            selected_text = tc.selectedText()
236        if not selected_text.startswith('$') and not selected_text.startswith('%'):
237            # Update selection to include the character before the
238            # selected word to include the $ or %.
239            tc.setPosition(start - 1 if start > 0 else 0)
240            tc.setPosition(end, QTextCursor.KeepAnchor)
241            selected_text = tc.selectedText()
242            # No match, reset position (otherwise we could replace an additional character)
243            if not selected_text.startswith('$') and not selected_text.startswith('%'):
244                tc.setPosition(start)
245                tc.setPosition(end, QTextCursor.KeepAnchor)
246        if not full_word:
247            tc.setPosition(current_position, QTextCursor.KeepAnchor)
248        return tc
249
250    def keyPressEvent(self, event):
251        if self.completer.popup().isVisible():
252            if event.key() in (Qt.Key_Tab, Qt.Key_Return, Qt.Key_Enter):
253                self.completer.activated.emit(self.completer.get_selected())
254                return
255
256        super().keyPressEvent(event)
257        self.handle_autocomplete(event)
258
259    def handle_autocomplete(self, event):
260        # Only trigger autocomplete on actual text input or if the user explicitly
261        # requested auto completion with Ctrl+Space (Control+Space on macOS)
262        modifier = QtCore.Qt.MetaModifier if IS_MACOS else QtCore.Qt.ControlModifier
263        force_completion_popup = event.key() == QtCore.Qt.Key_Space and event.modifiers() & modifier
264        if not (force_completion_popup
265                or event.key() in (Qt.Key_Backspace, Qt.Key_Delete)
266                or self.autocomplete_trigger_chars.match(event.text())):
267            self.popup_hide()
268            return
269
270        tc = self.cursor_select_word(full_word=False)
271        selected_text = tc.selectedText()
272        if force_completion_popup or (selected_text and selected_text[0] in ('$', '%')):
273            self.completer.setCompletionPrefix(selected_text)
274            popup = self.completer.popup()
275            popup.setCurrentIndex(self.completer.currentIndex())
276
277            cr = self.cursorRect()
278            cr.setWidth(
279                popup.sizeHintForColumn(0)
280                + popup.verticalScrollBar().sizeHint().width()
281            )
282            self.completer.complete(cr)
283        else:
284            self.popup_hide()
285