tree-wide: trivial code style tweaks
[git-cola.git] / cola / widgets / text.py
bloba801201ef48b49e4c53ce7b24c9f0528798b8e5a
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 ..i18n import N_
19 from . import defs
22 def get_stripped(widget):
23 return widget.get().strip()
26 class LineEdit(QtWidgets.QLineEdit):
28 cursor_changed = Signal(int, int)
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)
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)
64 class LineEditCursorPosition(object):
65 """Translate cursorPositionChanged(int,int) into cursorPosition(int,int)"""
67 def __init__(self, widget, row):
68 self._widget = widget
69 self._row = row
70 # Translate cursorPositionChanged into cursor_changed(int, int)
71 widget.cursorPositionChanged.connect(lambda old, new: self.emit())
73 def emit(self):
74 widget = self._widget
75 row = self._row
76 col = widget.cursorPosition()
77 widget.cursor_changed.emit(row, col)
79 def reset(self):
80 self._widget.setCursorPosition(0)
83 class BaseTextEditExtension(QtCore.QObject):
84 def __init__(self, widget, get_value, readonly):
85 QtCore.QObject.__init__(self, widget)
86 self.widget = widget
87 self.cursor_position = TextEditCursorPosition(widget, self)
88 if get_value is None:
89 get_value = get_stripped
90 self._get_value = get_value
91 self._tabwidth = 8
92 self._readonly = readonly
93 self._init_flags()
94 self.init()
96 def _init_flags(self):
97 widget = self.widget
98 widget.setMinimumSize(QtCore.QSize(10, 10))
99 widget.setWordWrapMode(QtGui.QTextOption.WordWrap)
100 widget.setLineWrapMode(widget.NoWrap)
101 if self._readonly:
102 widget.setReadOnly(True)
103 widget.setAcceptDrops(False)
104 widget.setTabChangesFocus(True)
105 widget.setUndoRedoEnabled(False)
106 widget.setTextInteractionFlags(
107 Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
110 def get(self):
111 """Return the raw unicode value from Qt"""
112 return self.widget.toPlainText()
114 def value(self):
115 """Return a safe value, e.g. a stripped value"""
116 return self._get_value(self.widget)
118 def set_value(self, value, block=False):
119 """Update the widget to the specified value"""
120 if block:
121 with qtutils.BlockSignals(self):
122 self._set_value(value)
123 else:
124 self._set_value(value)
126 def _set_value(self, value):
127 """Implementation helper to update the widget to the specified value"""
128 # Save cursor position
129 offset, selection_text = self.offset_and_selection()
130 old_value = get(self.widget)
132 # Update text
133 self.widget.setPlainText(value)
135 # Restore cursor
136 if selection_text and selection_text in value:
137 # If the old selection exists in the new text then re-select it.
138 idx = value.index(selection_text)
139 cursor = self.widget.textCursor()
140 cursor.setPosition(idx)
141 cursor.setPosition(idx + len(selection_text), QtGui.QTextCursor.KeepAnchor)
142 self.widget.setTextCursor(cursor)
144 elif value == old_value:
145 # Otherwise, if the text is identical and there is no selection
146 # then restore the cursor position.
147 cursor = self.widget.textCursor()
148 cursor.setPosition(offset)
149 self.widget.setTextCursor(cursor)
150 else:
151 # If none of the above applied then restore the cursor position.
152 position = max(0, min(offset, len(value) - 1))
153 cursor = self.widget.textCursor()
154 cursor.setPosition(position)
155 self.widget.setTextCursor(cursor)
156 cursor = self.widget.textCursor()
157 cursor.movePosition(QtGui.QTextCursor.StartOfLine)
158 self.widget.setTextCursor(cursor)
160 def set_cursor_position(self, new_position):
161 cursor = self.widget.textCursor()
162 cursor.setPosition(new_position)
163 self.widget.setTextCursor(cursor)
165 def tabwidth(self):
166 return self._tabwidth
168 def set_tabwidth(self, width):
169 self._tabwidth = width
170 font = self.widget.font()
171 fm = QtGui.QFontMetrics(font)
172 pixels = fm.width('M' * width)
173 self.widget.setTabStopWidth(pixels)
175 def selected_line(self):
176 contents = self.value()
177 cursor = self.widget.textCursor()
178 offset = min(cursor.position(), len(contents) - 1)
179 while offset >= 1 and contents[offset - 1] and contents[offset - 1] != '\n':
180 offset -= 1
181 data = contents[offset:]
182 if '\n' in data:
183 line, _ = data.split('\n', 1)
184 else:
185 line = data
186 return line
188 def cursor(self):
189 return self.widget.textCursor()
191 def has_selection(self):
192 return self.cursor().hasSelection()
194 def selected_text(self):
195 """Return the selected text"""
196 _, selection = self.offset_and_selection()
197 return selection
199 def offset_and_selection(self):
200 """Return the cursor offset and selected text"""
201 cursor = self.cursor()
202 offset = cursor.selectionStart()
203 selection_text = cursor.selection().toPlainText()
204 return offset, selection_text
206 def mouse_press_event(self, event):
207 # Move the text cursor so that the right-click events operate
208 # on the current position, not the last left-clicked position.
209 widget = self.widget
210 if event.button() == Qt.RightButton:
211 if not widget.textCursor().hasSelection():
212 cursor = widget.cursorForPosition(event.pos())
213 widget.setTextCursor(cursor)
215 # For extension by sub-classes
217 # pylint: disable=no-self-use
218 def init(self):
219 """Called during init for class-specific settings"""
220 return
222 # pylint: disable=no-self-use,unused-argument
223 def set_textwidth(self, width):
224 """Set the text width"""
225 return
227 # pylint: disable=no-self-use,unused-argument
228 def set_linebreak(self, brk):
229 """Enable word wrapping"""
230 return
233 class PlainTextEditExtension(BaseTextEditExtension):
234 def set_linebreak(self, brk):
235 if brk:
236 wrapmode = QtWidgets.QPlainTextEdit.WidgetWidth
237 else:
238 wrapmode = QtWidgets.QPlainTextEdit.NoWrap
239 self.widget.setLineWrapMode(wrapmode)
242 class PlainTextEdit(QtWidgets.QPlainTextEdit):
244 cursor_changed = Signal(int, int)
245 leave = Signal()
247 def __init__(self, parent=None, get_value=None, readonly=False, options=None):
248 QtWidgets.QPlainTextEdit.__init__(self, parent)
249 self.ext = PlainTextEditExtension(self, get_value, readonly)
250 self.cursor_position = self.ext.cursor_position
251 self.mouse_zoom = True
252 self.options = options
254 def get(self):
255 """Return the raw unicode value from Qt"""
256 return self.ext.get()
258 # For compatibility with QTextEdit
259 def setText(self, value):
260 self.set_value(value)
262 def value(self):
263 """Return a safe value, e.g. a stripped value"""
264 return self.ext.value()
266 def offset_and_selection(self):
267 """Return the cursor offset and selected text"""
268 return self.ext.offset_and_selection()
270 def set_value(self, value, block=False):
271 self.ext.set_value(value, block=block)
273 def set_mouse_zoom(self, value):
274 """Enable/disable text zooming in response to ctrl + mousewheel scroll events"""
275 self.mouse_zoom = value
277 def set_options(self, options):
278 """Register an Options widget"""
279 self.options = options
281 def set_word_wrapping(self, enabled, update=False):
282 """Enable/disable word wrapping"""
283 if update and self.options is not None:
284 with qtutils.BlockSignals(self.options.enable_word_wrapping):
285 self.options.enable_word_wrapping.setChecked(enabled)
286 if enabled:
287 self.setWordWrapMode(QtGui.QTextOption.WordWrap)
288 self.setLineWrapMode(QtWidgets.QPlainTextEdit.WidgetWidth)
289 else:
290 self.setWordWrapMode(QtGui.QTextOption.NoWrap)
291 self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
293 def has_selection(self):
294 return self.ext.has_selection()
296 def selected_line(self):
297 return self.ext.selected_line()
299 def selected_text(self):
300 """Return the selected text"""
301 return self.ext.selected_text()
303 def set_tabwidth(self, width):
304 self.ext.set_tabwidth(width)
306 def set_textwidth(self, width):
307 self.ext.set_textwidth(width)
309 def set_linebreak(self, brk):
310 self.ext.set_linebreak(brk)
312 def mousePressEvent(self, event):
313 self.ext.mouse_press_event(event)
314 super(PlainTextEdit, self).mousePressEvent(event)
316 def wheelEvent(self, event):
317 """Disable control+wheelscroll text resizing"""
318 if not self.mouse_zoom and (event.modifiers() & Qt.ControlModifier):
319 event.ignore()
320 return
321 super(PlainTextEdit, self).wheelEvent(event)
323 def contextMenuEvent(self, event):
324 """Generate a custom context menu"""
325 menu = self.createStandardContextMenu()
326 self.add_links_to_menu(menu)
327 menu.exec_(self.mapToGlobal(event.pos()))
329 def add_links_to_menu(self, menu):
330 """Add actions for opening URLs to a custom menu"""
331 links = self._get_links()
332 for url in links:
333 action = menu.addAction(N_('Open "%s"') % url)
334 action.setIcon(icons.external())
335 qtutils.connect_action(
336 action, partial(QtGui.QDesktopServices.openUrl, QtCore.QUrl(url))
339 def _get_links(self):
340 """Return http links on the current line"""
341 _, selection = self.offset_and_selection()
342 if selection:
343 line = selection
344 else:
345 line = self.selected_line()
346 if not line:
347 return []
348 return [
349 word for word in line.split() if word.startswith(('http://', 'https://'))
353 class TextEditExtension(BaseTextEditExtension):
354 def init(self):
355 widget = self.widget
356 widget.setAcceptRichText(False)
358 def set_linebreak(self, brk):
359 if brk:
360 wrapmode = QtWidgets.QTextEdit.FixedColumnWidth
361 else:
362 wrapmode = QtWidgets.QTextEdit.NoWrap
363 self.widget.setLineWrapMode(wrapmode)
365 def set_textwidth(self, width):
366 self.widget.setLineWrapColumnOrWidth(width)
369 class TextEdit(QtWidgets.QTextEdit):
371 cursor_changed = Signal(int, int)
372 leave = Signal()
374 def __init__(self, parent=None, get_value=None, readonly=False):
375 QtWidgets.QTextEdit.__init__(self, parent)
376 self.ext = TextEditExtension(self, get_value, readonly)
377 self.cursor_position = self.ext.cursor_position
378 self.expandtab_enabled = False
380 def get(self):
381 """Return the raw unicode value from Qt"""
382 return self.ext.get()
384 def value(self):
385 """Return a safe value, e.g. a stripped value"""
386 return self.ext.value()
388 def set_value(self, value, block=False):
389 self.ext.set_value(value, block=block)
391 def selected_line(self):
392 return self.ext.selected_line()
394 def selected_text(self):
395 """Return the selected text"""
396 return self.ext.selected_text()
398 def set_tabwidth(self, width):
399 self.ext.set_tabwidth(width)
401 def set_textwidth(self, width):
402 self.ext.set_textwidth(width)
404 def set_linebreak(self, brk):
405 self.ext.set_linebreak(brk)
407 def set_expandtab(self, value):
408 self.expandtab_enabled = value
410 def mousePressEvent(self, event):
411 self.ext.mouse_press_event(event)
412 super(TextEdit, self).mousePressEvent(event)
414 def wheelEvent(self, event):
415 """Disable control+wheelscroll text resizing"""
416 if event.modifiers() & Qt.ControlModifier:
417 event.ignore()
418 return
419 super(TextEdit, self).wheelEvent(event)
421 def should_expandtab(self, event):
422 return event.key() == Qt.Key_Tab and self.expandtab_enabled
424 def expandtab(self):
425 tabwidth = max(self.ext.tabwidth(), 1)
426 cursor = self.textCursor()
427 cursor.insertText(' ' * tabwidth)
429 def keyPressEvent(self, event):
430 expandtab = self.should_expandtab(event)
431 if expandtab:
432 self.expandtab()
433 event.accept()
434 else:
435 QtWidgets.QTextEdit.keyPressEvent(self, event)
437 def keyReleaseEvent(self, event):
438 expandtab = self.should_expandtab(event)
439 if expandtab:
440 event.ignore()
441 else:
442 QtWidgets.QTextEdit.keyReleaseEvent(self, event)
445 class TextEditCursorPosition(object):
446 def __init__(self, widget, ext):
447 self._widget = widget
448 self._ext = ext
449 widget.cursorPositionChanged.connect(self.emit)
451 def emit(self):
452 widget = self._widget
453 ext = self._ext
454 cursor = widget.textCursor()
455 position = cursor.position()
456 txt = widget.get()
457 before = txt[:position]
458 row = before.count('\n')
459 line = before.split('\n')[row]
460 col = cursor.columnNumber()
461 col += line[:col].count('\t') * (ext.tabwidth() - 1)
462 widget.cursor_changed.emit(row + 1, col)
464 def reset(self):
465 widget = self._widget
466 cursor = widget.textCursor()
467 cursor.setPosition(0)
468 widget.setTextCursor(cursor)
471 class MonoTextEdit(PlainTextEdit):
472 def __init__(self, context, parent=None, readonly=False):
473 PlainTextEdit.__init__(self, parent=parent, readonly=readonly)
474 self.setFont(qtutils.diff_font(context))
477 def get_value_hinted(widget):
478 text = get_stripped(widget)
479 hint = get(widget.hint)
480 if text == hint:
481 return ''
482 return text
485 class HintWidget(QtCore.QObject):
486 """Extend a widget to provide hint messages
488 This primarily exists because setPlaceholderText() is only available
489 in Qt5, so this class provides consistent behavior across versions.
493 def __init__(self, widget, hint):
494 QtCore.QObject.__init__(self, widget)
495 self._widget = widget
496 self._hint = hint
497 self._is_error = False
499 self.modern = modern = hasattr(widget, 'setPlaceholderText')
500 if modern:
501 widget.setPlaceholderText(hint)
503 # Palette for normal text
504 QPalette = QtGui.QPalette
505 palette = widget.palette()
507 hint_color = palette.color(QPalette.Disabled, QPalette.Text)
508 error_bg_color = QtGui.QColor(Qt.red).darker()
509 error_fg_color = QtGui.QColor(Qt.white)
511 hint_rgb = qtutils.rgb_css(hint_color)
512 error_bg_rgb = qtutils.rgb_css(error_bg_color)
513 error_fg_rgb = qtutils.rgb_css(error_fg_color)
515 env = dict(
516 name=widget.__class__.__name__,
517 error_fg_rgb=error_fg_rgb,
518 error_bg_rgb=error_bg_rgb,
519 hint_rgb=hint_rgb,
522 self._default_style = ''
524 self._hint_style = (
526 %(name)s {
527 color: %(hint_rgb)s;
530 % env
533 self._error_style = (
535 %(name)s {
536 color: %(error_fg_rgb)s;
537 background-color: %(error_bg_rgb)s;
540 % env
543 def init(self):
544 """Defered initialization"""
545 if self.modern:
546 self.widget().setPlaceholderText(self.value())
547 else:
548 self.widget().installEventFilter(self)
549 self.enable(True)
551 def widget(self):
552 """Return the parent text widget"""
553 return self._widget
555 def active(self):
556 """Return True when hint-mode is active"""
557 return self.value() == get_stripped(self._widget)
559 def value(self):
560 """Return the current hint text"""
561 return self._hint
563 def set_error(self, is_error):
564 """Enable/disable error mode"""
565 self._is_error = is_error
566 self.refresh()
568 def set_value(self, hint):
569 """Change the hint text"""
570 if self.modern:
571 self._hint = hint
572 self._widget.setPlaceholderText(hint)
573 else:
574 # If hint-mode is currently active, re-activate it
575 active = self.active()
576 self._hint = hint
577 if active or self.active():
578 self.enable(True)
580 def enable(self, enable):
581 """Enable/disable hint-mode"""
582 if not self.modern:
583 if enable and self._hint:
584 self._widget.set_value(self._hint, block=True)
585 self._widget.cursor_position.reset()
586 else:
587 self._widget.clear()
588 self._update_palette(enable)
590 def refresh(self):
591 """Update the palette to match the current mode"""
592 self._update_palette(self.active())
594 def _update_palette(self, hint):
595 """Update to palette for normal/error/hint mode"""
596 if self._is_error:
597 style = self._error_style
598 elif not self.modern and hint:
599 style = self._hint_style
600 else:
601 style = self._default_style
602 QtCore.QTimer.singleShot(0, lambda: self._widget.setStyleSheet(style))
604 def eventFilter(self, _obj, event):
605 """Enable/disable hint-mode when focus changes"""
606 etype = event.type()
607 if etype == QtCore.QEvent.FocusIn:
608 self.focus_in()
609 elif etype == QtCore.QEvent.FocusOut:
610 self.focus_out()
611 return False
613 def focus_in(self):
614 """Disable hint-mode when focused"""
615 widget = self.widget()
616 if self.active():
617 self.enable(False)
618 widget.cursor_position.emit()
620 def focus_out(self):
621 """Re-enable hint-mode when losing focus"""
622 widget = self.widget()
623 if not get(widget):
624 self.enable(True)
627 class HintedPlainTextEdit(PlainTextEdit):
628 """A hinted plain text edit"""
630 def __init__(self, context, hint, parent=None, readonly=False):
631 PlainTextEdit.__init__(
632 self, parent=parent, get_value=get_value_hinted, readonly=readonly
634 self.hint = HintWidget(self, hint)
635 self.hint.init()
636 self.context = context
637 self.setFont(qtutils.diff_font(context))
638 self.set_tabwidth(prefs.tabwidth(context))
639 # Refresh palettes when text changes
640 # pylint: disable=no-member
641 self.textChanged.connect(self.hint.refresh)
642 self.set_mouse_zoom(context.cfg.get(prefs.MOUSE_ZOOM, default=True))
644 def set_value(self, value, block=False):
645 """Set the widget text or enable hint mode when empty"""
646 if value or self.hint.modern:
647 PlainTextEdit.set_value(self, value, block=block)
648 else:
649 self.hint.enable(True)
652 class HintedTextEdit(TextEdit):
653 """A hinted text edit"""
655 def __init__(self, context, hint, parent=None, readonly=False):
656 TextEdit.__init__(
657 self, parent=parent, get_value=get_value_hinted, readonly=readonly
659 self.hint = HintWidget(self, hint)
660 self.hint.init()
661 # Refresh palettes when text changes
662 # pylint: disable=no-member
663 self.textChanged.connect(self.hint.refresh)
664 self.setFont(qtutils.diff_font(context))
666 def set_value(self, value, block=False):
667 """Set the widget text or enable hint mode when empty"""
668 if value or self.hint.modern:
669 TextEdit.set_value(self, value, block=block)
670 else:
671 self.hint.enable(True)
674 def anchor_mode(select):
675 """Return the QTextCursor mode to keep/discard the cursor selection"""
676 if select:
677 mode = QtGui.QTextCursor.KeepAnchor
678 else:
679 mode = QtGui.QTextCursor.MoveAnchor
680 return mode
683 # The vim-like read-only text view
686 class VimMixin(object):
687 def __init__(self, widget):
688 self.widget = widget
689 self.Base = widget.Base
690 # Common vim/unix-ish keyboard actions
691 self.add_navigation('End', hotkeys.GOTO_END)
692 self.add_navigation('Up', hotkeys.MOVE_UP, shift=hotkeys.MOVE_UP_SHIFT)
693 self.add_navigation('Down', hotkeys.MOVE_DOWN, shift=hotkeys.MOVE_DOWN_SHIFT)
694 self.add_navigation('Left', hotkeys.MOVE_LEFT, shift=hotkeys.MOVE_LEFT_SHIFT)
695 self.add_navigation('Right', hotkeys.MOVE_RIGHT, shift=hotkeys.MOVE_RIGHT_SHIFT)
696 self.add_navigation('WordLeft', hotkeys.WORD_LEFT)
697 self.add_navigation('WordRight', hotkeys.WORD_RIGHT)
698 self.add_navigation('Start', hotkeys.GOTO_START)
699 self.add_navigation('StartOfLine', hotkeys.START_OF_LINE)
700 self.add_navigation('EndOfLine', hotkeys.END_OF_LINE)
702 qtutils.add_action(
703 widget,
704 'PageUp',
705 widget.page_up,
706 hotkeys.SECONDARY_ACTION,
707 hotkeys.TEXT_UP,
709 qtutils.add_action(
710 widget,
711 'PageDown',
712 widget.page_down,
713 hotkeys.PRIMARY_ACTION,
714 hotkeys.TEXT_DOWN,
716 qtutils.add_action(
717 widget,
718 'SelectPageUp',
719 lambda: widget.page_up(select=True),
720 hotkeys.SELECT_BACK,
721 hotkeys.SELECT_UP,
723 qtutils.add_action(
724 widget,
725 'SelectPageDown',
726 lambda: widget.page_down(select=True),
727 hotkeys.SELECT_FORWARD,
728 hotkeys.SELECT_DOWN,
731 def add_navigation(self, name, hotkey, shift=None):
732 """Add a hotkey along with a shift-variant"""
733 widget = self.widget
734 direction = getattr(QtGui.QTextCursor, name)
735 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
736 if shift:
737 qtutils.add_action(
738 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
741 def move(self, direction, select=False, n=1):
742 widget = self.widget
743 cursor = widget.textCursor()
744 mode = anchor_mode(select)
745 for _ in range(n):
746 if cursor.movePosition(direction, mode, 1):
747 self.set_text_cursor(cursor)
749 def page(self, offset, select=False):
750 widget = self.widget
751 rect = widget.cursorRect()
752 x = rect.x()
753 y = rect.y() + offset
754 new_cursor = widget.cursorForPosition(QtCore.QPoint(x, y))
755 if new_cursor is not None:
756 cursor = widget.textCursor()
757 mode = anchor_mode(select)
758 cursor.setPosition(new_cursor.position(), mode)
760 self.set_text_cursor(cursor)
762 def page_down(self, select=False):
763 widget = self.widget
764 widget.page(widget.height() // 2, select=select)
766 def page_up(self, select=False):
767 widget = self.widget
768 widget.page(-widget.height() // 2, select=select)
770 def set_text_cursor(self, cursor):
771 widget = self.widget
772 widget.setTextCursor(cursor)
773 widget.ensureCursorVisible()
774 widget.viewport().update()
776 def keyPressEvent(self, event):
777 """Custom keyboard behaviors
779 The leave() signal is emitted when `Up` is pressed and we're already
780 at the beginning of the text. This allows the parent widget to
781 orchestrate some higher-level interaction, such as giving focus to
782 another widget.
784 When in the middle of the first line and `Up` is pressed, the cursor
785 is moved to the beginning of the line.
788 widget = self.widget
789 if event.key() == Qt.Key_Up:
790 cursor = widget.textCursor()
791 position = cursor.position()
792 if position == 0:
793 # The cursor is at the beginning of the line.
794 # Emit a signal so that the parent can e.g. change focus.
795 widget.leave.emit()
796 elif get(widget)[:position].count('\n') == 0:
797 # The cursor is in the middle of the first line of text.
798 # We can't go up ~ jump to the beginning of the line.
799 # Select the text if shift is pressed.
800 select = event.modifiers() & Qt.ShiftModifier
801 mode = anchor_mode(select)
802 cursor.movePosition(QtGui.QTextCursor.StartOfLine, mode)
803 widget.setTextCursor(cursor)
805 return self.Base.keyPressEvent(widget, event)
808 # pylint: disable=too-many-ancestors
809 class VimHintedPlainTextEdit(HintedPlainTextEdit):
810 """HintedPlainTextEdit with vim hotkeys
812 This can only be used in read-only mode.
816 Base = HintedPlainTextEdit
817 Mixin = VimMixin
819 def __init__(self, context, hint, parent=None):
820 HintedPlainTextEdit.__init__(self, context, hint, parent=parent, readonly=True)
821 self._mixin = self.Mixin(self)
823 def move(self, direction, select=False, n=1):
824 return self._mixin.page(direction, select=select, n=n)
826 def page(self, offset, select=False):
827 return self._mixin.page(offset, select=select)
829 def page_up(self, select=False):
830 return self._mixin.page_up(select=select)
832 def page_down(self, select=False):
833 return self._mixin.page_down(select=select)
835 def keyPressEvent(self, event):
836 return self._mixin.keyPressEvent(event)
839 # pylint: disable=too-many-ancestors
840 class VimTextEdit(MonoTextEdit):
841 """Text viewer with vim-like hotkeys
843 This can only be used in read-only mode.
847 Base = MonoTextEdit
848 Mixin = VimMixin
850 def __init__(self, context, parent=None, readonly=True):
851 MonoTextEdit.__init__(self, context, parent=None, readonly=readonly)
852 self._mixin = self.Mixin(self)
854 def move(self, direction, select=False, n=1):
855 return self._mixin.page(direction, select=select, n=n)
857 def page(self, offset, select=False):
858 return self._mixin.page(offset, select=select)
860 def page_up(self, select=False):
861 return self._mixin.page_up(select=select)
863 def page_down(self, select=False):
864 return self._mixin.page_down(select=select)
866 def keyPressEvent(self, event):
867 return self._mixin.keyPressEvent(event)
870 class HintedDefaultLineEdit(LineEdit):
871 """A line edit with hint text"""
873 def __init__(self, hint, tooltip=None, parent=None):
874 LineEdit.__init__(self, parent=parent, get_value=get_value_hinted)
875 if tooltip:
876 self.setToolTip(tooltip)
877 self.hint = HintWidget(self, hint)
878 self.hint.init()
879 # pylint: disable=no-member
880 self.textChanged.connect(lambda text: self.hint.refresh())
883 class HintedLineEdit(HintedDefaultLineEdit):
884 """A monospace line edit with hint text"""
886 def __init__(self, context, hint, tooltip=None, parent=None):
887 super(HintedLineEdit, self).__init__(hint, tooltip=tooltip, parent=parent)
888 self.setFont(qtutils.diff_font(context))
891 def text_dialog(context, text, title):
892 """Show a wall of text in a dialog"""
893 parent = qtutils.active_window()
895 label = QtWidgets.QLabel(parent)
896 label.setFont(qtutils.diff_font(context))
897 label.setText(text)
898 label.setMargin(defs.large_margin)
899 text_flags = Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
900 label.setTextInteractionFlags(text_flags)
902 widget = QtWidgets.QDialog(parent)
903 widget.setWindowModality(Qt.WindowModal)
904 widget.setWindowTitle(title)
906 scroll = QtWidgets.QScrollArea()
907 scroll.setWidget(label)
909 layout = qtutils.hbox(defs.margin, defs.spacing, scroll)
910 widget.setLayout(layout)
912 qtutils.add_action(
913 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
915 widget.show()
916 return widget
919 class VimTextBrowser(VimTextEdit):
920 """Text viewer with line number annotations"""
922 def __init__(self, context, parent=None, readonly=True):
923 VimTextEdit.__init__(self, context, parent=parent, readonly=readonly)
924 self.numbers = LineNumbers(self)
926 def resizeEvent(self, event):
927 super(VimTextBrowser, self).resizeEvent(event)
928 self.numbers.refresh_size()
931 class TextDecorator(QtWidgets.QWidget):
932 """Common functionality for providing line numbers in text widgets"""
934 def __init__(self, parent):
935 QtWidgets.QWidget.__init__(self, parent)
936 self.editor = parent
938 parent.blockCountChanged.connect(lambda x: self._refresh_viewport())
939 parent.cursorPositionChanged.connect(self.refresh)
940 parent.updateRequest.connect(self._refresh_rect)
942 def refresh(self):
943 """Refresh the numbers display"""
944 rect = self.editor.viewport().rect()
945 self._refresh_rect(rect, 0)
947 def _refresh_rect(self, rect, dy):
948 if dy:
949 self.scroll(0, dy)
950 else:
951 self.update(0, rect.y(), self.width(), rect.height())
953 if rect.contains(self.editor.viewport().rect()):
954 self._refresh_viewport()
956 def _refresh_viewport(self):
957 self.editor.setViewportMargins(self.width_hint(), 0, 0, 0)
959 def refresh_size(self):
960 rect = self.editor.contentsRect()
961 geom = QtCore.QRect(rect.left(), rect.top(), self.width_hint(), rect.height())
962 self.setGeometry(geom)
964 def sizeHint(self):
965 return QtCore.QSize(self.width_hint(), 0)
968 class LineNumbers(TextDecorator):
969 """Provide line numbers for QPlainTextEdit widgets"""
971 def __init__(self, parent):
972 TextDecorator.__init__(self, parent)
973 self.highlight_line = -1
975 def width_hint(self):
976 document = self.editor.document()
977 digits = int(math.log(max(1, document.blockCount()), 10)) + 2
978 return defs.large_margin + self.fontMetrics().width('0') * digits
980 def set_highlighted(self, line_number):
981 """Set the line to highlight"""
982 self.highlight_line = line_number
984 def paintEvent(self, event):
985 """Paint the line number"""
986 QPalette = QtGui.QPalette
987 painter = QtGui.QPainter(self)
988 editor = self.editor
989 palette = editor.palette()
991 painter.fillRect(event.rect(), palette.color(QPalette.Base))
993 content_offset = editor.contentOffset()
994 block = editor.firstVisibleBlock()
995 width = self.width()
996 event_rect_bottom = event.rect().bottom()
998 highlight = palette.color(QPalette.Highlight)
999 highlighted_text = palette.color(QPalette.HighlightedText)
1000 disabled = palette.color(QPalette.Disabled, QPalette.Text)
1002 while block.isValid():
1003 block_geom = editor.blockBoundingGeometry(block)
1004 block_top = block_geom.translated(content_offset).top()
1005 if not block.isVisible() or block_top >= event_rect_bottom:
1006 break
1008 rect = block_geom.translated(content_offset).toRect()
1009 block_number = block.blockNumber()
1010 if block_number == self.highlight_line:
1011 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
1012 painter.setPen(highlighted_text)
1013 else:
1014 painter.setPen(disabled)
1016 number = '%s' % (block_number + 1)
1017 painter.drawText(
1018 rect.x(),
1019 rect.y(),
1020 self.width() - defs.large_margin,
1021 rect.height(),
1022 Qt.AlignRight | Qt.AlignVCenter,
1023 number,
1025 block = block.next() # pylint: disable=next-method-called