TextInteraction.py 17.5 KB
Newer Older
1
2
3
4
5
6
7
8
9
#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
    Graphical text editing functionalities:
        - Autocompletion
        - Syntax highlighing
        - Automatic placement

10
    Copyright (c) 2012-2019 European Space Agency
11
12
13
14
15
16

    Designed and implemented by Maxime Perrotin for the TASTE project

    Contact: maxime.perrotin@esa.int
"""

17
import string
18
19
import logging

Maxime Perrotin's avatar
Maxime Perrotin committed
20
21
22
23
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from . import undoCommands
24
25
26
27
28
29

__all__ = ['EditableText']

LOG = logging.getLogger(__name__)

# pylint: disable=R0904
Maxime Perrotin's avatar
Maxime Perrotin committed
30
class Completer(QGraphicsProxyWidget):
31
32
33
34
35
36
37
38
    ''' Class for handling text autocompletion in the SDL scene '''
    def __init__(self, parent):
        ''' Create an autocompletion list popup '''
        widget = QListWidget()
        super(Completer, self).__init__(parent)
        self.setWidget(widget)
        self.string_list = QStringListModel()
        self._completer = QCompleter()
39
        self.parent = parent
40
41
42
43
44
45
46
47
48
49
50
        self._completer.setCaseSensitivity(Qt.CaseInsensitive)
        # For some reason the default minimum size is (61,61)
        # Set it to 0 so that the size of the box is not taken
        # into account when it is hidden.
        self.setMinimumSize(0, 0)
        self.prepareGeometryChange()
        self.resize(0, 0)
        self.hide()

    def set_completer_list(self):
        ''' Set list of items for the autocompleter popup '''
51
52
        compl = [item.replace('-', '_') for item in
                 self.parent.parentItem().completion_list]
53
        self.string_list.setStringList(compl)
54
55
56
57
58
59
60
61
62
63
        self._completer.setModel(self.string_list)

    def set_completion_prefix(self, completion_prefix):
        '''
            Set the current completion prefix (user-entered text)
            and set the corresponding list of words in the popup widget
        '''
        self._completer.setCompletionPrefix(completion_prefix)
        self.widget().clear()
        count = self._completer.completionCount()
Maxime Perrotin's avatar
Maxime Perrotin committed
64
        for i in range(count):
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
            self._completer.setCurrentRow(i)
            self.widget().addItem(self._completer.currentCompletion())
        self.prepareGeometryChange()
        if count:
            self.resize(self.widget().sizeHintForColumn(0) + 40, 70)
        else:
            self.resize(0, 0)
        return count

    # pylint: disable=C0103
    def keyPressEvent(self, e):
        super(Completer, self).keyPressEvent(e)
        if e.key() == Qt.Key_Escape:
            self.parentItem().setFocus()
        # Consume the event so that it is not repeated at EditableText level
        e.accept()

    # pylint: disable=C0103
    def focusOutEvent(self, event):
        ''' When the user leaves the popup, return focus to parent '''
        super(Completer, self).focusOutEvent(event)
        self.hide()
        self.resize(0, 0)
        self.parentItem().setFocus()


# pylint: disable=R0904
Maxime Perrotin's avatar
Maxime Perrotin committed
92
class Highlighter(QSyntaxHighlighter):
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
    ''' Class for handling syntax highlighting in editable text '''
    def __init__(self, parent, blackbold_patterns, redbold_patterns):
        ''' Define highlighting rules - inputs = lists of patterns '''
        super(Highlighter, self).__init__(parent)
        self.highlighting_rules = []

        # Black bold items (allowed keywords)
        black_bold_format = QTextCharFormat()
        black_bold_format.setFontWeight(QFont.Bold)
        self.highlighting_rules = [(QRegExp(pattern, cs=Qt.CaseInsensitive),
            black_bold_format) for pattern in blackbold_patterns]

        # Red bold items (reserved keywords)
        red_bold_format = QTextCharFormat()
        red_bold_format.setFontWeight(QFont.Bold)
        red_bold_format.setForeground(Qt.red)
        for pattern in redbold_patterns:
            self.highlighting_rules.append(
                    (QRegExp(pattern, cs=Qt.CaseInsensitive), red_bold_format))

        # Comments
        comment_format = QTextCharFormat()
        comment_format.setForeground(Qt.darkBlue)
        comment_format.setFontItalic(True)
        self.highlighting_rules.append((QRegExp('--[^\n]*'), comment_format))

    # pylint: disable=C0103
    def highlightBlock(self, text):
        ''' Redefined function to apply the highlighting rules '''
        for expression, formatter in self.highlighting_rules:
            index = expression.indexIn(text)
            while (index >= 0):
                length = expression.matchedLength()
                self.setFormat(index, length, formatter)
                index = expression.indexIn(text, index + length)


# pylint: disable=R0902
Maxime Perrotin's avatar
Maxime Perrotin committed
131
class EditableText(QGraphicsTextItem):
132
133
134
135
136
137
    '''
        Editable text area inside symbols
        Includes autocompletion when parent item needs it
    '''
    default_cursor = Qt.IBeamCursor
    hasParent = False
138
    word_under_cursor = Signal(str)
139
140
141

    def __init__(self, parent, text='...', hyperlink=None):
        super(EditableText, self).__init__(parent)
142
        self.parent = parent
143
144
        self.setFont(QFont('Ubuntu', 10))
        self.completer = Completer(self)
145
        self.completer.widget().itemActivated.connect(self.completion_selected)
146
147
148
149
150
151
152
        self.hyperlink = hyperlink
        self.setOpenExternalLinks(True)
        if hyperlink:
            self.setHtml('<a href="{hlink}">{text}</a>'.format
                    (hlink=hyperlink, text=text.replace('\n', '<br>')))
        else:
            self.setPlainText(text)
153
154
155
        self.setTextInteractionFlags(Qt.TextEditorInteraction
                                     | Qt.LinksAccessibleByMouse
                                     | Qt.LinksAccessibleByKeyboard)
156
157
158
159
160
161
        self.completer_has_focus = False
        self.editing = False
        self.try_resize()
        self.highlighter = Highlighter(
                self.document(), parent.blackbold, parent.redbold)
        self.completion_prefix = ''
162
        self.old_word = ''  # needed to detect change of word under cursor
Maxime Perrotin's avatar
Maxime Perrotin committed
163
        #self.set_textbox_position()
164
165
166
        self.set_text_alignment()
        # Increase the Z value of the text area so that the autocompleter
        # always appear on top of text's siblings (parents's followers)
167
        self.setZValue(self.zValue() + 1)
168
169
        # context is used for advanced autocompletion
        self.context = ''
170
171
172
173
174
        # Set cursor when mouse goes over the text
        self.setCursor(self.default_cursor)
        # Activate cache mode to boost rendering by calling paint less often
        # Removed - does not render text properly (eats up the right part)
        # self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
175
        self.force_focus = False
176
177
178

    def set_text_alignment(self):
        ''' Apply the required text alignment within the text box '''
179
        alignment = self.parent.text_alignment
180
181
182
183
184
185
186
187
188
189
190
        self.setTextWidth(self.boundingRect().width())
        fmt = QTextBlockFormat()
        fmt.setAlignment(alignment)
        cursor = self.textCursor()
        cursor.select(QTextCursor.Document)
        cursor.mergeBlockFormat(fmt)
        cursor.clearSelection()
        self.setTextCursor(cursor)

    def set_textbox_position(self):
        ''' Compute the textbox position '''
191
        parent_rect = self.parent.boundingRect()
192
193
        rect = self.boundingRect()
        # Use parent symbol alignment requirement
Maxime Perrotin's avatar
Maxime Perrotin committed
194
        # Does not support right alignment (just add it when needed)
195
        alignment = self.parent.textbox_alignment
196
197
198
199
200
        rect_center = parent_rect.center() - rect.center()
        if alignment & Qt.AlignLeft:
            x_pos = 0
        elif alignment & Qt.AlignHCenter:
            x_pos = rect_center.x()
201
202
        else:
            x_pos = 0
203
204
205
206
        if alignment & Qt.AlignTop:
            y_pos = 0
        elif alignment & Qt.AlignVCenter:
            y_pos = rect_center.y()
Maxime Perrotin's avatar
Maxime Perrotin committed
207
208
        elif alignment & Qt.AlignBottom:
            y_pos = parent_rect.height()
209
210
        else:
            y_pos = 0
211
212
213
214
215
216
217
        self.setPos(x_pos, y_pos)

    def try_resize(self):
        '''
            If needed, request a resizing of the parent item
            (when text size expands)
        '''
218
        if self.parent.auto_expand:
219
            self.setTextWidth(-1)
220
            parent_rect = self.parent.boundingRect()
221
            rect = self.boundingRect()
Maxime Perrotin's avatar
Maxime Perrotin committed
222
223
            if rect.width() + 15 > parent_rect.width():
                parent_rect.setWidth(rect.width() + 15)
224
            parent_rect.setHeight(max(rect.height(), parent_rect.height()))
225
            self.parent.resize_item(parent_rect)
226
            self.set_textbox_position()
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241

    @Slot(QListWidgetItem)
    def completion_selected(self, item):
        '''
            Slot connected to the autocompletion popup,
            invoked when selection is made
        '''
        if not(self.textInteractionFlags() & Qt.TextEditable):
            self.completer.hide()
            return
        text_cursor = self.textCursor()
        # Go back to the previously saved cursor position
        text_cursor.setPosition(self.cursor_position)
        extra = len(item.text()) - len(self.completion_prefix)
        if extra > 0:
242
243
244
245
            if len(self.completion_prefix):
                # Move back left only if there is a word to replace
                text_cursor.movePosition(QTextCursor.Left)
                text_cursor.movePosition(QTextCursor.EndOfWord)
246
247
248
249
250
251
            text_cursor.insertText(item.text()[-extra:])
            self.setTextCursor(text_cursor)
        self.completer_has_focus = False
        self.completer.hide()
        self.try_resize()

252

253
    def context_completion_list(self, force=False):
254
        ''' Advanced context-dependent autocompletion '''
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
        # Select text from the begining of a line to the cursor position
        # Then keep the last word including separators ('!' and '.')
        # This word (e.g. variable!field!subfield) is then used to update
        # the autocompletion list.
        cursor = self.textCursor()
        pos = cursor.positionInBlock() - 1
        cursor.select(QTextCursor.BlockUnderCursor)
        context = self.context
        try:
            # If not the first line of the text, Qt adds u+2029 as 1st char
            line = cursor.selectedText().replace(u'\u2029', '')
            if line[pos] in string.ascii_letters + '!' + '.' + '_':
                self.context = line[slice(0, pos + 1)].split()[-1]
            else:
                self.context = ''
        except IndexError:
            pass
272
        if (context != self.context) or force:
273
274
275
            self.completer.set_completer_list()


276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
    # pylint: disable=C0103
    def keyPressEvent(self, event):
        '''
            Activate the autocompletion window if relevant
        '''
        super(EditableText, self).keyPressEvent(event)
        # Typing Esc allows to stop editing text:
        if event.key() == Qt.Key_Escape:
            self.clearFocus()
            return
        # When completer is displayed, give it the focus with down key
        if self.completer.isVisible() and event.key() == Qt.Key_Down:
            self.completer_has_focus = True
            self.completer.setFocusProxy(None)
            self.completer.widget().setFocus()
            return
        self.try_resize()
        text_cursor = self.textCursor()
        text_cursor.select(QTextCursor.WordUnderCursor)
        self.completion_prefix = text_cursor.selectedText()

297
298
299
300
301
        # "self.completion_prefix" is the complete word under the cursor
        if self.completion_prefix != self.old_word:
            self.word_under_cursor.emit(self.completion_prefix)
            self.old_word = self.completion_prefix

302
        self.context_completion_list(force=(event.key()==Qt.Key_F8))
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323

        completion_count = self.completer.set_completion_prefix(
                self.completion_prefix)
        if(completion_count > 0 and len(self.completion_prefix) > 1) or(
                event.key() == Qt.Key_F8):
            # Save the position of the cursor
            self.cursor_position = self.textCursor().position()
            # Computing the coordinates of the completer
            # No direct Qt function for that.. doing it the hard way
            pos = self.textCursor().positionInBlock()
            block = self.textCursor().block()
            layout = block.layout()
            line = layout.lineForTextPosition(pos)
            rect = line.rect()
            relative_x, _ = line.cursorToX(pos)
            layout_pos = layout.position()
            pos_x = relative_x + layout_pos.x()
            pos_y = rect.y() + rect.height() + layout_pos.y()

            self.completer.setPos(pos_x, pos_y)
            self.completer.show()
324
            self.force_focus = True   # avoid unwanted syntax checks
325
326
            # Make sure parent item has higher visibility than its siblings
            # (useful in decision branches)
327
            self.parent.setZValue(1)
328
329
330
331
332
333
334
335
336
            self.completer.setFocusProxy(self)
            self.setTabChangesFocus(True)
        else:
            self.completer.setFocusProxy(None)
            self.completer.hide()
            self.completer.resize(0, 0)
            self.setFocus()
        self.completer_has_focus = False

337
338
339
340
341
342
343
344
345
346
    def mousePressEvent(self, event):
        '''
            If the completer box is active while the user clicks on another
            area of the text box, make it disappear first
        '''
        if self.completer.isVisible():
            self.completer.hide()
            self.completer.resize(0, 0)
        super(EditableText, self).mousePressEvent(event)

347
348
349
350
351
352
353
354
355
356
357
358
    # pylint: disable=C0103
    def focusOutEvent(self, event):
        '''
            When the user stops editing, this function is called
            In that case, hide the completer if it is not the item
            that got the focus.
        '''
        if not self.editing:
            return super(EditableText, self).focusOutEvent(event)
        if self.completer and not self.completer_has_focus:
            self.completer.hide()
            self.completer.resize(0, 0)
359
360
361
        if self.force_focus:
            # when user double-clicks on the Completer, it may be out of
            # the editable text. It is not right to leave the focus in that
Maxime Perrotin's avatar
Maxime Perrotin committed
362
            # case, as this would generate a syntax check while in fact
363
364
365
366
            # user is not done editing text
            self.setFocus()
            self.force_focus = False
            return
367
368
        if not self.completer or not self.completer.isVisible():
            # Trigger a select - side effect makes the toolbar update
369
            try:
370
                self.parent.select(True)
371
372
373
            except AttributeError:
                # Some parents may not be selectable (e.g. Signalroute)
                pass
374
375
376
377
378
379
            self.editing = False
            text_cursor = self.textCursor()
            if text_cursor.hasSelection():
                text_cursor.clearSelection()
                self.setTextCursor(text_cursor)
            # If something has changed, check syntax and create undo command
Maxime Perrotin's avatar
Maxime Perrotin committed
380
381
            if(self.oldSize != self.parent.boundingRect()
                    or self.parent.syntax_error or self.oldText != str(self)):
382
                # Call syntax checker from item containing the text (if any)
383
384
385
386
                if self.scene().check_syntax(self.parent):
                    # Keep focus
                    self.setFocus()
                    return
387
388
389
390
391
                # Update class completion list
                self.scene().update_completion_list(self.parentItem())
                # Create undo command, including possible CAM
                with undoCommands.UndoMacro(self.scene().undo_stack, 'Text'):
                    undo_cmd = undoCommands.ResizeSymbol(
392
393
                                          self.parent, self.oldSize,
                                          self.parent.boundingRect())
394
                    self.scene().undo_stack.push(undo_cmd)
395
                    try:
396
397
                        self.parent.cam(self.parent.pos(),
                                              self.parent.pos())
398
399
400
                    except AttributeError:
                        # Some parents may not have CAM function (e.g. Channel)
                        pass
401
402

                    undo_cmd = undoCommands.ReplaceText(self, self.oldText,
Maxime Perrotin's avatar
Maxime Perrotin committed
403
                                                        str(self))
404
405
                    self.scene().undo_stack.push(undo_cmd)
        self.set_text_alignment()
406
        # Reset Z-Values that were increased when getting focus
407
        top_level = self.parent.top_level()
408
        top_level.setZValue(top_level.zValue() - 1)
409
        self.parent.setZValue(self.parent.zValue() - 1)
410
411
412
413
414
415
        super(EditableText, self).focusOutEvent(event)

    # pylint: disable=C0103
    def focusInEvent(self, event):
        ''' When user starts editing text, save previous state for Undo '''
        super(EditableText, self).focusInEvent(event)
416
417
        # Change the Z-value of items to make sure the
        # completer is always be on top of other symbols
418
        top_level = self.parent.top_level()
419
        top_level.setZValue(top_level.zValue() + 1)
420
        self.parent.setZValue(self.parent.zValue() + 1)
421

422
        # Trigger a select - side effect makes the toolbar update
423
        try:
424
            self.parent.select(True)
425
        except AttributeError:
Maxime Perrotin's avatar
Maxime Perrotin committed
426
            # Some parents may not be selectable
427
            pass
428
        # Update completer list of keywords
429
        self.context = ''
430
431
432
433
434
435
        self.completer.set_completer_list()
        # Clear selection otherwise the "Delete" key may delete other items
        self.scene().clearSelection()
        # Set width to auto-expand, and disables alignment, while editing:
        self.setTextWidth(-1)
        if not self.editing:
Maxime Perrotin's avatar
Maxime Perrotin committed
436
            self.oldText = str(self)
437
            self.oldSize = self.parent.boundingRect()
438
439
440
441
442
            self.editing = True

    def __str__(self):
        ''' Print the text inside the symbol '''
        return self.toPlainText()