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 """Deferred 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.
990 Base
= HintedPlainTextEdit
993 def __init__(self
, context
, hint
, parent
=None):
994 HintedPlainTextEdit
.__init
__(self
, context
, hint
, parent
=parent
, readonly
=True)
995 self
._mixin
= self
.Mixin(self
)
997 def move(self
, direction
, select
=False, n
=1):
998 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1000 def page(self
, offset
, select
=False):
1001 return self
._mixin
.page(offset
, select
=select
)
1003 def page_up(self
, select
=False):
1004 return self
._mixin
.page_up(select
=select
)
1006 def page_down(self
, select
=False):
1007 return self
._mixin
.page_down(select
=select
)
1009 def keyPressEvent(self
, event
):
1010 return self
._mixin
.keyPressEvent(event
)
1013 # pylint: disable=too-many-ancestors
1014 class VimTextEdit(MonoTextEdit
):
1015 """Text viewer with vim-like hotkeys
1017 This can only be used in read-only mode.
1024 def __init__(self
, context
, parent
=None, readonly
=True):
1025 MonoTextEdit
.__init
__(self
, context
, parent
=None, readonly
=readonly
)
1026 self
._mixin
= self
.Mixin(self
)
1028 def move(self
, direction
, select
=False, n
=1):
1029 return self
._mixin
.page(direction
, select
=select
, n
=n
)
1031 def page(self
, offset
, select
=False):
1032 return self
._mixin
.page(offset
, select
=select
)
1034 def page_up(self
, select
=False):
1035 return self
._mixin
.page_up(select
=select
)
1037 def page_down(self
, select
=False):
1038 return self
._mixin
.page_down(select
=select
)
1040 def keyPressEvent(self
, event
):
1041 return self
._mixin
.keyPressEvent(event
)
1044 class HintedDefaultLineEdit(LineEdit
):
1045 """A line edit with hint text"""
1047 def __init__(self
, hint
, tooltip
=None, parent
=None):
1048 LineEdit
.__init
__(self
, parent
=parent
, get_value
=get_value_hinted
)
1050 self
.setToolTip(tooltip
)
1051 self
.hint
= HintWidget(self
, hint
)
1053 # pylint: disable=no-member
1054 self
.textChanged
.connect(lambda text
: self
.hint
.refresh())
1057 class HintedLineEdit(HintedDefaultLineEdit
):
1058 """A monospace line edit with hint text"""
1060 def __init__(self
, context
, hint
, tooltip
=None, parent
=None):
1061 super().__init
__(hint
, tooltip
=tooltip
, parent
=parent
)
1062 self
.setFont(qtutils
.diff_font(context
))
1065 def text_dialog(context
, text
, title
):
1066 """Show a wall of text in a dialog"""
1067 parent
= qtutils
.active_window()
1069 label
= QtWidgets
.QLabel(parent
)
1070 label
.setFont(qtutils
.diff_font(context
))
1072 label
.setMargin(defs
.large_margin
)
1073 text_flags
= Qt
.TextSelectableByKeyboard | Qt
.TextSelectableByMouse
1074 label
.setTextInteractionFlags(text_flags
)
1076 widget
= QtWidgets
.QDialog(parent
)
1077 widget
.setWindowModality(Qt
.WindowModal
)
1078 widget
.setWindowTitle(title
)
1080 scroll
= QtWidgets
.QScrollArea()
1081 scroll
.setWidget(label
)
1083 layout
= qtutils
.hbox(defs
.margin
, defs
.spacing
, scroll
)
1084 widget
.setLayout(layout
)
1087 widget
, N_('Close'), widget
.accept
, Qt
.Key_Question
, Qt
.Key_Enter
, Qt
.Key_Return
1093 class VimTextBrowser(VimTextEdit
):
1094 """Text viewer with line number annotations"""
1096 def __init__(self
, context
, parent
=None, readonly
=True):
1097 VimTextEdit
.__init
__(self
, context
, parent
=parent
, readonly
=readonly
)
1098 self
.numbers
= LineNumbers(self
)
1100 def resizeEvent(self
, event
):
1101 super().resizeEvent(event
)
1102 self
.numbers
.refresh_size()
1105 class TextDecorator(QtWidgets
.QWidget
):
1106 """Common functionality for providing line numbers in text widgets"""
1108 def __init__(self
, parent
):
1109 QtWidgets
.QWidget
.__init
__(self
, parent
)
1110 self
.editor
= parent
1112 parent
.blockCountChanged
.connect(lambda x
: self
._refresh
_viewport
())
1113 parent
.cursorPositionChanged
.connect(self
.refresh
)
1114 parent
.updateRequest
.connect(self
._refresh
_rect
)
1117 """Refresh the numbers display"""
1118 rect
= self
.editor
.viewport().rect()
1119 self
._refresh
_rect
(rect
, 0)
1121 def _refresh_rect(self
, rect
, dy
):
1125 self
.update(0, rect
.y(), self
.width(), rect
.height())
1127 if rect
.contains(self
.editor
.viewport().rect()):
1128 self
._refresh
_viewport
()
1130 def _refresh_viewport(self
):
1131 self
.editor
.setViewportMargins(self
.width_hint(), 0, 0, 0)
1133 def refresh_size(self
):
1134 rect
= self
.editor
.contentsRect()
1135 geom
= QtCore
.QRect(rect
.left(), rect
.top(), self
.width_hint(), rect
.height())
1136 self
.setGeometry(geom
)
1139 return QtCore
.QSize(self
.width_hint(), 0)
1142 class LineNumbers(TextDecorator
):
1143 """Provide line numbers for QPlainTextEdit widgets"""
1145 def __init__(self
, parent
):
1146 TextDecorator
.__init
__(self
, parent
)
1147 self
.highlight_line
= -1
1149 def width_hint(self
):
1150 document
= self
.editor
.document()
1151 digits
= int(math
.log(max(1, document
.blockCount()), 10)) + 2
1152 text_width
= qtutils
.text_width(self
.font(), '0')
1153 return defs
.large_margin
+ (text_width
* digits
)
1155 def set_highlighted(self
, line_number
):
1156 """Set the line to highlight"""
1157 self
.highlight_line
= line_number
1159 def paintEvent(self
, event
):
1160 """Paint the line number"""
1161 QPalette
= QtGui
.QPalette
1162 painter
= QtGui
.QPainter(self
)
1163 editor
= self
.editor
1164 palette
= editor
.palette()
1166 painter
.fillRect(event
.rect(), palette
.color(QPalette
.Base
))
1168 content_offset
= editor
.contentOffset()
1169 block
= editor
.firstVisibleBlock()
1170 width
= self
.width()
1171 event_rect_bottom
= event
.rect().bottom()
1173 highlight
= palette
.color(QPalette
.Highlight
)
1174 highlighted_text
= palette
.color(QPalette
.HighlightedText
)
1175 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
1177 while block
.isValid():
1178 block_geom
= editor
.blockBoundingGeometry(block
)
1179 block_top
= block_geom
.translated(content_offset
).top()
1180 if not block
.isVisible() or block_top
>= event_rect_bottom
:
1183 rect
= block_geom
.translated(content_offset
).toRect()
1184 block_number
= block
.blockNumber()
1185 if block_number
== self
.highlight_line
:
1186 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
1187 painter
.setPen(highlighted_text
)
1189 painter
.setPen(disabled
)
1191 number
= '%s' % (block_number
+ 1)
1195 self
.width() - defs
.large_margin
,
1197 Qt
.AlignRight | Qt
.AlignVCenter
,
1200 block
= block
.next()
1203 class TextLabel(QtWidgets
.QLabel
):
1204 """A text label that elides its display"""
1206 def __init__(self
, parent
=None, open_external_links
=True):
1207 QtWidgets
.QLabel
.__init
__(self
, parent
)
1212 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1213 policy
= QtWidgets
.QSizePolicy(
1214 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1216 self
.setSizePolicy(policy
)
1217 self
.setTextInteractionFlags(
1218 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1220 self
.setOpenExternalLinks(open_external_links
)
1225 def set_text(self
, text
):
1226 self
.set_template(text
, text
)
1228 def set_template(self
, text
, template
):
1229 self
._display
= text
1231 self
._template
= template
1232 self
.update_text(self
.width())
1233 self
.setText(self
._display
)
1235 def update_text(self
, width
):
1236 self
._display
= self
._text
1239 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1240 if text
!= self
._template
:
1241 self
._display
= text
1244 def setFont(self
, font
):
1245 self
._metrics
= QtGui
.QFontMetrics(font
)
1246 QtWidgets
.QLabel
.setFont(self
, font
)
1248 def resizeEvent(self
, event
):
1250 self
.update_text(event
.size().width())
1251 with qtutils
.BlockSignals(self
):
1252 self
.setText(self
._display
)
1253 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1256 class PlainTextLabel(TextLabel
):
1257 """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"""
1267 def __init__(self
, parent
=None):
1268 super().__init
__(parent
=parent
, open_external_links
=True)
1269 self
.setTextFormat(Qt
.RichText
)