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