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 font
= self
.widget
.font()
178 metrics
= QtGui
.QFontMetrics(font
)
179 pixels
= metrics
.width('M' * width
)
180 self
.widget
.setTabStopWidth(pixels
)
182 def selected_line(self
):
183 contents
= self
.value()
184 cursor
= self
.widget
.textCursor()
185 offset
= min(cursor
.position(), len(contents
) - 1)
186 while offset
>= 1 and contents
[offset
- 1] and contents
[offset
- 1] != '\n':
188 data
= contents
[offset
:]
190 line
, _
= data
.split('\n', 1)
196 return self
.widget
.textCursor()
198 def has_selection(self
):
199 return self
.cursor().hasSelection()
201 def selected_text(self
):
202 """Return the selected text"""
203 _
, selection
= self
.offset_and_selection()
206 def offset_and_selection(self
):
207 """Return the cursor offset and selected text"""
208 cursor
= self
.cursor()
209 offset
= cursor
.selectionStart()
210 selection_text
= cursor
.selection().toPlainText()
211 return offset
, selection_text
213 def mouse_press_event(self
, event
):
214 # Move the text cursor so that the right-click events operate
215 # on the current position, not the last left-clicked position.
217 if event
.button() == Qt
.RightButton
:
218 if not widget
.textCursor().hasSelection():
219 cursor
= widget
.cursorForPosition(event
.pos())
220 widget
.setTextCursor(cursor
)
222 def add_links_to_menu(self
, menu
):
223 """Add actions for opening URLs to a custom menu"""
224 links
= self
._get
_links
()
228 action
= menu
.addAction(N_('Open "%s"') % url
)
229 action
.setIcon(icons
.external())
230 qtutils
.connect_action(
231 action
, partial(QtGui
.QDesktopServices
.openUrl
, QtCore
.QUrl(url
))
234 def _get_links(self
):
235 """Return http links on the current line"""
236 _
, selection
= self
.offset_and_selection()
240 line
= self
.selected_line()
244 word
for word
in line
.split() if word
.startswith(('http://', 'https://'))
247 def create_context_menu(self
, event_pos
):
248 """Create a context menu for a widget"""
249 menu
= self
.widget
.createStandardContextMenu(event_pos
)
250 qtutils
.add_menu_actions(menu
, self
.widget
.menu_actions
)
251 self
.add_links_to_menu(menu
)
254 def context_menu_event(self
, event
):
255 """Default context menu event"""
256 event_pos
= event
.pos()
257 menu
= self
.widget
.create_context_menu(event_pos
)
258 menu
.exec_(self
.widget
.mapToGlobal(event_pos
))
260 # For extension by sub-classes
263 """Called during init for class-specific settings"""
266 # pylint: disable=unused-argument
267 def set_textwidth(self
, width
):
268 """Set the text width"""
271 # pylint: disable=unused-argument
272 def set_linebreak(self
, brk
):
273 """Enable word wrapping"""
277 class PlainTextEditExtension(BaseTextEditExtension
):
278 def set_linebreak(self
, brk
):
280 wrapmode
= QtWidgets
.QPlainTextEdit
.WidgetWidth
282 wrapmode
= QtWidgets
.QPlainTextEdit
.NoWrap
283 self
.widget
.setLineWrapMode(wrapmode
)
286 class PlainTextEdit(QtWidgets
.QPlainTextEdit
):
287 cursor_changed
= Signal(int, int)
290 def __init__(self
, parent
=None, get_value
=None, readonly
=False, options
=None):
291 QtWidgets
.QPlainTextEdit
.__init
__(self
, parent
)
292 self
.ext
= PlainTextEditExtension(self
, get_value
, readonly
)
293 self
.cursor_position
= self
.ext
.cursor_position
294 self
.mouse_zoom
= True
295 self
.options
= options
296 self
.menu_actions
= []
299 """Return the raw unicode value from Qt"""
300 return self
.ext
.get()
302 # For compatibility with QTextEdit
303 def setText(self
, value
):
304 self
.set_value(value
)
307 """Return a safe value, e.g. a stripped value"""
308 return self
.ext
.value()
310 def offset_and_selection(self
):
311 """Return the cursor offset and selected text"""
312 return self
.ext
.offset_and_selection()
314 def set_value(self
, value
, block
=False):
315 self
.ext
.set_value(value
, block
=block
)
317 def set_mouse_zoom(self
, value
):
318 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
319 self
.mouse_zoom
= value
321 def set_options(self
, options
):
322 """Register an Options widget"""
323 self
.options
= options
325 def set_word_wrapping(self
, enabled
, update
=False):
326 """Enable/disable word wrapping"""
327 if update
and self
.options
is not None:
328 with qtutils
.BlockSignals(self
.options
.enable_word_wrapping
):
329 self
.options
.enable_word_wrapping
.setChecked(enabled
)
331 self
.setWordWrapMode(QtGui
.QTextOption
.WordWrap
)
332 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.WidgetWidth
)
334 self
.setWordWrapMode(QtGui
.QTextOption
.NoWrap
)
335 self
.setLineWrapMode(QtWidgets
.QPlainTextEdit
.NoWrap
)
337 def has_selection(self
):
338 return self
.ext
.has_selection()
340 def selected_line(self
):
341 return self
.ext
.selected_line()
343 def selected_text(self
):
344 """Return the selected text"""
345 return self
.ext
.selected_text()
347 def set_tabwidth(self
, width
):
348 self
.ext
.set_tabwidth(width
)
350 def set_textwidth(self
, width
):
351 self
.ext
.set_textwidth(width
)
353 def set_linebreak(self
, brk
):
354 self
.ext
.set_linebreak(brk
)
356 def mousePressEvent(self
, event
):
357 self
.ext
.mouse_press_event(event
)
358 super().mousePressEvent(event
)
360 def wheelEvent(self
, event
):
361 """Disable control+wheelscroll text resizing"""
362 if not self
.mouse_zoom
and (event
.modifiers() & Qt
.ControlModifier
):
365 super().wheelEvent(event
)
367 def create_context_menu(self
, event_pos
):
368 """Create a custom context menu"""
369 return self
.ext
.create_context_menu(event_pos
)
371 def contextMenuEvent(self
, event
):
372 """Custom contextMenuEvent() for building our custom context menus"""
373 self
.ext
.context_menu_event(event
)
376 class TextSearchWidget(QtWidgets
.QWidget
):
377 """The search dialog that displays over a text edit field"""
379 def __init__(self
, widget
, parent
):
380 super().__init
__(parent
)
381 self
.setAutoFillBackground(True)
382 self
._widget
= widget
383 self
._parent
= parent
385 self
.text
= HintedDefaultLineEdit(N_('Find in diff'), parent
=self
)
387 self
.prev_button
= qtutils
.create_action_button(
388 tooltip
=N_('Find the previous occurrence of the phrase'), icon
=icons
.up()
391 self
.next_button
= qtutils
.create_action_button(
392 tooltip
=N_('Find the next occurrence of the phrase'), icon
=icons
.down()
395 self
.match_case_checkbox
= qtutils
.checkbox(N_('Match Case'))
396 self
.whole_words_checkbox
= qtutils
.checkbox(N_('Whole Words'))
398 self
.close_button
= qtutils
.create_action_button(
399 tooltip
=N_('Close the find bar'), icon
=icons
.close()
402 layout
= qtutils
.hbox(
408 self
.match_case_checkbox
,
409 self
.whole_words_checkbox
,
413 self
.setLayout(layout
)
414 self
.setFocusProxy(self
.text
)
416 self
.text
.esc_pressed
.connect(self
.hide_search
)
417 self
.text
.returnPressed
.connect(self
.search
)
418 self
.text
.textChanged
.connect(self
.search
)
420 self
.search_next_action
= qtutils
.add_action(
422 N_('Find next item'),
426 self
.search_prev_action
= qtutils
.add_action(
428 N_('Find previous item'),
429 self
.search_backwards
,
433 qtutils
.connect_button(self
.next_button
, self
.search
)
434 qtutils
.connect_button(self
.prev_button
, self
.search_backwards
)
435 qtutils
.connect_button(self
.close_button
, self
.hide_search
)
436 qtutils
.connect_checkbox(self
.match_case_checkbox
, lambda _
: self
.search())
437 qtutils
.connect_checkbox(self
.whole_words_checkbox
, lambda _
: self
.search())
440 """Emit a signal with the current search text"""
441 self
.search_text(backwards
=False)
443 def search_backwards(self
):
444 """Emit a signal with the current search text for a backwards search"""
445 self
.search_text(backwards
=True)
447 def hide_search(self
):
448 """Hide the search window"""
450 self
._parent
.setFocus()
452 def find_flags(self
, backwards
):
453 """Return QTextDocument.FindFlags for the current search options"""
454 flags
= QtGui
.QTextDocument
.FindFlag(0)
456 flags
= flags | QtGui
.QTextDocument
.FindBackward
457 if self
.match_case_checkbox
.isChecked():
458 flags
= flags | QtGui
.QTextDocument
.FindCaseSensitively
459 if self
.whole_words_checkbox
.isChecked():
460 flags
= flags | QtGui
.QTextDocument
.FindWholeWords
463 def is_case_sensitive(self
):
464 """Are we searching using a case-insensitive search?"""
465 return self
.match_case_checkbox
.isChecked()
467 def search_text(self
, backwards
=False):
468 """Search the diff text for the given text"""
469 text
= self
.text
.get()
470 cursor
= self
._widget
.textCursor()
471 if cursor
.hasSelection():
472 selected_text
= cursor
.selectedText()
473 case_sensitive
= self
.is_case_sensitive()
474 if text_matches(case_sensitive
, selected_text
, text
):
476 position
= cursor
.selectionStart()
478 position
= cursor
.selectionEnd()
481 position
= cursor
.selectionEnd()
483 position
= cursor
.selectionStart()
484 cursor
.setPosition(position
)
485 self
._widget
.setTextCursor(cursor
)
487 flags
= self
.find_flags(backwards
)
488 if not self
._widget
.find(text
, flags
):
490 location
= QtGui
.QTextCursor
.End
492 location
= QtGui
.QTextCursor
.Start
493 cursor
.movePosition(location
, QtGui
.QTextCursor
.MoveAnchor
)
494 self
._widget
.setTextCursor(cursor
)
495 self
._widget
.find(text
, flags
)
498 def text_matches(case_sensitive
, a
, b
):
499 """Compare text with case sensitivity taken into account"""
502 return a
.lower() == b
.lower()
505 class TextEditExtension(BaseTextEditExtension
):
508 widget
.setAcceptRichText(False)
510 def set_linebreak(self
, brk
):
512 wrapmode
= QtWidgets
.QTextEdit
.FixedColumnWidth
514 wrapmode
= QtWidgets
.QTextEdit
.NoWrap
515 self
.widget
.setLineWrapMode(wrapmode
)
517 def set_textwidth(self
, width
):
518 self
.widget
.setLineWrapColumnOrWidth(width
)
521 class TextEdit(QtWidgets
.QTextEdit
):
522 cursor_changed
= Signal(int, int)
525 def __init__(self
, parent
=None, get_value
=None, readonly
=False):
526 QtWidgets
.QTextEdit
.__init
__(self
, parent
)
527 self
.ext
= TextEditExtension(self
, get_value
, readonly
)
528 self
.cursor_position
= self
.ext
.cursor_position
529 self
.expandtab_enabled
= False
530 self
.menu_actions
= []
533 """Return the raw unicode value from Qt"""
534 return self
.ext
.get()
537 """Return a safe value, e.g. a stripped value"""
538 return self
.ext
.value()
540 def set_cursor_position(self
, position
):
541 """Set the cursor position"""
542 cursor
= self
.textCursor()
543 cursor
.setPosition(position
)
544 self
.setTextCursor(cursor
)
546 def set_value(self
, value
, block
=False):
547 self
.ext
.set_value(value
, block
=block
)
549 def selected_line(self
):
550 return self
.ext
.selected_line()
552 def selected_text(self
):
553 """Return the selected text"""
554 return self
.ext
.selected_text()
556 def set_tabwidth(self
, width
):
557 self
.ext
.set_tabwidth(width
)
559 def set_textwidth(self
, width
):
560 self
.ext
.set_textwidth(width
)
562 def set_linebreak(self
, brk
):
563 self
.ext
.set_linebreak(brk
)
565 def set_expandtab(self
, value
):
566 self
.expandtab_enabled
= value
568 def mousePressEvent(self
, event
):
569 self
.ext
.mouse_press_event(event
)
570 super().mousePressEvent(event
)
572 def wheelEvent(self
, event
):
573 """Disable control+wheelscroll text resizing"""
574 if event
.modifiers() & Qt
.ControlModifier
:
577 super().wheelEvent(event
)
579 def should_expandtab(self
, event
):
580 return event
.key() == Qt
.Key_Tab
and self
.expandtab_enabled
583 tabwidth
= max(self
.ext
.tabwidth(), 1)
584 cursor
= self
.textCursor()
585 cursor
.insertText(' ' * tabwidth
)
587 def create_context_menu(self
, event_pos
):
588 """Create a custom context menu"""
589 return self
.ext
.create_context_menu(event_pos
)
591 def contextMenuEvent(self
, event
):
592 """Custom contextMenuEvent() for building our custom context menus"""
593 self
.ext
.context_menu_event(event
)
595 def keyPressEvent(self
, event
):
596 """Override keyPressEvent to handle tab expansion"""
597 expandtab
= self
.should_expandtab(event
)
602 QtWidgets
.QTextEdit
.keyPressEvent(self
, event
)
604 def keyReleaseEvent(self
, event
):
605 """Override keyReleaseEvent to special-case tab expansion"""
606 expandtab
= self
.should_expandtab(event
)
610 QtWidgets
.QTextEdit
.keyReleaseEvent(self
, event
)
613 class TextEditCursorPosition
:
614 def __init__(self
, widget
, ext
):
615 self
._widget
= widget
617 widget
.cursorPositionChanged
.connect(self
.emit
)
620 widget
= self
._widget
622 cursor
= widget
.textCursor()
623 position
= cursor
.position()
625 before
= txt
[:position
]
626 row
= before
.count('\n')
627 line
= before
.split('\n')[row
]
628 col
= cursor
.columnNumber()
629 col
+= line
[:col
].count('\t') * (ext
.tabwidth() - 1)
630 widget
.cursor_changed
.emit(row
+ 1, col
)
633 widget
= self
._widget
634 cursor
= widget
.textCursor()
635 cursor
.setPosition(0)
636 widget
.setTextCursor(cursor
)
639 class MonoTextEdit(PlainTextEdit
):
640 def __init__(self
, context
, parent
=None, readonly
=False):
641 PlainTextEdit
.__init
__(self
, parent
=parent
, readonly
=readonly
)
642 self
.setFont(qtutils
.diff_font(context
))
645 def get_value_hinted(widget
):
646 text
= get_stripped(widget
)
647 hint
= get(widget
.hint
)
653 class HintWidget(QtCore
.QObject
):
654 """Extend a widget to provide hint messages
656 This primarily exists because setPlaceholderText() is only available
657 in Qt5, so this class provides consistent behavior across versions.
661 def __init__(self
, widget
, hint
):
662 QtCore
.QObject
.__init
__(self
, widget
)
663 self
._widget
= widget
665 self
._is
_error
= False
667 self
.modern
= modern
= hasattr(widget
, 'setPlaceholderText')
669 widget
.setPlaceholderText(hint
)
671 # Palette for normal text
672 QPalette
= QtGui
.QPalette
673 palette
= widget
.palette()
675 hint_color
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
676 error_bg_color
= QtGui
.QColor(Qt
.red
).darker()
677 error_fg_color
= QtGui
.QColor(Qt
.white
)
679 hint_rgb
= qtutils
.rgb_css(hint_color
)
680 error_bg_rgb
= qtutils
.rgb_css(error_bg_color
)
681 error_fg_rgb
= qtutils
.rgb_css(error_fg_color
)
684 'name': widget
.__class
__.__name
__,
685 'error_fg_rgb': error_fg_rgb
,
686 'error_bg_rgb': error_bg_rgb
,
687 'hint_rgb': hint_rgb
,
690 self
._default
_style
= ''
701 self
._error
_style
= (
704 color: %(error_fg_rgb)s;
705 background-color: %(error_bg_rgb)s;
712 """Defered initialization"""
714 self
.widget().setPlaceholderText(self
.value())
716 self
.widget().installEventFilter(self
)
720 """Return the parent text widget"""
724 """Return True when hint-mode is active"""
725 return self
.value() == get_stripped(self
._widget
)
728 """Return the current hint text"""
731 def set_error(self
, is_error
):
732 """Enable/disable error mode"""
733 self
._is
_error
= is_error
736 def set_value(self
, hint
):
737 """Change the hint text"""
740 self
._widget
.setPlaceholderText(hint
)
742 # If hint-mode is currently active, re-activate it
743 active
= self
.active()
745 if active
or self
.active():
748 def enable(self
, enable
):
749 """Enable/disable hint-mode"""
751 if enable
and self
._hint
:
752 self
._widget
.set_value(self
._hint
, block
=True)
753 self
._widget
.cursor_position
.reset()
756 self
._update
_palette
(enable
)
759 """Update the palette to match the current mode"""
760 self
._update
_palette
(self
.active())
762 def _update_palette(self
, hint
):
763 """Update to palette for normal/error/hint mode"""
765 style
= self
._error
_style
766 elif not self
.modern
and hint
:
767 style
= self
._hint
_style
769 style
= self
._default
_style
770 QtCore
.QTimer
.singleShot(
771 0, lambda: utils
.catch_runtime_error(self
._widget
.setStyleSheet
, style
)
774 def eventFilter(self
, _obj
, event
):
775 """Enable/disable hint-mode when focus changes"""
777 if etype
== QtCore
.QEvent
.FocusIn
:
779 elif etype
== QtCore
.QEvent
.FocusOut
:
784 """Disable hint-mode when focused"""
785 widget
= self
.widget()
788 widget
.cursor_position
.emit()
791 """Re-enable hint-mode when losing focus"""
792 widget
= self
.widget()
793 valid
, value
= utils
.catch_runtime_error(get
, widget
)
795 # The widget may have just been destroyed during application shutdown.
796 # We're receiving a focusOut event but the widget can no longer be used.
797 # This can be safely ignored.
803 class HintedPlainTextEdit(PlainTextEdit
):
804 """A hinted plain text edit"""
806 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
807 PlainTextEdit
.__init
__(
808 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
810 self
.hint
= HintWidget(self
, hint
)
812 self
.context
= context
813 self
.setFont(qtutils
.diff_font(context
))
814 self
.set_tabwidth(prefs
.tabwidth(context
))
815 # Refresh palettes when text changes
816 # pylint: disable=no-member
817 self
.textChanged
.connect(self
.hint
.refresh
)
818 self
.set_mouse_zoom(context
.cfg
.get(prefs
.MOUSE_ZOOM
, default
=True))
820 def set_value(self
, value
, block
=False):
821 """Set the widget text or enable hint mode when empty"""
822 if value
or self
.hint
.modern
:
823 PlainTextEdit
.set_value(self
, value
, block
=block
)
825 self
.hint
.enable(True)
828 class HintedTextEdit(TextEdit
):
829 """A hinted text edit"""
831 def __init__(self
, context
, hint
, parent
=None, readonly
=False):
833 self
, parent
=parent
, get_value
=get_value_hinted
, readonly
=readonly
835 self
.context
= context
836 self
.hint
= HintWidget(self
, hint
)
838 # Refresh palettes when text changes
839 # pylint: disable=no-member
840 self
.textChanged
.connect(self
.hint
.refresh
)
841 self
.setFont(qtutils
.diff_font(context
))
843 def set_value(self
, value
, block
=False):
844 """Set the widget text or enable hint mode when empty"""
845 if value
or self
.hint
.modern
:
846 TextEdit
.set_value(self
, value
, block
=block
)
848 self
.hint
.enable(True)
851 def anchor_mode(select
):
852 """Return the QTextCursor mode to keep/discard the cursor selection"""
854 mode
= QtGui
.QTextCursor
.KeepAnchor
856 mode
= QtGui
.QTextCursor
.MoveAnchor
860 # The vim-like read-only text view
864 def __init__(self
, widget
):
866 self
.Base
= widget
.Base
867 # Common vim/unix-ish keyboard actions
868 self
.add_navigation('End', hotkeys
.GOTO_END
)
869 self
.add_navigation('Up', hotkeys
.MOVE_UP
, shift
=hotkeys
.MOVE_UP_SHIFT
)
870 self
.add_navigation('Down', hotkeys
.MOVE_DOWN
, shift
=hotkeys
.MOVE_DOWN_SHIFT
)
871 self
.add_navigation('Left', hotkeys
.MOVE_LEFT
, shift
=hotkeys
.MOVE_LEFT_SHIFT
)
872 self
.add_navigation('Right', hotkeys
.MOVE_RIGHT
, shift
=hotkeys
.MOVE_RIGHT_SHIFT
)
873 self
.add_navigation('WordLeft', hotkeys
.WORD_LEFT
)
874 self
.add_navigation('WordRight', hotkeys
.WORD_RIGHT
)
875 self
.add_navigation('Start', hotkeys
.GOTO_START
)
876 self
.add_navigation('StartOfLine', hotkeys
.START_OF_LINE
)
877 self
.add_navigation('EndOfLine', hotkeys
.END_OF_LINE
)
883 hotkeys
.SECONDARY_ACTION
,
890 hotkeys
.PRIMARY_ACTION
,
896 lambda: widget
.page_up(select
=True),
903 lambda: widget
.page_down(select
=True),
904 hotkeys
.SELECT_FORWARD
,
908 def add_navigation(self
, name
, hotkey
, shift
=None):
909 """Add a hotkey along with a shift-variant"""
911 direction
= getattr(QtGui
.QTextCursor
, name
)
912 qtutils
.add_action(widget
, name
, lambda: self
.move(direction
), hotkey
)
915 widget
, 'Shift' + name
, lambda: self
.move(direction
, select
=True), shift
918 def move(self
, direction
, select
=False, n
=1):
920 cursor
= widget
.textCursor()
921 mode
= anchor_mode(select
)
923 if cursor
.movePosition(direction
, mode
, 1):
924 self
.set_text_cursor(cursor
)
926 def page(self
, offset
, select
=False):
928 rect
= widget
.cursorRect()
930 y
= rect
.y() + offset
931 new_cursor
= widget
.cursorForPosition(QtCore
.QPoint(x
, y
))
932 if new_cursor
is not None:
933 cursor
= widget
.textCursor()
934 mode
= anchor_mode(select
)
935 cursor
.setPosition(new_cursor
.position(), mode
)
937 self
.set_text_cursor(cursor
)
939 def page_down(self
, select
=False):
941 widget
.page(widget
.height() // 2, select
=select
)
943 def page_up(self
, select
=False):
945 widget
.page(-widget
.height() // 2, select
=select
)
947 def set_text_cursor(self
, cursor
):
949 widget
.setTextCursor(cursor
)
950 widget
.ensureCursorVisible()
951 widget
.viewport().update()
953 def keyPressEvent(self
, event
):
954 """Custom keyboard behaviors
956 The leave() signal is emitted when `Up` is pressed and we're already
957 at the beginning of the text. This allows the parent widget to
958 orchestrate some higher-level interaction, such as giving focus to
961 When in the middle of the first line and `Up` is pressed, the cursor
962 is moved to the beginning of the line.
966 if event
.key() == Qt
.Key_Up
:
967 cursor
= widget
.textCursor()
968 position
= cursor
.position()
970 # The cursor is at the beginning of the line.
971 # Emit a signal so that the parent can e.g. change focus.
973 elif get(widget
)[:position
].count('\n') == 0:
974 # The cursor is in the middle of the first line of text.
975 # We can't go up ~ jump to the beginning of the line.
976 # Select the text if shift is pressed.
977 select
= event
.modifiers() & Qt
.ShiftModifier
978 mode
= anchor_mode(select
)
979 cursor
.movePosition(QtGui
.QTextCursor
.StartOfLine
, mode
)
980 widget
.setTextCursor(cursor
)
982 return self
.Base
.keyPressEvent(widget
, event
)
985 # pylint: disable=too-many-ancestors
986 class VimHintedPlainTextEdit(HintedPlainTextEdit
):
987 """HintedPlainTextEdit with vim hotkeys
989 This can only be used in read-only mode.
993 Base
= HintedPlainTextEdit
996 def __init__(self
, context
, hint
, parent
=None):
997 HintedPlainTextEdit
.__init
__(self
, context
, hint
, parent
=parent
, readonly
=True)
998 self
._mixin
= self
.Mixin(self
)
1000 def move(self
, direction
, select
=False, n
=1):
1001 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1003 def page(self
, offset
, select
=False):
1004 return self
._mixin
.page(offset
, select
=select
)
1006 def page_up(self
, select
=False):
1007 return self
._mixin
.page_up(select
=select
)
1009 def page_down(self
, select
=False):
1010 return self
._mixin
.page_down(select
=select
)
1012 def keyPressEvent(self
, event
):
1013 return self
._mixin
.keyPressEvent(event
)
1016 # pylint: disable=too-many-ancestors
1017 class VimTextEdit(MonoTextEdit
):
1018 """Text viewer with vim-like hotkeys
1020 This can only be used in read-only mode.
1027 def __init__(self
, context
, parent
=None, readonly
=True):
1028 MonoTextEdit
.__init
__(self
, context
, parent
=None, readonly
=readonly
)
1029 self
._mixin
= self
.Mixin(self
)
1031 def move(self
, direction
, select
=False, n
=1):
1032 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1034 def page(self
, offset
, select
=False):
1035 return self
._mixin
.page(offset
, select
=select
)
1037 def page_up(self
, select
=False):
1038 return self
._mixin
.page_up(select
=select
)
1040 def page_down(self
, select
=False):
1041 return self
._mixin
.page_down(select
=select
)
1043 def keyPressEvent(self
, event
):
1044 return self
._mixin
.keyPressEvent(event
)
1047 class HintedDefaultLineEdit(LineEdit
):
1048 """A line edit with hint text"""
1050 def __init__(self
, hint
, tooltip
=None, parent
=None):
1051 LineEdit
.__init
__(self
, parent
=parent
, get_value
=get_value_hinted
)
1053 self
.setToolTip(tooltip
)
1054 self
.hint
= HintWidget(self
, hint
)
1056 # pylint: disable=no-member
1057 self
.textChanged
.connect(lambda text
: self
.hint
.refresh())
1060 class HintedLineEdit(HintedDefaultLineEdit
):
1061 """A monospace line edit with hint text"""
1063 def __init__(self
, context
, hint
, tooltip
=None, parent
=None):
1064 super().__init
__(hint
, tooltip
=tooltip
, parent
=parent
)
1065 self
.setFont(qtutils
.diff_font(context
))
1068 def text_dialog(context
, text
, title
):
1069 """Show a wall of text in a dialog"""
1070 parent
= qtutils
.active_window()
1072 label
= QtWidgets
.QLabel(parent
)
1073 label
.setFont(qtutils
.diff_font(context
))
1075 label
.setMargin(defs
.large_margin
)
1076 text_flags
= Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
1077 label
.setTextInteractionFlags(text_flags
)
1079 widget
= QtWidgets
.QDialog(parent
)
1080 widget
.setWindowModality(Qt
.WindowModal
)
1081 widget
.setWindowTitle(title
)
1083 scroll
= QtWidgets
.QScrollArea()
1084 scroll
.setWidget(label
)
1086 layout
= qtutils
.hbox(defs
.margin
, defs
.spacing
, scroll
)
1087 widget
.setLayout(layout
)
1090 widget
, N_('Close'), widget
.accept
, Qt
.Key_Question
, Qt
.Key_Enter
, Qt
.Key_Return
1096 class VimTextBrowser(VimTextEdit
):
1097 """Text viewer with line number annotations"""
1099 def __init__(self
, context
, parent
=None, readonly
=True):
1100 VimTextEdit
.__init
__(self
, context
, parent
=parent
, readonly
=readonly
)
1101 self
.numbers
= LineNumbers(self
)
1103 def resizeEvent(self
, event
):
1104 super().resizeEvent(event
)
1105 self
.numbers
.refresh_size()
1108 class TextDecorator(QtWidgets
.QWidget
):
1109 """Common functionality for providing line numbers in text widgets"""
1111 def __init__(self
, parent
):
1112 QtWidgets
.QWidget
.__init
__(self
, parent
)
1113 self
.editor
= parent
1115 parent
.blockCountChanged
.connect(lambda x
: self
._refresh
_viewport
())
1116 parent
.cursorPositionChanged
.connect(self
.refresh
)
1117 parent
.updateRequest
.connect(self
._refresh
_rect
)
1120 """Refresh the numbers display"""
1121 rect
= self
.editor
.viewport().rect()
1122 self
._refresh
_rect
(rect
, 0)
1124 def _refresh_rect(self
, rect
, dy
):
1128 self
.update(0, rect
.y(), self
.width(), rect
.height())
1130 if rect
.contains(self
.editor
.viewport().rect()):
1131 self
._refresh
_viewport
()
1133 def _refresh_viewport(self
):
1134 self
.editor
.setViewportMargins(self
.width_hint(), 0, 0, 0)
1136 def refresh_size(self
):
1137 rect
= self
.editor
.contentsRect()
1138 geom
= QtCore
.QRect(rect
.left(), rect
.top(), self
.width_hint(), rect
.height())
1139 self
.setGeometry(geom
)
1142 return QtCore
.QSize(self
.width_hint(), 0)
1145 class LineNumbers(TextDecorator
):
1146 """Provide line numbers for QPlainTextEdit widgets"""
1148 def __init__(self
, parent
):
1149 TextDecorator
.__init
__(self
, parent
)
1150 self
.highlight_line
= -1
1152 def width_hint(self
):
1153 document
= self
.editor
.document()
1154 digits
= int(math
.log(max(1, document
.blockCount()), 10)) + 2
1155 return defs
.large_margin
+ self
.fontMetrics().width('0') * digits
1157 def set_highlighted(self
, line_number
):
1158 """Set the line to highlight"""
1159 self
.highlight_line
= line_number
1161 def paintEvent(self
, event
):
1162 """Paint the line number"""
1163 QPalette
= QtGui
.QPalette
1164 painter
= QtGui
.QPainter(self
)
1165 editor
= self
.editor
1166 palette
= editor
.palette()
1168 painter
.fillRect(event
.rect(), palette
.color(QPalette
.Base
))
1170 content_offset
= editor
.contentOffset()
1171 block
= editor
.firstVisibleBlock()
1172 width
= self
.width()
1173 event_rect_bottom
= event
.rect().bottom()
1175 highlight
= palette
.color(QPalette
.Highlight
)
1176 highlighted_text
= palette
.color(QPalette
.HighlightedText
)
1177 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
1179 while block
.isValid():
1180 block_geom
= editor
.blockBoundingGeometry(block
)
1181 block_top
= block_geom
.translated(content_offset
).top()
1182 if not block
.isVisible() or block_top
>= event_rect_bottom
:
1185 rect
= block_geom
.translated(content_offset
).toRect()
1186 block_number
= block
.blockNumber()
1187 if block_number
== self
.highlight_line
:
1188 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
1189 painter
.setPen(highlighted_text
)
1191 painter
.setPen(disabled
)
1193 number
= '%s' % (block_number
+ 1)
1197 self
.width() - defs
.large_margin
,
1199 Qt
.AlignRight | Qt
.AlignVCenter
,
1202 block
= block
.next()
1205 class TextLabel(QtWidgets
.QLabel
):
1206 """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"""
1259 def __init__(self
, parent
=None):
1260 super().__init
__(parent
=parent
, open_external_links
=False)
1261 self
.setTextFormat(Qt
.PlainText
)
1264 class RichTextLabel(TextLabel
):
1265 """A richtext label that elides its display"""
1266 def __init__(self
, parent
=None):
1267 super().__init
__(parent
=parent
, open_external_links
=True)
1268 self
.setTextFormat(Qt
.RichText
)