widgets: move PlainTextLabel and RichTextLabel to the text module
[git-cola.git] / cola / widgets / text.py
blob507707c92280ff3fa658ac30d44528d108ac326e
1 """Text widgets"""
2 # pylint: disable=unexpected-keyword-arg
3 from functools import partial
4 import math
6 from qtpy import QtCore
7 from qtpy import QtGui
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
15 from .. import icons
16 from .. import qtutils
17 from .. import utils
18 from ..i18n import N_
19 from . import defs
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)
32 self._row = row
33 if get_value is None:
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)
42 def get(self):
43 """Return the raw unicode value from Qt"""
44 return self.text()
46 def value(self):
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"""
52 if block:
53 with qtutils.BlockSignals(self):
54 self._set_value(value)
55 else:
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()
61 self.setText(value)
62 self.setCursorPosition(pos)
64 def keyPressEvent(self, event):
65 key = event.key()
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):
75 self._widget = widget
76 self._row = row
77 # Translate cursorPositionChanged into cursor_changed(int, int)
78 widget.cursorPositionChanged.connect(lambda old, new: self.emit())
80 def emit(self):
81 widget = self._widget
82 row = self._row
83 col = widget.cursorPosition()
84 widget.cursor_changed.emit(row, col)
86 def reset(self):
87 self._widget.setCursorPosition(0)
90 class BaseTextEditExtension(QtCore.QObject):
91 def __init__(self, widget, get_value, readonly):
92 QtCore.QObject.__init__(self, widget)
93 self.widget = widget
94 self.cursor_position = TextEditCursorPosition(widget, self)
95 if get_value is None:
96 get_value = get_stripped
97 self._get_value = get_value
98 self._tabwidth = 8
99 self._readonly = readonly
100 self._init_flags()
101 self.init()
103 def _init_flags(self):
104 widget = self.widget
105 widget.setMinimumSize(QtCore.QSize(10, 10))
106 widget.setWordWrapMode(QtGui.QTextOption.WordWrap)
107 widget.setLineWrapMode(widget.NoWrap)
108 if self._readonly:
109 widget.setReadOnly(True)
110 widget.setAcceptDrops(False)
111 widget.setTabChangesFocus(True)
112 widget.setUndoRedoEnabled(False)
113 widget.setTextInteractionFlags(
114 Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
117 def get(self):
118 """Return the raw unicode value from Qt"""
119 return self.widget.toPlainText()
121 def value(self):
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"""
127 if block:
128 with qtutils.BlockSignals(self):
129 self._set_value(value)
130 else:
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)
139 # Update text
140 self.widget.setPlainText(value)
142 # Restore cursor
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)
157 else:
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)
172 def tabwidth(self):
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':
187 offset -= 1
188 data = contents[offset:]
189 if '\n' in data:
190 line, _ = data.split('\n', 1)
191 else:
192 line = data
193 return line
195 def cursor(self):
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()
204 return 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.
216 widget = self.widget
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()
225 if links:
226 menu.addSeparator()
227 for url in 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()
237 if selection:
238 line = selection
239 else:
240 line = self.selected_line()
241 if not line:
242 return []
243 return [
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)
252 return 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
262 def init(self):
263 """Called during init for class-specific settings"""
264 return
266 # pylint: disable=unused-argument
267 def set_textwidth(self, width):
268 """Set the text width"""
269 return
271 # pylint: disable=unused-argument
272 def set_linebreak(self, brk):
273 """Enable word wrapping"""
274 return
277 class PlainTextEditExtension(BaseTextEditExtension):
278 def set_linebreak(self, brk):
279 if brk:
280 wrapmode = QtWidgets.QPlainTextEdit.WidgetWidth
281 else:
282 wrapmode = QtWidgets.QPlainTextEdit.NoWrap
283 self.widget.setLineWrapMode(wrapmode)
286 class PlainTextEdit(QtWidgets.QPlainTextEdit):
287 cursor_changed = Signal(int, int)
288 leave = Signal()
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 = []
298 def get(self):
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)
306 def value(self):
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)
330 if enabled:
331 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
332 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
333 else:
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):
363 event.ignore()
364 return
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(
403 defs.margin,
404 defs.button_spacing,
405 self.text,
406 self.prev_button,
407 self.next_button,
408 self.match_case_checkbox,
409 self.whole_words_checkbox,
410 qtutils.STRETCH,
411 self.close_button,
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(
421 parent,
422 N_('Find next item'),
423 self.search,
424 hotkeys.SEARCH_NEXT,
426 self.search_prev_action = qtutils.add_action(
427 parent,
428 N_('Find previous item'),
429 self.search_backwards,
430 hotkeys.SEARCH_PREV,
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())
439 def search(self):
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"""
449 self.hide()
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)
455 if backwards:
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
461 return flags
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):
475 if backwards:
476 position = cursor.selectionStart()
477 else:
478 position = cursor.selectionEnd()
479 else:
480 if backwards:
481 position = cursor.selectionEnd()
482 else:
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):
489 if backwards:
490 location = QtGui.QTextCursor.End
491 else:
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"""
500 if case_sensitive:
501 return a == b
502 return a.lower() == b.lower()
505 class TextEditExtension(BaseTextEditExtension):
506 def init(self):
507 widget = self.widget
508 widget.setAcceptRichText(False)
510 def set_linebreak(self, brk):
511 if brk:
512 wrapmode = QtWidgets.QTextEdit.FixedColumnWidth
513 else:
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)
523 leave = Signal()
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 = []
532 def get(self):
533 """Return the raw unicode value from Qt"""
534 return self.ext.get()
536 def value(self):
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:
575 event.ignore()
576 return
577 super().wheelEvent(event)
579 def should_expandtab(self, event):
580 return event.key() == Qt.Key_Tab and self.expandtab_enabled
582 def expandtab(self):
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)
598 if expandtab:
599 self.expandtab()
600 event.accept()
601 else:
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)
607 if expandtab:
608 event.ignore()
609 else:
610 QtWidgets.QTextEdit.keyReleaseEvent(self, event)
613 class TextEditCursorPosition:
614 def __init__(self, widget, ext):
615 self._widget = widget
616 self._ext = ext
617 widget.cursorPositionChanged.connect(self.emit)
619 def emit(self):
620 widget = self._widget
621 ext = self._ext
622 cursor = widget.textCursor()
623 position = cursor.position()
624 txt = widget.get()
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)
632 def reset(self):
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)
648 if text == hint:
649 return ''
650 return text
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
664 self._hint = hint
665 self._is_error = False
667 self.modern = modern = hasattr(widget, 'setPlaceholderText')
668 if modern:
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)
683 env = {
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 = ''
692 self._hint_style = (
694 %(name)s {
695 color: %(hint_rgb)s;
698 % env
701 self._error_style = (
703 %(name)s {
704 color: %(error_fg_rgb)s;
705 background-color: %(error_bg_rgb)s;
708 % env
711 def init(self):
712 """Defered initialization"""
713 if self.modern:
714 self.widget().setPlaceholderText(self.value())
715 else:
716 self.widget().installEventFilter(self)
717 self.enable(True)
719 def widget(self):
720 """Return the parent text widget"""
721 return self._widget
723 def active(self):
724 """Return True when hint-mode is active"""
725 return self.value() == get_stripped(self._widget)
727 def value(self):
728 """Return the current hint text"""
729 return self._hint
731 def set_error(self, is_error):
732 """Enable/disable error mode"""
733 self._is_error = is_error
734 self.refresh()
736 def set_value(self, hint):
737 """Change the hint text"""
738 if self.modern:
739 self._hint = hint
740 self._widget.setPlaceholderText(hint)
741 else:
742 # If hint-mode is currently active, re-activate it
743 active = self.active()
744 self._hint = hint
745 if active or self.active():
746 self.enable(True)
748 def enable(self, enable):
749 """Enable/disable hint-mode"""
750 if not self.modern:
751 if enable and self._hint:
752 self._widget.set_value(self._hint, block=True)
753 self._widget.cursor_position.reset()
754 else:
755 self._widget.clear()
756 self._update_palette(enable)
758 def refresh(self):
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"""
764 if self._is_error:
765 style = self._error_style
766 elif not self.modern and hint:
767 style = self._hint_style
768 else:
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"""
776 etype = event.type()
777 if etype == QtCore.QEvent.FocusIn:
778 self.focus_in()
779 elif etype == QtCore.QEvent.FocusOut:
780 self.focus_out()
781 return False
783 def focus_in(self):
784 """Disable hint-mode when focused"""
785 widget = self.widget()
786 if self.active():
787 self.enable(False)
788 widget.cursor_position.emit()
790 def focus_out(self):
791 """Re-enable hint-mode when losing focus"""
792 widget = self.widget()
793 valid, value = utils.catch_runtime_error(get, widget)
794 if not valid:
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.
798 return
799 if not value:
800 self.enable(True)
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)
811 self.hint.init()
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)
824 else:
825 self.hint.enable(True)
828 class HintedTextEdit(TextEdit):
829 """A hinted text edit"""
831 def __init__(self, context, hint, parent=None, readonly=False):
832 TextEdit.__init__(
833 self, parent=parent, get_value=get_value_hinted, readonly=readonly
835 self.context = context
836 self.hint = HintWidget(self, hint)
837 self.hint.init()
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)
847 else:
848 self.hint.enable(True)
851 def anchor_mode(select):
852 """Return the QTextCursor mode to keep/discard the cursor selection"""
853 if select:
854 mode = QtGui.QTextCursor.KeepAnchor
855 else:
856 mode = QtGui.QTextCursor.MoveAnchor
857 return mode
860 # The vim-like read-only text view
863 class VimMixin:
864 def __init__(self, widget):
865 self.widget = 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)
879 qtutils.add_action(
880 widget,
881 'PageUp',
882 widget.page_up,
883 hotkeys.SECONDARY_ACTION,
884 hotkeys.TEXT_UP,
886 qtutils.add_action(
887 widget,
888 'PageDown',
889 widget.page_down,
890 hotkeys.PRIMARY_ACTION,
891 hotkeys.TEXT_DOWN,
893 qtutils.add_action(
894 widget,
895 'SelectPageUp',
896 lambda: widget.page_up(select=True),
897 hotkeys.SELECT_BACK,
898 hotkeys.SELECT_UP,
900 qtutils.add_action(
901 widget,
902 'SelectPageDown',
903 lambda: widget.page_down(select=True),
904 hotkeys.SELECT_FORWARD,
905 hotkeys.SELECT_DOWN,
908 def add_navigation(self, name, hotkey, shift=None):
909 """Add a hotkey along with a shift-variant"""
910 widget = self.widget
911 direction = getattr(QtGui.QTextCursor, name)
912 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
913 if shift:
914 qtutils.add_action(
915 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
918 def move(self, direction, select=False, n=1):
919 widget = self.widget
920 cursor = widget.textCursor()
921 mode = anchor_mode(select)
922 for _ in range(n):
923 if cursor.movePosition(direction, mode, 1):
924 self.set_text_cursor(cursor)
926 def page(self, offset, select=False):
927 widget = self.widget
928 rect = widget.cursorRect()
929 x = rect.x()
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):
940 widget = self.widget
941 widget.page(widget.height() // 2, select=select)
943 def page_up(self, select=False):
944 widget = self.widget
945 widget.page(-widget.height() // 2, select=select)
947 def set_text_cursor(self, cursor):
948 widget = self.widget
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
959 another widget.
961 When in the middle of the first line and `Up` is pressed, the cursor
962 is moved to the beginning of the line.
965 widget = self.widget
966 if event.key() == Qt.Key_Up:
967 cursor = widget.textCursor()
968 position = cursor.position()
969 if position == 0:
970 # The cursor is at the beginning of the line.
971 # Emit a signal so that the parent can e.g. change focus.
972 widget.leave.emit()
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
994 Mixin = VimMixin
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.
1024 Base = MonoTextEdit
1025 Mixin = VimMixin
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)
1052 if tooltip:
1053 self.setToolTip(tooltip)
1054 self.hint = HintWidget(self, hint)
1055 self.hint.init()
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))
1074 label.setText(text)
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)
1089 qtutils.add_action(
1090 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
1092 widget.show()
1093 return widget
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)
1119 def refresh(self):
1120 """Refresh the numbers display"""
1121 rect = self.editor.viewport().rect()
1122 self._refresh_rect(rect, 0)
1124 def _refresh_rect(self, rect, dy):
1125 if dy:
1126 self.scroll(0, dy)
1127 else:
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)
1141 def sizeHint(self):
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:
1183 break
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)
1190 else:
1191 painter.setPen(disabled)
1193 number = '%s' % (block_number + 1)
1194 painter.drawText(
1195 rect.x(),
1196 rect.y(),
1197 self.width() - defs.large_margin,
1198 rect.height(),
1199 Qt.AlignRight | Qt.AlignVCenter,
1200 number,
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)
1209 self._display = ''
1210 self._template = ''
1211 self._text = ''
1212 self._elide = False
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)
1223 def elide(self):
1224 self._elide = True
1226 def set_text(self, text):
1227 self.set_template(text, text)
1229 def set_template(self, text, template):
1230 self._display = text
1231 self._text = 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
1238 if not self._elide:
1239 return
1240 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1241 if text != self._template:
1242 self._display = text
1244 # Qt overrides
1245 def setFont(self, font):
1246 self._metrics = QtGui.QFontMetrics(font)
1247 QtWidgets.QLabel.setFont(self, font)
1249 def resizeEvent(self, event):
1250 if self._elide:
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)