doc: add Thomas to the credits
[git-cola.git] / cola / widgets / completion.py
blob33ab8cf6bf775c31bc5ffbe5001babc8cddf7755
1 from __future__ import division, absolute_import, unicode_literals
2 import re
4 from qtpy import QtCore
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
8 from qtpy.QtCore import Signal
10 from .. import core
11 from .. import gitcmds
12 from .. import icons
13 from .. import qtutils
14 from .. import utils
15 from . import defs
16 from .text import HintedLineEdit
19 class ValidateRegex(object):
21 def __init__(self, regex):
22 self.regex = re.compile(regex) # regex to scrub
24 def validate(self, string, idx):
25 """Scrub and validate the user-supplied input"""
26 state = QtGui.QValidator.Acceptable
27 if self.regex.search(string):
28 string = self.regex.sub('', string) # scrub matching bits
29 idx = min(idx-1, len(string))
30 return (state, string, idx)
33 class RemoteValidator(QtGui.QValidator):
34 """Prevent invalid remote names"""
36 def __init__(self, parent=None):
37 super(RemoteValidator, self).__init__(parent)
38 self._validate = ValidateRegex(r'[ \t\\/]')
40 def validate(self, string, idx):
41 return self._validate.validate(string, idx)
44 class BranchValidator(QtGui.QValidator):
45 """Prevent invalid branch names"""
47 def __init__(self, git, parent=None):
48 super(BranchValidator, self).__init__(parent)
49 self._git = git
50 self._validate = ValidateRegex(r'[ \t\\]') # forward-slash is okay
52 def validate(self, string, idx):
53 """Scrub and validate the user-supplied input"""
54 state, string, idx = self._validate.validate(string, idx)
55 if string: # Allow empty strings
56 status, _, _ = self._git.check_ref_format(string, branch=True)
57 if status != 0:
58 # The intermediate string, when deleting characters, might
59 # end in a name that is invalid to Git, but we must allow it
60 # otherwise we won't be able to delete it using backspace.
61 if string.endswith('/') or string.endswith('.'):
62 state = self.Intermediate
63 else:
64 state = self.Invalid
65 return (state, string, idx)
68 class CompletionLineEdit(HintedLineEdit):
69 """A lineedit with advanced completion abilities"""
71 activated = Signal()
72 changed = Signal()
73 cleared = Signal()
74 enter = Signal()
75 up = Signal()
76 down = Signal()
78 # Activation keys will cause a selected completion item to be chosen
79 ACTIVATION_KEYS = (Qt.Key_Return, Qt.Key_Enter)
81 # Navigation keys trigger signals that widgets can use for customization
82 NAVIGATION_KEYS = {
83 Qt.Key_Return: 'enter',
84 Qt.Key_Enter: 'enter',
85 Qt.Key_Up: 'up',
86 Qt.Key_Down: 'down',
89 def __init__(self, context, model_factory, hint='', parent=None):
90 HintedLineEdit.__init__(self, context, hint, parent=parent)
91 # Tracks when the completion popup was active during key events
93 self.context = context
94 # The most recently selected completion item
95 self._selection = None
97 # Create a completion model
98 completion_model = model_factory(context, self)
99 completer = Completer(completion_model, self)
100 completer.setWidget(self)
101 self._completer = completer
102 self._completion_model = completion_model
104 # The delegate highlights matching completion text in the popup widget
105 self._delegate = HighlightDelegate(self)
106 completer.popup().setItemDelegate(self._delegate)
108 self.textChanged.connect(self._text_changed)
109 self._completer.activated.connect(self.choose_completion)
110 self._completion_model.updated.connect(self._completions_updated,
111 type=Qt.QueuedConnection)
112 self.destroyed.connect(self.dispose)
114 def __del__(self):
115 self.dispose()
117 # pylint: disable=unused-argument
118 def dispose(self, *args):
119 self._completer.dispose()
121 def completion_selection(self):
122 """Return the last completion's selection"""
123 return self._selection
125 def complete(self):
126 """Trigger the completion popup to appear and offer completions"""
127 self._completer.complete()
129 def refresh(self):
130 """Refresh the completion model"""
131 return self._completer.model().update()
133 def popup(self):
134 """Return the completer's popup"""
135 return self._completer.popup()
137 def _is_case_sensitive(self, text):
138 return bool([char for char in text if char.isupper()])
140 def _text_changed(self, full_text):
141 match_text = self._last_word()
142 self._do_text_changed(full_text, match_text)
143 self.complete_last_word()
145 def _do_text_changed(self, full_text, match_text):
146 case_sensitive = self._is_case_sensitive(match_text)
147 if case_sensitive:
148 self._completer.setCaseSensitivity(Qt.CaseSensitive)
149 else:
150 self._completer.setCaseSensitivity(Qt.CaseInsensitive)
151 self._delegate.set_highlight_text(match_text, case_sensitive)
152 self._completer.set_match_text(full_text, match_text, case_sensitive)
154 def update_matches(self):
155 text = self._last_word()
156 case_sensitive = self._is_case_sensitive(text)
157 self._completer.setCompletionPrefix(text)
158 self._completer.model().update_matches(case_sensitive)
160 def choose_completion(self, completion):
162 This is the event handler for the QCompleter.activated(QString) signal,
163 it is called when the user selects an item in the completer popup.
165 if not completion:
166 self._do_text_changed('', '')
167 return
168 words = self._words()
169 if words and not self._ends_with_whitespace():
170 words.pop()
172 words.append(completion)
173 text = core.list2cmdline(words)
174 self.setText(text)
175 self.changed.emit()
176 self._do_text_changed(text, '')
177 self.popup().hide()
179 def _words(self):
180 return utils.shell_split(self.value())
182 def _ends_with_whitespace(self):
183 value = self.value()
184 return value != value.rstrip()
186 def _last_word(self):
187 if self._ends_with_whitespace():
188 return ''
189 words = self._words()
190 if not words:
191 return self.value()
192 if not words[-1]:
193 return ''
194 return words[-1]
196 def complete_last_word(self):
197 self.update_matches()
198 self.complete()
200 def close_popup(self):
201 if self.popup().isVisible():
202 self.popup().close()
204 def _completions_updated(self):
205 popup = self.popup()
206 if not popup.isVisible():
207 return
208 # Select the first item
209 idx = self._completion_model.index(0, 0)
210 selection = QtCore.QItemSelection(idx, idx)
211 mode = QtCore.QItemSelectionModel.Select
212 popup.selectionModel().select(selection, mode)
214 def selected_completion(self):
215 """Return the selected completion item"""
216 popup = self.popup()
217 if not popup.isVisible():
218 return None
219 model = popup.selectionModel()
220 indexes = model.selectedIndexes()
221 if not indexes:
222 return None
223 idx = indexes[0]
224 item = self._completion_model.itemFromIndex(idx)
225 if not item:
226 return None
227 return item.text()
229 def select_completion(self):
230 """Choose the selected completion option from the completion popup"""
231 result = False
232 visible = self.popup().isVisible()
233 if visible:
234 selection = self.selected_completion()
235 if selection:
236 self.choose_completion(selection)
237 result = True
238 return result
240 # Qt overrides
241 def event(self, event):
242 """Override QWidget::event() for tab completion"""
243 event_type = event.type()
245 if (event_type == QtCore.QEvent.KeyPress
246 and event.key() == Qt.Key_Tab
247 and self.select_completion()):
248 return True
250 # Make sure the popup goes away during teardown
251 if event_type == QtCore.QEvent.Hide:
252 self.close_popup()
254 return super(CompletionLineEdit, self).event(event)
256 def keyPressEvent(self, event):
257 """Process completion and navigation events"""
258 super(CompletionLineEdit, self).keyPressEvent(event)
259 visible = self.popup().isVisible()
261 # Hide the popup when the field is empty
262 is_empty = not self.value()
263 if is_empty:
264 self.cleared.emit()
265 if visible:
266 self.popup().hide()
268 # Activation keys select the completion when pressed and emit the
269 # activated signal. Navigation keys have lower priority, and only
270 # emit when it wasn't already handled as an activation event.
271 key = event.key()
272 if key in self.ACTIVATION_KEYS and visible:
273 if self.select_completion():
274 self.activated.emit()
275 return
277 navigation = self.NAVIGATION_KEYS.get(key, None)
278 if navigation:
279 signal = getattr(self, navigation)
280 signal.emit()
283 class GatherCompletionsThread(QtCore.QThread):
285 items_gathered = Signal(object)
287 def __init__(self, model):
288 QtCore.QThread.__init__(self)
289 self.model = model
290 self.case_sensitive = False
291 self.running = False
293 def dispose(self):
294 self.running = False
295 self.wait()
297 def run(self):
298 text = None
299 self.running = True
300 # Loop when the matched text changes between the start and end time.
301 # This happens when gather_matches() takes too long and the
302 # model's match_text changes in-between.
303 while self.running and text != self.model.match_text:
304 text = self.model.match_text
305 items = self.model.gather_matches(self.case_sensitive)
307 if self.running and text is not None:
308 self.items_gathered.emit(items)
311 class HighlightDelegate(QtWidgets.QStyledItemDelegate):
312 """A delegate used for auto-completion to give formatted completion"""
314 def __init__(self, parent):
315 QtWidgets.QStyledItemDelegate.__init__(self, parent)
316 self.widget = parent
317 self.highlight_text = ''
318 self.case_sensitive = False
320 self.doc = QtGui.QTextDocument()
321 # older PyQt4 does not have setDocumentMargin
322 if hasattr(self.doc, 'setDocumentMargin'):
323 self.doc.setDocumentMargin(0)
325 def set_highlight_text(self, text, case_sensitive):
326 """Sets the text that will be made bold when displayed"""
327 self.highlight_text = text
328 self.case_sensitive = case_sensitive
330 def paint(self, painter, option, index):
331 """Overloaded Qt method for custom painting of a model index"""
332 if not self.highlight_text:
333 QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
334 return
335 text = index.data()
336 if self.case_sensitive:
337 html = text.replace(self.highlight_text,
338 '<strong>%s</strong>' % self.highlight_text)
339 else:
340 match = re.match(r'(.*)(%s)(.*)' % re.escape(self.highlight_text),
341 text, re.IGNORECASE)
342 if match:
343 start = match.group(1) or ''
344 middle = match.group(2) or ''
345 end = match.group(3) or ''
346 html = (start + ('<strong>%s</strong>' % middle) + end)
347 else:
348 html = text
349 self.doc.setHtml(html)
351 # Painting item without text, Text Document will paint the text
352 params = QtWidgets.QStyleOptionViewItem(option)
353 self.initStyleOption(params, index)
354 params.text = ''
356 style = QtWidgets.QApplication.style()
357 style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, params, painter)
358 ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
360 # Highlighting text if item is selected
361 if params.state & QtWidgets.QStyle.State_Selected:
362 color = params.palette.color(QtGui.QPalette.Active,
363 QtGui.QPalette.HighlightedText)
364 ctx.palette.setColor(QtGui.QPalette.Text, color)
366 # translate the painter to where the text is drawn
367 item_text = QtWidgets.QStyle.SE_ItemViewItemText
368 rect = style.subElementRect(item_text, params, self.widget)
369 painter.save()
371 start = rect.topLeft() + QtCore.QPoint(defs.margin, 0)
372 painter.translate(start)
374 # tell the text document to draw the html for us
375 self.doc.documentLayout().draw(painter, ctx)
376 painter.restore()
379 def ref_sort_key(ref):
380 """Sort key function that causes shorter refs to sort first, but
381 alphabetizes refs of equal length (in order to make local branches sort
382 before remote ones)."""
383 return len(ref), ref
386 class CompletionModel(QtGui.QStandardItemModel):
388 updated = Signal()
389 items_gathered = Signal(object)
390 model_updated = Signal()
392 def __init__(self, context, parent):
393 QtGui.QStandardItemModel.__init__(self, parent)
394 self.context = context
395 self.match_text = ''
396 self.full_text = ''
397 self.case_sensitive = False
399 self.update_thread = GatherCompletionsThread(self)
400 self.update_thread.items_gathered.connect(self.apply_matches,
401 type=Qt.QueuedConnection)
403 def update(self):
404 case_sensitive = self.update_thread.case_sensitive
405 self.update_matches(case_sensitive)
407 def set_match_text(self, full_text, match_text, case_sensitive):
408 self.full_text = full_text
409 self.match_text = match_text
410 self.update_matches(case_sensitive)
412 def update_matches(self, case_sensitive):
413 self.case_sensitive = case_sensitive
414 self.update_thread.case_sensitive = case_sensitive
415 if not self.update_thread.isRunning():
416 self.update_thread.start()
418 # pylint: disable=unused-argument
419 def gather_matches(self, case_sensitive):
420 return ((), (), set())
422 def apply_matches(self, match_tuple):
423 matched_refs, matched_paths, dirs = match_tuple
424 QStandardItem = QtGui.QStandardItem
426 dir_icon = icons.directory()
427 git_icon = icons.cola()
429 items = []
430 for ref in matched_refs:
431 item = QStandardItem()
432 item.setText(ref)
433 item.setIcon(git_icon)
434 items.append(item)
436 from_filename = icons.from_filename
437 for match in matched_paths:
438 item = QStandardItem()
439 item.setText(match)
440 if match in dirs:
441 item.setIcon(dir_icon)
442 else:
443 item.setIcon(from_filename(match))
444 items.append(item)
446 try:
447 self.clear()
448 self.invisibleRootItem().appendRows(items)
449 self.updated.emit()
450 except RuntimeError: # C++ object has been deleted
451 pass
453 def dispose(self):
454 self.update_thread.dispose()
457 def _identity(x):
458 return x
461 def _lower(x):
462 return x.lower()
465 def filter_matches(match_text, candidates, case_sensitive,
466 sort_key=lambda x: x):
467 """Filter candidates and return the matches"""
469 if case_sensitive:
470 case_transform = _identity
471 else:
472 case_transform = _lower
474 if match_text:
475 match_text = case_transform(match_text)
476 matches = [r for r in candidates if match_text in case_transform(r)]
477 else:
478 matches = list(candidates)
480 matches.sort(key=lambda x: sort_key(case_transform(x)))
481 return matches
484 def filter_path_matches(match_text, file_list, case_sensitive):
485 """Return matching completions from a list of candidate files"""
487 files = set(file_list)
488 files_and_dirs = utils.add_parents(files)
489 dirs = files_and_dirs.difference(files)
491 paths = filter_matches(match_text, files_and_dirs, case_sensitive)
492 return (paths, dirs)
495 class Completer(QtWidgets.QCompleter):
497 def __init__(self, model, parent):
498 QtWidgets.QCompleter.__init__(self, parent)
499 self._model = model
500 self.setCompletionMode(QtWidgets.QCompleter.UnfilteredPopupCompletion)
501 self.setCaseSensitivity(Qt.CaseInsensitive)
503 model.model_updated.connect(self.update, type=Qt.QueuedConnection)
504 self.setModel(model)
506 def update(self):
507 self._model.update()
509 def dispose(self):
510 self._model.dispose()
512 def set_match_text(self, full_text, match_text, case_sensitive):
513 self._model.set_match_text(full_text, match_text, case_sensitive)
516 class GitCompletionModel(CompletionModel):
518 def __init__(self, context, parent):
519 CompletionModel.__init__(self, context, parent)
520 self.main_model = model = context.model
521 msg = model.message_updated
522 model.add_observer(msg, self.emit_model_updated)
524 def gather_matches(self, case_sensitive):
525 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
526 sort_key=ref_sort_key)
527 return (refs, (), set())
529 def emit_model_updated(self):
530 try:
531 self.model_updated.emit()
532 except RuntimeError: # C++ object has been deleted
533 self.dispose()
535 def matches(self):
536 return []
538 def dispose(self):
539 super(GitCompletionModel, self).dispose()
540 self.main_model.remove_observer(self.emit_model_updated)
543 class GitRefCompletionModel(GitCompletionModel):
544 """Completer for branches and tags"""
546 def __init__(self, context, parent):
547 GitCompletionModel.__init__(self, context, parent)
549 def matches(self):
550 model = self.main_model
551 return model.local_branches + model.remote_branches + model.tags
554 def find_potential_branches(model):
555 remotes = model.remotes
556 remote_branches = model.remote_branches
558 ambiguous = set()
559 allnames = set(model.local_branches)
560 potential = []
562 for remote_branch in remote_branches:
563 branch = gitcmds.strip_remote(remotes, remote_branch)
564 if branch in allnames or branch == remote_branch:
565 ambiguous.add(branch)
566 continue
567 potential.append(branch)
568 allnames.add(branch)
570 potential_branches = [p for p in potential if p not in ambiguous]
571 return potential_branches
574 class GitCreateBranchCompletionModel(GitCompletionModel):
575 """Completer for naming new branches"""
577 def matches(self):
578 model = self.main_model
579 potential_branches = find_potential_branches(model)
580 return (model.local_branches +
581 potential_branches +
582 model.tags)
585 class GitCheckoutBranchCompletionModel(GitCompletionModel):
586 """Completer for git checkout <branch>"""
588 def matches(self):
589 model = self.main_model
590 potential_branches = find_potential_branches(model)
591 return (model.local_branches +
592 potential_branches +
593 model.remote_branches +
594 model.tags)
597 class GitBranchCompletionModel(GitCompletionModel):
598 """Completer for local branches"""
600 def __init__(self, context, parent):
601 GitCompletionModel.__init__(self, context, parent)
603 def matches(self):
604 model = self.main_model
605 return model.local_branches
608 class GitRemoteBranchCompletionModel(GitCompletionModel):
609 """Completer for remote branches"""
611 def __init__(self, context, parent):
612 GitCompletionModel.__init__(self, context, parent)
614 def matches(self):
615 model = self.main_model
616 return model.remote_branches
619 class GitPathCompletionModel(GitCompletionModel):
620 """Base class for path completion"""
622 def __init__(self, context, parent):
623 GitCompletionModel.__init__(self, context, parent)
625 def candidate_paths(self):
626 return []
628 def gather_matches(self, case_sensitive):
629 paths, dirs = filter_path_matches(self.match_text,
630 self.candidate_paths(),
631 case_sensitive)
632 return ((), paths, dirs)
635 class GitStatusFilterCompletionModel(GitPathCompletionModel):
636 """Completer for modified files and folders for status filtering"""
638 def __init__(self, context, parent):
639 GitPathCompletionModel.__init__(self, context, parent)
641 def candidate_paths(self):
642 model = self.main_model
643 return (model.staged + model.unmerged +
644 model.modified + model.untracked)
647 class GitTrackedCompletionModel(GitPathCompletionModel):
648 """Completer for tracked files and folders"""
650 def __init__(self, context, parent):
651 GitPathCompletionModel.__init__(self, context, parent)
652 self.model_updated.connect(self.gather_paths, type=Qt.QueuedConnection)
653 self._paths = []
655 def gather_paths(self):
656 context = self.context
657 self._paths = gitcmds.tracked_files(context)
659 def gather_matches(self, case_sensitive):
660 if not self._paths:
661 self.gather_paths()
663 refs = []
664 paths, dirs = filter_path_matches(self.match_text, self._paths,
665 case_sensitive)
666 return (refs, paths, dirs)
669 class GitLogCompletionModel(GitRefCompletionModel):
670 """Completer for arguments suitable for git-log like commands"""
672 def __init__(self, context, parent):
673 GitRefCompletionModel.__init__(self, context, parent)
674 self.model_updated.connect(self.gather_paths, type=Qt.QueuedConnection)
675 self._paths = []
677 def gather_paths(self):
678 context = self.context
679 self._paths = gitcmds.tracked_files(context)
681 def gather_matches(self, case_sensitive):
682 if not self._paths:
683 self.gather_paths()
684 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
685 sort_key=ref_sort_key)
686 paths, dirs = filter_path_matches(self.match_text, self._paths,
687 case_sensitive)
688 has_doubledash = (self.match_text == '--' or
689 self.full_text.startswith('-- ') or
690 ' -- ' in self.full_text)
691 if has_doubledash:
692 refs = []
693 elif refs and paths:
694 paths.insert(0, '--')
696 return (refs, paths, dirs)
699 def bind_lineedit(model, hint=''):
700 """Create a line edit bound against a specific model"""
702 class BoundLineEdit(CompletionLineEdit):
704 def __init__(self, context, hint=hint, parent=None):
705 CompletionLineEdit.__init__(self, context, model,
706 hint=hint, parent=parent)
707 self.context = context
709 return BoundLineEdit
712 # Concrete classes
713 GitLogLineEdit = bind_lineedit(GitLogCompletionModel, hint='<ref>')
714 GitRefLineEdit = bind_lineedit(GitRefCompletionModel, hint='<ref>')
715 GitCheckoutBranchLineEdit = bind_lineedit(GitCheckoutBranchCompletionModel,
716 hint='<branch>')
717 GitCreateBranchLineEdit = bind_lineedit(GitCreateBranchCompletionModel,
718 hint='<branch>')
719 GitBranchLineEdit = bind_lineedit(GitBranchCompletionModel, hint='<branch>')
720 GitRemoteBranchLineEdit = bind_lineedit(GitRemoteBranchCompletionModel,
721 hint='<remote-branch>')
722 GitStatusFilterLineEdit = bind_lineedit(GitStatusFilterCompletionModel,
723 hint='<path>')
724 GitTrackedLineEdit = bind_lineedit(GitTrackedCompletionModel, hint='<path>')
727 class GitDialog(QtWidgets.QDialog):
729 def __init__(self, lineedit, context, title, text, parent, icon=None):
730 QtWidgets.QDialog.__init__(self, parent)
731 self.context = context
732 self.setWindowTitle(title)
733 self.setWindowModality(Qt.WindowModal)
734 self.setMinimumWidth(333)
736 self.label = QtWidgets.QLabel()
737 self.label.setText(title)
738 self.lineedit = lineedit(context)
739 self.ok_button = qtutils.ok_button(text, icon=icon, enabled=False)
740 self.close_button = qtutils.close_button()
742 self.button_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
743 qtutils.STRETCH,
744 self.ok_button, self.close_button)
746 self.main_layout = qtutils.vbox(defs.margin, defs.spacing,
747 self.label, self.lineedit,
748 self.button_layout)
749 self.setLayout(self.main_layout)
751 self.lineedit.textChanged.connect(self.text_changed)
752 self.lineedit.enter.connect(self.accept)
753 qtutils.connect_button(self.ok_button, self.accept)
754 qtutils.connect_button(self.close_button, self.reject)
756 self.setFocusProxy(self.lineedit)
757 self.lineedit.setFocus(True)
759 def text(self):
760 return self.lineedit.text()
762 def text_changed(self, _txt):
763 self.ok_button.setEnabled(bool(self.text()))
765 def set_text(self, ref):
766 self.lineedit.setText(ref)
768 @classmethod
769 def get(cls, context, title, text, parent, default=None, icon=None):
770 dlg = cls(context, title, text, parent, icon=icon)
771 if default:
772 dlg.set_text(default)
774 dlg.show()
776 def show_popup():
777 x = dlg.lineedit.x()
778 y = dlg.lineedit.y() + dlg.lineedit.height()
779 point = QtCore.QPoint(x, y)
780 mapped = dlg.mapToGlobal(point)
781 dlg.lineedit.popup().move(mapped.x(), mapped.y())
782 dlg.lineedit.popup().show()
783 dlg.lineedit.refresh()
784 dlg.lineedit.setFocus(True)
786 QtCore.QTimer().singleShot(100, show_popup)
788 if dlg.exec_() == cls.Accepted:
789 return dlg.text()
790 return None
793 class GitRefDialog(GitDialog):
795 def __init__(self, context, title, text, parent, icon=None):
796 GitDialog.__init__(
797 self, GitRefLineEdit, context, title, text, parent, icon=icon)
800 class GitCheckoutBranchDialog(GitDialog):
802 def __init__(self, context, title, text, parent, icon=None):
803 GitDialog.__init__(self, GitCheckoutBranchLineEdit,
804 context, title, text, parent, icon=icon)
807 class GitBranchDialog(GitDialog):
809 def __init__(self, context, title, text, parent, icon=None):
810 GitDialog.__init__(
811 self, GitBranchLineEdit, context, title, text, parent, icon=icon)
814 class GitRemoteBranchDialog(GitDialog):
816 def __init__(self, context, title, text, parent, icon=None):
817 GitDialog.__init__(self, GitRemoteBranchLineEdit,
818 context, title, text, parent, icon=icon)