2 # pylint: disable=unexpected-keyword-arg
3 from functools
import partial
6 from qtpy
import QtCore
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
12 from ..models
import prefs
13 from ..qtutils
import get
14 from .. import hotkeys
16 from .. import qtutils
22 def get_stripped(widget
):
23 return widget
.get().strip()
26 class LineEdit(QtWidgets
.QLineEdit
):
27 cursor_changed
= Signal(int, int)
28 esc_pressed
= Signal()
30 def __init__(self
, parent
=None, row
=1, get_value
=None, clear_button
=False):
31 QtWidgets
.QLineEdit
.__init
__(self
, parent
)
34 get_value
= get_stripped
35 self
._get
_value
= get_value
36 self
.cursor_position
= LineEditCursorPosition(self
, row
)
37 self
.menu_actions
= []
39 if clear_button
and hasattr(self
, 'setClearButtonEnabled'):
40 self
.setClearButtonEnabled(True)
43 """Return the raw unicode value from Qt"""
47 """Return the processed value, e.g. stripped"""
48 return self
._get
_value
(self
)
50 def set_value(self
, value
, block
=False):
51 """Update the widget to the specified value"""
53 with qtutils
.BlockSignals(self
):
54 self
._set
_value
(value
)
56 self
._set
_value
(value
)
58 def _set_value(self
, value
):
59 """Implementation helper to update the widget to the specified value"""
60 pos
= self
.cursorPosition()
62 self
.setCursorPosition(pos
)
64 def keyPressEvent(self
, event
):
66 if key
== Qt
.Key_Escape
:
67 self
.esc_pressed
.emit()
68 super().keyPressEvent(event
)
71 class LineEditCursorPosition
:
72 """Translate cursorPositionChanged(int,int) into cursorPosition(int,int)"""
74 def __init__(self
, widget
, row
):
77 # Translate cursorPositionChanged into cursor_changed(int, int)
78 widget
.cursorPositionChanged
.connect(lambda old
, new
: self
.emit())
83 col
= widget
.cursorPosition()
84 widget
.cursor_changed
.emit(row
, col
)
87 self
._widget
.setCursorPosition(0)
90 class BaseTextEditExtension(QtCore
.QObject
):
91 def __init__(self
, widget
, get_value
, readonly
):
92 QtCore
.QObject
.__init
__(self
, widget
)
94 self
.cursor_position
= TextEditCursorPosition(widget
, self
)
96 get_value
= get_stripped
97 self
._get
_value
= get_value
99 self
._readonly
= readonly
103 def _init_flags(self
):
105 widget
.setMinimumSize(QtCore
.QSize(10, 10))
106 widget
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
107 widget
.setLineWrapMode(widget
.NoWrap
)
109 widget
.setReadOnly(True)
110 widget
.setAcceptDrops(False)
111 widget
.setTabChangesFocus(True)
112 widget
.setUndoRedoEnabled(False)
113 widget
.setTextInteractionFlags(
114 Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
118 """Return the raw unicode value from Qt"""
119 return self
.widget
.toPlainText()
122 """Return a safe value, e.g. a stripped value"""
123 return self
._get
_value
(self
.widget
)
125 def set_value(self
, value
, block
=False):
126 """Update the widget to the specified value"""
128 with qtutils
.BlockSignals(self
):
129 self
._set
_value
(value
)
131 self
._set
_value
(value
)
133 def _set_value(self
, value
):
134 """Implementation helper to update the widget to the specified value"""
135 # Save cursor position
136 offset
, selection_text
= self
.offset_and_selection()
137 old_value
= get(self
.widget
)
140 self
.widget
.setPlainText(value
)
143 if selection_text
and selection_text
in value
:
144 # If the old selection exists in the new text then re-select it.
145 idx
= value
.index(selection_text
)
146 cursor
= self
.widget
.textCursor()
147 cursor
.setPosition(idx
)
148 cursor
.setPosition(idx
+ len(selection_text
), QtGui
.QTextCursor
.KeepAnchor
)
149 self
.widget
.setTextCursor(cursor
)
151 elif value
== old_value
:
152 # Otherwise, if the text is identical and there is no selection
153 # then restore the cursor position.
154 cursor
= self
.widget
.textCursor()
155 cursor
.setPosition(offset
)
156 self
.widget
.setTextCursor(cursor
)
158 # If none of the above applied then restore the cursor position.
159 position
= max(0, min(offset
, len(value
) - 1))
160 cursor
= self
.widget
.textCursor()
161 cursor
.setPosition(position
)
162 self
.widget
.setTextCursor(cursor
)
163 cursor
= self
.widget
.textCursor()
164 cursor
.movePosition(QtGui
.QTextCursor
.StartOfLine
)
165 self
.widget
.setTextCursor(cursor
)
167 def set_cursor_position(self
, new_position
):
168 cursor
= self
.widget
.textCursor()
169 cursor
.setPosition(new_position
)
170 self
.widget
.setTextCursor(cursor
)
173 return self
._tabwidth
175 def set_tabwidth(self
, width
):
176 self
._tabwidth
= width
177 pixels
= qtutils
.text_width(self
.widget
.font(), 'M') * width
178 self
.widget
.setTabStopWidth(pixels
)
180 def selected_line(self
):
181 contents
= self
.value()
182 cursor
= self
.widget
.textCursor()
183 offset
= min(cursor
.position(), len(contents
) - 1)
184 while offset
>= 1 and contents
[offset
- 1] and contents
[offset
- 1] != '\n':
186 data
= contents
[offset
:]
188 line
, _
= data
.split('\n', 1)
194 return self
.widget
.textCursor()
196 def has_selection(self
):
197 return self
.cursor().hasSelection()
199 def selected_text(self
):
200 """Return the selected text"""
201 _
, selection
= self
.offset_and_selection()
204 def offset_and_selection(self
):
205 """Return the cursor offset and selected text"""
206 cursor
= self
.cursor()
207 offset
= cursor
.selectionStart()
208 selection_text
= cursor
.selection().toPlainText()
209 return offset
, selection_text
211 def mouse_press_event(self
, event
):
212 # Move the text cursor so that the right-click events operate
213 # on the current position, not the last left-clicked position.
215 if event
.button() == Qt
.RightButton
:
216 if not widget
.textCursor().hasSelection():
217 cursor
= widget
.cursorForPosition(event
.pos())
218 widget
.setTextCursor(cursor
)
220 def add_links_to_menu(self
, menu
):
221 """Add actions for opening URLs to a custom menu"""
222 links
= self
._get
_links
()
226 action
= menu
.addAction(N_('Open "%s"') % url
)
227 action
.setIcon(icons
.external())
228 qtutils
.connect_action(
229 action
, partial(QtGui
.QDesktopServices
.openUrl
, QtCore
.QUrl(url
))
232 def _get_links(self
):
233 """Return http links on the current line"""
234 _
, selection
= self
.offset_and_selection()
238 line
= self
.selected_line()
242 word
for word
in line
.split() if word
.startswith(('http://', 'https://'))
245 def create_context_menu(self
, event_pos
):
246 """Create a context menu for a widget"""
247 menu
= self
.widget
.createStandardContextMenu(event_pos
)
248 qtutils
.add_menu_actions(menu
, self
.widget
.menu_actions
)
249 self
.add_links_to_menu(menu
)
252 def context_menu_event(self
, event
):
253 """Default context menu event"""
254 event_pos
= event
.pos()
255 menu
= self
.widget
.create_context_menu(event_pos
)
256 menu
.exec_(self
.widget
.mapToGlobal(event_pos
))
258 # For extension by sub-classes
261 """Called during init for class-specific settings"""
264 # pylint: disable=unused-argument
265 def set_textwidth(self
, width
):
266 """Set the text width"""
269 # pylint: disable=unused-argument
270 def set_linebreak(self
, brk
):
271 """Enable word wrapping"""
275 class PlainTextEditExtension(BaseTextEditExtension
):
276 def set_linebreak(self
, brk
):
278 wrapmode
= QtWidgets
.QPlainTextEdit
.WidgetWidth
280 wrapmode
= QtWidgets
.QPlainTextEdit
.NoWrap
281 self
.widget
.setLineWrapMode(wrapmode
)
284 class PlainTextEdit(QtWidgets
.QPlainTextEdit
):
285 cursor_changed
= Signal(int, int)
288 def __init__(self
, parent
=None, get_value
=None, readonly
=False, options
=None):
289 QtWidgets
.QPlainTextEdit
.__init
__(self
, parent
)
290 self
.ext
= PlainTextEditExtension(self
, get_value
, readonly
)
291 self
.cursor_position
= self
.ext
.cursor_position
292 self
.mouse_zoom
= True
293 self
.options
= options
294 self
.menu_actions
= []
297 """Return the raw unicode value from Qt"""
298 return self
.ext
.get()
300 # For compatibility with QTextEdit
301 def setText(self
, value
):
302 self
.set_value(value
)
305 """Return a safe value, e.g. a stripped value"""
306 return self
.ext
.value()
308 def offset_and_selection(self
):
309 """Return the cursor offset and selected text"""
310 return self
.ext
.offset_and_selection()
312 def set_value(self
, value
, block
=False):
313 self
.ext
.set_value(value
, block
=block
)
315 def set_mouse_zoom(self
, value
):
316 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
317 self
.mouse_zoom
= value
319 def set_options(self
, options
):
320 """Register an Options widget"""
321 self
.options
= options
323 def set_word_wrapping(self
, enabled
, update
=False):
324 """Enable/disable word wrapping"""
325 if update
and self
.options
is not None:
326 with qtutils
.BlockSignals(self
.options
.enable_word_wrapping
):
327 self
.options
.enable_word_wrapping
.setChecked(enabled
)
329 self
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
330 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.WidgetWidth
)
332 self
.setWordWrapMode(QtGui
.QTextOption
.NoWrap
)
333 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.NoWrap
)
335 def has_selection(self
):
336 return self
.ext
.has_selection()
338 def selected_line(self
):
339 return self
.ext
.selected_line()
341 def selected_text(self
):
342 """Return the selected text"""
343 return self
.ext
.selected_text()
345 def set_tabwidth(self
, width
):
346 self
.ext
.set_tabwidth(width
)
348 def set_textwidth(self
, width
):
349 self
.ext
.set_textwidth(width
)
351 def set_linebreak(self
, brk
):
352 self
.ext
.set_linebreak(brk
)
354 def mousePressEvent(self
, event
):
355 self
.ext
.mouse_press_event(event
)
356 super().mousePressEvent(event
)
358 def wheelEvent(self
, event
):
359 """Disable control+wheelscroll text resizing"""
360 if not self
.mouse_zoom
and (event
.modifiers() & Qt
.ControlModifier
):
363 super().wheelEvent(event
)
365 def create_context_menu(self
, event_pos
):
366 """Create a custom context menu"""
367 return self
.ext
.create_context_menu(event_pos
)
369 def contextMenuEvent(self
, event
):
370 """Custom contextMenuEvent() for building our custom context menus"""
371 self
.ext
.context_menu_event(event
)
374 class TextSearchWidget(QtWidgets
.QWidget
):
375 """The search dialog that displays over a text edit field"""
377 def __init__(self
, widget
, parent
):
378 super().__init
__(parent
)
379 self
.setAutoFillBackground(True)
380 self
._widget
= widget
381 self
._parent
= parent
383 self
.text
= HintedDefaultLineEdit(N_('Find in diff'), parent
=self
)
385 self
.prev_button
= qtutils
.create_action_button(
386 tooltip
=N_('Find the previous occurrence of the phrase'), icon
=icons
.up()
389 self
.next_button
= qtutils
.create_action_button(
390 tooltip
=N_('Find the next occurrence of the phrase'), icon
=icons
.down()
393 self
.match_case_checkbox
= qtutils
.checkbox(N_('Match Case'))
394 self
.whole_words_checkbox
= qtutils
.checkbox(N_('Whole Words'))
396 self
.close_button
= qtutils
.create_action_button(
397 tooltip
=N_('Close the find bar'), icon
=icons
.close()
400 layout
= qtutils
.hbox(
406 self
.match_case_checkbox
,
407 self
.whole_words_checkbox
,
411 self
.setLayout(layout
)
412 self
.setFocusProxy(self
.text
)
414 self
.text
.esc_pressed
.connect(self
.hide_search
)
415 self
.text
.returnPressed
.connect(self
.search
)
416 self
.text
.textChanged
.connect(self
.search
)
418 self
.search_next_action
= qtutils
.add_action(
420 N_('Find next item'),
424 self
.search_prev_action
= qtutils
.add_action(
426 N_('Find previous item'),
427 self
.search_backwards
,
431 qtutils
.connect_button(self
.next_button
, self
.search
)
432 qtutils
.connect_button(self
.prev_button
, self
.search_backwards
)
433 qtutils
.connect_button(self
.close_button
, self
.hide_search
)
434 qtutils
.connect_checkbox(self
.match_case_checkbox
, lambda _
: self
.search())
435 qtutils
.connect_checkbox(self
.whole_words_checkbox
, lambda _
: self
.search())
438 """Emit a signal with the current search text"""
439 self
.search_text(backwards
=False)
441 def search_backwards(self
):
442 """Emit a signal with the current search text for a backwards search"""
443 self
.search_text(backwards
=True)
445 def hide_search(self
):
446 """Hide the search window"""
448 self
._parent
.setFocus()
450 def find_flags(self
, backwards
):
451 """Return QTextDocument.FindFlags for the current search options"""
452 flags
= QtGui
.QTextDocument
.FindFlag(0)
454 flags
= flags | QtGui
.QTextDocument
.FindBackward
455 if self
.match_case_checkbox
.isChecked():
456 flags
= flags | QtGui
.QTextDocument
.FindCaseSensitively
457 if self
.whole_words_checkbox
.isChecked():
458 flags
= flags | QtGui
.QTextDocument
.FindWholeWords
461 def is_case_sensitive(self
):
462 """Are we searching using a case-insensitive search?"""
463 return self
.match_case_checkbox
.isChecked()
465 def search_text(self
, backwards
=False):
466 """Search the diff text for the given text"""
467 text
= self
.text
.get()
468 cursor
= self
._widget
.textCursor()
469 if cursor
.hasSelection():
470 selected_text
= cursor
.selectedText()
471 case_sensitive
= self
.is_case_sensitive()
472 if text_matches(case_sensitive
, selected_text
, text
):
474 position
= cursor
.selectionStart()
476 position
= cursor
.selectionEnd()
479 position
= cursor
.selectionEnd()
481 position
= cursor
.selectionStart()
482 cursor
.setPosition(position
)
483 self
._widget
.setTextCursor(cursor
)
485 flags
= self
.find_flags(backwards
)
486 if not self
._widget
.find(text
, flags
):
488 location
= QtGui
.QTextCursor
.End
490 location
= QtGui
.QTextCursor
.Start
491 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
492 self
._widget
.setTextCursor(cursor
)
493 self
._widget
.find(text
, flags
)
496 def text_matches(case_sensitive
, a
, b
):
497 """Compare text with case sensitivity taken into account"""
500 return a
.lower() == b
.lower()
503 class TextEditExtension(BaseTextEditExtension
):
506 widget
.setAcceptRichText(False)
508 def set_linebreak(self
, brk
):
510 wrapmode
= QtWidgets
.QTextEdit
.FixedColumnWidth
512 wrapmode
= QtWidgets
.QTextEdit
.NoWrap
513 self
.widget
.setLineWrapMode(wrapmode
)
515 def set_textwidth(self
, width
):
516 self
.widget
.setLineWrapColumnOrWidth(width
)
519 class TextEdit(QtWidgets
.QTextEdit
):
520 cursor_changed
= Signal(int, int)
523 def __init__(self
, parent
=None, get_value
=None, readonly
=False):
524 QtWidgets
.QTextEdit
.__init
__(self
, parent
)
525 self
.ext
= TextEditExtension(self
, get_value
, readonly
)
526 self
.cursor_position
= self
.ext
.cursor_position
527 self
.expandtab_enabled
= False
528 self
.menu_actions
= []
531 """Return the raw unicode value from Qt"""
532 return self
.ext
.get()
535 """Return a safe value, e.g. a stripped value"""
536 return self
.ext
.value()
538 def set_cursor_position(self
, position
):
539 """Set the cursor position"""
540 cursor
= self
.textCursor()
541 cursor
.setPosition(position
)
542 self
.setTextCursor(cursor
)
544 def set_value(self
, value
, block
=False):
545 self
.ext
.set_value(value
, block
=block
)
547 def selected_line(self
):
548 return self
.ext
.selected_line()
550 def selected_text(self
):
551 """Return the selected text"""
552 return self
.ext
.selected_text()
554 def set_tabwidth(self
, width
):
555 self
.ext
.set_tabwidth(width
)
557 def set_textwidth(self
, width
):
558 self
.ext
.set_textwidth(width
)
560 def set_linebreak(self
, brk
):
561 self
.ext
.set_linebreak(brk
)
563 def set_expandtab(self
, value
):
564 self
.expandtab_enabled
= value
566 def mousePressEvent(self
, event
):
567 self
.ext
.mouse_press_event(event
)
568 super().mousePressEvent(event
)
570 def wheelEvent(self
, event
):
571 """Disable control+wheelscroll text resizing"""
572 if event
.modifiers() & Qt
.ControlModifier
:
575 super().wheelEvent(event
)
577 def should_expandtab(self
, event
):
578 return event
.key() == Qt
.Key_Tab
and self
.expandtab_enabled
581 tabwidth
= max(self
.ext
.tabwidth(), 1)
582 cursor
= self
.textCursor()
583 cursor
.insertText(' ' * tabwidth
)
585 def create_context_menu(self
, event_pos
):
586 """Create a custom context menu"""
587 return self
.ext
.create_context_menu(event_pos
)
589 def contextMenuEvent(self
, event
):
590 """Custom contextMenuEvent() for building our custom context menus"""
591 self
.ext
.context_menu_event(event
)
593 def keyPressEvent(self
, event
):
594 """Override keyPressEvent to handle tab expansion"""
595 expandtab
= self
.should_expandtab(event
)
600 QtWidgets
.QTextEdit
.keyPressEvent(self
, event
)
602 def keyReleaseEvent(self
, event
):
603 """Override keyReleaseEvent to special-case tab expansion"""
604 expandtab
= self
.should_expandtab(event
)
608 QtWidgets
.QTextEdit
.keyReleaseEvent(self
, event
)
611 class TextEditCursorPosition
:
612 def __init__(self
, widget
, ext
):
613 self
._widget
= widget
615 widget
.cursorPositionChanged
.connect(self
.emit
)
618 widget
= self
._widget
620 cursor
= widget
.textCursor()
621 position
= cursor
.position()
623 before
= txt
[:position
]
624 row
= before
.count('\n')
625 line
= before
.split('\n')[row
]
626 col
= cursor
.columnNumber()
627 col
+= line
[:col
].count('\t') * (ext
.tabwidth() - 1)
628 widget
.cursor_changed
.emit(row
+ 1, col
)
631 widget
= self
._widget
632 cursor
= widget
.textCursor()
633 cursor
.setPosition(0)
634 widget
.setTextCursor(cursor
)
637 class MonoTextEdit(PlainTextEdit
):
638 def __init__(self
, context
, parent
=None, readonly
=False):
639 PlainTextEdit
.__init
__(self
, parent
=parent
, readonly
=readonly
)
640 self
.setFont(qtutils
.diff_font(context
))
643 def get_value_hinted(widget
):
644 text
= get_stripped(widget
)
645 hint
= get(widget
.hint
)
651 class HintWidget(QtCore
.QObject
):
652 """Extend a widget to provide hint messages
654 This primarily exists because setPlaceholderText() is only available
655 in Qt5, so this class provides consistent behavior across versions.
659 def __init__(self
, widget
, hint
):
660 QtCore
.QObject
.__init
__(self
, widget
)
661 self
._widget
= widget
663 self
._is
_error
= False
665 self
.modern
= modern
= hasattr(widget
, 'setPlaceholderText')
667 widget
.setPlaceholderText(hint
)
669 # Palette for normal text
670 QPalette
= QtGui
.QPalette
671 palette
= widget
.palette()
673 hint_color
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
674 error_bg_color
= QtGui
.QColor(Qt
.red
).darker()
675 error_fg_color
= QtGui
.QColor(Qt
.white
)
677 hint_rgb
= qtutils
.rgb_css(hint_color
)
678 error_bg_rgb
= qtutils
.rgb_css(error_bg_color
)
679 error_fg_rgb
= qtutils
.rgb_css(error_fg_color
)
682 'name': widget
.__class
__.__name
__,
683 'error_fg_rgb': error_fg_rgb
,
684 'error_bg_rgb': error_bg_rgb
,
685 'hint_rgb': hint_rgb
,
688 self
._default
_style
= ''
699 self
._error
_style
= (
702 color: %(error_fg_rgb)s;
703 background-color: %(error_bg_rgb)s;
710 """Defered initialization"""
712 self
.widget().setPlaceholderText(self
.value())
714 self
.widget().installEventFilter(self
)
718 """Return the parent text widget"""
722 """Return True when hint-mode is active"""
723 return self
.value() == get_stripped(self
._widget
)
726 """Return the current hint text"""
729 def set_error(self
, is_error
):
730 """Enable/disable error mode"""
731 self
._is
_error
= is_error
734 def set_value(self
, hint
):
735 """Change the hint text"""
738 self
._widget
.setPlaceholderText(hint
)
740 # If hint-mode is currently active, re-activate it
741 active
= self
.active()
743 if active
or self
.active():
746 def enable(self
, enable
):
747 """Enable/disable hint-mode"""
749 if enable
and self
._hint
:
750 self
._widget
.set_value(self
._hint
, block
=True)
751 self
._widget
.cursor_position
.reset()
754 self
._update
_palette
(enable
)
757 """Update the palette to match the current mode"""
758 self
._update
_palette
(self
.active())
760 def _update_palette(self
, hint
):
761 """Update to palette for normal/error/hint mode"""
763 style
= self
._error
_style
764 elif not self
.modern
and hint
:
765 style
= self
._hint
_style
767 style
= self
._default
_style
768 QtCore
.QTimer
.singleShot(
769 0, lambda: utils
.catch_runtime_error(self
._widget
.setStyleSheet
, style
)
772 def eventFilter(self
, _obj
, event
):
773 """Enable/disable hint-mode when focus changes"""
775 if etype
== QtCore
.QEvent
.FocusIn
:
777 elif etype
== QtCore
.QEvent
.FocusOut
:
782 """Disable hint-mode when focused"""
783 widget
= self
.widget()
786 widget
.cursor_position
.emit()
789 """Re-enable hint-mode when losing focus"""
790 widget
= self
.widget()
791 valid
, value
= utils
.catch_runtime_error(get
, widget
)
793 # The widget may have just been destroyed during application shutdown.
794 # We're receiving a focusOut event but the widget can no longer be used.
795 # This can be safely ignored.
801 class HintedPlainTextEdit(PlainTextEdit
):
802 """A hinted plain text edit"""
804 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
805 PlainTextEdit
.__init
__(
806 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
808 self
.hint
= HintWidget(self
, hint
)
810 self
.context
= context
811 self
.setFont(qtutils
.diff_font(context
))
812 self
.set_tabwidth(prefs
.tabwidth(context
))
813 # Refresh palettes when text changes
814 # pylint: disable=no-member
815 self
.textChanged
.connect(self
.hint
.refresh
)
816 self
.set_mouse_zoom(context
.cfg
.get(prefs
.MOUSE_ZOOM
, default
=True))
818 def set_value(self
, value
, block
=False):
819 """Set the widget text or enable hint mode when empty"""
820 if value
or self
.hint
.modern
:
821 PlainTextEdit
.set_value(self
, value
, block
=block
)
823 self
.hint
.enable(True)
826 class HintedTextEdit(TextEdit
):
827 """A hinted text edit"""
829 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
831 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
833 self
.context
= context
834 self
.hint
= HintWidget(self
, hint
)
836 # Refresh palettes when text changes
837 # pylint: disable=no-member
838 self
.textChanged
.connect(self
.hint
.refresh
)
839 self
.setFont(qtutils
.diff_font(context
))
841 def set_value(self
, value
, block
=False):
842 """Set the widget text or enable hint mode when empty"""
843 if value
or self
.hint
.modern
:
844 TextEdit
.set_value(self
, value
, block
=block
)
846 self
.hint
.enable(True)
849 def anchor_mode(select
):
850 """Return the QTextCursor mode to keep/discard the cursor selection"""
852 mode
= QtGui
.QTextCursor
.KeepAnchor
854 mode
= QtGui
.QTextCursor
.MoveAnchor
858 # The vim-like read-only text view
862 def __init__(self
, widget
):
864 self
.Base
= widget
.Base
865 # Common vim/unix-ish keyboard actions
866 self
.add_navigation('End', hotkeys
.GOTO_END
)
867 self
.add_navigation('Up', hotkeys
.MOVE_UP
, shift
=hotkeys
.MOVE_UP_SHIFT
)
868 self
.add_navigation('Down', hotkeys
.MOVE_DOWN
, shift
=hotkeys
.MOVE_DOWN_SHIFT
)
869 self
.add_navigation('Left', hotkeys
.MOVE_LEFT
, shift
=hotkeys
.MOVE_LEFT_SHIFT
)
870 self
.add_navigation('Right', hotkeys
.MOVE_RIGHT
, shift
=hotkeys
.MOVE_RIGHT_SHIFT
)
871 self
.add_navigation('WordLeft', hotkeys
.WORD_LEFT
)
872 self
.add_navigation('WordRight', hotkeys
.WORD_RIGHT
)
873 self
.add_navigation('Start', hotkeys
.GOTO_START
)
874 self
.add_navigation('StartOfLine', hotkeys
.START_OF_LINE
)
875 self
.add_navigation('EndOfLine', hotkeys
.END_OF_LINE
)
881 hotkeys
.SECONDARY_ACTION
,
888 hotkeys
.PRIMARY_ACTION
,
894 lambda: widget
.page_up(select
=True),
901 lambda: widget
.page_down(select
=True),
902 hotkeys
.SELECT_FORWARD
,
906 def add_navigation(self
, name
, hotkey
, shift
=None):
907 """Add a hotkey along with a shift-variant"""
909 direction
= getattr(QtGui
.QTextCursor
, name
)
910 qtutils
.add_action(widget
, name
, lambda: self
.move(direction
), hotkey
)
913 widget
, 'Shift' + name
, lambda: self
.move(direction
, select
=True), shift
916 def move(self
, direction
, select
=False, n
=1):
918 cursor
= widget
.textCursor()
919 mode
= anchor_mode(select
)
921 if cursor
.movePosition(direction
, mode
, 1):
922 self
.set_text_cursor(cursor
)
924 def page(self
, offset
, select
=False):
926 rect
= widget
.cursorRect()
928 y
= rect
.y() + offset
929 new_cursor
= widget
.cursorForPosition(QtCore
.QPoint(x
, y
))
930 if new_cursor
is not None:
931 cursor
= widget
.textCursor()
932 mode
= anchor_mode(select
)
933 cursor
.setPosition(new_cursor
.position(), mode
)
935 self
.set_text_cursor(cursor
)
937 def page_down(self
, select
=False):
939 widget
.page(widget
.height() // 2, select
=select
)
941 def page_up(self
, select
=False):
943 widget
.page(-widget
.height() // 2, select
=select
)
945 def set_text_cursor(self
, cursor
):
947 widget
.setTextCursor(cursor
)
948 widget
.ensureCursorVisible()
949 widget
.viewport().update()
951 def keyPressEvent(self
, event
):
952 """Custom keyboard behaviors
954 The leave() signal is emitted when `Up` is pressed and we're already
955 at the beginning of the text. This allows the parent widget to
956 orchestrate some higher-level interaction, such as giving focus to
959 When in the middle of the first line and `Up` is pressed, the cursor
960 is moved to the beginning of the line.
964 if event
.key() == Qt
.Key_Up
:
965 cursor
= widget
.textCursor()
966 position
= cursor
.position()
968 # The cursor is at the beginning of the line.
969 # Emit a signal so that the parent can e.g. change focus.
971 elif get(widget
)[:position
].count('\n') == 0:
972 # The cursor is in the middle of the first line of text.
973 # We can't go up ~ jump to the beginning of the line.
974 # Select the text if shift is pressed.
975 select
= event
.modifiers() & Qt
.ShiftModifier
976 mode
= anchor_mode(select
)
977 cursor
.movePosition(QtGui
.QTextCursor
.StartOfLine
, mode
)
978 widget
.setTextCursor(cursor
)
980 return self
.Base
.keyPressEvent(widget
, event
)
983 # pylint: disable=too-many-ancestors
984 class VimHintedPlainTextEdit(HintedPlainTextEdit
):
985 """HintedPlainTextEdit with vim hotkeys
987 This can only be used in read-only mode.
991 Base
= HintedPlainTextEdit
994 def __init__(self
, context
, hint
, parent
=None):
995 HintedPlainTextEdit
.__init
__(self
, context
, hint
, parent
=parent
, readonly
=True)
996 self
._mixin
= self
.Mixin(self
)
998 def move(self
, direction
, select
=False, n
=1):
999 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1001 def page(self
, offset
, select
=False):
1002 return self
._mixin
.page(offset
, select
=select
)
1004 def page_up(self
, select
=False):
1005 return self
._mixin
.page_up(select
=select
)
1007 def page_down(self
, select
=False):
1008 return self
._mixin
.page_down(select
=select
)
1010 def keyPressEvent(self
, event
):
1011 return self
._mixin
.keyPressEvent(event
)
1014 # pylint: disable=too-many-ancestors
1015 class VimTextEdit(MonoTextEdit
):
1016 """Text viewer with vim-like hotkeys
1018 This can only be used in read-only mode.
1025 def __init__(self
, context
, parent
=None, readonly
=True):
1026 MonoTextEdit
.__init
__(self
, context
, parent
=None, readonly
=readonly
)
1027 self
._mixin
= self
.Mixin(self
)
1029 def move(self
, direction
, select
=False, n
=1):
1030 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1032 def page(self
, offset
, select
=False):
1033 return self
._mixin
.page(offset
, select
=select
)
1035 def page_up(self
, select
=False):
1036 return self
._mixin
.page_up(select
=select
)
1038 def page_down(self
, select
=False):
1039 return self
._mixin
.page_down(select
=select
)
1041 def keyPressEvent(self
, event
):
1042 return self
._mixin
.keyPressEvent(event
)
1045 class HintedDefaultLineEdit(LineEdit
):
1046 """A line edit with hint text"""
1048 def __init__(self
, hint
, tooltip
=None, parent
=None):
1049 LineEdit
.__init
__(self
, parent
=parent
, get_value
=get_value_hinted
)
1051 self
.setToolTip(tooltip
)
1052 self
.hint
= HintWidget(self
, hint
)
1054 # pylint: disable=no-member
1055 self
.textChanged
.connect(lambda text
: self
.hint
.refresh())
1058 class HintedLineEdit(HintedDefaultLineEdit
):
1059 """A monospace line edit with hint text"""
1061 def __init__(self
, context
, hint
, tooltip
=None, parent
=None):
1062 super().__init
__(hint
, tooltip
=tooltip
, parent
=parent
)
1063 self
.setFont(qtutils
.diff_font(context
))
1066 def text_dialog(context
, text
, title
):
1067 """Show a wall of text in a dialog"""
1068 parent
= qtutils
.active_window()
1070 label
= QtWidgets
.QLabel(parent
)
1071 label
.setFont(qtutils
.diff_font(context
))
1073 label
.setMargin(defs
.large_margin
)
1074 text_flags
= Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
1075 label
.setTextInteractionFlags(text_flags
)
1077 widget
= QtWidgets
.QDialog(parent
)
1078 widget
.setWindowModality(Qt
.WindowModal
)
1079 widget
.setWindowTitle(title
)
1081 scroll
= QtWidgets
.QScrollArea()
1082 scroll
.setWidget(label
)
1084 layout
= qtutils
.hbox(defs
.margin
, defs
.spacing
, scroll
)
1085 widget
.setLayout(layout
)
1088 widget
, N_('Close'), widget
.accept
, Qt
.Key_Question
, Qt
.Key_Enter
, Qt
.Key_Return
1094 class VimTextBrowser(VimTextEdit
):
1095 """Text viewer with line number annotations"""
1097 def __init__(self
, context
, parent
=None, readonly
=True):
1098 VimTextEdit
.__init
__(self
, context
, parent
=parent
, readonly
=readonly
)
1099 self
.numbers
= LineNumbers(self
)
1101 def resizeEvent(self
, event
):
1102 super().resizeEvent(event
)
1103 self
.numbers
.refresh_size()
1106 class TextDecorator(QtWidgets
.QWidget
):
1107 """Common functionality for providing line numbers in text widgets"""
1109 def __init__(self
, parent
):
1110 QtWidgets
.QWidget
.__init
__(self
, parent
)
1111 self
.editor
= parent
1113 parent
.blockCountChanged
.connect(lambda x
: self
._refresh
_viewport
())
1114 parent
.cursorPositionChanged
.connect(self
.refresh
)
1115 parent
.updateRequest
.connect(self
._refresh
_rect
)
1118 """Refresh the numbers display"""
1119 rect
= self
.editor
.viewport().rect()
1120 self
._refresh
_rect
(rect
, 0)
1122 def _refresh_rect(self
, rect
, dy
):
1126 self
.update(0, rect
.y(), self
.width(), rect
.height())
1128 if rect
.contains(self
.editor
.viewport().rect()):
1129 self
._refresh
_viewport
()
1131 def _refresh_viewport(self
):
1132 self
.editor
.setViewportMargins(self
.width_hint(), 0, 0, 0)
1134 def refresh_size(self
):
1135 rect
= self
.editor
.contentsRect()
1136 geom
= QtCore
.QRect(rect
.left(), rect
.top(), self
.width_hint(), rect
.height())
1137 self
.setGeometry(geom
)
1140 return QtCore
.QSize(self
.width_hint(), 0)
1143 class LineNumbers(TextDecorator
):
1144 """Provide line numbers for QPlainTextEdit widgets"""
1146 def __init__(self
, parent
):
1147 TextDecorator
.__init
__(self
, parent
)
1148 self
.highlight_line
= -1
1150 def width_hint(self
):
1151 document
= self
.editor
.document()
1152 digits
= int(math
.log(max(1, document
.blockCount()), 10)) + 2
1153 text_width
= qtutils
.text_width(self
.font(), '0')
1154 return defs
.large_margin
+ (text_width
* digits
)
1156 def set_highlighted(self
, line_number
):
1157 """Set the line to highlight"""
1158 self
.highlight_line
= line_number
1160 def paintEvent(self
, event
):
1161 """Paint the line number"""
1162 QPalette
= QtGui
.QPalette
1163 painter
= QtGui
.QPainter(self
)
1164 editor
= self
.editor
1165 palette
= editor
.palette()
1167 painter
.fillRect(event
.rect(), palette
.color(QPalette
.Base
))
1169 content_offset
= editor
.contentOffset()
1170 block
= editor
.firstVisibleBlock()
1171 width
= self
.width()
1172 event_rect_bottom
= event
.rect().bottom()
1174 highlight
= palette
.color(QPalette
.Highlight
)
1175 highlighted_text
= palette
.color(QPalette
.HighlightedText
)
1176 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
1178 while block
.isValid():
1179 block_geom
= editor
.blockBoundingGeometry(block
)
1180 block_top
= block_geom
.translated(content_offset
).top()
1181 if not block
.isVisible() or block_top
>= event_rect_bottom
:
1184 rect
= block_geom
.translated(content_offset
).toRect()
1185 block_number
= block
.blockNumber()
1186 if block_number
== self
.highlight_line
:
1187 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
1188 painter
.setPen(highlighted_text
)
1190 painter
.setPen(disabled
)
1192 number
= '%s' % (block_number
+ 1)
1196 self
.width() - defs
.large_margin
,
1198 Qt
.AlignRight | Qt
.AlignVCenter
,
1201 block
= block
.next()
1204 class TextLabel(QtWidgets
.QLabel
):
1205 """A text label that elides its display"""
1207 def __init__(self
, parent
=None, open_external_links
=True):
1208 QtWidgets
.QLabel
.__init
__(self
, parent
)
1213 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1214 policy
= QtWidgets
.QSizePolicy(
1215 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1217 self
.setSizePolicy(policy
)
1218 self
.setTextInteractionFlags(
1219 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1221 self
.setOpenExternalLinks(open_external_links
)
1226 def set_text(self
, text
):
1227 self
.set_template(text
, text
)
1229 def set_template(self
, text
, template
):
1230 self
._display
= text
1232 self
._template
= template
1233 self
.update_text(self
.width())
1234 self
.setText(self
._display
)
1236 def update_text(self
, width
):
1237 self
._display
= self
._text
1240 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1241 if text
!= self
._template
:
1242 self
._display
= text
1245 def setFont(self
, font
):
1246 self
._metrics
= QtGui
.QFontMetrics(font
)
1247 QtWidgets
.QLabel
.setFont(self
, font
)
1249 def resizeEvent(self
, event
):
1251 self
.update_text(event
.size().width())
1252 with qtutils
.BlockSignals(self
):
1253 self
.setText(self
._display
)
1254 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1257 class PlainTextLabel(TextLabel
):
1258 """A plaintext label that elides its display"""
1260 def __init__(self
, parent
=None):
1261 super().__init
__(parent
=parent
, open_external_links
=False)
1262 self
.setTextFormat(Qt
.PlainText
)
1265 class RichTextLabel(TextLabel
):
1266 """A richtext label that elides its display"""
1268 def __init__(self
, parent
=None):
1269 super().__init
__(parent
=parent
, open_external_links
=True)
1270 self
.setTextFormat(Qt
.RichText
)