2 from functools
import partial
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
11 from ..models
import prefs
12 from ..qtutils
import get
13 from .. import hotkeys
15 from .. import qtutils
21 def get_stripped(widget
):
22 return widget
.get().strip()
25 class LineEdit(QtWidgets
.QLineEdit
):
26 cursor_changed
= Signal(int, int)
27 esc_pressed
= Signal()
29 def __init__(self
, parent
=None, row
=1, get_value
=None, clear_button
=False):
30 QtWidgets
.QLineEdit
.__init
__(self
, parent
)
33 get_value
= get_stripped
34 self
._get
_value
= get_value
35 self
.cursor_position
= LineEditCursorPosition(self
, row
)
36 self
.menu_actions
= []
38 if clear_button
and hasattr(self
, 'setClearButtonEnabled'):
39 self
.setClearButtonEnabled(True)
42 """Return the raw Unicode value from Qt"""
46 """Return the processed value, e.g. stripped"""
47 return self
._get
_value
(self
)
49 def set_value(self
, value
, block
=False):
50 """Update the widget to the specified value"""
52 with qtutils
.BlockSignals(self
):
53 self
._set
_value
(value
)
55 self
._set
_value
(value
)
57 def _set_value(self
, value
):
58 """Implementation helper to update the widget to the specified value"""
59 pos
= self
.cursorPosition()
61 self
.setCursorPosition(pos
)
63 def keyPressEvent(self
, event
):
65 if key
== Qt
.Key_Escape
:
66 self
.esc_pressed
.emit()
67 super().keyPressEvent(event
)
70 class LineEditCursorPosition
:
71 """Translate cursorPositionChanged(int,int) into cursorPosition(int,int)"""
73 def __init__(self
, widget
, row
):
76 # Translate cursorPositionChanged into cursor_changed(int, int)
77 widget
.cursorPositionChanged
.connect(lambda old
, new
: self
.emit())
82 col
= widget
.cursorPosition()
83 widget
.cursor_changed
.emit(row
, col
)
86 self
._widget
.setCursorPosition(0)
89 class BaseTextEditExtension(QtCore
.QObject
):
90 def __init__(self
, widget
, get_value
, readonly
):
91 QtCore
.QObject
.__init
__(self
, widget
)
93 self
.cursor_position
= TextEditCursorPosition(widget
, self
)
95 get_value
= get_stripped
96 self
._get
_value
= get_value
98 self
._readonly
= readonly
102 def _init_flags(self
):
104 widget
.setMinimumSize(QtCore
.QSize(10, 10))
105 widget
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
106 widget
.setLineWrapMode(widget
.NoWrap
)
108 widget
.setReadOnly(True)
109 widget
.setAcceptDrops(False)
110 widget
.setTabChangesFocus(True)
111 widget
.setUndoRedoEnabled(False)
112 widget
.setTextInteractionFlags(
113 Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
117 """Return the raw Unicode value from Qt"""
118 return self
.widget
.toPlainText()
121 """Return a safe value, e.g. a stripped value"""
122 return self
._get
_value
(self
.widget
)
124 def set_value(self
, value
, block
=False):
125 """Update the widget to the specified value"""
127 with qtutils
.BlockSignals(self
):
128 self
._set
_value
(value
)
130 self
._set
_value
(value
)
132 def _set_value(self
, value
):
133 """Implementation helper to update the widget to the specified value"""
134 # Save cursor position
135 offset
, selection_text
= self
.offset_and_selection()
136 old_value
= get(self
.widget
)
139 self
.widget
.setPlainText(value
)
142 if selection_text
and selection_text
in value
:
143 # If the old selection exists in the new text then re-select it.
144 idx
= value
.index(selection_text
)
145 cursor
= self
.widget
.textCursor()
146 cursor
.setPosition(idx
)
147 cursor
.setPosition(idx
+ len(selection_text
), QtGui
.QTextCursor
.KeepAnchor
)
148 self
.widget
.setTextCursor(cursor
)
150 elif value
== old_value
:
151 # Otherwise, if the text is identical and there is no selection
152 # then restore the cursor position.
153 cursor
= self
.widget
.textCursor()
154 cursor
.setPosition(offset
)
155 self
.widget
.setTextCursor(cursor
)
157 # If none of the above applied then restore the cursor position.
158 position
= max(0, min(offset
, len(value
) - 1))
159 cursor
= self
.widget
.textCursor()
160 cursor
.setPosition(position
)
161 self
.widget
.setTextCursor(cursor
)
162 cursor
= self
.widget
.textCursor()
163 cursor
.movePosition(QtGui
.QTextCursor
.StartOfLine
)
164 self
.widget
.setTextCursor(cursor
)
166 def set_cursor_position(self
, new_position
):
167 cursor
= self
.widget
.textCursor()
168 cursor
.setPosition(new_position
)
169 self
.widget
.setTextCursor(cursor
)
172 return self
._tabwidth
174 def set_tabwidth(self
, width
):
175 self
._tabwidth
= width
176 pixels
= qtutils
.text_width(self
.widget
.font(), 'M') * width
177 self
.widget
.setTabStopWidth(pixels
)
179 def selected_line(self
):
180 contents
= self
.value()
181 cursor
= self
.widget
.textCursor()
182 offset
= min(cursor
.position(), len(contents
) - 1)
183 while offset
>= 1 and contents
[offset
- 1] and contents
[offset
- 1] != '\n':
185 data
= contents
[offset
:]
187 line
, _
= data
.split('\n', 1)
193 return self
.widget
.textCursor()
195 def has_selection(self
):
196 return self
.cursor().hasSelection()
198 def selected_text(self
):
199 """Return the selected text"""
200 _
, selection
= self
.offset_and_selection()
203 def offset_and_selection(self
):
204 """Return the cursor offset and selected text"""
205 cursor
= self
.cursor()
206 offset
= cursor
.selectionStart()
207 selection_text
= cursor
.selection().toPlainText()
208 return offset
, selection_text
210 def mouse_press_event(self
, event
):
211 # Move the text cursor so that the right-click events operate
212 # on the current position, not the last left-clicked position.
214 if event
.button() == Qt
.RightButton
:
215 if not widget
.textCursor().hasSelection():
216 cursor
= widget
.cursorForPosition(event
.pos())
217 widget
.setTextCursor(cursor
)
219 def add_links_to_menu(self
, menu
):
220 """Add actions for opening URLs to a custom menu"""
221 links
= self
._get
_links
()
225 action
= menu
.addAction(N_('Open "%s"') % url
)
226 action
.setIcon(icons
.external())
227 qtutils
.connect_action(
228 action
, partial(QtGui
.QDesktopServices
.openUrl
, QtCore
.QUrl(url
))
231 def _get_links(self
):
232 """Return http links on the current line"""
233 _
, selection
= self
.offset_and_selection()
237 line
= self
.selected_line()
241 word
for word
in line
.split() if word
.startswith(('http://', 'https://'))
244 def create_context_menu(self
, event_pos
):
245 """Create a context menu for a widget"""
246 menu
= self
.widget
.createStandardContextMenu(event_pos
)
247 qtutils
.add_menu_actions(menu
, self
.widget
.menu_actions
)
248 self
.add_links_to_menu(menu
)
251 def context_menu_event(self
, event
):
252 """Default context menu event"""
253 event_pos
= event
.pos()
254 menu
= self
.widget
.create_context_menu(event_pos
)
255 menu
.exec_(self
.widget
.mapToGlobal(event_pos
))
257 # For extension by sub-classes
260 """Called during init for class-specific settings"""
263 def set_textwidth(self
, width
):
264 """Set the text width"""
267 def set_linebreak(self
, brk
):
268 """Enable word wrapping"""
272 class PlainTextEditExtension(BaseTextEditExtension
):
273 def set_linebreak(self
, brk
):
275 wrapmode
= QtWidgets
.QPlainTextEdit
.WidgetWidth
277 wrapmode
= QtWidgets
.QPlainTextEdit
.NoWrap
278 self
.widget
.setLineWrapMode(wrapmode
)
281 class PlainTextEdit(QtWidgets
.QPlainTextEdit
):
282 cursor_changed
= Signal(int, int)
285 def __init__(self
, parent
=None, get_value
=None, readonly
=False, options
=None):
286 QtWidgets
.QPlainTextEdit
.__init
__(self
, parent
)
287 self
.ext
= PlainTextEditExtension(self
, get_value
, readonly
)
288 self
.cursor_position
= self
.ext
.cursor_position
289 self
.mouse_zoom
= True
290 self
.options
= options
291 self
.menu_actions
= []
294 """Return the raw Unicode value from Qt"""
295 return self
.ext
.get()
297 # For compatibility with QTextEdit
298 def setText(self
, value
):
299 self
.set_value(value
)
302 """Return a safe value, e.g. a stripped value"""
303 return self
.ext
.value()
305 def offset_and_selection(self
):
306 """Return the cursor offset and selected text"""
307 return self
.ext
.offset_and_selection()
309 def set_value(self
, value
, block
=False):
310 self
.ext
.set_value(value
, block
=block
)
312 def set_mouse_zoom(self
, value
):
313 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
314 self
.mouse_zoom
= value
316 def set_options(self
, options
):
317 """Register an Options widget"""
318 self
.options
= options
320 def set_word_wrapping(self
, enabled
, update
=False):
321 """Enable/disable word wrapping"""
322 if update
and self
.options
is not None:
323 with qtutils
.BlockSignals(self
.options
.enable_word_wrapping
):
324 self
.options
.enable_word_wrapping
.setChecked(enabled
)
326 self
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
327 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.WidgetWidth
)
329 self
.setWordWrapMode(QtGui
.QTextOption
.NoWrap
)
330 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.NoWrap
)
332 def has_selection(self
):
333 return self
.ext
.has_selection()
335 def selected_line(self
):
336 return self
.ext
.selected_line()
338 def selected_text(self
):
339 """Return the selected text"""
340 return self
.ext
.selected_text()
342 def set_tabwidth(self
, width
):
343 self
.ext
.set_tabwidth(width
)
345 def set_textwidth(self
, width
):
346 self
.ext
.set_textwidth(width
)
348 def set_linebreak(self
, brk
):
349 self
.ext
.set_linebreak(brk
)
351 def mousePressEvent(self
, event
):
352 self
.ext
.mouse_press_event(event
)
353 super().mousePressEvent(event
)
355 def wheelEvent(self
, event
):
356 """Disable control+wheelscroll text resizing"""
357 if not self
.mouse_zoom
and (event
.modifiers() & Qt
.ControlModifier
):
360 super().wheelEvent(event
)
362 def create_context_menu(self
, event_pos
):
363 """Create a custom context menu"""
364 return self
.ext
.create_context_menu(event_pos
)
366 def contextMenuEvent(self
, event
):
367 """Custom contextMenuEvent() for building our custom context menus"""
368 self
.ext
.context_menu_event(event
)
371 class TextSearchWidget(QtWidgets
.QWidget
):
372 """The search dialog that displays over a text edit field"""
374 def __init__(self
, widget
, parent
):
375 super().__init
__(parent
)
376 self
.setAutoFillBackground(True)
377 self
._widget
= widget
378 self
._parent
= parent
380 self
.text
= HintedDefaultLineEdit(N_('Find in diff'), parent
=self
)
382 self
.prev_button
= qtutils
.create_action_button(
383 tooltip
=N_('Find the previous occurrence of the phrase'), icon
=icons
.up()
386 self
.next_button
= qtutils
.create_action_button(
387 tooltip
=N_('Find the next occurrence of the phrase'), icon
=icons
.down()
390 self
.match_case_checkbox
= qtutils
.checkbox(N_('Match Case'))
391 self
.whole_words_checkbox
= qtutils
.checkbox(N_('Whole Words'))
393 self
.close_button
= qtutils
.create_action_button(
394 tooltip
=N_('Close the find bar'), icon
=icons
.close()
397 layout
= qtutils
.hbox(
403 self
.match_case_checkbox
,
404 self
.whole_words_checkbox
,
408 self
.setLayout(layout
)
409 self
.setFocusProxy(self
.text
)
411 self
.text
.esc_pressed
.connect(self
.hide_search
)
412 self
.text
.returnPressed
.connect(self
.search
)
413 self
.text
.textChanged
.connect(self
.search
)
415 self
.search_next_action
= qtutils
.add_action(
417 N_('Find next item'),
421 self
.search_prev_action
= qtutils
.add_action(
423 N_('Find previous item'),
424 self
.search_backwards
,
428 qtutils
.connect_button(self
.next_button
, self
.search
)
429 qtutils
.connect_button(self
.prev_button
, self
.search_backwards
)
430 qtutils
.connect_button(self
.close_button
, self
.hide_search
)
431 qtutils
.connect_checkbox(self
.match_case_checkbox
, lambda _
: self
.search())
432 qtutils
.connect_checkbox(self
.whole_words_checkbox
, lambda _
: self
.search())
435 """Emit a signal with the current search text"""
436 self
.search_text(backwards
=False)
438 def search_backwards(self
):
439 """Emit a signal with the current search text for a backwards search"""
440 self
.search_text(backwards
=True)
442 def hide_search(self
):
443 """Hide the search window"""
445 self
._parent
.setFocus()
447 def find_flags(self
, backwards
):
448 """Return QTextDocument.FindFlags for the current search options"""
449 flags
= QtGui
.QTextDocument
.FindFlag(0)
451 flags
= flags | QtGui
.QTextDocument
.FindBackward
452 if self
.match_case_checkbox
.isChecked():
453 flags
= flags | QtGui
.QTextDocument
.FindCaseSensitively
454 if self
.whole_words_checkbox
.isChecked():
455 flags
= flags | QtGui
.QTextDocument
.FindWholeWords
458 def is_case_sensitive(self
):
459 """Are we searching using a case-insensitive search?"""
460 return self
.match_case_checkbox
.isChecked()
462 def search_text(self
, backwards
=False):
463 """Search the diff text for the given text"""
464 text
= self
.text
.get()
465 cursor
= self
._widget
.textCursor()
466 if cursor
.hasSelection():
467 selected_text
= cursor
.selectedText()
468 case_sensitive
= self
.is_case_sensitive()
469 if text_matches(case_sensitive
, selected_text
, text
):
471 position
= cursor
.selectionStart()
473 position
= cursor
.selectionEnd()
476 position
= cursor
.selectionEnd()
478 position
= cursor
.selectionStart()
479 cursor
.setPosition(position
)
480 self
._widget
.setTextCursor(cursor
)
482 flags
= self
.find_flags(backwards
)
483 if not self
._widget
.find(text
, flags
):
485 location
= QtGui
.QTextCursor
.End
487 location
= QtGui
.QTextCursor
.Start
488 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
489 self
._widget
.setTextCursor(cursor
)
490 self
._widget
.find(text
, flags
)
493 def text_matches(case_sensitive
, a
, b
):
494 """Compare text with case sensitivity taken into account"""
497 return a
.lower() == b
.lower()
500 class TextEditExtension(BaseTextEditExtension
):
503 widget
.setAcceptRichText(False)
505 def set_linebreak(self
, brk
):
507 wrapmode
= QtWidgets
.QTextEdit
.FixedColumnWidth
509 wrapmode
= QtWidgets
.QTextEdit
.NoWrap
510 self
.widget
.setLineWrapMode(wrapmode
)
512 def set_textwidth(self
, width
):
513 self
.widget
.setLineWrapColumnOrWidth(width
)
516 class TextEdit(QtWidgets
.QTextEdit
):
517 cursor_changed
= Signal(int, int)
520 def __init__(self
, parent
=None, get_value
=None, readonly
=False):
521 QtWidgets
.QTextEdit
.__init
__(self
, parent
)
522 self
.ext
= TextEditExtension(self
, get_value
, readonly
)
523 self
.cursor_position
= self
.ext
.cursor_position
524 self
.expandtab_enabled
= False
525 self
.menu_actions
= []
528 """Return the raw Unicode value from Qt"""
529 return self
.ext
.get()
532 """Return a safe value, e.g. a stripped value"""
533 return self
.ext
.value()
535 def set_cursor_position(self
, position
):
536 """Set the cursor position"""
537 cursor
= self
.textCursor()
538 cursor
.setPosition(position
)
539 self
.setTextCursor(cursor
)
541 def set_value(self
, value
, block
=False):
542 self
.ext
.set_value(value
, block
=block
)
544 def selected_line(self
):
545 return self
.ext
.selected_line()
547 def selected_text(self
):
548 """Return the selected text"""
549 return self
.ext
.selected_text()
551 def set_tabwidth(self
, width
):
552 self
.ext
.set_tabwidth(width
)
554 def set_textwidth(self
, width
):
555 self
.ext
.set_textwidth(width
)
557 def set_linebreak(self
, brk
):
558 self
.ext
.set_linebreak(brk
)
560 def set_expandtab(self
, value
):
561 self
.expandtab_enabled
= value
563 def mousePressEvent(self
, event
):
564 self
.ext
.mouse_press_event(event
)
565 super().mousePressEvent(event
)
567 def wheelEvent(self
, event
):
568 """Disable control+wheelscroll text resizing"""
569 if event
.modifiers() & Qt
.ControlModifier
:
572 super().wheelEvent(event
)
574 def should_expandtab(self
, event
):
575 return event
.key() == Qt
.Key_Tab
and self
.expandtab_enabled
578 tabwidth
= max(self
.ext
.tabwidth(), 1)
579 cursor
= self
.textCursor()
580 cursor
.insertText(' ' * tabwidth
)
582 def create_context_menu(self
, event_pos
):
583 """Create a custom context menu"""
584 return self
.ext
.create_context_menu(event_pos
)
586 def contextMenuEvent(self
, event
):
587 """Custom contextMenuEvent() for building our custom context menus"""
588 self
.ext
.context_menu_event(event
)
590 def keyPressEvent(self
, event
):
591 """Override keyPressEvent to handle tab expansion"""
592 expandtab
= self
.should_expandtab(event
)
597 QtWidgets
.QTextEdit
.keyPressEvent(self
, event
)
599 def keyReleaseEvent(self
, event
):
600 """Override keyReleaseEvent to special-case tab expansion"""
601 expandtab
= self
.should_expandtab(event
)
605 QtWidgets
.QTextEdit
.keyReleaseEvent(self
, event
)
608 class TextEditCursorPosition
:
609 def __init__(self
, widget
, ext
):
610 self
._widget
= widget
612 widget
.cursorPositionChanged
.connect(self
.emit
)
615 widget
= self
._widget
617 cursor
= widget
.textCursor()
618 position
= cursor
.position()
620 before
= txt
[:position
]
621 row
= before
.count('\n')
622 line
= before
.split('\n')[row
]
623 col
= cursor
.columnNumber()
624 col
+= line
[:col
].count('\t') * (ext
.tabwidth() - 1)
625 widget
.cursor_changed
.emit(row
+ 1, col
)
628 widget
= self
._widget
629 cursor
= widget
.textCursor()
630 cursor
.setPosition(0)
631 widget
.setTextCursor(cursor
)
634 class MonoTextEdit(PlainTextEdit
):
635 def __init__(self
, context
, parent
=None, readonly
=False):
636 PlainTextEdit
.__init
__(self
, parent
=parent
, readonly
=readonly
)
637 self
.setFont(qtutils
.diff_font(context
))
640 def get_value_hinted(widget
):
641 text
= get_stripped(widget
)
642 hint
= get(widget
.hint
)
648 class HintWidget(QtCore
.QObject
):
649 """Extend a widget to provide hint messages
651 This primarily exists because setPlaceholderText() is only available
652 in Qt5, so this class provides consistent behavior across versions.
656 def __init__(self
, widget
, hint
):
657 QtCore
.QObject
.__init
__(self
, widget
)
658 self
._widget
= widget
660 self
._is
_error
= False
662 self
.modern
= modern
= hasattr(widget
, 'setPlaceholderText')
664 widget
.setPlaceholderText(hint
)
666 # Palette for normal text
667 QPalette
= QtGui
.QPalette
668 palette
= widget
.palette()
670 hint_color
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
671 error_bg_color
= QtGui
.QColor(Qt
.red
).darker()
672 error_fg_color
= QtGui
.QColor(Qt
.white
)
674 hint_rgb
= qtutils
.rgb_css(hint_color
)
675 error_bg_rgb
= qtutils
.rgb_css(error_bg_color
)
676 error_fg_rgb
= qtutils
.rgb_css(error_fg_color
)
679 'name': widget
.__class
__.__name
__,
680 'error_fg_rgb': error_fg_rgb
,
681 'error_bg_rgb': error_bg_rgb
,
682 'hint_rgb': hint_rgb
,
685 self
._default
_style
= ''
696 self
._error
_style
= (
699 color: %(error_fg_rgb)s;
700 background-color: %(error_bg_rgb)s;
707 """Deferred initialization"""
709 self
.widget().setPlaceholderText(self
.value())
711 self
.widget().installEventFilter(self
)
715 """Return the parent text widget"""
719 """Return True when hint-mode is active"""
720 return self
.value() == get_stripped(self
._widget
)
723 """Return the current hint text"""
726 def set_error(self
, is_error
):
727 """Enable/disable error mode"""
728 self
._is
_error
= is_error
731 def set_value(self
, hint
):
732 """Change the hint text"""
735 self
._widget
.setPlaceholderText(hint
)
737 # If hint-mode is currently active, re-activate it
738 active
= self
.active()
740 if active
or self
.active():
743 def enable(self
, enable
):
744 """Enable/disable hint-mode"""
746 if enable
and self
._hint
:
747 self
._widget
.set_value(self
._hint
, block
=True)
748 self
._widget
.cursor_position
.reset()
751 self
._update
_palette
(enable
)
754 """Update the palette to match the current mode"""
755 self
._update
_palette
(self
.active())
757 def _update_palette(self
, hint
):
758 """Update to palette for normal/error/hint mode"""
760 style
= self
._error
_style
761 elif not self
.modern
and hint
:
762 style
= self
._hint
_style
764 style
= self
._default
_style
765 QtCore
.QTimer
.singleShot(
766 0, lambda: utils
.catch_runtime_error(self
._widget
.setStyleSheet
, style
)
769 def eventFilter(self
, _obj
, event
):
770 """Enable/disable hint-mode when focus changes"""
772 if etype
== QtCore
.QEvent
.FocusIn
:
774 elif etype
== QtCore
.QEvent
.FocusOut
:
779 """Disable hint-mode when focused"""
780 widget
= self
.widget()
783 widget
.cursor_position
.emit()
786 """Re-enable hint-mode when losing focus"""
787 widget
= self
.widget()
788 valid
, value
= utils
.catch_runtime_error(get
, widget
)
790 # The widget may have just been destroyed during application shutdown.
791 # We're receiving a focusOut event but the widget can no longer be used.
792 # This can be safely ignored.
798 class HintedPlainTextEdit(PlainTextEdit
):
799 """A hinted plain text edit"""
801 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
802 PlainTextEdit
.__init
__(
803 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
805 self
.hint
= HintWidget(self
, hint
)
807 self
.context
= context
808 self
.setFont(qtutils
.diff_font(context
))
809 self
.set_tabwidth(prefs
.tabwidth(context
))
810 # Refresh palettes when text changes
811 self
.textChanged
.connect(self
.hint
.refresh
)
812 self
.set_mouse_zoom(context
.cfg
.get(prefs
.MOUSE_ZOOM
, default
=True))
814 def set_value(self
, value
, block
=False):
815 """Set the widget text or enable hint mode when empty"""
816 if value
or self
.hint
.modern
:
817 PlainTextEdit
.set_value(self
, value
, block
=block
)
819 self
.hint
.enable(True)
822 class HintedTextEdit(TextEdit
):
823 """A hinted text edit"""
825 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
827 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
829 self
.context
= context
830 self
.hint
= HintWidget(self
, hint
)
832 # Refresh palettes when text changes
833 self
.textChanged
.connect(self
.hint
.refresh
)
834 self
.setFont(qtutils
.diff_font(context
))
836 def set_value(self
, value
, block
=False):
837 """Set the widget text or enable hint mode when empty"""
838 if value
or self
.hint
.modern
:
839 TextEdit
.set_value(self
, value
, block
=block
)
841 self
.hint
.enable(True)
844 def anchor_mode(select
):
845 """Return the QTextCursor mode to keep/discard the cursor selection"""
847 mode
= QtGui
.QTextCursor
.KeepAnchor
849 mode
= QtGui
.QTextCursor
.MoveAnchor
853 # The vim-like read-only text view
857 def __init__(self
, widget
):
859 self
.Base
= widget
.Base
860 # Common vim/Unix-ish keyboard actions
861 self
.add_navigation('End', hotkeys
.GOTO_END
)
862 self
.add_navigation('Up', hotkeys
.MOVE_UP
, shift
=hotkeys
.MOVE_UP_SHIFT
)
863 self
.add_navigation('Down', hotkeys
.MOVE_DOWN
, shift
=hotkeys
.MOVE_DOWN_SHIFT
)
864 self
.add_navigation('Left', hotkeys
.MOVE_LEFT
, shift
=hotkeys
.MOVE_LEFT_SHIFT
)
865 self
.add_navigation('Right', hotkeys
.MOVE_RIGHT
, shift
=hotkeys
.MOVE_RIGHT_SHIFT
)
866 self
.add_navigation('WordLeft', hotkeys
.WORD_LEFT
)
867 self
.add_navigation('WordRight', hotkeys
.WORD_RIGHT
)
868 self
.add_navigation('Start', hotkeys
.GOTO_START
)
869 self
.add_navigation('StartOfLine', hotkeys
.START_OF_LINE
)
870 self
.add_navigation('EndOfLine', hotkeys
.END_OF_LINE
)
876 hotkeys
.SECONDARY_ACTION
,
883 hotkeys
.PRIMARY_ACTION
,
889 lambda: widget
.page_up(select
=True),
896 lambda: widget
.page_down(select
=True),
897 hotkeys
.SELECT_FORWARD
,
901 def add_navigation(self
, name
, hotkey
, shift
=None):
902 """Add a hotkey along with a shift-variant"""
904 direction
= getattr(QtGui
.QTextCursor
, name
)
905 qtutils
.add_action(widget
, name
, lambda: self
.move(direction
), hotkey
)
908 widget
, 'Shift' + name
, lambda: self
.move(direction
, select
=True), shift
911 def move(self
, direction
, select
=False, n
=1):
913 cursor
= widget
.textCursor()
914 mode
= anchor_mode(select
)
916 if cursor
.movePosition(direction
, mode
, 1):
917 self
.set_text_cursor(cursor
)
919 def page(self
, offset
, select
=False):
921 rect
= widget
.cursorRect()
923 y
= rect
.y() + offset
924 new_cursor
= widget
.cursorForPosition(QtCore
.QPoint(x
, y
))
925 if new_cursor
is not None:
926 cursor
= widget
.textCursor()
927 mode
= anchor_mode(select
)
928 cursor
.setPosition(new_cursor
.position(), mode
)
930 self
.set_text_cursor(cursor
)
932 def page_down(self
, select
=False):
934 widget
.page(widget
.height() // 2, select
=select
)
936 def page_up(self
, select
=False):
938 widget
.page(-widget
.height() // 2, select
=select
)
940 def set_text_cursor(self
, cursor
):
942 widget
.setTextCursor(cursor
)
943 widget
.ensureCursorVisible()
944 widget
.viewport().update()
946 def keyPressEvent(self
, event
):
947 """Custom keyboard behaviors
949 The leave() signal is emitted when `Up` is pressed and we're already
950 at the beginning of the text. This allows the parent widget to
951 orchestrate some higher-level interaction, such as giving focus to
954 When in the middle of the first line and `Up` is pressed, the cursor
955 is moved to the beginning of the line.
959 if event
.key() == Qt
.Key_Up
:
960 cursor
= widget
.textCursor()
961 position
= cursor
.position()
963 # The cursor is at the beginning of the line.
964 # Emit a signal so that the parent can e.g. change focus.
966 elif get(widget
)[:position
].count('\n') == 0:
967 # The cursor is in the middle of the first line of text.
968 # We can't go up ~ jump to the beginning of the line.
969 # Select the text if shift is pressed.
970 select
= event
.modifiers() & Qt
.ShiftModifier
971 mode
= anchor_mode(select
)
972 cursor
.movePosition(QtGui
.QTextCursor
.StartOfLine
, mode
)
973 widget
.setTextCursor(cursor
)
975 return self
.Base
.keyPressEvent(widget
, event
)
978 class VimHintedPlainTextEdit(HintedPlainTextEdit
):
979 """HintedPlainTextEdit with vim hotkeys
981 This can only be used in read-only mode.
984 Base
= HintedPlainTextEdit
987 def __init__(self
, context
, hint
, parent
=None):
988 HintedPlainTextEdit
.__init
__(self
, context
, hint
, parent
=parent
, readonly
=True)
989 self
._mixin
= self
.Mixin(self
)
991 def move(self
, direction
, select
=False, n
=1):
992 return self
._mixin
.page(direction
, select
=select
, n
=n
)
994 def page(self
, offset
, select
=False):
995 return self
._mixin
.page(offset
, select
=select
)
997 def page_up(self
, select
=False):
998 return self
._mixin
.page_up(select
=select
)
1000 def page_down(self
, select
=False):
1001 return self
._mixin
.page_down(select
=select
)
1003 def keyPressEvent(self
, event
):
1004 return self
._mixin
.keyPressEvent(event
)
1007 class VimTextEdit(MonoTextEdit
):
1008 """Text viewer with vim-like hotkeys
1010 This can only be used in read-only mode.
1017 def __init__(self
, context
, parent
=None, readonly
=True):
1018 MonoTextEdit
.__init
__(self
, context
, parent
=None, readonly
=readonly
)
1019 self
._mixin
= self
.Mixin(self
)
1021 def move(self
, direction
, select
=False, n
=1):
1022 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1024 def page(self
, offset
, select
=False):
1025 return self
._mixin
.page(offset
, select
=select
)
1027 def page_up(self
, select
=False):
1028 return self
._mixin
.page_up(select
=select
)
1030 def page_down(self
, select
=False):
1031 return self
._mixin
.page_down(select
=select
)
1033 def keyPressEvent(self
, event
):
1034 return self
._mixin
.keyPressEvent(event
)
1037 class HintedDefaultLineEdit(LineEdit
):
1038 """A line edit with hint text"""
1040 def __init__(self
, hint
, tooltip
=None, parent
=None):
1041 LineEdit
.__init
__(self
, parent
=parent
, get_value
=get_value_hinted
)
1043 self
.setToolTip(tooltip
)
1044 self
.hint
= HintWidget(self
, hint
)
1046 self
.textChanged
.connect(lambda text
: self
.hint
.refresh())
1049 class HintedLineEdit(HintedDefaultLineEdit
):
1050 """A monospace line edit with hint text"""
1052 def __init__(self
, context
, hint
, tooltip
=None, parent
=None):
1053 super().__init
__(hint
, tooltip
=tooltip
, parent
=parent
)
1054 self
.setFont(qtutils
.diff_font(context
))
1057 def text_dialog(context
, text
, title
):
1058 """Show a wall of text in a dialog"""
1059 parent
= qtutils
.active_window()
1061 label
= QtWidgets
.QLabel(parent
)
1062 label
.setFont(qtutils
.diff_font(context
))
1064 label
.setMargin(defs
.large_margin
)
1065 text_flags
= Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
1066 label
.setTextInteractionFlags(text_flags
)
1068 widget
= QtWidgets
.QDialog(parent
)
1069 widget
.setWindowModality(Qt
.WindowModal
)
1070 widget
.setWindowTitle(title
)
1072 scroll
= QtWidgets
.QScrollArea()
1073 scroll
.setWidget(label
)
1075 layout
= qtutils
.hbox(defs
.margin
, defs
.spacing
, scroll
)
1076 widget
.setLayout(layout
)
1079 widget
, N_('Close'), widget
.accept
, Qt
.Key_Question
, Qt
.Key_Enter
, Qt
.Key_Return
1085 class VimTextBrowser(VimTextEdit
):
1086 """Text viewer with line number annotations"""
1088 def __init__(self
, context
, parent
=None, readonly
=True):
1089 VimTextEdit
.__init
__(self
, context
, parent
=parent
, readonly
=readonly
)
1090 self
.numbers
= LineNumbers(self
)
1092 def resizeEvent(self
, event
):
1093 super().resizeEvent(event
)
1094 self
.numbers
.refresh_size()
1097 class TextDecorator(QtWidgets
.QWidget
):
1098 """Common functionality for providing line numbers in text widgets"""
1100 def __init__(self
, parent
):
1101 QtWidgets
.QWidget
.__init
__(self
, parent
)
1102 self
.editor
= parent
1104 parent
.blockCountChanged
.connect(lambda x
: self
._refresh
_viewport
())
1105 parent
.cursorPositionChanged
.connect(self
.refresh
)
1106 parent
.updateRequest
.connect(self
._refresh
_rect
)
1109 """Refresh the numbers display"""
1110 rect
= self
.editor
.viewport().rect()
1111 self
._refresh
_rect
(rect
, 0)
1113 def _refresh_rect(self
, rect
, dy
):
1117 self
.update(0, rect
.y(), self
.width(), rect
.height())
1119 if rect
.contains(self
.editor
.viewport().rect()):
1120 self
._refresh
_viewport
()
1122 def _refresh_viewport(self
):
1123 self
.editor
.setViewportMargins(self
.width_hint(), 0, 0, 0)
1125 def refresh_size(self
):
1126 rect
= self
.editor
.contentsRect()
1127 geom
= QtCore
.QRect(rect
.left(), rect
.top(), self
.width_hint(), rect
.height())
1128 self
.setGeometry(geom
)
1131 return QtCore
.QSize(self
.width_hint(), 0)
1134 class LineNumbers(TextDecorator
):
1135 """Provide line numbers for QPlainTextEdit widgets"""
1137 def __init__(self
, parent
):
1138 TextDecorator
.__init
__(self
, parent
)
1139 self
.highlight_line
= -1
1141 def width_hint(self
):
1142 document
= self
.editor
.document()
1143 digits
= int(math
.log(max(1, document
.blockCount()), 10)) + 2
1144 text_width
= qtutils
.text_width(self
.font(), '0')
1145 return defs
.large_margin
+ (text_width
* digits
)
1147 def set_highlighted(self
, line_number
):
1148 """Set the line to highlight"""
1149 self
.highlight_line
= line_number
1151 def paintEvent(self
, event
):
1152 """Paint the line number"""
1153 QPalette
= QtGui
.QPalette
1154 painter
= QtGui
.QPainter(self
)
1155 editor
= self
.editor
1156 palette
= editor
.palette()
1158 painter
.fillRect(event
.rect(), palette
.color(QPalette
.Base
))
1160 content_offset
= editor
.contentOffset()
1161 block
= editor
.firstVisibleBlock()
1162 width
= self
.width()
1163 event_rect_bottom
= event
.rect().bottom()
1165 highlight
= palette
.color(QPalette
.Highlight
)
1166 highlighted_text
= palette
.color(QPalette
.HighlightedText
)
1167 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
1169 while block
.isValid():
1170 block_geom
= editor
.blockBoundingGeometry(block
)
1171 block_top
= block_geom
.translated(content_offset
).top()
1172 if not block
.isVisible() or block_top
>= event_rect_bottom
:
1175 rect
= block_geom
.translated(content_offset
).toRect()
1176 block_number
= block
.blockNumber()
1177 if block_number
== self
.highlight_line
:
1178 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
1179 painter
.setPen(highlighted_text
)
1181 painter
.setPen(disabled
)
1183 number
= '%s' % (block_number
+ 1)
1187 self
.width() - defs
.large_margin
,
1189 Qt
.AlignRight | Qt
.AlignVCenter
,
1192 block
= block
.next()
1195 class TextLabel(QtWidgets
.QLabel
):
1196 """A text label that elides its display"""
1198 def __init__(self
, parent
=None, open_external_links
=True, selectable
=True):
1199 QtWidgets
.QLabel
.__init
__(self
, parent
)
1204 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1205 policy
= QtWidgets
.QSizePolicy(
1206 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1208 self
.setSizePolicy(policy
)
1210 interaction_flags
= Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1212 interaction_flags
= Qt
.LinksAccessibleByMouse
1213 self
.setTextInteractionFlags(interaction_flags
)
1214 self
.setOpenExternalLinks(open_external_links
)
1219 def set_text(self
, text
):
1220 self
.set_template(text
, text
)
1222 def set_template(self
, text
, template
):
1223 self
._display
= text
1225 self
._template
= template
1226 self
.update_text(self
.width())
1227 self
.setText(self
._display
)
1229 def update_text(self
, width
):
1230 self
._display
= self
._text
1233 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1234 if text
!= self
._template
:
1235 self
._display
= text
1238 """Return the label's inner text value"""
1242 def setFont(self
, font
):
1243 self
._metrics
= QtGui
.QFontMetrics(font
)
1244 QtWidgets
.QLabel
.setFont(self
, font
)
1246 def resizeEvent(self
, event
):
1248 self
.update_text(event
.size().width())
1249 with qtutils
.BlockSignals(self
):
1250 self
.setText(self
._display
)
1251 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1254 class PlainTextLabel(TextLabel
):
1255 """A plaintext label that elides its display"""
1257 def __init__(self
, selectable
=True, parent
=None):
1259 selectable
=selectable
, parent
=parent
, open_external_links
=False
1261 self
.setTextFormat(Qt
.PlainText
)
1264 class RichTextLabel(TextLabel
):
1265 """A richtext label that elides its display"""
1267 def __init__(self
, selectable
=True, parent
=None):
1268 super().__init
__(selectable
=selectable
, open_external_links
=True, parent
=parent
)
1269 self
.setTextFormat(Qt
.RichText
)