qtutils: add text_width() and text_size() helper functions
[git-cola.git] / cola / widgets / text.py
blob28fb21268f3bfe76cc9418cad768077ff73af064
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 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':
185 offset -= 1
186 data = contents[offset:]
187 if '\n' in data:
188 line, _ = data.split('\n', 1)
189 else:
190 line = data
191 return line
193 def cursor(self):
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()
202 return 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.
214 widget = self.widget
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()
223 if links:
224 menu.addSeparator()
225 for url in 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()
235 if selection:
236 line = selection
237 else:
238 line = self.selected_line()
239 if not line:
240 return []
241 return [
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)
250 return 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
260 def init(self):
261 """Called during init for class-specific settings"""
262 return
264 # pylint: disable=unused-argument
265 def set_textwidth(self, width):
266 """Set the text width"""
267 return
269 # pylint: disable=unused-argument
270 def set_linebreak(self, brk):
271 """Enable word wrapping"""
272 return
275 class PlainTextEditExtension(BaseTextEditExtension):
276 def set_linebreak(self, brk):
277 if brk:
278 wrapmode = QtWidgets.QPlainTextEdit.WidgetWidth
279 else:
280 wrapmode = QtWidgets.QPlainTextEdit.NoWrap
281 self.widget.setLineWrapMode(wrapmode)
284 class PlainTextEdit(QtWidgets.QPlainTextEdit):
285 cursor_changed = Signal(int, int)
286 leave = Signal()
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 = []
296 def get(self):
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)
304 def value(self):
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)
328 if enabled:
329 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
330 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
331 else:
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):
361 event.ignore()
362 return
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(
401 defs.margin,
402 defs.button_spacing,
403 self.text,
404 self.prev_button,
405 self.next_button,
406 self.match_case_checkbox,
407 self.whole_words_checkbox,
408 qtutils.STRETCH,
409 self.close_button,
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(
419 parent,
420 N_('Find next item'),
421 self.search,
422 hotkeys.SEARCH_NEXT,
424 self.search_prev_action = qtutils.add_action(
425 parent,
426 N_('Find previous item'),
427 self.search_backwards,
428 hotkeys.SEARCH_PREV,
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())
437 def search(self):
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"""
447 self.hide()
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)
453 if backwards:
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
459 return flags
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):
473 if backwards:
474 position = cursor.selectionStart()
475 else:
476 position = cursor.selectionEnd()
477 else:
478 if backwards:
479 position = cursor.selectionEnd()
480 else:
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):
487 if backwards:
488 location = QtGui.QTextCursor.End
489 else:
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"""
498 if case_sensitive:
499 return a == b
500 return a.lower() == b.lower()
503 class TextEditExtension(BaseTextEditExtension):
504 def init(self):
505 widget = self.widget
506 widget.setAcceptRichText(False)
508 def set_linebreak(self, brk):
509 if brk:
510 wrapmode = QtWidgets.QTextEdit.FixedColumnWidth
511 else:
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)
521 leave = Signal()
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 = []
530 def get(self):
531 """Return the raw unicode value from Qt"""
532 return self.ext.get()
534 def value(self):
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:
573 event.ignore()
574 return
575 super().wheelEvent(event)
577 def should_expandtab(self, event):
578 return event.key() == Qt.Key_Tab and self.expandtab_enabled
580 def expandtab(self):
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)
596 if expandtab:
597 self.expandtab()
598 event.accept()
599 else:
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)
605 if expandtab:
606 event.ignore()
607 else:
608 QtWidgets.QTextEdit.keyReleaseEvent(self, event)
611 class TextEditCursorPosition:
612 def __init__(self, widget, ext):
613 self._widget = widget
614 self._ext = ext
615 widget.cursorPositionChanged.connect(self.emit)
617 def emit(self):
618 widget = self._widget
619 ext = self._ext
620 cursor = widget.textCursor()
621 position = cursor.position()
622 txt = widget.get()
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)
630 def reset(self):
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)
646 if text == hint:
647 return ''
648 return text
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
662 self._hint = hint
663 self._is_error = False
665 self.modern = modern = hasattr(widget, 'setPlaceholderText')
666 if modern:
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)
681 env = {
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 = ''
690 self._hint_style = (
692 %(name)s {
693 color: %(hint_rgb)s;
696 % env
699 self._error_style = (
701 %(name)s {
702 color: %(error_fg_rgb)s;
703 background-color: %(error_bg_rgb)s;
706 % env
709 def init(self):
710 """Defered initialization"""
711 if self.modern:
712 self.widget().setPlaceholderText(self.value())
713 else:
714 self.widget().installEventFilter(self)
715 self.enable(True)
717 def widget(self):
718 """Return the parent text widget"""
719 return self._widget
721 def active(self):
722 """Return True when hint-mode is active"""
723 return self.value() == get_stripped(self._widget)
725 def value(self):
726 """Return the current hint text"""
727 return self._hint
729 def set_error(self, is_error):
730 """Enable/disable error mode"""
731 self._is_error = is_error
732 self.refresh()
734 def set_value(self, hint):
735 """Change the hint text"""
736 if self.modern:
737 self._hint = hint
738 self._widget.setPlaceholderText(hint)
739 else:
740 # If hint-mode is currently active, re-activate it
741 active = self.active()
742 self._hint = hint
743 if active or self.active():
744 self.enable(True)
746 def enable(self, enable):
747 """Enable/disable hint-mode"""
748 if not self.modern:
749 if enable and self._hint:
750 self._widget.set_value(self._hint, block=True)
751 self._widget.cursor_position.reset()
752 else:
753 self._widget.clear()
754 self._update_palette(enable)
756 def refresh(self):
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"""
762 if self._is_error:
763 style = self._error_style
764 elif not self.modern and hint:
765 style = self._hint_style
766 else:
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"""
774 etype = event.type()
775 if etype == QtCore.QEvent.FocusIn:
776 self.focus_in()
777 elif etype == QtCore.QEvent.FocusOut:
778 self.focus_out()
779 return False
781 def focus_in(self):
782 """Disable hint-mode when focused"""
783 widget = self.widget()
784 if self.active():
785 self.enable(False)
786 widget.cursor_position.emit()
788 def focus_out(self):
789 """Re-enable hint-mode when losing focus"""
790 widget = self.widget()
791 valid, value = utils.catch_runtime_error(get, widget)
792 if not valid:
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.
796 return
797 if not value:
798 self.enable(True)
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)
809 self.hint.init()
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)
822 else:
823 self.hint.enable(True)
826 class HintedTextEdit(TextEdit):
827 """A hinted text edit"""
829 def __init__(self, context, hint, parent=None, readonly=False):
830 TextEdit.__init__(
831 self, parent=parent, get_value=get_value_hinted, readonly=readonly
833 self.context = context
834 self.hint = HintWidget(self, hint)
835 self.hint.init()
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)
845 else:
846 self.hint.enable(True)
849 def anchor_mode(select):
850 """Return the QTextCursor mode to keep/discard the cursor selection"""
851 if select:
852 mode = QtGui.QTextCursor.KeepAnchor
853 else:
854 mode = QtGui.QTextCursor.MoveAnchor
855 return mode
858 # The vim-like read-only text view
861 class VimMixin:
862 def __init__(self, widget):
863 self.widget = 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)
877 qtutils.add_action(
878 widget,
879 'PageUp',
880 widget.page_up,
881 hotkeys.SECONDARY_ACTION,
882 hotkeys.TEXT_UP,
884 qtutils.add_action(
885 widget,
886 'PageDown',
887 widget.page_down,
888 hotkeys.PRIMARY_ACTION,
889 hotkeys.TEXT_DOWN,
891 qtutils.add_action(
892 widget,
893 'SelectPageUp',
894 lambda: widget.page_up(select=True),
895 hotkeys.SELECT_BACK,
896 hotkeys.SELECT_UP,
898 qtutils.add_action(
899 widget,
900 'SelectPageDown',
901 lambda: widget.page_down(select=True),
902 hotkeys.SELECT_FORWARD,
903 hotkeys.SELECT_DOWN,
906 def add_navigation(self, name, hotkey, shift=None):
907 """Add a hotkey along with a shift-variant"""
908 widget = self.widget
909 direction = getattr(QtGui.QTextCursor, name)
910 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
911 if shift:
912 qtutils.add_action(
913 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
916 def move(self, direction, select=False, n=1):
917 widget = self.widget
918 cursor = widget.textCursor()
919 mode = anchor_mode(select)
920 for _ in range(n):
921 if cursor.movePosition(direction, mode, 1):
922 self.set_text_cursor(cursor)
924 def page(self, offset, select=False):
925 widget = self.widget
926 rect = widget.cursorRect()
927 x = rect.x()
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):
938 widget = self.widget
939 widget.page(widget.height() // 2, select=select)
941 def page_up(self, select=False):
942 widget = self.widget
943 widget.page(-widget.height() // 2, select=select)
945 def set_text_cursor(self, cursor):
946 widget = self.widget
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
957 another widget.
959 When in the middle of the first line and `Up` is pressed, the cursor
960 is moved to the beginning of the line.
963 widget = self.widget
964 if event.key() == Qt.Key_Up:
965 cursor = widget.textCursor()
966 position = cursor.position()
967 if position == 0:
968 # The cursor is at the beginning of the line.
969 # Emit a signal so that the parent can e.g. change focus.
970 widget.leave.emit()
971 elif get(widget)[:position].count('\n') == 0:
972 # The cursor is in the middle of the first line of text.
973 # We can't go up ~ jump to the beginning of the line.
974 # Select the text if shift is pressed.
975 select = event.modifiers() & Qt.ShiftModifier
976 mode = anchor_mode(select)
977 cursor.movePosition(QtGui.QTextCursor.StartOfLine, mode)
978 widget.setTextCursor(cursor)
980 return self.Base.keyPressEvent(widget, event)
983 # pylint: disable=too-many-ancestors
984 class VimHintedPlainTextEdit(HintedPlainTextEdit):
985 """HintedPlainTextEdit with vim hotkeys
987 This can only be used in read-only mode.
991 Base = HintedPlainTextEdit
992 Mixin = VimMixin
994 def __init__(self, context, hint, parent=None):
995 HintedPlainTextEdit.__init__(self, context, hint, parent=parent, readonly=True)
996 self._mixin = self.Mixin(self)
998 def move(self, direction, select=False, n=1):
999 return self._mixin.page(direction, select=select, n=n)
1001 def page(self, offset, select=False):
1002 return self._mixin.page(offset, select=select)
1004 def page_up(self, select=False):
1005 return self._mixin.page_up(select=select)
1007 def page_down(self, select=False):
1008 return self._mixin.page_down(select=select)
1010 def keyPressEvent(self, event):
1011 return self._mixin.keyPressEvent(event)
1014 # pylint: disable=too-many-ancestors
1015 class VimTextEdit(MonoTextEdit):
1016 """Text viewer with vim-like hotkeys
1018 This can only be used in read-only mode.
1022 Base = MonoTextEdit
1023 Mixin = VimMixin
1025 def __init__(self, context, parent=None, readonly=True):
1026 MonoTextEdit.__init__(self, context, parent=None, readonly=readonly)
1027 self._mixin = self.Mixin(self)
1029 def move(self, direction, select=False, n=1):
1030 return self._mixin.page(direction, select=select, n=n)
1032 def page(self, offset, select=False):
1033 return self._mixin.page(offset, select=select)
1035 def page_up(self, select=False):
1036 return self._mixin.page_up(select=select)
1038 def page_down(self, select=False):
1039 return self._mixin.page_down(select=select)
1041 def keyPressEvent(self, event):
1042 return self._mixin.keyPressEvent(event)
1045 class HintedDefaultLineEdit(LineEdit):
1046 """A line edit with hint text"""
1048 def __init__(self, hint, tooltip=None, parent=None):
1049 LineEdit.__init__(self, parent=parent, get_value=get_value_hinted)
1050 if tooltip:
1051 self.setToolTip(tooltip)
1052 self.hint = HintWidget(self, hint)
1053 self.hint.init()
1054 # pylint: disable=no-member
1055 self.textChanged.connect(lambda text: self.hint.refresh())
1058 class HintedLineEdit(HintedDefaultLineEdit):
1059 """A monospace line edit with hint text"""
1061 def __init__(self, context, hint, tooltip=None, parent=None):
1062 super().__init__(hint, tooltip=tooltip, parent=parent)
1063 self.setFont(qtutils.diff_font(context))
1066 def text_dialog(context, text, title):
1067 """Show a wall of text in a dialog"""
1068 parent = qtutils.active_window()
1070 label = QtWidgets.QLabel(parent)
1071 label.setFont(qtutils.diff_font(context))
1072 label.setText(text)
1073 label.setMargin(defs.large_margin)
1074 text_flags = Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
1075 label.setTextInteractionFlags(text_flags)
1077 widget = QtWidgets.QDialog(parent)
1078 widget.setWindowModality(Qt.WindowModal)
1079 widget.setWindowTitle(title)
1081 scroll = QtWidgets.QScrollArea()
1082 scroll.setWidget(label)
1084 layout = qtutils.hbox(defs.margin, defs.spacing, scroll)
1085 widget.setLayout(layout)
1087 qtutils.add_action(
1088 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
1090 widget.show()
1091 return widget
1094 class VimTextBrowser(VimTextEdit):
1095 """Text viewer with line number annotations"""
1097 def __init__(self, context, parent=None, readonly=True):
1098 VimTextEdit.__init__(self, context, parent=parent, readonly=readonly)
1099 self.numbers = LineNumbers(self)
1101 def resizeEvent(self, event):
1102 super().resizeEvent(event)
1103 self.numbers.refresh_size()
1106 class TextDecorator(QtWidgets.QWidget):
1107 """Common functionality for providing line numbers in text widgets"""
1109 def __init__(self, parent):
1110 QtWidgets.QWidget.__init__(self, parent)
1111 self.editor = parent
1113 parent.blockCountChanged.connect(lambda x: self._refresh_viewport())
1114 parent.cursorPositionChanged.connect(self.refresh)
1115 parent.updateRequest.connect(self._refresh_rect)
1117 def refresh(self):
1118 """Refresh the numbers display"""
1119 rect = self.editor.viewport().rect()
1120 self._refresh_rect(rect, 0)
1122 def _refresh_rect(self, rect, dy):
1123 if dy:
1124 self.scroll(0, dy)
1125 else:
1126 self.update(0, rect.y(), self.width(), rect.height())
1128 if rect.contains(self.editor.viewport().rect()):
1129 self._refresh_viewport()
1131 def _refresh_viewport(self):
1132 self.editor.setViewportMargins(self.width_hint(), 0, 0, 0)
1134 def refresh_size(self):
1135 rect = self.editor.contentsRect()
1136 geom = QtCore.QRect(rect.left(), rect.top(), self.width_hint(), rect.height())
1137 self.setGeometry(geom)
1139 def sizeHint(self):
1140 return QtCore.QSize(self.width_hint(), 0)
1143 class LineNumbers(TextDecorator):
1144 """Provide line numbers for QPlainTextEdit widgets"""
1146 def __init__(self, parent):
1147 TextDecorator.__init__(self, parent)
1148 self.highlight_line = -1
1150 def width_hint(self):
1151 document = self.editor.document()
1152 digits = int(math.log(max(1, document.blockCount()), 10)) + 2
1153 text_width = qtutils.text_width(self.font(), '0')
1154 return defs.large_margin + (text_width * digits)
1156 def set_highlighted(self, line_number):
1157 """Set the line to highlight"""
1158 self.highlight_line = line_number
1160 def paintEvent(self, event):
1161 """Paint the line number"""
1162 QPalette = QtGui.QPalette
1163 painter = QtGui.QPainter(self)
1164 editor = self.editor
1165 palette = editor.palette()
1167 painter.fillRect(event.rect(), palette.color(QPalette.Base))
1169 content_offset = editor.contentOffset()
1170 block = editor.firstVisibleBlock()
1171 width = self.width()
1172 event_rect_bottom = event.rect().bottom()
1174 highlight = palette.color(QPalette.Highlight)
1175 highlighted_text = palette.color(QPalette.HighlightedText)
1176 disabled = palette.color(QPalette.Disabled, QPalette.Text)
1178 while block.isValid():
1179 block_geom = editor.blockBoundingGeometry(block)
1180 block_top = block_geom.translated(content_offset).top()
1181 if not block.isVisible() or block_top >= event_rect_bottom:
1182 break
1184 rect = block_geom.translated(content_offset).toRect()
1185 block_number = block.blockNumber()
1186 if block_number == self.highlight_line:
1187 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
1188 painter.setPen(highlighted_text)
1189 else:
1190 painter.setPen(disabled)
1192 number = '%s' % (block_number + 1)
1193 painter.drawText(
1194 rect.x(),
1195 rect.y(),
1196 self.width() - defs.large_margin,
1197 rect.height(),
1198 Qt.AlignRight | Qt.AlignVCenter,
1199 number,
1201 block = block.next()
1204 class TextLabel(QtWidgets.QLabel):
1205 """A text label that elides its display"""
1207 def __init__(self, parent=None, open_external_links=True):
1208 QtWidgets.QLabel.__init__(self, parent)
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"""
1260 def __init__(self, parent=None):
1261 super().__init__(parent=parent, open_external_links=False)
1262 self.setTextFormat(Qt.PlainText)
1265 class RichTextLabel(TextLabel):
1266 """A richtext label that elides its display"""
1268 def __init__(self, parent=None):
1269 super().__init__(parent=parent, open_external_links=True)
1270 self.setTextFormat(Qt.RichText)