spellcheck: allow the spellcheck object to be externally provided
[git-cola.git] / cola / widgets / text.py
blob31b58f67ee8a2d103bc1c177a9ec7561cc60321c
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.context = context
660 self.hint = HintWidget(self, hint)
661 self.hint.init()
662 # Refresh palettes when text changes
663 # pylint: disable=no-member
664 self.textChanged.connect(self.hint.refresh)
665 self.setFont(qtutils.diff_font(context))
667 def set_value(self, value, block=False):
668 """Set the widget text or enable hint mode when empty"""
669 if value or self.hint.modern:
670 TextEdit.set_value(self, value, block=block)
671 else:
672 self.hint.enable(True)
675 def anchor_mode(select):
676 """Return the QTextCursor mode to keep/discard the cursor selection"""
677 if select:
678 mode = QtGui.QTextCursor.KeepAnchor
679 else:
680 mode = QtGui.QTextCursor.MoveAnchor
681 return mode
684 # The vim-like read-only text view
687 class VimMixin(object):
688 def __init__(self, widget):
689 self.widget = widget
690 self.Base = widget.Base
691 # Common vim/unix-ish keyboard actions
692 self.add_navigation('End', hotkeys.GOTO_END)
693 self.add_navigation('Up', hotkeys.MOVE_UP, shift=hotkeys.MOVE_UP_SHIFT)
694 self.add_navigation('Down', hotkeys.MOVE_DOWN, shift=hotkeys.MOVE_DOWN_SHIFT)
695 self.add_navigation('Left', hotkeys.MOVE_LEFT, shift=hotkeys.MOVE_LEFT_SHIFT)
696 self.add_navigation('Right', hotkeys.MOVE_RIGHT, shift=hotkeys.MOVE_RIGHT_SHIFT)
697 self.add_navigation('WordLeft', hotkeys.WORD_LEFT)
698 self.add_navigation('WordRight', hotkeys.WORD_RIGHT)
699 self.add_navigation('Start', hotkeys.GOTO_START)
700 self.add_navigation('StartOfLine', hotkeys.START_OF_LINE)
701 self.add_navigation('EndOfLine', hotkeys.END_OF_LINE)
703 qtutils.add_action(
704 widget,
705 'PageUp',
706 widget.page_up,
707 hotkeys.SECONDARY_ACTION,
708 hotkeys.TEXT_UP,
710 qtutils.add_action(
711 widget,
712 'PageDown',
713 widget.page_down,
714 hotkeys.PRIMARY_ACTION,
715 hotkeys.TEXT_DOWN,
717 qtutils.add_action(
718 widget,
719 'SelectPageUp',
720 lambda: widget.page_up(select=True),
721 hotkeys.SELECT_BACK,
722 hotkeys.SELECT_UP,
724 qtutils.add_action(
725 widget,
726 'SelectPageDown',
727 lambda: widget.page_down(select=True),
728 hotkeys.SELECT_FORWARD,
729 hotkeys.SELECT_DOWN,
732 def add_navigation(self, name, hotkey, shift=None):
733 """Add a hotkey along with a shift-variant"""
734 widget = self.widget
735 direction = getattr(QtGui.QTextCursor, name)
736 qtutils.add_action(widget, name, lambda: self.move(direction), hotkey)
737 if shift:
738 qtutils.add_action(
739 widget, 'Shift' + name, lambda: self.move(direction, select=True), shift
742 def move(self, direction, select=False, n=1):
743 widget = self.widget
744 cursor = widget.textCursor()
745 mode = anchor_mode(select)
746 for _ in range(n):
747 if cursor.movePosition(direction, mode, 1):
748 self.set_text_cursor(cursor)
750 def page(self, offset, select=False):
751 widget = self.widget
752 rect = widget.cursorRect()
753 x = rect.x()
754 y = rect.y() + offset
755 new_cursor = widget.cursorForPosition(QtCore.QPoint(x, y))
756 if new_cursor is not None:
757 cursor = widget.textCursor()
758 mode = anchor_mode(select)
759 cursor.setPosition(new_cursor.position(), mode)
761 self.set_text_cursor(cursor)
763 def page_down(self, select=False):
764 widget = self.widget
765 widget.page(widget.height() // 2, select=select)
767 def page_up(self, select=False):
768 widget = self.widget
769 widget.page(-widget.height() // 2, select=select)
771 def set_text_cursor(self, cursor):
772 widget = self.widget
773 widget.setTextCursor(cursor)
774 widget.ensureCursorVisible()
775 widget.viewport().update()
777 def keyPressEvent(self, event):
778 """Custom keyboard behaviors
780 The leave() signal is emitted when `Up` is pressed and we're already
781 at the beginning of the text. This allows the parent widget to
782 orchestrate some higher-level interaction, such as giving focus to
783 another widget.
785 When in the middle of the first line and `Up` is pressed, the cursor
786 is moved to the beginning of the line.
789 widget = self.widget
790 if event.key() == Qt.Key_Up:
791 cursor = widget.textCursor()
792 position = cursor.position()
793 if position == 0:
794 # The cursor is at the beginning of the line.
795 # Emit a signal so that the parent can e.g. change focus.
796 widget.leave.emit()
797 elif get(widget)[:position].count('\n') == 0:
798 # The cursor is in the middle of the first line of text.
799 # We can't go up ~ jump to the beginning of the line.
800 # Select the text if shift is pressed.
801 select = event.modifiers() & Qt.ShiftModifier
802 mode = anchor_mode(select)
803 cursor.movePosition(QtGui.QTextCursor.StartOfLine, mode)
804 widget.setTextCursor(cursor)
806 return self.Base.keyPressEvent(widget, event)
809 # pylint: disable=too-many-ancestors
810 class VimHintedPlainTextEdit(HintedPlainTextEdit):
811 """HintedPlainTextEdit with vim hotkeys
813 This can only be used in read-only mode.
817 Base = HintedPlainTextEdit
818 Mixin = VimMixin
820 def __init__(self, context, hint, parent=None):
821 HintedPlainTextEdit.__init__(self, context, hint, parent=parent, readonly=True)
822 self._mixin = self.Mixin(self)
824 def move(self, direction, select=False, n=1):
825 return self._mixin.page(direction, select=select, n=n)
827 def page(self, offset, select=False):
828 return self._mixin.page(offset, select=select)
830 def page_up(self, select=False):
831 return self._mixin.page_up(select=select)
833 def page_down(self, select=False):
834 return self._mixin.page_down(select=select)
836 def keyPressEvent(self, event):
837 return self._mixin.keyPressEvent(event)
840 # pylint: disable=too-many-ancestors
841 class VimTextEdit(MonoTextEdit):
842 """Text viewer with vim-like hotkeys
844 This can only be used in read-only mode.
848 Base = MonoTextEdit
849 Mixin = VimMixin
851 def __init__(self, context, parent=None, readonly=True):
852 MonoTextEdit.__init__(self, context, parent=None, readonly=readonly)
853 self._mixin = self.Mixin(self)
855 def move(self, direction, select=False, n=1):
856 return self._mixin.page(direction, select=select, n=n)
858 def page(self, offset, select=False):
859 return self._mixin.page(offset, select=select)
861 def page_up(self, select=False):
862 return self._mixin.page_up(select=select)
864 def page_down(self, select=False):
865 return self._mixin.page_down(select=select)
867 def keyPressEvent(self, event):
868 return self._mixin.keyPressEvent(event)
871 class HintedDefaultLineEdit(LineEdit):
872 """A line edit with hint text"""
874 def __init__(self, hint, tooltip=None, parent=None):
875 LineEdit.__init__(self, parent=parent, get_value=get_value_hinted)
876 if tooltip:
877 self.setToolTip(tooltip)
878 self.hint = HintWidget(self, hint)
879 self.hint.init()
880 # pylint: disable=no-member
881 self.textChanged.connect(lambda text: self.hint.refresh())
884 class HintedLineEdit(HintedDefaultLineEdit):
885 """A monospace line edit with hint text"""
887 def __init__(self, context, hint, tooltip=None, parent=None):
888 super(HintedLineEdit, self).__init__(hint, tooltip=tooltip, parent=parent)
889 self.setFont(qtutils.diff_font(context))
892 def text_dialog(context, text, title):
893 """Show a wall of text in a dialog"""
894 parent = qtutils.active_window()
896 label = QtWidgets.QLabel(parent)
897 label.setFont(qtutils.diff_font(context))
898 label.setText(text)
899 label.setMargin(defs.large_margin)
900 text_flags = Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse
901 label.setTextInteractionFlags(text_flags)
903 widget = QtWidgets.QDialog(parent)
904 widget.setWindowModality(Qt.WindowModal)
905 widget.setWindowTitle(title)
907 scroll = QtWidgets.QScrollArea()
908 scroll.setWidget(label)
910 layout = qtutils.hbox(defs.margin, defs.spacing, scroll)
911 widget.setLayout(layout)
913 qtutils.add_action(
914 widget, N_('Close'), widget.accept, Qt.Key_Question, Qt.Key_Enter, Qt.Key_Return
916 widget.show()
917 return widget
920 class VimTextBrowser(VimTextEdit):
921 """Text viewer with line number annotations"""
923 def __init__(self, context, parent=None, readonly=True):
924 VimTextEdit.__init__(self, context, parent=parent, readonly=readonly)
925 self.numbers = LineNumbers(self)
927 def resizeEvent(self, event):
928 super(VimTextBrowser, self).resizeEvent(event)
929 self.numbers.refresh_size()
932 class TextDecorator(QtWidgets.QWidget):
933 """Common functionality for providing line numbers in text widgets"""
935 def __init__(self, parent):
936 QtWidgets.QWidget.__init__(self, parent)
937 self.editor = parent
939 parent.blockCountChanged.connect(lambda x: self._refresh_viewport())
940 parent.cursorPositionChanged.connect(self.refresh)
941 parent.updateRequest.connect(self._refresh_rect)
943 def refresh(self):
944 """Refresh the numbers display"""
945 rect = self.editor.viewport().rect()
946 self._refresh_rect(rect, 0)
948 def _refresh_rect(self, rect, dy):
949 if dy:
950 self.scroll(0, dy)
951 else:
952 self.update(0, rect.y(), self.width(), rect.height())
954 if rect.contains(self.editor.viewport().rect()):
955 self._refresh_viewport()
957 def _refresh_viewport(self):
958 self.editor.setViewportMargins(self.width_hint(), 0, 0, 0)
960 def refresh_size(self):
961 rect = self.editor.contentsRect()
962 geom = QtCore.QRect(rect.left(), rect.top(), self.width_hint(), rect.height())
963 self.setGeometry(geom)
965 def sizeHint(self):
966 return QtCore.QSize(self.width_hint(), 0)
969 class LineNumbers(TextDecorator):
970 """Provide line numbers for QPlainTextEdit widgets"""
972 def __init__(self, parent):
973 TextDecorator.__init__(self, parent)
974 self.highlight_line = -1
976 def width_hint(self):
977 document = self.editor.document()
978 digits = int(math.log(max(1, document.blockCount()), 10)) + 2
979 return defs.large_margin + self.fontMetrics().width('0') * digits
981 def set_highlighted(self, line_number):
982 """Set the line to highlight"""
983 self.highlight_line = line_number
985 def paintEvent(self, event):
986 """Paint the line number"""
987 QPalette = QtGui.QPalette
988 painter = QtGui.QPainter(self)
989 editor = self.editor
990 palette = editor.palette()
992 painter.fillRect(event.rect(), palette.color(QPalette.Base))
994 content_offset = editor.contentOffset()
995 block = editor.firstVisibleBlock()
996 width = self.width()
997 event_rect_bottom = event.rect().bottom()
999 highlight = palette.color(QPalette.Highlight)
1000 highlighted_text = palette.color(QPalette.HighlightedText)
1001 disabled = palette.color(QPalette.Disabled, QPalette.Text)
1003 while block.isValid():
1004 block_geom = editor.blockBoundingGeometry(block)
1005 block_top = block_geom.translated(content_offset).top()
1006 if not block.isVisible() or block_top >= event_rect_bottom:
1007 break
1009 rect = block_geom.translated(content_offset).toRect()
1010 block_number = block.blockNumber()
1011 if block_number == self.highlight_line:
1012 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
1013 painter.setPen(highlighted_text)
1014 else:
1015 painter.setPen(disabled)
1017 number = '%s' % (block_number + 1)
1018 painter.drawText(
1019 rect.x(),
1020 rect.y(),
1021 self.width() - defs.large_margin,
1022 rect.height(),
1023 Qt.AlignRight | Qt.AlignVCenter,
1024 number,
1026 block = block.next() # pylint: disable=next-method-called