diff: move the search logic into the TextSearchWidget
[git-cola.git] / cola / widgets / text.py
blob3aa4703e4a22c578c449532c9af99115cb9ce326
1 """Text widgets"""
2 # pylint: disable=unexpected-keyword-arg
3 from __future__ import absolute_import, division, print_function, unicode_literals
4 from functools import partial
5 import math
7 from qtpy import QtCore
8 from qtpy import QtGui
9 from qtpy import QtWidgets
10 from qtpy.QtCore import Qt
11 from qtpy.QtCore import Signal
13 from ..models import prefs
14 from ..qtutils import get
15 from .. import hotkeys
16 from .. import icons
17 from .. import qtutils
18 from .. import utils
19 from ..i18n import N_
20 from . import defs
23 def get_stripped(widget):
24 return widget.get().strip()
27 class LineEdit(QtWidgets.QLineEdit):
29 cursor_changed = Signal(int, int)
30 esc_pressed = Signal()
32 def __init__(self, parent=None, row=1, get_value=None, clear_button=False):
33 QtWidgets.QLineEdit.__init__(self, parent)
34 self._row = row
35 if get_value is None:
36 get_value = get_stripped
37 self._get_value = get_value
38 self.cursor_position = LineEditCursorPosition(self, row)
39 self.menu_actions = []
41 if clear_button and hasattr(self, 'setClearButtonEnabled'):
42 self.setClearButtonEnabled(True)
44 def get(self):
45 """Return the raw unicode value from Qt"""
46 return self.text()
48 def value(self):
49 """Return the processed value, e.g. stripped"""
50 return self._get_value(self)
52 def set_value(self, value, block=False):
53 """Update the widget to the specified value"""
54 if block:
55 with qtutils.BlockSignals(self):
56 self._set_value(value)
57 else:
58 self._set_value(value)
60 def _set_value(self, value):
61 """Implementation helper to update the widget to the specified value"""
62 pos = self.cursorPosition()
63 self.setText(value)
64 self.setCursorPosition(pos)
66 def keyPressEvent(self, event):
67 key = event.key()
68 if key == Qt.Key_Escape:
69 self.esc_pressed.emit()
70 super(LineEdit, self).keyPressEvent(event)
73 class LineEditCursorPosition(object):
74 """Translate cursorPositionChanged(int,int) into cursorPosition(int,int)"""
76 def __init__(self, widget, row):
77 self._widget = widget
78 self._row = row
79 # Translate cursorPositionChanged into cursor_changed(int, int)
80 widget.cursorPositionChanged.connect(lambda old, new: self.emit())
82 def emit(self):
83 widget = self._widget
84 row = self._row
85 col = widget.cursorPosition()
86 widget.cursor_changed.emit(row, col)
88 def reset(self):
89 self._widget.setCursorPosition(0)
92 class BaseTextEditExtension(QtCore.QObject):
93 def __init__(self, widget, get_value, readonly):
94 QtCore.QObject.__init__(self, widget)
95 self.widget = widget
96 self.cursor_position = TextEditCursorPosition(widget, self)
97 if get_value is None:
98 get_value = get_stripped
99 self._get_value = get_value
100 self._tabwidth = 8
101 self._readonly = readonly
102 self._init_flags()
103 self.init()
105 def _init_flags(self):
106 widget = self.widget
107 widget.setMinimumSize(QtCore.QSize(10, 10))
108 widget.setWordWrapMode(QtGui.QTextOption.WordWrap)
109 widget.setLineWrapMode(widget.NoWrap)
110 if self._readonly:
111 widget.setReadOnly(True)
112 widget.setAcceptDrops(False)
113 widget.setTabChangesFocus(True)
114 widget.setUndoRedoEnabled(False)
115 widget.setTextInteractionFlags(
116 Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
119 def get(self):
120 """Return the raw unicode value from Qt"""
121 return self.widget.toPlainText()
123 def value(self):
124 """Return a safe value, e.g. a stripped value"""
125 return self._get_value(self.widget)
127 def set_value(self, value, block=False):
128 """Update the widget to the specified value"""
129 if block:
130 with qtutils.BlockSignals(self):
131 self._set_value(value)
132 else:
133 self._set_value(value)
135 def _set_value(self, value):
136 """Implementation helper to update the widget to the specified value"""
137 # Save cursor position
138 offset, selection_text = self.offset_and_selection()
139 old_value = get(self.widget)
141 # Update text
142 self.widget.setPlainText(value)
144 # Restore cursor
145 if selection_text and selection_text in value:
146 # If the old selection exists in the new text then re-select it.
147 idx = value.index(selection_text)
148 cursor = self.widget.textCursor()
149 cursor.setPosition(idx)
150 cursor.setPosition(idx + len(selection_text), QtGui.QTextCursor.KeepAnchor)
151 self.widget.setTextCursor(cursor)
153 elif value == old_value:
154 # Otherwise, if the text is identical and there is no selection
155 # then restore the cursor position.
156 cursor = self.widget.textCursor()
157 cursor.setPosition(offset)
158 self.widget.setTextCursor(cursor)
159 else:
160 # If none of the above applied then restore the cursor position.
161 position = max(0, min(offset, len(value) - 1))
162 cursor = self.widget.textCursor()
163 cursor.setPosition(position)
164 self.widget.setTextCursor(cursor)
165 cursor = self.widget.textCursor()
166 cursor.movePosition(QtGui.QTextCursor.StartOfLine)
167 self.widget.setTextCursor(cursor)
169 def set_cursor_position(self, new_position):
170 cursor = self.widget.textCursor()
171 cursor.setPosition(new_position)
172 self.widget.setTextCursor(cursor)
174 def tabwidth(self):
175 return self._tabwidth
177 def set_tabwidth(self, width):
178 self._tabwidth = width
179 font = self.widget.font()
180 metrics = QtGui.QFontMetrics(font)
181 pixels = metrics.width('M' * width)
182 self.widget.setTabStopWidth(pixels)
184 def selected_line(self):
185 contents = self.value()
186 cursor = self.widget.textCursor()
187 offset = min(cursor.position(), len(contents) - 1)
188 while offset >= 1 and contents[offset - 1] and contents[offset - 1] != '\n':
189 offset -= 1
190 data = contents[offset:]
191 if '\n' in data:
192 line, _ = data.split('\n', 1)
193 else:
194 line = data
195 return line
197 def cursor(self):
198 return self.widget.textCursor()
200 def has_selection(self):
201 return self.cursor().hasSelection()
203 def selected_text(self):
204 """Return the selected text"""
205 _, selection = self.offset_and_selection()
206 return selection
208 def offset_and_selection(self):
209 """Return the cursor offset and selected text"""
210 cursor = self.cursor()
211 offset = cursor.selectionStart()
212 selection_text = cursor.selection().toPlainText()
213 return offset, selection_text
215 def mouse_press_event(self, event):
216 # Move the text cursor so that the right-click events operate
217 # on the current position, not the last left-clicked position.
218 widget = self.widget
219 if event.button() == Qt.RightButton:
220 if not widget.textCursor().hasSelection():
221 cursor = widget.cursorForPosition(event.pos())
222 widget.setTextCursor(cursor)
224 def add_links_to_menu(self, menu):
225 """Add actions for opening URLs to a custom menu"""
226 links = self._get_links()
227 if links:
228 menu.addSeparator()
229 for url in links:
230 action = menu.addAction(N_('Open "%s"') % url)
231 action.setIcon(icons.external())
232 qtutils.connect_action(
233 action, partial(QtGui.QDesktopServices.openUrl, QtCore.QUrl(url))
236 def _get_links(self):
237 """Return http links on the current line"""
238 _, selection = self.offset_and_selection()
239 if selection:
240 line = selection
241 else:
242 line = self.selected_line()
243 if not line:
244 return []
245 return [
246 word for word in line.split() if word.startswith(('http://', 'https://'))
249 def create_context_menu(self, event_pos):
250 """Create a context menu for a widget"""
251 menu = self.widget.createStandardContextMenu(event_pos)
252 qtutils.add_menu_actions(menu, self.widget.menu_actions)
253 self.add_links_to_menu(menu)
254 return menu
256 def context_menu_event(self, event):
257 """Default context menu event"""
258 event_pos = event.pos()
259 menu = self.widget.create_context_menu(event_pos)
260 menu.exec_(self.widget.mapToGlobal(event_pos))
262 # For extension by sub-classes
264 def init(self):
265 """Called during init for class-specific settings"""
266 return
268 # pylint: disable=unused-argument
269 def set_textwidth(self, width):
270 """Set the text width"""
271 return
273 # pylint: disable=unused-argument
274 def set_linebreak(self, brk):
275 """Enable word wrapping"""
276 return
279 class PlainTextEditExtension(BaseTextEditExtension):
280 def set_linebreak(self, brk):
281 if brk:
282 wrapmode = QtWidgets.QPlainTextEdit.WidgetWidth
283 else:
284 wrapmode = QtWidgets.QPlainTextEdit.NoWrap
285 self.widget.setLineWrapMode(wrapmode)
288 class PlainTextEdit(QtWidgets.QPlainTextEdit):
290 cursor_changed = Signal(int, int)
291 leave = Signal()
293 def __init__(self, parent=None, get_value=None, readonly=False, options=None):
294 QtWidgets.QPlainTextEdit.__init__(self, parent)
295 self.ext = PlainTextEditExtension(self, get_value, readonly)
296 self.cursor_position = self.ext.cursor_position
297 self.mouse_zoom = True
298 self.options = options
299 self.menu_actions = []
301 def get(self):
302 """Return the raw unicode value from Qt"""
303 return self.ext.get()
305 # For compatibility with QTextEdit
306 def setText(self, value):
307 self.set_value(value)
309 def value(self):
310 """Return a safe value, e.g. a stripped value"""
311 return self.ext.value()
313 def offset_and_selection(self):
314 """Return the cursor offset and selected text"""
315 return self.ext.offset_and_selection()
317 def set_value(self, value, block=False):
318 self.ext.set_value(value, block=block)
320 def set_mouse_zoom(self, value):
321 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
322 self.mouse_zoom = value
324 def set_options(self, options):
325 """Register an Options widget"""
326 self.options = options
328 def set_word_wrapping(self, enabled, update=False):
329 """Enable/disable word wrapping"""
330 if update and self.options is not None:
331 with qtutils.BlockSignals(self.options.enable_word_wrapping):
332 self.options.enable_word_wrapping.setChecked(enabled)
333 if enabled:
334 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
335 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
336 else:
337 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
338 self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
340 def has_selection(self):
341 return self.ext.has_selection()
343 def selected_line(self):
344 return self.ext.selected_line()
346 def selected_text(self):
347 """Return the selected text"""
348 return self.ext.selected_text()
350 def set_tabwidth(self, width):
351 self.ext.set_tabwidth(width)
353 def set_textwidth(self, width):
354 self.ext.set_textwidth(width)
356 def set_linebreak(self, brk):
357 self.ext.set_linebreak(brk)
359 def mousePressEvent(self, event):
360 self.ext.mouse_press_event(event)
361 super(PlainTextEdit, self).mousePressEvent(event)
363 def wheelEvent(self, event):
364 """Disable control+wheelscroll text resizing"""
365 if not self.mouse_zoom and (event.modifiers() & Qt.ControlModifier):
366 event.ignore()
367 return
368 super(PlainTextEdit, self).wheelEvent(event)
370 def create_context_menu(self, event_pos):
371 """Create a custom context menu"""
372 return self.ext.create_context_menu(event_pos)
374 def contextMenuEvent(self, event):
375 """Custom contextMenuEvent() for building our custom context menus"""
376 self.ext.context_menu_event(event)
379 class TextSearchWidget(QtWidgets.QWidget):
380 """The search dialog that displays over a text edit field"""
382 def __init__(self, widget, parent):
383 super(TextSearchWidget, self).__init__(parent)
384 self.setAutoFillBackground(True)
385 self._widget = widget
386 self._parent = parent
388 self.text = HintedDefaultLineEdit(N_('Find in diff'), parent=self)
390 self.prev_button = qtutils.create_action_button(
391 tooltip=N_('Find the previous occurrence of the phrase'), icon=icons.up()
394 self.next_button = qtutils.create_action_button(
395 tooltip=N_('Find the next occurrence of the phrase'), icon=icons.down()
398 self.match_case_checkbox = qtutils.checkbox(N_('Match Case'))
399 self.whole_words_checkbox = qtutils.checkbox(N_('Whole Words'))
401 self.close_button = qtutils.create_action_button(
402 tooltip=N_('Close the find bar'), icon=icons.close()
405 layout = qtutils.hbox(
406 defs.margin,
407 defs.button_spacing,
408 self.text,
409 self.prev_button,
410 self.next_button,
411 self.match_case_checkbox,
412 self.whole_words_checkbox,
413 qtutils.STRETCH,
414 self.close_button,
416 self.setLayout(layout)
417 self.setFocusProxy(self.text)
419 self.text.esc_pressed.connect(self.hide_search)
420 self.text.returnPressed.connect(self.search)
421 self.text.textChanged.connect(self.search)
423 qtutils.connect_button(self.next_button, self.search)
424 qtutils.connect_button(self.prev_button, self.search_backwards)
425 qtutils.connect_button(self.close_button, self.hide_search)
426 qtutils.connect_checkbox(self.match_case_checkbox, lambda _: self.search())
427 qtutils.connect_checkbox(self.whole_words_checkbox, lambda _: self.search())
429 def search(self):
430 """Emit a signal with the current search text"""
431 self.search_text(backwards=False)
433 def search_backwards(self):
434 """Emit a signal with the current search text for a backwards search"""
435 self.search_text(backwards=True)
437 def hide_search(self):
438 """Hide the search window"""
439 self.hide()
440 self._parent.setFocus(True)
442 def find_flags(self, backwards):
443 """Return QTextDocument.FindFlags for the current search options"""
444 flags = QtGui.QTextDocument.FindFlags()
445 if backwards:
446 flags = flags | QtGui.QTextDocument.FindBackward
447 if self.match_case_checkbox.isChecked():
448 flags = flags | QtGui.QTextDocument.FindCaseSensitively
449 if self.whole_words_checkbox.isChecked():
450 flags = flags | QtGui.QTextDocument.FindWholeWords
451 return flags
453 def is_case_sensitive(self):
454 """Are we searching using a case-insensitive search?"""
455 return self.match_case_checkbox.isChecked()
457 def search_text(self, backwards=False):
458 """Search the diff text for the given text"""
459 text = self.text.get()
460 cursor = self._widget.textCursor()
461 if cursor.hasSelection():
462 selected_text = cursor.selectedText()
463 case_sensitive = self.is_case_sensitive()
464 if text_matches(case_sensitive, selected_text, text):
465 if backwards:
466 position = cursor.selectionStart()
467 else:
468 position = cursor.selectionEnd()
469 else:
470 if backwards:
471 position = cursor.selectionEnd()
472 else:
473 position = cursor.selectionStart()
474 cursor.setPosition(position)
475 self._widget.setTextCursor(cursor)
477 flags = self.find_flags(backwards)
478 if not self._widget.find(text, flags):
479 if backwards:
480 location = QtGui.QTextCursor.End
481 else:
482 location = QtGui.QTextCursor.Start
483 cursor.movePosition(location, QtGui.QTextCursor.MoveAnchor)
484 self._widget.setTextCursor(cursor)
485 self._widget.find(text, flags)
488 def text_matches(case_sensitive, a, b):
489 """Compare text with case sensitivity taken into account"""
490 if case_sensitive:
491 return a == b
492 return a.lower() == b.lower()
495 class TextEditExtension(BaseTextEditExtension):
496 def init(self):
497 widget = self.widget
498 widget.setAcceptRichText(False)
500 def set_linebreak(self, brk):
501 if brk:
502 wrapmode = QtWidgets.QTextEdit.FixedColumnWidth
503 else:
504 wrapmode = QtWidgets.QTextEdit.NoWrap
505 self.widget.setLineWrapMode(wrapmode)
507 def set_textwidth(self, width):
508 self.widget.setLineWrapColumnOrWidth(width)
511 class TextEdit(QtWidgets.QTextEdit):
513 cursor_changed = Signal(int, int)
514 leave = Signal()
516 def __init__(self, parent=None, get_value=None, readonly=False):
517 QtWidgets.QTextEdit.__init__(self, parent)
518 self.ext = TextEditExtension(self, get_value, readonly)
519 self.cursor_position = self.ext.cursor_position
520 self.expandtab_enabled = False
521 self.menu_actions = []
523 def get(self):
524 """Return the raw unicode value from Qt"""
525 return self.ext.get()
527 def value(self):
528 """Return a safe value, e.g. a stripped value"""
529 return self.ext.value()
531 def set_cursor_position(self, position):
532 """Set the cursor position"""
533 cursor = self.textCursor()
534 cursor.setPosition(position)
535 self.setTextCursor(cursor)
537 def set_value(self, value, block=False):
538 self.ext.set_value(value, block=block)
540 def selected_line(self):
541 return self.ext.selected_line()
543 def selected_text(self):
544 """Return the selected text"""
545 return self.ext.selected_text()
547 def set_tabwidth(self, width):
548 self.ext.set_tabwidth(width)
550 def set_textwidth(self, width):
551 self.ext.set_textwidth(width)
553 def set_linebreak(self, brk):
554 self.ext.set_linebreak(brk)
556 def set_expandtab(self, value):
557 self.expandtab_enabled = value
559 def mousePressEvent(self, event):
560 self.ext.mouse_press_event(event)
561 super(TextEdit, self).mousePressEvent(event)
563 def wheelEvent(self, event):
564 """Disable control+wheelscroll text resizing"""
565 if event.modifiers() & Qt.ControlModifier:
566 event.ignore()
567 return
568 super(TextEdit, self).wheelEvent(event)
570 def should_expandtab(self, event):
571 return event.key() == Qt.Key_Tab and self.expandtab_enabled
573 def expandtab(self):
574 tabwidth = max(self.ext.tabwidth(), 1)
575 cursor = self.textCursor()
576 cursor.insertText(' ' * tabwidth)
578 def create_context_menu(self, event_pos):
579 """Create a custom context menu"""
580 return self.ext.create_context_menu(event_pos)
582 def contextMenuEvent(self, event):
583 """Custom contextMenuEvent() for building our custom context menus"""
584 self.ext.context_menu_event(event)
586 def keyPressEvent(self, event):
587 """Override keyPressEvent to handle tab expansion"""
588 expandtab = self.should_expandtab(event)
589 if expandtab:
590 self.expandtab()
591 event.accept()
592 else:
593 QtWidgets.QTextEdit.keyPressEvent(self, event)
595 def keyReleaseEvent(self, event):
596 """Override keyReleaseEvent to special-case tab expansion"""
597 expandtab = self.should_expandtab(event)
598 if expandtab:
599 event.ignore()
600 else:
601 QtWidgets.QTextEdit.keyReleaseEvent(self, event)
604 class TextEditCursorPosition(object):
605 def __init__(self, widget, ext):
606 self._widget = widget
607 self._ext = ext
608 widget.cursorPositionChanged.connect(self.emit)
610 def emit(self):
611 widget = self._widget
612 ext = self._ext
613 cursor = widget.textCursor()
614 position = cursor.position()
615 txt = widget.get()
616 before = txt[:position]
617 row = before.count('\n')
618 line = before.split('\n')[row]
619 col = cursor.columnNumber()
620 col += line[:col].count('\t') * (ext.tabwidth() - 1)
621 widget.cursor_changed.emit(row + 1, col)
623 def reset(self):
624 widget = self._widget
625 cursor = widget.textCursor()
626 cursor.setPosition(0)
627 widget.setTextCursor(cursor)
630 class MonoTextEdit(PlainTextEdit):
631 def __init__(self, context, parent=None, readonly=False):
632 PlainTextEdit.__init__(self, parent=parent, readonly=readonly)
633 self.setFont(qtutils.diff_font(context))
636 def get_value_hinted(widget):
637 text = get_stripped(widget)
638 hint = get(widget.hint)
639 if text == hint:
640 return ''
641 return text
644 class HintWidget(QtCore.QObject):
645 """Extend a widget to provide hint messages
647 This primarily exists because setPlaceholderText() is only available
648 in Qt5, so this class provides consistent behavior across versions.
652 def __init__(self, widget, hint):
653 QtCore.QObject.__init__(self, widget)
654 self._widget = widget
655 self._hint = hint
656 self._is_error = False
658 self.modern = modern = hasattr(widget, 'setPlaceholderText')
659 if modern:
660 widget.setPlaceholderText(hint)
662 # Palette for normal text
663 QPalette = QtGui.QPalette
664 palette = widget.palette()
666 hint_color = palette.color(QPalette.Disabled, QPalette.Text)
667 error_bg_color = QtGui.QColor(Qt.red).darker()
668 error_fg_color = QtGui.QColor(Qt.white)
670 hint_rgb = qtutils.rgb_css(hint_color)
671 error_bg_rgb = qtutils.rgb_css(error_bg_color)
672 error_fg_rgb = qtutils.rgb_css(error_fg_color)
674 env = dict(
675 name=widget.__class__.__name__,
676 error_fg_rgb=error_fg_rgb,
677 error_bg_rgb=error_bg_rgb,
678 hint_rgb=hint_rgb,
681 self._default_style = ''
683 self._hint_style = (
685 %(name)s {
686 color: %(hint_rgb)s;
689 % env
692 self._error_style = (
694 %(name)s {
695 color: %(error_fg_rgb)s;
696 background-color: %(error_bg_rgb)s;
699 % env
702 def init(self):
703 """Defered initialization"""
704 if self.modern:
705 self.widget().setPlaceholderText(self.value())
706 else:
707 self.widget().installEventFilter(self)
708 self.enable(True)
710 def widget(self):
711 """Return the parent text widget"""
712 return self._widget
714 def active(self):
715 """Return True when hint-mode is active"""
716 return self.value() == get_stripped(self._widget)
718 def value(self):
719 """Return the current hint text"""
720 return self._hint
722 def set_error(self, is_error):
723 """Enable/disable error mode"""
724 self._is_error = is_error
725 self.refresh()
727 def set_value(self, hint):
728 """Change the hint text"""
729 if self.modern:
730 self._hint = hint
731 self._widget.setPlaceholderText(hint)
732 else:
733 # If hint-mode is currently active, re-activate it
734 active = self.active()
735 self._hint = hint
736 if active or self.active():
737 self.enable(True)
739 def enable(self, enable):
740 """Enable/disable hint-mode"""
741 if not self.modern:
742 if enable and self._hint:
743 self._widget.set_value(self._hint, block=True)
744 self._widget.cursor_position.reset()
745 else:
746 self._widget.clear()
747 self._update_palette(enable)
749 def refresh(self):
750 """Update the palette to match the current mode"""
751 self._update_palette(self.active())
753 def _update_palette(self, hint):
754 """Update to palette for normal/error/hint mode"""
755 if self._is_error:
756 style = self._error_style
757 elif not self.modern and hint:
758 style = self._hint_style
759 else:
760 style = self._default_style
761 QtCore.QTimer.singleShot(
762 0, lambda: utils.catch_runtime_error(self._widget.setStyleSheet, style)
765 def eventFilter(self, _obj, event):
766 """Enable/disable hint-mode when focus changes"""
767 etype = event.type()
768 if etype == QtCore.QEvent.FocusIn:
769 self.focus_in()
770 elif etype == QtCore.QEvent.FocusOut:
771 self.focus_out()
772 return False
774 def focus_in(self):
775 """Disable hint-mode when focused"""
776 widget = self.widget()
777 if self.active():
778 self.enable(False)
779 widget.cursor_position.emit()
781 def focus_out(self):
782 """Re-enable hint-mode when losing focus"""
783 widget = self.widget()
784 valid, value = utils.catch_runtime_error(get, widget)
785 if not valid:
786 # The widget may have just been destroyed during application shutdown.
787 # We're receiving a focusOut event but the widget can no longer be used.
788 # This can be safely ignored.
789 return
790 if not value:
791 self.enable(True)
794 class HintedPlainTextEdit(PlainTextEdit):
795 """A hinted plain text edit"""
797 def __init__(self, context, hint, parent=None, readonly=False):
798 PlainTextEdit.__init__(
799 self, parent=parent, get_value=get_value_hinted, readonly=readonly
801 self.hint = HintWidget(self, hint)
802 self.hint.init()
803 self.context = context
804 self.setFont(qtutils.diff_font(context))
805 self.set_tabwidth(prefs.tabwidth(context))
806 # Refresh palettes when text changes
807 # pylint: disable=no-member
808 self.textChanged.connect(self.hint.refresh)
809 self.set_mouse_zoom(context.cfg.get(prefs.MOUSE_ZOOM, default=True))
811 def set_value(self, value, block=False):
812 """Set the widget text or enable hint mode when empty"""
813 if value or self.hint.modern:
814 PlainTextEdit.set_value(self, value, block=block)
815 else:
816 self.hint.enable(True)
819 class HintedTextEdit(TextEdit):
820 """A hinted text edit"""
822 def __init__(self, context, hint, parent=None, readonly=False):
823 TextEdit.__init__(
824 self, parent=parent, get_value=get_value_hinted, readonly=readonly
826 self.context = context
827 self.hint = HintWidget(self, hint)
828 self.hint.init()
829 # Refresh palettes when text changes
830 # pylint: disable=no-member
831 self.textChanged.connect(self.hint.refresh)
832 self.setFont(qtutils.diff_font(context))
834 def set_value(self, value, block=False):
835 """Set the widget text or enable hint mode when empty"""
836 if value or self.hint.modern:
837 TextEdit.set_value(self, value, block=block)
838 else:
839 self.hint.enable(True)
842 def anchor_mode(select):
843 """Return the QTextCursor mode to keep/discard the cursor selection"""
844 if select:
845 mode = QtGui.QTextCursor.KeepAnchor
846 else:
847 mode = QtGui.QTextCursor.MoveAnchor
848 return mode
851 # The vim-like read-only text view
854 class VimMixin(object):
855 def __init__(self, widget):
856 self.widget = widget
857 self.Base = widget.Base
858 # Common vim/unix-ish keyboard actions
859 self.add_navigation('End', hotkeys.GOTO_END)
860 self.add_navigation('Up', hotkeys.MOVE_UP, shift=hotkeys.MOVE_UP_SHIFT)
861 self.add_navigation('Down', hotkeys.MOVE_DOWN, shift=hotkeys.MOVE_DOWN_SHIFT)
862 self.add_navigation('Left', hotkeys.MOVE_LEFT, shift=hotkeys.MOVE_LEFT_SHIFT)
863 self.add_navigation('Right', hotkeys.MOVE_RIGHT, shift=hotkeys.MOVE_RIGHT_SHIFT)
864 self.add_navigation('WordLeft', hotkeys.WORD_LEFT)
865 self.add_navigation('WordRight', hotkeys.WORD_RIGHT)
866 self.add_navigation('Start', hotkeys.GOTO_START)
867 self.add_navigation('StartOfLine', hotkeys.START_OF_LINE)
868 self.add_navigation('EndOfLine', hotkeys.END_OF_LINE)
870 qtutils.add_action(
871 widget,
872 'PageUp',
873 widget.page_up,
874 hotkeys.SECONDARY_ACTION,
875 hotkeys.TEXT_UP,
877 qtutils.add_action(
878 widget,
879 'PageDown',
880 widget.page_down,
881 hotkeys.PRIMARY_ACTION,
882 hotkeys.TEXT_DOWN,
884 qtutils.add_action(
885 widget,
886 'SelectPageUp',
887 lambda: widget.page_up(select=True),
888 hotkeys.SELECT_BACK,
889 hotkeys.SELECT_UP,
891 qtutils.add_action(
892 widget,
893 'SelectPageDown',
894 lambda: widget.page_down(select=True),
895 hotkeys.SELECT_FORWARD,
896 hotkeys.SELECT_DOWN,
899 def add_navigation(self, name, hotkey, shift=None):
900 """Add a hotkey along with a shift-variant"""
901 widget = self.widget
902 direction = getattr(QtGui.QTextCursor, name)
903 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
904 if shift:
905 qtutils.add_action(
906 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
909 def move(self, direction, select=False, n=1):
910 widget = self.widget
911 cursor = widget.textCursor()
912 mode = anchor_mode(select)
913 for _ in range(n):
914 if cursor.movePosition(direction, mode, 1):
915 self.set_text_cursor(cursor)
917 def page(self, offset, select=False):
918 widget = self.widget
919 rect = widget.cursorRect()
920 x = rect.x()
921 y = rect.y() + offset
922 new_cursor = widget.cursorForPosition(QtCore.QPoint(x, y))
923 if new_cursor is not None:
924 cursor = widget.textCursor()
925 mode = anchor_mode(select)
926 cursor.setPosition(new_cursor.position(), mode)
928 self.set_text_cursor(cursor)
930 def page_down(self, select=False):
931 widget = self.widget
932 widget.page(widget.height() // 2, select=select)
934 def page_up(self, select=False):
935 widget = self.widget
936 widget.page(-widget.height() // 2, select=select)
938 def set_text_cursor(self, cursor):
939 widget = self.widget
940 widget.setTextCursor(cursor)
941 widget.ensureCursorVisible()
942 widget.viewport().update()
944 def keyPressEvent(self, event):
945 """Custom keyboard behaviors
947 The leave() signal is emitted when `Up` is pressed and we're already
948 at the beginning of the text. This allows the parent widget to
949 orchestrate some higher-level interaction, such as giving focus to
950 another widget.
952 When in the middle of the first line and `Up` is pressed, the cursor
953 is moved to the beginning of the line.
956 widget = self.widget
957 if event.key() == Qt.Key_Up:
958 cursor = widget.textCursor()
959 position = cursor.position()
960 if position == 0:
961 # The cursor is at the beginning of the line.
962 # Emit a signal so that the parent can e.g. change focus.
963 widget.leave.emit()
964 elif get(widget)[:position].count('\n') == 0:
965 # The cursor is in the middle of the first line of text.
966 # We can't go up ~ jump to the beginning of the line.
967 # Select the text if shift is pressed.
968 select = event.modifiers() & Qt.ShiftModifier
969 mode = anchor_mode(select)
970 cursor.movePosition(QtGui.QTextCursor.StartOfLine, mode)
971 widget.setTextCursor(cursor)
973 return self.Base.keyPressEvent(widget, event)
976 # pylint: disable=too-many-ancestors
977 class VimHintedPlainTextEdit(HintedPlainTextEdit):
978 """HintedPlainTextEdit with vim hotkeys
980 This can only be used in read-only mode.
984 Base = HintedPlainTextEdit
985 Mixin = VimMixin
987 def __init__(self, context, hint, parent=None):
988 HintedPlainTextEdit.__init__(self, context, hint, parent=parent, readonly=True)
989 self._mixin = self.Mixin(self)
991 def move(self, direction, select=False, n=1):
992 return self._mixin.page(direction, select=select, n=n)
994 def page(self, offset, select=False):
995 return self._mixin.page(offset, select=select)
997 def page_up(self, select=False):
998 return self._mixin.page_up(select=select)
1000 def page_down(self, select=False):
1001 return self._mixin.page_down(select=select)
1003 def keyPressEvent(self, event):
1004 return self._mixin.keyPressEvent(event)
1007 # pylint: disable=too-many-ancestors
1008 class VimTextEdit(MonoTextEdit):
1009 """Text viewer with vim-like hotkeys
1011 This can only be used in read-only mode.
1015 Base = MonoTextEdit
1016 Mixin = VimMixin
1018 def __init__(self, context, parent=None, readonly=True):
1019 MonoTextEdit.__init__(self, context, parent=None, readonly=readonly)
1020 self._mixin = self.Mixin(self)
1022 def move(self, direction, select=False, n=1):
1023 return self._mixin.page(direction, select=select, n=n)
1025 def page(self, offset, select=False):
1026 return self._mixin.page(offset, select=select)
1028 def page_up(self, select=False):
1029 return self._mixin.page_up(select=select)
1031 def page_down(self, select=False):
1032 return self._mixin.page_down(select=select)
1034 def keyPressEvent(self, event):
1035 return self._mixin.keyPressEvent(event)
1038 class HintedDefaultLineEdit(LineEdit):
1039 """A line edit with hint text"""
1041 def __init__(self, hint, tooltip=None, parent=None):
1042 LineEdit.__init__(self, parent=parent, get_value=get_value_hinted)
1043 if tooltip:
1044 self.setToolTip(tooltip)
1045 self.hint = HintWidget(self, hint)
1046 self.hint.init()
1047 # pylint: disable=no-member
1048 self.textChanged.connect(lambda text: self.hint.refresh())
1051 class HintedLineEdit(HintedDefaultLineEdit):
1052 """A monospace line edit with hint text"""
1054 def __init__(self, context, hint, tooltip=None, parent=None):
1055 super(HintedLineEdit, self).__init__(hint, tooltip=tooltip, parent=parent)
1056 self.setFont(qtutils.diff_font(context))
1059 def text_dialog(context, text, title):
1060 """Show a wall of text in a dialog"""
1061 parent = qtutils.active_window()
1063 label = QtWidgets.QLabel(parent)
1064 label.setFont(qtutils.diff_font(context))
1065 label.setText(text)
1066 label.setMargin(defs.large_margin)
1067 text_flags = Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
1068 label.setTextInteractionFlags(text_flags)
1070 widget = QtWidgets.QDialog(parent)
1071 widget.setWindowModality(Qt.WindowModal)
1072 widget.setWindowTitle(title)
1074 scroll = QtWidgets.QScrollArea()
1075 scroll.setWidget(label)
1077 layout = qtutils.hbox(defs.margin, defs.spacing, scroll)
1078 widget.setLayout(layout)
1080 qtutils.add_action(
1081 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
1083 widget.show()
1084 return widget
1087 class VimTextBrowser(VimTextEdit):
1088 """Text viewer with line number annotations"""
1090 def __init__(self, context, parent=None, readonly=True):
1091 VimTextEdit.__init__(self, context, parent=parent, readonly=readonly)
1092 self.numbers = LineNumbers(self)
1094 def resizeEvent(self, event):
1095 super(VimTextBrowser, self).resizeEvent(event)
1096 self.numbers.refresh_size()
1099 class TextDecorator(QtWidgets.QWidget):
1100 """Common functionality for providing line numbers in text widgets"""
1102 def __init__(self, parent):
1103 QtWidgets.QWidget.__init__(self, parent)
1104 self.editor = parent
1106 parent.blockCountChanged.connect(lambda x: self._refresh_viewport())
1107 parent.cursorPositionChanged.connect(self.refresh)
1108 parent.updateRequest.connect(self._refresh_rect)
1110 def refresh(self):
1111 """Refresh the numbers display"""
1112 rect = self.editor.viewport().rect()
1113 self._refresh_rect(rect, 0)
1115 def _refresh_rect(self, rect, dy):
1116 if dy:
1117 self.scroll(0, dy)
1118 else:
1119 self.update(0, rect.y(), self.width(), rect.height())
1121 if rect.contains(self.editor.viewport().rect()):
1122 self._refresh_viewport()
1124 def _refresh_viewport(self):
1125 self.editor.setViewportMargins(self.width_hint(), 0, 0, 0)
1127 def refresh_size(self):
1128 rect = self.editor.contentsRect()
1129 geom = QtCore.QRect(rect.left(), rect.top(), self.width_hint(), rect.height())
1130 self.setGeometry(geom)
1132 def sizeHint(self):
1133 return QtCore.QSize(self.width_hint(), 0)
1136 class LineNumbers(TextDecorator):
1137 """Provide line numbers for QPlainTextEdit widgets"""
1139 def __init__(self, parent):
1140 TextDecorator.__init__(self, parent)
1141 self.highlight_line = -1
1143 def width_hint(self):
1144 document = self.editor.document()
1145 digits = int(math.log(max(1, document.blockCount()), 10)) + 2
1146 return defs.large_margin + self.fontMetrics().width('0') * digits
1148 def set_highlighted(self, line_number):
1149 """Set the line to highlight"""
1150 self.highlight_line = line_number
1152 def paintEvent(self, event):
1153 """Paint the line number"""
1154 QPalette = QtGui.QPalette
1155 painter = QtGui.QPainter(self)
1156 editor = self.editor
1157 palette = editor.palette()
1159 painter.fillRect(event.rect(), palette.color(QPalette.Base))
1161 content_offset = editor.contentOffset()
1162 block = editor.firstVisibleBlock()
1163 width = self.width()
1164 event_rect_bottom = event.rect().bottom()
1166 highlight = palette.color(QPalette.Highlight)
1167 highlighted_text = palette.color(QPalette.HighlightedText)
1168 disabled = palette.color(QPalette.Disabled, QPalette.Text)
1170 while block.isValid():
1171 block_geom = editor.blockBoundingGeometry(block)
1172 block_top = block_geom.translated(content_offset).top()
1173 if not block.isVisible() or block_top >= event_rect_bottom:
1174 break
1176 rect = block_geom.translated(content_offset).toRect()
1177 block_number = block.blockNumber()
1178 if block_number == self.highlight_line:
1179 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
1180 painter.setPen(highlighted_text)
1181 else:
1182 painter.setPen(disabled)
1184 number = '%s' % (block_number + 1)
1185 painter.drawText(
1186 rect.x(),
1187 rect.y(),
1188 self.width() - defs.large_margin,
1189 rect.height(),
1190 Qt.AlignRight | Qt.AlignVCenter,
1191 number,
1193 block = block.next()