forked from esa/opengeode
/
TextInteraction.py
410 lines (367 loc) · 16.2 KB
/
TextInteraction.py
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Graphical text editing functionalities:
- Autocompletion
- Syntax highlighing
- Automatic placement
Copyright (c) 2012-2014 European Space Agency
Designed and implemented by Maxime Perrotin for the TASTE project
Contact: maxime.perrotin@esa.int
"""
import string
import logging
from PySide.QtCore import Qt, QRegExp, Slot
from PySide.QtGui import(QGraphicsTextItem, QGraphicsProxyWidget, QListWidget,
QStringListModel, QCompleter, QListWidgetItem, QFont,
QTextCursor, QSyntaxHighlighter, QTextCharFormat,
QTextBlockFormat, QStringListModel)
import undoCommands
__all__ = ['EditableText']
LOG = logging.getLogger(__name__)
# pylint: disable=R0904
class Completer(QGraphicsProxyWidget, object):
''' 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()
self.parent = parent
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 '''
compl = list(self.parent.parentItem().completion_list)
self.string_list.setStringList(compl)
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()
for i in xrange(count):
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
class Highlighter(QSyntaxHighlighter, object):
''' 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
class EditableText(QGraphicsTextItem, object):
'''
Editable text area inside symbols
Includes autocompletion when parent item needs it
'''
default_cursor = Qt.IBeamCursor
hasParent = False
def __init__(self, parent, text='...', hyperlink=None):
super(EditableText, self).__init__(parent)
self.setFont(QFont('Ubuntu', 10))
self.completer = Completer(self)
self.completer.widget().itemActivated.connect(
self.completion_selected)
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)
self.setTextInteractionFlags(Qt.TextEditorInteraction
| Qt.LinksAccessibleByMouse
| Qt.LinksAccessibleByKeyboard)
self.completer_has_focus = False
self.editing = False
self.try_resize()
self.highlighter = Highlighter(
self.document(), parent.blackbold, parent.redbold)
self.completion_prefix = ''
self.set_textbox_position()
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)
self.setZValue(1)
# context is used for advanced autocompletion
self.context = ''
# 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)
def set_text_alignment(self):
''' Apply the required text alignment within the text box '''
alignment = self.parentItem().text_alignment
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 '''
parent_rect = self.parentItem().boundingRect()
rect = self.boundingRect()
# Use parent symbol alignment requirement
# Does not support right nor bottom alignment
alignment = self.parentItem().textbox_alignment
rect_center = parent_rect.center() - rect.center()
if alignment & Qt.AlignLeft:
x_pos = 0
elif alignment & Qt.AlignHCenter:
x_pos = rect_center.x()
if alignment & Qt.AlignTop:
y_pos = 0
elif alignment & Qt.AlignVCenter:
y_pos = rect_center.y()
self.setPos(x_pos, y_pos)
def paint(self, painter, _, ___):
''' Place the textbox in the parent symbol and draw it '''
self.set_textbox_position()
super(EditableText, self).paint(painter, _, ___)
def try_resize(self):
'''
If needed, request a resizing of the parent item
(when text size expands)
'''
if self.parentItem().auto_expand:
self.setTextWidth(-1)
parent_rect = self.parentItem().boundingRect()
rect = self.boundingRect()
if rect.width() + 30 > parent_rect.width():
parent_rect.setWidth(rect.width() + 30)
parent_rect.setHeight(max(rect.height(), parent_rect.height()))
self.parentItem().resize_item(parent_rect)
@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:
text_cursor.movePosition(QTextCursor.Left)
text_cursor.movePosition(QTextCursor.EndOfWord)
text_cursor.insertText(item.text()[-extra:])
self.setTextCursor(text_cursor)
self.completer_has_focus = False
self.completer.hide()
self.try_resize()
def context_completion_list(self):
''' Advanced context-dependent autocompletion for SEQUENCE fields '''
# 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
if context != self.context:
#print 'refreshing list with', self.context.encode('utf-8')
self.completer.set_completer_list()
# 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()
self.context_completion_list()
completion_count = self.completer.set_completion_prefix(
self.completion_prefix)
if event.key() in (Qt.Key_Period, Qt.Key_Exclam):
# Enable autocompletion of complex types
pass # placeholder to update autocompletion list
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()
# Make sure parent item has higher visibility than its siblings
# (useful in decision branches)
self.parentItem().setZValue(1)
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
# 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)
if not self.completer or not self.completer.isVisible():
# Trigger a select - side effect makes the toolbar update
try:
self.parentItem().select(True)
except AttributeError:
# Some parents may not be selectable (e.g. Signalroute)
pass
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
if(self.oldSize != self.parentItem().boundingRect() or
self.oldText != unicode(self)):
# Call syntax checker from item containing the text (if any)
self.scene().check_syntax(self.parentItem())
# 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(
self.parentItem(), self.oldSize,
self.parentItem().boundingRect())
self.scene().undo_stack.push(undo_cmd)
try:
self.parentItem().cam(self.parentItem().pos(),
self.parentItem().pos())
except AttributeError:
# Some parents may not have CAM function (e.g. Channel)
pass
undo_cmd = undoCommands.ReplaceText(self, self.oldText,
unicode(self))
self.scene().undo_stack.push(undo_cmd)
self.set_text_alignment()
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)
# Trigger a select - side effect makes the toolbar update
try:
self.parentItem().select(True)
except AttributeError:
# Some parents may not be selectable (e.g. Signalroute)
pass
# Update completer list of keywords
self.context = ''
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:
self.oldText = unicode(self)
self.oldSize = self.parentItem().boundingRect()
self.editing = True
def __str__(self):
''' Print the text inside the symbol '''
raise TypeError('Use UNICODE, not string!')
def __unicode__(self):
return self.toPlainText()