fetch: add support for the traditional FETCH_HEAD behavior
[git-cola.git] / cola / widgets / text.py
blob49cdf9edb54d1f8896f0a4550a748e39ae2b6d16
1 """Text widgets"""
2 from functools import partial
3 import math
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..models import prefs
12 from ..qtutils import get
13 from .. import hotkeys
14 from .. import icons
15 from .. import qtutils
16 from .. import utils
17 from ..i18n import N_
18 from . import defs
21 def get_stripped(widget):
22 return widget.get().strip()
25 class LineEdit(QtWidgets.QLineEdit):
26 cursor_changed = Signal(int, int)
27 esc_pressed = Signal()
29 def __init__(self, parent=None, row=1, get_value=None, clear_button=False):
30 QtWidgets.QLineEdit.__init__(self, parent)
31 self._row = row
32 if get_value is None:
33 get_value = get_stripped
34 self._get_value = get_value
35 self.cursor_position = LineEditCursorPosition(self, row)
36 self.menu_actions = []
38 if clear_button and hasattr(self, 'setClearButtonEnabled'):
39 self.setClearButtonEnabled(True)
41 def get(self):
42 """Return the raw Unicode value from Qt"""
43 return self.text()
45 def value(self):
46 """Return the processed value, e.g. stripped"""
47 return self._get_value(self)
49 def set_value(self, value, block=False):
50 """Update the widget to the specified value"""
51 if block:
52 with qtutils.BlockSignals(self):
53 self._set_value(value)
54 else:
55 self._set_value(value)
57 def _set_value(self, value):
58 """Implementation helper to update the widget to the specified value"""
59 pos = self.cursorPosition()
60 self.setText(value)
61 self.setCursorPosition(pos)
63 def keyPressEvent(self, event):
64 key = event.key()
65 if key == Qt.Key_Escape:
66 self.esc_pressed.emit()
67 super().keyPressEvent(event)
70 class LineEditCursorPosition:
71 """Translate cursorPositionChanged(int,int) into cursorPosition(int,int)"""
73 def __init__(self, widget, row):
74 self._widget = widget
75 self._row = row
76 # Translate cursorPositionChanged into cursor_changed(int, int)
77 widget.cursorPositionChanged.connect(lambda old, new: self.emit())
79 def emit(self):
80 widget = self._widget
81 row = self._row
82 col = widget.cursorPosition()
83 widget.cursor_changed.emit(row, col)
85 def reset(self):
86 self._widget.setCursorPosition(0)
89 class BaseTextEditExtension(QtCore.QObject):
90 def __init__(self, widget, get_value, readonly):
91 QtCore.QObject.__init__(self, widget)
92 self.widget = widget
93 self.cursor_position = TextEditCursorPosition(widget, self)
94 if get_value is None:
95 get_value = get_stripped
96 self._get_value = get_value
97 self._tabwidth = 8
98 self._readonly = readonly
99 self._init_flags()
100 self.init()
102 def _init_flags(self):
103 widget = self.widget
104 widget.setMinimumSize(QtCore.QSize(10, 10))
105 widget.setWordWrapMode(QtGui.QTextOption.WordWrap)
106 widget.setLineWrapMode(widget.NoWrap)
107 if self._readonly:
108 widget.setReadOnly(True)
109 widget.setAcceptDrops(False)
110 widget.setTabChangesFocus(True)
111 widget.setUndoRedoEnabled(False)
112 widget.setTextInteractionFlags(
113 Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
116 def get(self):
117 """Return the raw Unicode value from Qt"""
118 return self.widget.toPlainText()
120 def value(self):
121 """Return a safe value, e.g. a stripped value"""
122 return self._get_value(self.widget)
124 def set_value(self, value, block=False):
125 """Update the widget to the specified value"""
126 if block:
127 with qtutils.BlockSignals(self):
128 self._set_value(value)
129 else:
130 self._set_value(value)
132 def _set_value(self, value):
133 """Implementation helper to update the widget to the specified value"""
134 # Save cursor position
135 offset, selection_text = self.offset_and_selection()
136 old_value = get(self.widget)
138 # Update text
139 self.widget.setPlainText(value)
141 # Restore cursor
142 if selection_text and selection_text in value:
143 # If the old selection exists in the new text then re-select it.
144 idx = value.index(selection_text)
145 cursor = self.widget.textCursor()
146 cursor.setPosition(idx)
147 cursor.setPosition(idx + len(selection_text), QtGui.QTextCursor.KeepAnchor)
148 self.widget.setTextCursor(cursor)
150 elif value == old_value:
151 # Otherwise, if the text is identical and there is no selection
152 # then restore the cursor position.
153 cursor = self.widget.textCursor()
154 cursor.setPosition(offset)
155 self.widget.setTextCursor(cursor)
156 else:
157 # If none of the above applied then restore the cursor position.
158 position = max(0, min(offset, len(value) - 1))
159 cursor = self.widget.textCursor()
160 cursor.setPosition(position)
161 self.widget.setTextCursor(cursor)
162 cursor = self.widget.textCursor()
163 cursor.movePosition(QtGui.QTextCursor.StartOfLine)
164 self.widget.setTextCursor(cursor)
166 def set_cursor_position(self, new_position):
167 cursor = self.widget.textCursor()
168 cursor.setPosition(new_position)
169 self.widget.setTextCursor(cursor)
171 def tabwidth(self):
172 return self._tabwidth
174 def set_tabwidth(self, width):
175 self._tabwidth = width
176 pixels = qtutils.text_width(self.widget.font(), 'M') * width
177 self.widget.setTabStopWidth(pixels)
179 def selected_line(self):
180 contents = self.value()
181 cursor = self.widget.textCursor()
182 offset = min(cursor.position(), len(contents) - 1)
183 while offset >= 1 and contents[offset - 1] and contents[offset - 1] != '\n':
184 offset -= 1
185 data = contents[offset:]
186 if '\n' in data:
187 line, _ = data.split('\n', 1)
188 else:
189 line = data
190 return line
192 def cursor(self):
193 return self.widget.textCursor()
195 def has_selection(self):
196 return self.cursor().hasSelection()
198 def selected_text(self):
199 """Return the selected text"""
200 _, selection = self.offset_and_selection()
201 return selection
203 def offset_and_selection(self):
204 """Return the cursor offset and selected text"""
205 cursor = self.cursor()
206 offset = cursor.selectionStart()
207 selection_text = cursor.selection().toPlainText()
208 return offset, selection_text
210 def mouse_press_event(self, event):
211 # Move the text cursor so that the right-click events operate
212 # on the current position, not the last left-clicked position.
213 widget = self.widget
214 if event.button() == Qt.RightButton:
215 if not widget.textCursor().hasSelection():
216 cursor = widget.cursorForPosition(event.pos())
217 widget.setTextCursor(cursor)
219 def add_links_to_menu(self, menu):
220 """Add actions for opening URLs to a custom menu"""
221 links = self._get_links()
222 if links:
223 menu.addSeparator()
224 for url in links:
225 action = menu.addAction(N_('Open "%s"') % url)
226 action.setIcon(icons.external())
227 qtutils.connect_action(
228 action, partial(QtGui.QDesktopServices.openUrl, QtCore.QUrl(url))
231 def _get_links(self):
232 """Return http links on the current line"""
233 _, selection = self.offset_and_selection()
234 if selection:
235 line = selection
236 else:
237 line = self.selected_line()
238 if not line:
239 return []
240 return [
241 word for word in line.split() if word.startswith(('http://', 'https://'))
244 def create_context_menu(self, event_pos):
245 """Create a context menu for a widget"""
246 menu = self.widget.createStandardContextMenu(event_pos)
247 qtutils.add_menu_actions(menu, self.widget.menu_actions)
248 self.add_links_to_menu(menu)
249 return menu
251 def context_menu_event(self, event):
252 """Default context menu event"""
253 event_pos = event.pos()
254 menu = self.widget.create_context_menu(event_pos)
255 menu.exec_(self.widget.mapToGlobal(event_pos))
257 # For extension by sub-classes
259 def init(self):
260 """Called during init for class-specific settings"""
261 return
263 def set_textwidth(self, width):
264 """Set the text width"""
265 return
267 def set_linebreak(self, brk):
268 """Enable word wrapping"""
269 return
272 class PlainTextEditExtension(BaseTextEditExtension):
273 def set_linebreak(self, brk):
274 if brk:
275 wrapmode = QtWidgets.QPlainTextEdit.WidgetWidth
276 else:
277 wrapmode = QtWidgets.QPlainTextEdit.NoWrap
278 self.widget.setLineWrapMode(wrapmode)
281 class PlainTextEdit(QtWidgets.QPlainTextEdit):
282 cursor_changed = Signal(int, int)
283 leave = Signal()
285 def __init__(self, parent=None, get_value=None, readonly=False, options=None):
286 QtWidgets.QPlainTextEdit.__init__(self, parent)
287 self.ext = PlainTextEditExtension(self, get_value, readonly)
288 self.cursor_position = self.ext.cursor_position
289 self.mouse_zoom = True
290 self.options = options
291 self.menu_actions = []
293 def get(self):
294 """Return the raw Unicode value from Qt"""
295 return self.ext.get()
297 # For compatibility with QTextEdit
298 def setText(self, value):
299 self.set_value(value)
301 def value(self):
302 """Return a safe value, e.g. a stripped value"""
303 return self.ext.value()
305 def offset_and_selection(self):
306 """Return the cursor offset and selected text"""
307 return self.ext.offset_and_selection()
309 def set_value(self, value, block=False):
310 self.ext.set_value(value, block=block)
312 def set_mouse_zoom(self, value):
313 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
314 self.mouse_zoom = value
316 def set_options(self, options):
317 """Register an Options widget"""
318 self.options = options
320 def set_word_wrapping(self, enabled, update=False):
321 """Enable/disable word wrapping"""
322 if update and self.options is not None:
323 with qtutils.BlockSignals(self.options.enable_word_wrapping):
324 self.options.enable_word_wrapping.setChecked(enabled)
325 if enabled:
326 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
327 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
328 else:
329 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
330 self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
332 def has_selection(self):
333 return self.ext.has_selection()
335 def selected_line(self):
336 return self.ext.selected_line()
338 def selected_text(self):
339 """Return the selected text"""
340 return self.ext.selected_text()
342 def set_tabwidth(self, width):
343 self.ext.set_tabwidth(width)
345 def set_textwidth(self, width):
346 self.ext.set_textwidth(width)
348 def set_linebreak(self, brk):
349 self.ext.set_linebreak(brk)
351 def mousePressEvent(self, event):
352 self.ext.mouse_press_event(event)
353 super().mousePressEvent(event)
355 def wheelEvent(self, event):
356 """Disable control+wheelscroll text resizing"""
357 if not self.mouse_zoom and (event.modifiers() & Qt.ControlModifier):
358 event.ignore()
359 return
360 super().wheelEvent(event)
362 def create_context_menu(self, event_pos):
363 """Create a custom context menu"""
364 return self.ext.create_context_menu(event_pos)
366 def contextMenuEvent(self, event):
367 """Custom contextMenuEvent() for building our custom context menus"""
368 self.ext.context_menu_event(event)
371 class TextSearchWidget(QtWidgets.QWidget):
372 """The search dialog that displays over a text edit field"""
374 def __init__(self, widget, parent):
375 super().__init__(parent)
376 self.setAutoFillBackground(True)
377 self._widget = widget
378 self._parent = parent
380 self.text = HintedDefaultLineEdit(N_('Find in diff'), parent=self)
382 self.prev_button = qtutils.create_action_button(
383 tooltip=N_('Find the previous occurrence of the phrase'), icon=icons.up()
386 self.next_button = qtutils.create_action_button(
387 tooltip=N_('Find the next occurrence of the phrase'), icon=icons.down()
390 self.match_case_checkbox = qtutils.checkbox(N_('Match Case'))
391 self.whole_words_checkbox = qtutils.checkbox(N_('Whole Words'))
393 self.close_button = qtutils.create_action_button(
394 tooltip=N_('Close the find bar'), icon=icons.close()
397 layout = qtutils.hbox(
398 defs.margin,
399 defs.button_spacing,
400 self.text,
401 self.prev_button,
402 self.next_button,
403 self.match_case_checkbox,
404 self.whole_words_checkbox,
405 qtutils.STRETCH,
406 self.close_button,
408 self.setLayout(layout)
409 self.setFocusProxy(self.text)
411 self.text.esc_pressed.connect(self.hide_search)
412 self.text.returnPressed.connect(self.search)
413 self.text.textChanged.connect(self.search)
415 self.search_next_action = qtutils.add_action(
416 parent,
417 N_('Find next item'),
418 self.search,
419 hotkeys.SEARCH_NEXT,
421 self.search_prev_action = qtutils.add_action(
422 parent,
423 N_('Find previous item'),
424 self.search_backwards,
425 hotkeys.SEARCH_PREV,
428 qtutils.connect_button(self.next_button, self.search)
429 qtutils.connect_button(self.prev_button, self.search_backwards)
430 qtutils.connect_button(self.close_button, self.hide_search)
431 qtutils.connect_checkbox(self.match_case_checkbox, lambda _: self.search())
432 qtutils.connect_checkbox(self.whole_words_checkbox, lambda _: self.search())
434 def search(self):
435 """Emit a signal with the current search text"""
436 self.search_text(backwards=False)
438 def search_backwards(self):
439 """Emit a signal with the current search text for a backwards search"""
440 self.search_text(backwards=True)
442 def hide_search(self):
443 """Hide the search window"""
444 self.hide()
445 self._parent.setFocus()
447 def find_flags(self, backwards):
448 """Return QTextDocument.FindFlags for the current search options"""
449 flags = QtGui.QTextDocument.FindFlag(0)
450 if backwards:
451 flags = flags | QtGui.QTextDocument.FindBackward
452 if self.match_case_checkbox.isChecked():
453 flags = flags | QtGui.QTextDocument.FindCaseSensitively
454 if self.whole_words_checkbox.isChecked():
455 flags = flags | QtGui.QTextDocument.FindWholeWords
456 return flags
458 def is_case_sensitive(self):
459 """Are we searching using a case-insensitive search?"""
460 return self.match_case_checkbox.isChecked()
462 def search_text(self, backwards=False):
463 """Search the diff text for the given text"""
464 text = self.text.get()
465 cursor = self._widget.textCursor()
466 if cursor.hasSelection():
467 selected_text = cursor.selectedText()
468 case_sensitive = self.is_case_sensitive()
469 if text_matches(case_sensitive, selected_text, text):
470 if backwards:
471 position = cursor.selectionStart()
472 else:
473 position = cursor.selectionEnd()
474 else:
475 if backwards:
476 position = cursor.selectionEnd()
477 else:
478 position = cursor.selectionStart()
479 cursor.setPosition(position)
480 self._widget.setTextCursor(cursor)
482 flags = self.find_flags(backwards)
483 if not self._widget.find(text, flags):
484 if backwards:
485 location = QtGui.QTextCursor.End
486 else:
487 location = QtGui.QTextCursor.Start
488 cursor.movePosition(location, QtGui.QTextCursor.MoveAnchor)
489 self._widget.setTextCursor(cursor)
490 self._widget.find(text, flags)
493 def text_matches(case_sensitive, a, b):
494 """Compare text with case sensitivity taken into account"""
495 if case_sensitive:
496 return a == b
497 return a.lower() == b.lower()
500 class TextEditExtension(BaseTextEditExtension):
501 def init(self):
502 widget = self.widget
503 widget.setAcceptRichText(False)
505 def set_linebreak(self, brk):
506 if brk:
507 wrapmode = QtWidgets.QTextEdit.FixedColumnWidth
508 else:
509 wrapmode = QtWidgets.QTextEdit.NoWrap
510 self.widget.setLineWrapMode(wrapmode)
512 def set_textwidth(self, width):
513 self.widget.setLineWrapColumnOrWidth(width)
516 class TextEdit(QtWidgets.QTextEdit):
517 cursor_changed = Signal(int, int)
518 leave = Signal()
520 def __init__(self, parent=None, get_value=None, readonly=False):
521 QtWidgets.QTextEdit.__init__(self, parent)
522 self.ext = TextEditExtension(self, get_value, readonly)
523 self.cursor_position = self.ext.cursor_position
524 self.expandtab_enabled = False
525 self.menu_actions = []
527 def get(self):
528 """Return the raw Unicode value from Qt"""
529 return self.ext.get()
531 def value(self):
532 """Return a safe value, e.g. a stripped value"""
533 return self.ext.value()
535 def set_cursor_position(self, position):
536 """Set the cursor position"""
537 cursor = self.textCursor()
538 cursor.setPosition(position)
539 self.setTextCursor(cursor)
541 def set_value(self, value, block=False):
542 self.ext.set_value(value, block=block)
544 def selected_line(self):
545 return self.ext.selected_line()
547 def selected_text(self):
548 """Return the selected text"""
549 return self.ext.selected_text()
551 def set_tabwidth(self, width):
552 self.ext.set_tabwidth(width)
554 def set_textwidth(self, width):
555 self.ext.set_textwidth(width)
557 def set_linebreak(self, brk):
558 self.ext.set_linebreak(brk)
560 def set_expandtab(self, value):
561 self.expandtab_enabled = value
563 def mousePressEvent(self, event):
564 self.ext.mouse_press_event(event)
565 super().mousePressEvent(event)
567 def wheelEvent(self, event):
568 """Disable control+wheelscroll text resizing"""
569 if event.modifiers() & Qt.ControlModifier:
570 event.ignore()
571 return
572 super().wheelEvent(event)
574 def should_expandtab(self, event):
575 return event.key() == Qt.Key_Tab and self.expandtab_enabled
577 def expandtab(self):
578 tabwidth = max(self.ext.tabwidth(), 1)
579 cursor = self.textCursor()
580 cursor.insertText(' ' * tabwidth)
582 def create_context_menu(self, event_pos):
583 """Create a custom context menu"""
584 return self.ext.create_context_menu(event_pos)
586 def contextMenuEvent(self, event):
587 """Custom contextMenuEvent() for building our custom context menus"""
588 self.ext.context_menu_event(event)
590 def keyPressEvent(self, event):
591 """Override keyPressEvent to handle tab expansion"""
592 expandtab = self.should_expandtab(event)
593 if expandtab:
594 self.expandtab()
595 event.accept()
596 else:
597 QtWidgets.QTextEdit.keyPressEvent(self, event)
599 def keyReleaseEvent(self, event):
600 """Override keyReleaseEvent to special-case tab expansion"""
601 expandtab = self.should_expandtab(event)
602 if expandtab:
603 event.ignore()
604 else:
605 QtWidgets.QTextEdit.keyReleaseEvent(self, event)
608 class TextEditCursorPosition:
609 def __init__(self, widget, ext):
610 self._widget = widget
611 self._ext = ext
612 widget.cursorPositionChanged.connect(self.emit)
614 def emit(self):
615 widget = self._widget
616 ext = self._ext
617 cursor = widget.textCursor()
618 position = cursor.position()
619 txt = widget.get()
620 before = txt[:position]
621 row = before.count('\n')
622 line = before.split('\n')[row]
623 col = cursor.columnNumber()
624 col += line[:col].count('\t') * (ext.tabwidth() - 1)
625 widget.cursor_changed.emit(row + 1, col)
627 def reset(self):
628 widget = self._widget
629 cursor = widget.textCursor()
630 cursor.setPosition(0)
631 widget.setTextCursor(cursor)
634 class MonoTextEdit(PlainTextEdit):
635 def __init__(self, context, parent=None, readonly=False):
636 PlainTextEdit.__init__(self, parent=parent, readonly=readonly)
637 self.setFont(qtutils.diff_font(context))
640 def get_value_hinted(widget):
641 text = get_stripped(widget)
642 hint = get(widget.hint)
643 if text == hint:
644 return ''
645 return text
648 class HintWidget(QtCore.QObject):
649 """Extend a widget to provide hint messages
651 This primarily exists because setPlaceholderText() is only available
652 in Qt5, so this class provides consistent behavior across versions.
656 def __init__(self, widget, hint):
657 QtCore.QObject.__init__(self, widget)
658 self._widget = widget
659 self._hint = hint
660 self._is_error = False
662 self.modern = modern = hasattr(widget, 'setPlaceholderText')
663 if modern:
664 widget.setPlaceholderText(hint)
666 # Palette for normal text
667 QPalette = QtGui.QPalette
668 palette = widget.palette()
670 hint_color = palette.color(QPalette.Disabled, QPalette.Text)
671 error_bg_color = QtGui.QColor(Qt.red).darker()
672 error_fg_color = QtGui.QColor(Qt.white)
674 hint_rgb = qtutils.rgb_css(hint_color)
675 error_bg_rgb = qtutils.rgb_css(error_bg_color)
676 error_fg_rgb = qtutils.rgb_css(error_fg_color)
678 env = {
679 'name': widget.__class__.__name__,
680 'error_fg_rgb': error_fg_rgb,
681 'error_bg_rgb': error_bg_rgb,
682 'hint_rgb': hint_rgb,
685 self._default_style = ''
687 self._hint_style = (
689 %(name)s {
690 color: %(hint_rgb)s;
693 % env
696 self._error_style = (
698 %(name)s {
699 color: %(error_fg_rgb)s;
700 background-color: %(error_bg_rgb)s;
703 % env
706 def init(self):
707 """Deferred initialization"""
708 if self.modern:
709 self.widget().setPlaceholderText(self.value())
710 else:
711 self.widget().installEventFilter(self)
712 self.enable(True)
714 def widget(self):
715 """Return the parent text widget"""
716 return self._widget
718 def active(self):
719 """Return True when hint-mode is active"""
720 return self.value() == get_stripped(self._widget)
722 def value(self):
723 """Return the current hint text"""
724 return self._hint
726 def set_error(self, is_error):
727 """Enable/disable error mode"""
728 self._is_error = is_error
729 self.refresh()
731 def set_value(self, hint):
732 """Change the hint text"""
733 if self.modern:
734 self._hint = hint
735 self._widget.setPlaceholderText(hint)
736 else:
737 # If hint-mode is currently active, re-activate it
738 active = self.active()
739 self._hint = hint
740 if active or self.active():
741 self.enable(True)
743 def enable(self, enable):
744 """Enable/disable hint-mode"""
745 if not self.modern:
746 if enable and self._hint:
747 self._widget.set_value(self._hint, block=True)
748 self._widget.cursor_position.reset()
749 else:
750 self._widget.clear()
751 self._update_palette(enable)
753 def refresh(self):
754 """Update the palette to match the current mode"""
755 self._update_palette(self.active())
757 def _update_palette(self, hint):
758 """Update to palette for normal/error/hint mode"""
759 if self._is_error:
760 style = self._error_style
761 elif not self.modern and hint:
762 style = self._hint_style
763 else:
764 style = self._default_style
765 QtCore.QTimer.singleShot(
766 0, lambda: utils.catch_runtime_error(self._widget.setStyleSheet, style)
769 def eventFilter(self, _obj, event):
770 """Enable/disable hint-mode when focus changes"""
771 etype = event.type()
772 if etype == QtCore.QEvent.FocusIn:
773 self.focus_in()
774 elif etype == QtCore.QEvent.FocusOut:
775 self.focus_out()
776 return False
778 def focus_in(self):
779 """Disable hint-mode when focused"""
780 widget = self.widget()
781 if self.active():
782 self.enable(False)
783 widget.cursor_position.emit()
785 def focus_out(self):
786 """Re-enable hint-mode when losing focus"""
787 widget = self.widget()
788 valid, value = utils.catch_runtime_error(get, widget)
789 if not valid:
790 # The widget may have just been destroyed during application shutdown.
791 # We're receiving a focusOut event but the widget can no longer be used.
792 # This can be safely ignored.
793 return
794 if not value:
795 self.enable(True)
798 class HintedPlainTextEdit(PlainTextEdit):
799 """A hinted plain text edit"""
801 def __init__(self, context, hint, parent=None, readonly=False):
802 PlainTextEdit.__init__(
803 self, parent=parent, get_value=get_value_hinted, readonly=readonly
805 self.hint = HintWidget(self, hint)
806 self.hint.init()
807 self.context = context
808 self.setFont(qtutils.diff_font(context))
809 self.set_tabwidth(prefs.tabwidth(context))
810 # Refresh palettes when text changes
811 self.textChanged.connect(self.hint.refresh)
812 self.set_mouse_zoom(context.cfg.get(prefs.MOUSE_ZOOM, default=True))
814 def set_value(self, value, block=False):
815 """Set the widget text or enable hint mode when empty"""
816 if value or self.hint.modern:
817 PlainTextEdit.set_value(self, value, block=block)
818 else:
819 self.hint.enable(True)
822 class HintedTextEdit(TextEdit):
823 """A hinted text edit"""
825 def __init__(self, context, hint, parent=None, readonly=False):
826 TextEdit.__init__(
827 self, parent=parent, get_value=get_value_hinted, readonly=readonly
829 self.context = context
830 self.hint = HintWidget(self, hint)
831 self.hint.init()
832 # Refresh palettes when text changes
833 self.textChanged.connect(self.hint.refresh)
834 self.setFont(qtutils.diff_font(context))
836 def set_value(self, value, block=False):
837 """Set the widget text or enable hint mode when empty"""
838 if value or self.hint.modern:
839 TextEdit.set_value(self, value, block=block)
840 else:
841 self.hint.enable(True)
844 def anchor_mode(select):
845 """Return the QTextCursor mode to keep/discard the cursor selection"""
846 if select:
847 mode = QtGui.QTextCursor.KeepAnchor
848 else:
849 mode = QtGui.QTextCursor.MoveAnchor
850 return mode
853 # The vim-like read-only text view
856 class VimMixin:
857 def __init__(self, widget):
858 self.widget = widget
859 self.Base = widget.Base
860 # Common vim/Unix-ish keyboard actions
861 self.add_navigation('End', hotkeys.GOTO_END)
862 self.add_navigation('Up', hotkeys.MOVE_UP, shift=hotkeys.MOVE_UP_SHIFT)
863 self.add_navigation('Down', hotkeys.MOVE_DOWN, shift=hotkeys.MOVE_DOWN_SHIFT)
864 self.add_navigation('Left', hotkeys.MOVE_LEFT, shift=hotkeys.MOVE_LEFT_SHIFT)
865 self.add_navigation('Right', hotkeys.MOVE_RIGHT, shift=hotkeys.MOVE_RIGHT_SHIFT)
866 self.add_navigation('WordLeft', hotkeys.WORD_LEFT)
867 self.add_navigation('WordRight', hotkeys.WORD_RIGHT)
868 self.add_navigation('Start', hotkeys.GOTO_START)
869 self.add_navigation('StartOfLine', hotkeys.START_OF_LINE)
870 self.add_navigation('EndOfLine', hotkeys.END_OF_LINE)
872 qtutils.add_action(
873 widget,
874 'PageUp',
875 widget.page_up,
876 hotkeys.SECONDARY_ACTION,
877 hotkeys.TEXT_UP,
879 qtutils.add_action(
880 widget,
881 'PageDown',
882 widget.page_down,
883 hotkeys.PRIMARY_ACTION,
884 hotkeys.TEXT_DOWN,
886 qtutils.add_action(
887 widget,
888 'SelectPageUp',
889 lambda: widget.page_up(select=True),
890 hotkeys.SELECT_BACK,
891 hotkeys.SELECT_UP,
893 qtutils.add_action(
894 widget,
895 'SelectPageDown',
896 lambda: widget.page_down(select=True),
897 hotkeys.SELECT_FORWARD,
898 hotkeys.SELECT_DOWN,
901 def add_navigation(self, name, hotkey, shift=None):
902 """Add a hotkey along with a shift-variant"""
903 widget = self.widget
904 direction = getattr(QtGui.QTextCursor, name)
905 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
906 if shift:
907 qtutils.add_action(
908 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
911 def move(self, direction, select=False, n=1):
912 widget = self.widget
913 cursor = widget.textCursor()
914 mode = anchor_mode(select)
915 for _ in range(n):
916 if cursor.movePosition(direction, mode, 1):
917 self.set_text_cursor(cursor)
919 def page(self, offset, select=False):
920 widget = self.widget
921 rect = widget.cursorRect()
922 x = rect.x()
923 y = rect.y() + offset
924 new_cursor = widget.cursorForPosition(QtCore.QPoint(x, y))
925 if new_cursor is not None:
926 cursor = widget.textCursor()
927 mode = anchor_mode(select)
928 cursor.setPosition(new_cursor.position(), mode)
930 self.set_text_cursor(cursor)
932 def page_down(self, select=False):
933 widget = self.widget
934 widget.page(widget.height() // 2, select=select)
936 def page_up(self, select=False):
937 widget = self.widget
938 widget.page(-widget.height() // 2, select=select)
940 def set_text_cursor(self, cursor):
941 widget = self.widget
942 widget.setTextCursor(cursor)
943 widget.ensureCursorVisible()
944 widget.viewport().update()
946 def keyPressEvent(self, event):
947 """Custom keyboard behaviors
949 The leave() signal is emitted when `Up` is pressed and we're already
950 at the beginning of the text. This allows the parent widget to
951 orchestrate some higher-level interaction, such as giving focus to
952 another widget.
954 When in the middle of the first line and `Up` is pressed, the cursor
955 is moved to the beginning of the line.
958 widget = self.widget
959 if event.key() == Qt.Key_Up:
960 cursor = widget.textCursor()
961 position = cursor.position()
962 if position == 0:
963 # The cursor is at the beginning of the line.
964 # Emit a signal so that the parent can e.g. change focus.
965 widget.leave.emit()
966 elif get(widget)[:position].count('\n') == 0:
967 # The cursor is in the middle of the first line of text.
968 # We can't go up ~ jump to the beginning of the line.
969 # Select the text if shift is pressed.
970 select = event.modifiers() & Qt.ShiftModifier
971 mode = anchor_mode(select)
972 cursor.movePosition(QtGui.QTextCursor.StartOfLine, mode)
973 widget.setTextCursor(cursor)
975 return self.Base.keyPressEvent(widget, event)
978 class VimHintedPlainTextEdit(HintedPlainTextEdit):
979 """HintedPlainTextEdit with vim hotkeys
981 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 class VimTextEdit(MonoTextEdit):
1008 """Text viewer with vim-like hotkeys
1010 This can only be used in read-only mode.
1014 Base = MonoTextEdit
1015 Mixin = VimMixin
1017 def __init__(self, context, parent=None, readonly=True):
1018 MonoTextEdit.__init__(self, context, parent=None, readonly=readonly)
1019 self._mixin = self.Mixin(self)
1021 def move(self, direction, select=False, n=1):
1022 return self._mixin.page(direction, select=select, n=n)
1024 def page(self, offset, select=False):
1025 return self._mixin.page(offset, select=select)
1027 def page_up(self, select=False):
1028 return self._mixin.page_up(select=select)
1030 def page_down(self, select=False):
1031 return self._mixin.page_down(select=select)
1033 def keyPressEvent(self, event):
1034 return self._mixin.keyPressEvent(event)
1037 class HintedDefaultLineEdit(LineEdit):
1038 """A line edit with hint text"""
1040 def __init__(self, hint, tooltip=None, parent=None):
1041 LineEdit.__init__(self, parent=parent, get_value=get_value_hinted)
1042 if tooltip:
1043 self.setToolTip(tooltip)
1044 self.hint = HintWidget(self, hint)
1045 self.hint.init()
1046 self.textChanged.connect(lambda text: self.hint.refresh())
1049 class HintedLineEdit(HintedDefaultLineEdit):
1050 """A monospace line edit with hint text"""
1052 def __init__(self, context, hint, tooltip=None, parent=None):
1053 super().__init__(hint, tooltip=tooltip, parent=parent)
1054 self.setFont(qtutils.diff_font(context))
1057 def text_dialog(context, text, title):
1058 """Show a wall of text in a dialog"""
1059 parent = qtutils.active_window()
1061 label = QtWidgets.QLabel(parent)
1062 label.setFont(qtutils.diff_font(context))
1063 label.setText(text)
1064 label.setMargin(defs.large_margin)
1065 text_flags = Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
1066 label.setTextInteractionFlags(text_flags)
1068 widget = QtWidgets.QDialog(parent)
1069 widget.setWindowModality(Qt.WindowModal)
1070 widget.setWindowTitle(title)
1072 scroll = QtWidgets.QScrollArea()
1073 scroll.setWidget(label)
1075 layout = qtutils.hbox(defs.margin, defs.spacing, scroll)
1076 widget.setLayout(layout)
1078 qtutils.add_action(
1079 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
1081 widget.show()
1082 return widget
1085 class VimTextBrowser(VimTextEdit):
1086 """Text viewer with line number annotations"""
1088 def __init__(self, context, parent=None, readonly=True):
1089 VimTextEdit.__init__(self, context, parent=parent, readonly=readonly)
1090 self.numbers = LineNumbers(self)
1092 def resizeEvent(self, event):
1093 super().resizeEvent(event)
1094 self.numbers.refresh_size()
1097 class TextDecorator(QtWidgets.QWidget):
1098 """Common functionality for providing line numbers in text widgets"""
1100 def __init__(self, parent):
1101 QtWidgets.QWidget.__init__(self, parent)
1102 self.editor = parent
1104 parent.blockCountChanged.connect(lambda x: self._refresh_viewport())
1105 parent.cursorPositionChanged.connect(self.refresh)
1106 parent.updateRequest.connect(self._refresh_rect)
1108 def refresh(self):
1109 """Refresh the numbers display"""
1110 rect = self.editor.viewport().rect()
1111 self._refresh_rect(rect, 0)
1113 def _refresh_rect(self, rect, dy):
1114 if dy:
1115 self.scroll(0, dy)
1116 else:
1117 self.update(0, rect.y(), self.width(), rect.height())
1119 if rect.contains(self.editor.viewport().rect()):
1120 self._refresh_viewport()
1122 def _refresh_viewport(self):
1123 self.editor.setViewportMargins(self.width_hint(), 0, 0, 0)
1125 def refresh_size(self):
1126 rect = self.editor.contentsRect()
1127 geom = QtCore.QRect(rect.left(), rect.top(), self.width_hint(), rect.height())
1128 self.setGeometry(geom)
1130 def sizeHint(self):
1131 return QtCore.QSize(self.width_hint(), 0)
1134 class LineNumbers(TextDecorator):
1135 """Provide line numbers for QPlainTextEdit widgets"""
1137 def __init__(self, parent):
1138 TextDecorator.__init__(self, parent)
1139 self.highlight_line = -1
1141 def width_hint(self):
1142 document = self.editor.document()
1143 digits = int(math.log(max(1, document.blockCount()), 10)) + 2
1144 text_width = qtutils.text_width(self.font(), '0')
1145 return defs.large_margin + (text_width * digits)
1147 def set_highlighted(self, line_number):
1148 """Set the line to highlight"""
1149 self.highlight_line = line_number
1151 def paintEvent(self, event):
1152 """Paint the line number"""
1153 QPalette = QtGui.QPalette
1154 painter = QtGui.QPainter(self)
1155 editor = self.editor
1156 palette = editor.palette()
1158 painter.fillRect(event.rect(), palette.color(QPalette.Base))
1160 content_offset = editor.contentOffset()
1161 block = editor.firstVisibleBlock()
1162 width = self.width()
1163 event_rect_bottom = event.rect().bottom()
1165 highlight = palette.color(QPalette.Highlight)
1166 highlighted_text = palette.color(QPalette.HighlightedText)
1167 disabled = palette.color(QPalette.Disabled, QPalette.Text)
1169 while block.isValid():
1170 block_geom = editor.blockBoundingGeometry(block)
1171 block_top = block_geom.translated(content_offset).top()
1172 if not block.isVisible() or block_top >= event_rect_bottom:
1173 break
1175 rect = block_geom.translated(content_offset).toRect()
1176 block_number = block.blockNumber()
1177 if block_number == self.highlight_line:
1178 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
1179 painter.setPen(highlighted_text)
1180 else:
1181 painter.setPen(disabled)
1183 number = '%s' % (block_number + 1)
1184 painter.drawText(
1185 rect.x(),
1186 rect.y(),
1187 self.width() - defs.large_margin,
1188 rect.height(),
1189 Qt.AlignRight | Qt.AlignVCenter,
1190 number,
1192 block = block.next()
1195 class TextLabel(QtWidgets.QLabel):
1196 """A text label that elides its display"""
1198 def __init__(self, parent=None, open_external_links=True):
1199 QtWidgets.QLabel.__init__(self, parent)
1200 self._display = ''
1201 self._template = ''
1202 self._text = ''
1203 self._elide = False
1204 self._metrics = QtGui.QFontMetrics(self.font())
1205 policy = QtWidgets.QSizePolicy(
1206 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1208 self.setSizePolicy(policy)
1209 self.setTextInteractionFlags(
1210 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1212 self.setOpenExternalLinks(open_external_links)
1214 def elide(self):
1215 self._elide = True
1217 def set_text(self, text):
1218 self.set_template(text, text)
1220 def set_template(self, text, template):
1221 self._display = text
1222 self._text = text
1223 self._template = template
1224 self.update_text(self.width())
1225 self.setText(self._display)
1227 def update_text(self, width):
1228 self._display = self._text
1229 if not self._elide:
1230 return
1231 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1232 if text != self._template:
1233 self._display = text
1235 # Qt overrides
1236 def setFont(self, font):
1237 self._metrics = QtGui.QFontMetrics(font)
1238 QtWidgets.QLabel.setFont(self, font)
1240 def resizeEvent(self, event):
1241 if self._elide:
1242 self.update_text(event.size().width())
1243 with qtutils.BlockSignals(self):
1244 self.setText(self._display)
1245 QtWidgets.QLabel.resizeEvent(self, event)
1248 class PlainTextLabel(TextLabel):
1249 """A plaintext label that elides its display"""
1251 def __init__(self, parent=None):
1252 super().__init__(parent=parent, open_external_links=False)
1253 self.setTextFormat(Qt.PlainText)
1256 class RichTextLabel(TextLabel):
1257 """A richtext label that elides its display"""
1259 def __init__(self, parent=None):
1260 super().__init__(parent=parent, open_external_links=True)
1261 self.setTextFormat(Qt.RichText)