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 snippets widget.
22"""
23
24
25from PyQt5.QtCore import QEvent, QItemSelectionModel, QModelIndex, Qt
26from PyQt5.QtGui import QKeySequence
27from PyQt5.QtWidgets import (
28    QAction, QApplication, QCompleter, QFileDialog, QHBoxLayout, QMenu,
29    QMessageBox, QPushButton, QSplitter, QTextBrowser, QToolButton,
30    QTreeView, QVBoxLayout, QWidget)
31
32import app
33import userguide
34import icons
35import widgets.lineedit
36import textformats
37import actioncollectionmanager
38
39from . import actions
40from . import model
41from . import snippets
42from . import edit
43from . import insert
44from . import highlight
45
46
47class Widget(QWidget):
48    def __init__(self, panel):
49        super(Widget, self).__init__(panel)
50
51        layout = QVBoxLayout()
52        self.setLayout(layout)
53        layout.setSpacing(0)
54
55        self.searchEntry = SearchLineEdit()
56        self.treeView = QTreeView(contextMenuPolicy=Qt.CustomContextMenu)
57        self.textView = QTextBrowser()
58
59        applyButton = QToolButton(autoRaise=True)
60        editButton = QToolButton(autoRaise=True)
61        addButton = QToolButton(autoRaise=True)
62        self.menuButton = QPushButton(flat=True)
63        menu = QMenu(self.menuButton)
64        self.menuButton.setMenu(menu)
65
66        splitter = QSplitter(Qt.Vertical)
67        top = QHBoxLayout()
68        layout.addLayout(top)
69        splitter.addWidget(self.treeView)
70        splitter.addWidget(self.textView)
71        layout.addWidget(splitter)
72        splitter.setSizes([200, 100])
73        splitter.setCollapsible(0, False)
74
75        top.addWidget(self.searchEntry)
76        top.addWidget(applyButton)
77        top.addSpacing(10)
78        top.addWidget(addButton)
79        top.addWidget(editButton)
80        top.addWidget(self.menuButton)
81
82        # action generator for actions added to search entry
83        def act(slot, icon=None):
84            a = QAction(self, triggered=slot)
85            self.addAction(a)
86            a.setShortcutContext(Qt.WidgetWithChildrenShortcut)
87            icon and a.setIcon(icons.get(icon))
88            return a
89
90        # hide if ESC pressed in lineedit
91        a = act(self.slotEscapePressed)
92        a.setShortcut(QKeySequence(Qt.Key_Escape))
93
94        # import action
95        a = self.importAction = act(self.slotImport, 'document-open')
96        menu.addAction(a)
97
98        # export action
99        a = self.exportAction = act(self.slotExport, 'document-save-as')
100        menu.addAction(a)
101
102        # apply button
103        a = self.applyAction = act(self.slotApply, 'edit-paste')
104        applyButton.setDefaultAction(a)
105        menu.addSeparator()
106        menu.addAction(a)
107
108        # add button
109        a = self.addAction_ = act(self.slotAdd, 'list-add')
110        a.setShortcut(QKeySequence(Qt.Key_Insert))
111        addButton.setDefaultAction(a)
112        menu.addSeparator()
113        menu.addAction(a)
114
115        # edit button
116        a = self.editAction = act(self.slotEdit, 'document-edit')
117        a.setShortcut(QKeySequence(Qt.Key_F2))
118        editButton.setDefaultAction(a)
119        menu.addAction(a)
120
121        # set shortcut action
122        a = self.shortcutAction = act(self.slotShortcut, 'preferences-desktop-keyboard-shortcuts')
123        menu.addAction(a)
124
125        # delete action
126        a = self.deleteAction = act(self.slotDelete, 'list-remove')
127        a.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_Delete))
128        menu.addAction(a)
129
130        # restore action
131        a = self.restoreAction = act(self.slotRestore)
132        menu.addSeparator()
133        menu.addAction(a)
134
135        # help button
136        a = self.helpAction = act(self.slotHelp, 'help-contents')
137        menu.addSeparator()
138        menu.addAction(a)
139
140        self.treeView.setSelectionBehavior(QTreeView.SelectRows)
141        self.treeView.setSelectionMode(QTreeView.ExtendedSelection)
142        self.treeView.setRootIsDecorated(False)
143        self.treeView.setAllColumnsShowFocus(True)
144        self.treeView.setModel(model.model())
145        self.treeView.setCurrentIndex(QModelIndex())
146
147        # signals
148        self.searchEntry.returnPressed.connect(self.slotReturnPressed)
149        self.searchEntry.textChanged.connect(self.updateFilter)
150        self.treeView.doubleClicked.connect(self.slotDoubleClicked)
151        self.treeView.customContextMenuRequested.connect(self.showContextMenu)
152        self.treeView.selectionModel().currentChanged.connect(self.updateText)
153        self.treeView.model().dataChanged.connect(self.updateFilter)
154
155        # highlight text
156        self.highlighter = highlight.Highlighter(self.textView.document())
157
158        # complete on snippet variables
159        self.searchEntry.setCompleter(QCompleter([
160            ':icon', ':indent', ':menu', ':name', ':python', ':selection',
161            ':set', ':symbol', ':template', ':template-run'], self.searchEntry))
162        self.readSettings()
163        app.settingsChanged.connect(self.readSettings)
164        app.translateUI(self)
165        self.updateColumnSizes()
166        self.setAcceptDrops(True)
167
168    def dropEvent(self, ev):
169        if not ev.source() and ev.mimeData().hasUrls():
170            filename = ev.mimeData().urls()[0].toLocalFile()
171            if filename:
172                ev.accept()
173                from . import import_export
174                import_export.load(filename, self)
175
176    def dragEnterEvent(self, ev):
177        if not ev.source() and ev.mimeData().hasUrls():
178            ev.accept()
179
180    def translateUI(self):
181        try:
182            self.searchEntry.setPlaceholderText(_("Search..."))
183        except AttributeError:
184            pass # not in Qt 4.6
185        shortcut = lambda a: a.shortcut().toString(QKeySequence.NativeText)
186        self.menuButton.setText(_("&Menu"))
187        self.addAction_.setText(_("&Add..."))
188        self.addAction_.setToolTip(
189            _("Add a new snippet. ({key})").format(key=shortcut(self.addAction_)))
190        self.editAction.setText(_("&Edit..."))
191        self.editAction.setToolTip(
192            _("Edit the current snippet. ({key})").format(key=shortcut(self.editAction)))
193        self.shortcutAction.setText(_("Configure Keyboard &Shortcut..."))
194        self.deleteAction.setText(_("&Remove"))
195        self.deleteAction.setToolTip(_("Remove the selected snippets."))
196        self.applyAction.setText(_("A&pply"))
197        self.applyAction.setToolTip(_("Apply the current snippet."))
198        self.importAction.setText(_("&Import..."))
199        self.importAction.setToolTip(_("Import snippets from a file."))
200        self.exportAction.setText(_("E&xport..."))
201        self.exportAction.setToolTip(_("Export snippets to a file."))
202        self.restoreAction.setText(_("Restore &Built-in Snippets..."))
203        self.restoreAction.setToolTip(
204            _("Restore deleted or changed built-in snippets."))
205        self.helpAction.setText(_("&Help"))
206        self.searchEntry.setToolTip(_(
207            "Enter text to search in the snippets list.\n"
208            "See \"What's This\" for more information."))
209        self.searchEntry.setWhatsThis(''.join(map("<p>{0}</p>\n".format, (
210            _("Enter text to search in the snippets list, and "
211              "press Enter to apply the currently selected snippet."),
212            _("If the search text fully matches the value of the '{name}' variable "
213              "of a snippet, that snippet is selected.").format(name="name"),
214            _("If the search text starts with a colon ':', the rest of the "
215              "search text filters snippets that define the given variable. "
216              "After a space a value can also be entered, snippets will then "
217              "match if the value of the given variable contains the text after "
218              "the space."),
219            _("E.g. entering {menu} will show all snippets that are displayed "
220              "in the insert menu.").format(menu="<code>:menu</code>"),
221            ))))
222
223    def sizeHint(self):
224        return self.parent().mainwindow().size() / 4
225
226    def readSettings(self):
227        data = textformats.formatData('editor')
228        self.textView.setFont(data.font)
229        self.textView.setPalette(data.palette())
230
231    def showContextMenu(self, pos):
232        """Called when the user right-clicks the tree view."""
233        self.menuButton.menu().popup(self.treeView.viewport().mapToGlobal(pos))
234
235    def slotReturnPressed(self):
236        """Called when the user presses Return in the search entry. Applies current snippet."""
237        name = self.currentSnippet()
238        if name:
239            view = self.parent().mainwindow().currentView()
240            insert.insert(name, view)
241            self.parent().hide() # make configurable?
242            view.setFocus()
243
244    def slotEscapePressed(self):
245        """Called when the user presses ESC in the search entry. Hides the panel."""
246        self.parent().hide()
247        self.parent().mainwindow().currentView().setFocus()
248
249    def slotDoubleClicked(self, index):
250        name = self.treeView.model().name(index)
251        view = self.parent().mainwindow().currentView()
252        insert.insert(name, view)
253
254    def slotAdd(self):
255        """Called when the user wants to add a new snippet."""
256        edit.Edit(self, None)
257
258    def slotEdit(self):
259        """Called when the user wants to edit a snippet."""
260        name = self.currentSnippet()
261        if name:
262            edit.Edit(self, name)
263
264    def slotShortcut(self):
265        """Called when the user selects the Configure Shortcut action."""
266        from widgets import shortcuteditdialog
267        name = self.currentSnippet()
268        if name:
269            collection = self.parent().snippetActions
270            action = actions.action(name, None, collection)
271            default = collection.defaults().get(name)
272            mgr = actioncollectionmanager.manager(self.parent().mainwindow())
273            cb = mgr.findShortcutConflict
274            dlg = shortcuteditdialog.ShortcutEditDialog(self, cb, (collection, name))
275
276            if dlg.editAction(action, default):
277                mgr.removeShortcuts(action.shortcuts())
278                collection.setShortcuts(name, action.shortcuts())
279                self.treeView.update()
280
281    def slotDelete(self):
282        """Called when the user wants to delete the selected rows."""
283        rows = sorted(set(i.row() for i in self.treeView.selectedIndexes()), reverse=True)
284        if rows:
285            for row in rows:
286                name = self.treeView.model().names()[row]
287                self.parent().snippetActions.setShortcuts(name, [])
288                self.treeView.model().removeRow(row)
289            self.updateFilter()
290
291    def slotApply(self):
292        """Called when the user clicks the apply button. Applies current snippet."""
293        name = self.currentSnippet()
294        if name:
295            view = self.parent().mainwindow().currentView()
296            insert.insert(name, view)
297
298    def slotImport(self):
299        """Called when the user activates the import action."""
300        filetypes = "{0} (*.xml);;{1} (*)".format(_("XML Files"), _("All Files"))
301        caption = app.caption(_("dialog title", "Import Snippets"))
302        filename = None
303        filename = QFileDialog.getOpenFileName(self, caption, filename, filetypes)[0]
304        if filename:
305            from . import import_export
306            import_export.load(filename, self)
307
308    def slotExport(self):
309        """Called when the user activates the export action."""
310        allrows = [row for row in range(model.model().rowCount())
311                       if not self.treeView.isRowHidden(row, QModelIndex())]
312        selectedrows = [i.row() for i in self.treeView.selectedIndexes()
313                                if i.column() == 0 and i.row() in allrows]
314        names = self.treeView.model().names()
315        names = [names[row] for row in selectedrows or allrows]
316
317        filetypes = "{0} (*.xml);;{1} (*)".format(_("XML Files"), _("All Files"))
318        n = len(names)
319        caption = app.caption(_("dialog title",
320            "Export {num} Snippet", "Export {num} Snippets", n).format(num=n))
321        filename = QFileDialog.getSaveFileName(self, caption, None, filetypes)[0]
322        if filename:
323            from . import import_export
324            try:
325                import_export.save(names, filename)
326            except (IOError, OSError) as e:
327                QMessageBox.critical(self, _("Error"), _(
328                    "Can't write to destination:\n\n{url}\n\n{error}").format(
329                    url=filename, error=e.strerror))
330
331    def slotRestore(self):
332        """Called when the user activates the Restore action."""
333        from . import restore
334        dlg = restore.RestoreDialog(self)
335        dlg.setWindowModality(Qt.WindowModal)
336        dlg.populate()
337        dlg.show()
338        dlg.finished.connect(dlg.deleteLater)
339
340    def slotHelp(self):
341        """Called when the user clicks the small help button."""
342        userguide.show("snippets")
343
344    def currentSnippet(self):
345        """Returns the name of the current snippet if it is visible."""
346        row = self.treeView.currentIndex().row()
347        if row != -1 and not self.treeView.isRowHidden(row, QModelIndex()):
348            return self.treeView.model().names()[row]
349
350    def updateFilter(self):
351        """Called when the text in the entry changes, updates search results."""
352        text = self.searchEntry.text()
353        ltext = text.lower()
354        filterVars = text.startswith(':')
355        if filterVars:
356            try:
357                fvar, fval = text[1:].split(None, 1)
358                fhide = lambda v: v.get(fvar) in (True, None) or fval not in v.get(fvar)
359            except ValueError:
360                fvar = text[1:].strip()
361                fhide = lambda v: not v.get(fvar)
362        for row in range(self.treeView.model().rowCount()):
363            name = self.treeView.model().names()[row]
364            nameid = snippets.get(name).variables.get('name', '')
365            if filterVars:
366                hide = fhide(snippets.get(name).variables)
367            elif nameid == text:
368                i = self.treeView.model().createIndex(row, 0)
369                self.treeView.selectionModel().setCurrentIndex(i, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows)
370                hide = False
371            elif nameid.lower().startswith(ltext):
372                hide = False
373            elif ltext in snippets.title(name).lower():
374                hide = False
375            else:
376                hide = True
377            self.treeView.setRowHidden(row, QModelIndex(), hide)
378        self.updateText()
379
380    def updateText(self):
381        """Called when the current snippet changes."""
382        name = self.currentSnippet()
383        self.textView.clear()
384        if name:
385            s = snippets.get(name)
386            self.highlighter.setPython('python' in s.variables)
387            self.textView.setPlainText(s.text)
388
389    def updateColumnSizes(self):
390        self.treeView.resizeColumnToContents(0)
391        self.treeView.resizeColumnToContents(1)
392
393
394class SearchLineEdit(widgets.lineedit.LineEdit):
395    def __init__(self, *args):
396        super(SearchLineEdit, self).__init__(*args)
397
398    def event(self, ev):
399        if ev.type() == QEvent.KeyPress and any(ev.matches(key) for key in (
400            QKeySequence.MoveToNextLine, QKeySequence.SelectNextLine,
401            QKeySequence.MoveToPreviousLine, QKeySequence.SelectPreviousLine,
402            QKeySequence.MoveToNextPage, QKeySequence.SelectNextPage,
403            QKeySequence.MoveToPreviousPage, QKeySequence.SelectPreviousPage)):
404            QApplication.sendEvent(self.parent().treeView, ev)
405            return True
406        return super(SearchLineEdit, self).event(ev)
407
408
409