File: view.py

package info (click to toggle)
frescobaldi 3.0.0~git20161001.0.eec60717%2Bds1-2
  • links: PTS, VCS
  • area: main
  • in suites: stretch
  • size: 19,792 kB
  • ctags: 5,843
  • sloc: python: 37,853; sh: 180; makefile: 69
file content (266 lines) | stat: -rw-r--r-- 10,941 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# This file is part of the Frescobaldi project, http://www.frescobaldi.org/
#
# Copyright (c) 2008 - 2014 by Wilbert Berendsen
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
# See http://www.gnu.org/licenses/ for more information.

"""
View is basically a QPlainTextEdit instance.

It is used to edit a Document. The ViewManager (see viewmanager.py)
has support for showing multiple Views in a window.
"""


import weakref

from PyQt5.QtCore import QEvent, QMimeData, QSettings, Qt, QTimer, pyqtSignal
from PyQt5.QtGui import (
    QContextMenuEvent, QKeySequence, QPainter, QTextCursor)
from PyQt5.QtWidgets import QApplication, QPlainTextEdit

import app
import metainfo
import textformats
import cursortools
import variables
import cursorkeys


metainfo.define('auto_indent', True)
metainfo.define('position', 0)


class View(QPlainTextEdit):
    """View is the text editor widget a Document is displayed and edited with.
    
    It is basically a QPlainTextEdit with some extra features:
    - it draws a grey cursor when out of focus
    - it reads basic palette colors from the preferences
    - it determines tab width from the document variables (defaulting to 8 characters)
    - it stores the cursor position in the metainfo
    - it runs the auto_indenter when enabled (also checked via metainfo)
    - it can display a widget in the bottom using showWidget and hideWidget.
    
    """
    def __init__(self, document):
        """Creates the View for the given document."""
        super(View, self).__init__()
        self.setDocument(document)
        self.setLineWrapMode(QPlainTextEdit.NoWrap)
        self.setCursorWidth(2)
        # restore saved cursor position (defaulting to 0)
        document.loaded.connect(self.restoreCursor)
        document.loaded.connect(self.setTabWidth)
        document.closed.connect(self.slotDocumentClosed)
        variables.manager(document).changed.connect(self.setTabWidth)
        self.restoreCursor()
        app.settingsChanged.connect(self.readSettings)
        self.readSettings() # will also call updateCursor
        # line wrap preference is only read on init
        wrap = QSettings().value("view_preferences/wrap_lines", False, bool)
        self.setLineWrapMode(QPlainTextEdit.WidgetWidth if wrap else QPlainTextEdit.NoWrap)
        self.installEventFilter(cursorkeys.handler)
        app.viewCreated(self)

    def event(self, ev):
        if ev in (
                # avoid the line separator, makes no sense in plain text
                QKeySequence.InsertLineSeparator,
                # those can better be called via the menu actions, then they
                # work better
                QKeySequence.Undo,
                QKeySequence.Redo,
            ):
            return False
        # handle Tab and Backtab
        if ev.type() == QEvent.KeyPress:
            cursor = self.textCursor()
            if ev.key() == Qt.Key_Tab and ev.modifiers() == Qt.NoModifier:
                # tab pressed, insert a tab when no selection and in text,
                # else increase the indent
                if not cursor.hasSelection():
                    block = cursor.block()
                    text = block.text()[:cursor.position() - block.position()]
                    if text and not text.isspace():
                        if variables.get(self.document(), 'document-tabs', True):
                            cursor.insertText('\t')
                        else:
                            tabwidth = variables.get(self.document(), 'tab-width', 8)
                            spaces = tabwidth - len(text.expandtabs(tabwidth)) % tabwidth
                            cursor.insertText(' ' * spaces)
                        self.setTextCursor(cursor)
                        return True
                import indent
                indent.increase_indent(cursor)
                if not cursor.hasSelection():
                    cursortools.strip_indent(cursor)
                    self.setTextCursor(cursor)
                return True
            elif ev.key() == Qt.Key_Backtab and ev.modifiers() == Qt.ShiftModifier:
                # shift-tab pressed, decrease the indent
                import indent
                indent.decrease_indent(cursor)
                if not cursor.hasSelection():
                    cursortools.strip_indent(cursor)
                    self.setTextCursor(cursor)
                return True
        return super(View, self).event(ev)

    def keyPressEvent(self, ev):
        super(View, self).keyPressEvent(ev)
        
        if metainfo.info(self.document()).auto_indent:
            # run the indenter on Return or when the user entered a dedent token.
            import indent
            cursor = self.textCursor()
            if ev.text() == '\r' or (ev.text() in ('}', '#', '>') and indent.indentable(cursor)):
                indent.auto_indent_block(cursor.block())
                # fix subsequent vertical moves
                cursor.setPosition(cursor.position())
                self.setTextCursor(cursor)
            
    def focusOutEvent(self, ev):
        """Reimplemented to store the cursor position on focus out."""
        super(View, self).focusOutEvent(ev)
        self.storeCursor()

    def dragEnterEvent(self, ev):
        """Reimplemented to avoid showing the cursor when dropping URLs."""
        if ev.mimeData().hasUrls():
            ev.accept()
        else:
            super(View, self).dragEnterEvent(ev)
        
    def dragMoveEvent(self, ev):
        """Reimplemented to avoid showing the cursor when dropping URLs."""
        if ev.mimeData().hasUrls():
            ev.accept()
        else:
            super(View, self).dragMoveEvent(ev)
        
    def dropEvent(self, ev):
        """Called when something is dropped.
        
        Calls dropEvent() of MainWindow if URLs are dropped.
        
        """
        if ev.mimeData().hasUrls():
            self.window().dropEvent(ev)
        else:
            super(View, self).dropEvent(ev)

    def paintEvent(self, ev):
        """Reimplemented to paint a cursor if we have no focus."""
        super(View, self).paintEvent(ev)
        if not self.hasFocus():
            rect = self.cursorRect()
            if rect.intersects(ev.rect()):
                color = self.palette().text().color()
                color.setAlpha(128)
                QPainter(self.viewport()).fillRect(rect, color)
    
    def gotoTextCursor(self, cursor, numlines=3):
        """Go to the specified cursor.
        
        If possible, at least numlines (default: 3) number of surrounding lines
        is shown. The number of surrounding lines can also be set in the
        preferences, under the key "view_preferences/context_lines". This
        setting takes precedence.
        
        """
        numlines = QSettings().value("view_preferences/context_lines", numlines, int)
        if numlines > 0:
            c = QTextCursor(cursor)
            c.setPosition(cursor.selectionEnd())
            c.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, numlines)
            self.setTextCursor(c)
            c.setPosition(cursor.selectionStart())
            c.movePosition(QTextCursor.Up, QTextCursor.MoveAnchor, numlines)
            self.setTextCursor(c)
        self.setTextCursor(cursor)
    
    def readSettings(self):
        data = textformats.formatData('editor')
        self.setFont(data.font)
        self.setPalette(data.palette())
        self.setTabWidth()
        
    def slotDocumentClosed(self):
        if self.hasFocus():
            self.storeCursor()
            
    def restoreCursor(self):
        """Places the cursor on the position saved in metainfo."""
        cursor = QTextCursor(self.document())
        cursor.setPosition(metainfo.info(self.document()).position)
        self.setTextCursor(cursor)
        QTimer.singleShot(0, self.ensureCursorVisible)
    
    def storeCursor(self):
        """Stores our cursor position in the metainfo."""
        metainfo.info(self.document()).position = self.textCursor().position()

    def setTabWidth(self):
        """(Internal) Reads the tab-width variable and the font settings to set the tabStopWidth."""
        tabwidth = QSettings().value("indent/tab_width", 8, int)
        tabwidth = self.fontMetrics().width(" ") * variables.get(self.document(), 'tab-width', tabwidth)
        self.setTabStopWidth(tabwidth)
    
    def contextMenuEvent(self, ev):
        """Called when the user requests the context menu."""
        cursor = self.textCursor()
        if ev.reason() == QContextMenuEvent.Mouse:
            # if clicked inside the selection, retain it, otherwise de-select
            # and move the cursor to the clicked position
            pos = self.mapToGlobal(ev.pos())
            clicked = self.cursorForPosition(ev.pos())
            if not cursor.selectionStart() <= clicked.position() < cursor.selectionEnd():
                self.setTextCursor(clicked)
        else:
            pos = self.viewport().mapToGlobal(self.cursorRect().center())
        import contextmenu
        menu = contextmenu.contextmenu(self)
        menu.popup(pos)
        menu.setFocus() # so we get a FocusOut event and the grey cursor gets painted
        menu.exec_()
        menu.deleteLater()

    def mousePressEvent(self, ev):
        """Called when a mouse button is clicked."""
        # implements ctrl-click
        if ev.button() == Qt.LeftButton and ev.modifiers() == Qt.ControlModifier:
            cursor = self.textCursor()
            clicked = self.cursorForPosition(ev.pos())
            if cursor.selectionStart() <= clicked.position() < cursor.selectionEnd():
                clicked = cursor
            # include files?
            import open_file_at_cursor
            if open_file_at_cursor.open_file_at_cursor(self.window(), clicked):
                return
            # go to definition?
            import definition
            if definition.goto_definition(self.window(), clicked):
                return
        super(View, self).mousePressEvent(ev)
    
    def createMimeDataFromSelection(self):
        """Reimplemented to only copy plain text."""
        m = QMimeData()
        m.setText(self.textCursor().selection().toPlainText())
        return m