i18n: update translations
[git-cola.git] / cola / widgets / text.py
blob6ef2c99ad2e50374514fd8f2674a88fb416dcd23
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 """Deferred 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.
990 Base = HintedPlainTextEdit
991 Mixin = VimMixin
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.
1021 Base = MonoTextEdit
1022 Mixin = VimMixin
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)
1049 if tooltip:
1050 self.setToolTip(tooltip)
1051 self.hint = HintWidget(self, hint)
1052 self.hint.init()
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))
1071 label.setText(text)
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)
1086 qtutils.add_action(
1087 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
1089 widget.show()
1090 return widget
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)
1116 def refresh(self):
1117 """Refresh the numbers display"""
1118 rect = self.editor.viewport().rect()
1119 self._refresh_rect(rect, 0)
1121 def _refresh_rect(self, rect, dy):
1122 if dy:
1123 self.scroll(0, dy)
1124 else:
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)
1138 def sizeHint(self):
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:
1181 break
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)
1188 else:
1189 painter.setPen(disabled)
1191 number = '%s' % (block_number + 1)
1192 painter.drawText(
1193 rect.x(),
1194 rect.y(),
1195 self.width() - defs.large_margin,
1196 rect.height(),
1197 Qt.AlignRight | Qt.AlignVCenter,
1198 number,
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)
1208 self._display = ''
1209 self._template = ''
1210 self._text = ''
1211 self._elide = False
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)
1222 def elide(self):
1223 self._elide = True
1225 def set_text(self, text):
1226 self.set_template(text, text)
1228 def set_template(self, text, template):
1229 self._display = text
1230 self._text = 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
1237 if not self._elide:
1238 return
1239 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1240 if text != self._template:
1241 self._display = text
1243 # Qt overrides
1244 def setFont(self, font):
1245 self._metrics = QtGui.QFontMetrics(font)
1246 QtWidgets.QLabel.setFont(self, font)
1248 def resizeEvent(self, event):
1249 if self._elide:
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)