maint: prefer functions over methods
[git-cola.git] / cola / widgets / completion.py
blob40245be9c31aa713addb2fdba38924623c4838e5
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 def _is_case_sensitive(text):
69 return bool([char for char in text if char.isupper()])
72 class CompletionLineEdit(HintedLineEdit):
73 """A lineedit with advanced completion abilities"""
75 activated = Signal()
76 changed = Signal()
77 cleared = Signal()
78 enter = Signal()
79 up = Signal()
80 down = Signal()
82 # Activation keys will cause a selected completion item to be chosen
83 ACTIVATION_KEYS = (Qt.Key_Return, Qt.Key_Enter)
85 # Navigation keys trigger signals that widgets can use for customization
86 NAVIGATION_KEYS = {
87 Qt.Key_Return: 'enter',
88 Qt.Key_Enter: 'enter',
89 Qt.Key_Up: 'up',
90 Qt.Key_Down: 'down',
93 def __init__(self, context, model_factory, hint='', parent=None):
94 HintedLineEdit.__init__(self, context, hint, parent=parent)
95 # Tracks when the completion popup was active during key events
97 self.context = context
98 # The most recently selected completion item
99 self._selection = None
101 # Create a completion model
102 completion_model = model_factory(context, self)
103 completer = Completer(completion_model, self)
104 completer.setWidget(self)
105 self._completer = completer
106 self._completion_model = completion_model
108 # The delegate highlights matching completion text in the popup widget
109 self._delegate = HighlightDelegate(self)
110 completer.popup().setItemDelegate(self._delegate)
112 self.textChanged.connect(self._text_changed)
113 self._completer.activated.connect(self.choose_completion)
114 self._completion_model.updated.connect(self._completions_updated,
115 type=Qt.QueuedConnection)
116 self.destroyed.connect(self.dispose)
118 def __del__(self):
119 self.dispose()
121 # pylint: disable=unused-argument
122 def dispose(self, *args):
123 self._completer.dispose()
125 def completion_selection(self):
126 """Return the last completion's selection"""
127 return self._selection
129 def complete(self):
130 """Trigger the completion popup to appear and offer completions"""
131 self._completer.complete()
133 def refresh(self):
134 """Refresh the completion model"""
135 return self._completer.model().update()
137 def popup(self):
138 """Return the completer's popup"""
139 return self._completer.popup()
141 def _text_changed(self, full_text):
142 match_text = self._last_word()
143 self._do_text_changed(full_text, match_text)
144 self.complete_last_word()
146 def _do_text_changed(self, full_text, match_text):
147 case_sensitive = _is_case_sensitive(match_text)
148 if case_sensitive:
149 self._completer.setCaseSensitivity(Qt.CaseSensitive)
150 else:
151 self._completer.setCaseSensitivity(Qt.CaseInsensitive)
152 self._delegate.set_highlight_text(match_text, case_sensitive)
153 self._completer.set_match_text(full_text, match_text, case_sensitive)
155 def update_matches(self):
156 text = self._last_word()
157 case_sensitive = _is_case_sensitive(text)
158 self._completer.setCompletionPrefix(text)
159 self._completer.model().update_matches(case_sensitive)
161 def choose_completion(self, completion):
163 This is the event handler for the QCompleter.activated(QString) signal,
164 it is called when the user selects an item in the completer popup.
166 if not completion:
167 self._do_text_changed('', '')
168 return
169 words = self._words()
170 if words and not self._ends_with_whitespace():
171 words.pop()
173 words.append(completion)
174 text = core.list2cmdline(words)
175 self.setText(text)
176 self.changed.emit()
177 self._do_text_changed(text, '')
178 self.popup().hide()
180 def _words(self):
181 return utils.shell_split(self.value())
183 def _ends_with_whitespace(self):
184 value = self.value()
185 return value != value.rstrip()
187 def _last_word(self):
188 if self._ends_with_whitespace():
189 return ''
190 words = self._words()
191 if not words:
192 return self.value()
193 if not words[-1]:
194 return ''
195 return words[-1]
197 def complete_last_word(self):
198 self.update_matches()
199 self.complete()
201 def close_popup(self):
202 if self.popup().isVisible():
203 self.popup().close()
205 def _completions_updated(self):
206 popup = self.popup()
207 if not popup.isVisible():
208 return
209 # Select the first item
210 idx = self._completion_model.index(0, 0)
211 selection = QtCore.QItemSelection(idx, idx)
212 mode = QtCore.QItemSelectionModel.Select
213 popup.selectionModel().select(selection, mode)
215 def selected_completion(self):
216 """Return the selected completion item"""
217 popup = self.popup()
218 if not popup.isVisible():
219 return None
220 model = popup.selectionModel()
221 indexes = model.selectedIndexes()
222 if not indexes:
223 return None
224 idx = indexes[0]
225 item = self._completion_model.itemFromIndex(idx)
226 if not item:
227 return None
228 return item.text()
230 def select_completion(self):
231 """Choose the selected completion option from the completion popup"""
232 result = False
233 visible = self.popup().isVisible()
234 if visible:
235 selection = self.selected_completion()
236 if selection:
237 self.choose_completion(selection)
238 result = True
239 return result
241 # Qt overrides
242 def event(self, event):
243 """Override QWidget::event() for tab completion"""
244 event_type = event.type()
246 if (event_type == QtCore.QEvent.KeyPress
247 and event.key() == Qt.Key_Tab
248 and self.select_completion()):
249 return True
251 # Make sure the popup goes away during teardown
252 if event_type == QtCore.QEvent.Hide:
253 self.close_popup()
255 return super(CompletionLineEdit, self).event(event)
257 def keyPressEvent(self, event):
258 """Process completion and navigation events"""
259 super(CompletionLineEdit, self).keyPressEvent(event)
260 visible = self.popup().isVisible()
262 # Hide the popup when the field is empty
263 is_empty = not self.value()
264 if is_empty:
265 self.cleared.emit()
266 if visible:
267 self.popup().hide()
269 # Activation keys select the completion when pressed and emit the
270 # activated signal. Navigation keys have lower priority, and only
271 # emit when it wasn't already handled as an activation event.
272 key = event.key()
273 if key in self.ACTIVATION_KEYS and visible:
274 if self.select_completion():
275 self.activated.emit()
276 return
278 navigation = self.NAVIGATION_KEYS.get(key, None)
279 if navigation:
280 signal = getattr(self, navigation)
281 signal.emit()
284 class GatherCompletionsThread(QtCore.QThread):
286 items_gathered = Signal(object)
288 def __init__(self, model):
289 QtCore.QThread.__init__(self)
290 self.model = model
291 self.case_sensitive = False
292 self.running = False
294 def dispose(self):
295 self.running = False
296 self.wait()
298 def run(self):
299 text = None
300 self.running = True
301 # Loop when the matched text changes between the start and end time.
302 # This happens when gather_matches() takes too long and the
303 # model's match_text changes in-between.
304 while self.running and text != self.model.match_text:
305 text = self.model.match_text
306 items = self.model.gather_matches(self.case_sensitive)
308 if self.running and text is not None:
309 self.items_gathered.emit(items)
312 class HighlightDelegate(QtWidgets.QStyledItemDelegate):
313 """A delegate used for auto-completion to give formatted completion"""
315 def __init__(self, parent):
316 QtWidgets.QStyledItemDelegate.__init__(self, parent)
317 self.widget = parent
318 self.highlight_text = ''
319 self.case_sensitive = False
321 self.doc = QtGui.QTextDocument()
322 # older PyQt4 does not have setDocumentMargin
323 if hasattr(self.doc, 'setDocumentMargin'):
324 self.doc.setDocumentMargin(0)
326 def set_highlight_text(self, text, case_sensitive):
327 """Sets the text that will be made bold when displayed"""
328 self.highlight_text = text
329 self.case_sensitive = case_sensitive
331 def paint(self, painter, option, index):
332 """Overloaded Qt method for custom painting of a model index"""
333 if not self.highlight_text:
334 QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
335 return
336 text = index.data()
337 if self.case_sensitive:
338 html = text.replace(self.highlight_text,
339 '<strong>%s</strong>' % self.highlight_text)
340 else:
341 match = re.match(r'(.*)(%s)(.*)' % re.escape(self.highlight_text),
342 text, re.IGNORECASE)
343 if match:
344 start = match.group(1) or ''
345 middle = match.group(2) or ''
346 end = match.group(3) or ''
347 html = (start + ('<strong>%s</strong>' % middle) + end)
348 else:
349 html = text
350 self.doc.setHtml(html)
352 # Painting item without text, Text Document will paint the text
353 params = QtWidgets.QStyleOptionViewItem(option)
354 self.initStyleOption(params, index)
355 params.text = ''
357 style = QtWidgets.QApplication.style()
358 style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, params, painter)
359 ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
361 # Highlighting text if item is selected
362 if params.state & QtWidgets.QStyle.State_Selected:
363 color = params.palette.color(QtGui.QPalette.Active,
364 QtGui.QPalette.HighlightedText)
365 ctx.palette.setColor(QtGui.QPalette.Text, color)
367 # translate the painter to where the text is drawn
368 item_text = QtWidgets.QStyle.SE_ItemViewItemText
369 rect = style.subElementRect(item_text, params, self.widget)
370 painter.save()
372 start = rect.topLeft() + QtCore.QPoint(defs.margin, 0)
373 painter.translate(start)
375 # tell the text document to draw the html for us
376 self.doc.documentLayout().draw(painter, ctx)
377 painter.restore()
380 def ref_sort_key(ref):
381 """Sort key function that causes shorter refs to sort first, but
382 alphabetizes refs of equal length (in order to make local branches sort
383 before remote ones)."""
384 return len(ref), ref
387 class CompletionModel(QtGui.QStandardItemModel):
389 updated = Signal()
390 items_gathered = Signal(object)
391 model_updated = Signal()
393 def __init__(self, context, parent):
394 QtGui.QStandardItemModel.__init__(self, parent)
395 self.context = context
396 self.match_text = ''
397 self.full_text = ''
398 self.case_sensitive = False
400 self.update_thread = GatherCompletionsThread(self)
401 self.update_thread.items_gathered.connect(self.apply_matches,
402 type=Qt.QueuedConnection)
404 def update(self):
405 case_sensitive = self.update_thread.case_sensitive
406 self.update_matches(case_sensitive)
408 def set_match_text(self, full_text, match_text, case_sensitive):
409 self.full_text = full_text
410 self.match_text = match_text
411 self.update_matches(case_sensitive)
413 def update_matches(self, case_sensitive):
414 self.case_sensitive = case_sensitive
415 self.update_thread.case_sensitive = case_sensitive
416 if not self.update_thread.isRunning():
417 self.update_thread.start()
419 # pylint: disable=unused-argument
420 def gather_matches(self, case_sensitive):
421 return ((), (), set())
423 def apply_matches(self, match_tuple):
424 matched_refs, matched_paths, dirs = match_tuple
425 QStandardItem = QtGui.QStandardItem
427 dir_icon = icons.directory()
428 git_icon = icons.cola()
430 items = []
431 for ref in matched_refs:
432 item = QStandardItem()
433 item.setText(ref)
434 item.setIcon(git_icon)
435 items.append(item)
437 from_filename = icons.from_filename
438 for match in matched_paths:
439 item = QStandardItem()
440 item.setText(match)
441 if match in dirs:
442 item.setIcon(dir_icon)
443 else:
444 item.setIcon(from_filename(match))
445 items.append(item)
447 try:
448 self.clear()
449 self.invisibleRootItem().appendRows(items)
450 self.updated.emit()
451 except RuntimeError: # C++ object has been deleted
452 pass
454 def dispose(self):
455 self.update_thread.dispose()
458 def _identity(x):
459 return x
462 def _lower(x):
463 return x.lower()
466 def filter_matches(match_text, candidates, case_sensitive,
467 sort_key=lambda x: x):
468 """Filter candidates and return the matches"""
470 if case_sensitive:
471 case_transform = _identity
472 else:
473 case_transform = _lower
475 if match_text:
476 match_text = case_transform(match_text)
477 matches = [r for r in candidates if match_text in case_transform(r)]
478 else:
479 matches = list(candidates)
481 matches.sort(key=lambda x: sort_key(case_transform(x)))
482 return matches
485 def filter_path_matches(match_text, file_list, case_sensitive):
486 """Return matching completions from a list of candidate files"""
488 files = set(file_list)
489 files_and_dirs = utils.add_parents(files)
490 dirs = files_and_dirs.difference(files)
492 paths = filter_matches(match_text, files_and_dirs, case_sensitive)
493 return (paths, dirs)
496 class Completer(QtWidgets.QCompleter):
498 def __init__(self, model, parent):
499 QtWidgets.QCompleter.__init__(self, parent)
500 self._model = model
501 self.setCompletionMode(QtWidgets.QCompleter.UnfilteredPopupCompletion)
502 self.setCaseSensitivity(Qt.CaseInsensitive)
504 model.model_updated.connect(self.update, type=Qt.QueuedConnection)
505 self.setModel(model)
507 def update(self):
508 self._model.update()
510 def dispose(self):
511 self._model.dispose()
513 def set_match_text(self, full_text, match_text, case_sensitive):
514 self._model.set_match_text(full_text, match_text, case_sensitive)
517 class GitCompletionModel(CompletionModel):
519 def __init__(self, context, parent):
520 CompletionModel.__init__(self, context, parent)
521 self.main_model = model = context.model
522 msg = model.message_updated
523 model.add_observer(msg, self.emit_model_updated)
525 def gather_matches(self, case_sensitive):
526 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
527 sort_key=ref_sort_key)
528 return (refs, (), set())
530 def emit_model_updated(self):
531 try:
532 self.model_updated.emit()
533 except RuntimeError: # C++ object has been deleted
534 self.dispose()
536 def matches(self):
537 return []
539 def dispose(self):
540 super(GitCompletionModel, self).dispose()
541 self.main_model.remove_observer(self.emit_model_updated)
544 class GitRefCompletionModel(GitCompletionModel):
545 """Completer for branches and tags"""
547 def __init__(self, context, parent):
548 GitCompletionModel.__init__(self, context, parent)
550 def matches(self):
551 model = self.main_model
552 return model.local_branches + model.remote_branches + model.tags
555 def find_potential_branches(model):
556 remotes = model.remotes
557 remote_branches = model.remote_branches
559 ambiguous = set()
560 allnames = set(model.local_branches)
561 potential = []
563 for remote_branch in remote_branches:
564 branch = gitcmds.strip_remote(remotes, remote_branch)
565 if branch in allnames or branch == remote_branch:
566 ambiguous.add(branch)
567 continue
568 potential.append(branch)
569 allnames.add(branch)
571 potential_branches = [p for p in potential if p not in ambiguous]
572 return potential_branches
575 class GitCreateBranchCompletionModel(GitCompletionModel):
576 """Completer for naming new branches"""
578 def matches(self):
579 model = self.main_model
580 potential_branches = find_potential_branches(model)
581 return (model.local_branches +
582 potential_branches +
583 model.tags)
586 class GitCheckoutBranchCompletionModel(GitCompletionModel):
587 """Completer for git checkout <branch>"""
589 def matches(self):
590 model = self.main_model
591 potential_branches = find_potential_branches(model)
592 return (model.local_branches +
593 potential_branches +
594 model.remote_branches +
595 model.tags)
598 class GitBranchCompletionModel(GitCompletionModel):
599 """Completer for local branches"""
601 def __init__(self, context, parent):
602 GitCompletionModel.__init__(self, context, parent)
604 def matches(self):
605 model = self.main_model
606 return model.local_branches
609 class GitRemoteBranchCompletionModel(GitCompletionModel):
610 """Completer for remote branches"""
612 def __init__(self, context, parent):
613 GitCompletionModel.__init__(self, context, parent)
615 def matches(self):
616 model = self.main_model
617 return model.remote_branches
620 class GitPathCompletionModel(GitCompletionModel):
621 """Base class for path completion"""
623 def __init__(self, context, parent):
624 GitCompletionModel.__init__(self, context, parent)
626 def candidate_paths(self):
627 return []
629 def gather_matches(self, case_sensitive):
630 paths, dirs = filter_path_matches(self.match_text,
631 self.candidate_paths(),
632 case_sensitive)
633 return ((), paths, dirs)
636 class GitStatusFilterCompletionModel(GitPathCompletionModel):
637 """Completer for modified files and folders for status filtering"""
639 def __init__(self, context, parent):
640 GitPathCompletionModel.__init__(self, context, parent)
642 def candidate_paths(self):
643 model = self.main_model
644 return (model.staged + model.unmerged +
645 model.modified + model.untracked)
648 class GitTrackedCompletionModel(GitPathCompletionModel):
649 """Completer for tracked files and folders"""
651 def __init__(self, context, parent):
652 GitPathCompletionModel.__init__(self, context, parent)
653 self.model_updated.connect(self.gather_paths, type=Qt.QueuedConnection)
654 self._paths = []
656 def gather_paths(self):
657 context = self.context
658 self._paths = gitcmds.tracked_files(context)
660 def gather_matches(self, case_sensitive):
661 if not self._paths:
662 self.gather_paths()
664 refs = []
665 paths, dirs = filter_path_matches(self.match_text, self._paths,
666 case_sensitive)
667 return (refs, paths, dirs)
670 class GitLogCompletionModel(GitRefCompletionModel):
671 """Completer for arguments suitable for git-log like commands"""
673 def __init__(self, context, parent):
674 GitRefCompletionModel.__init__(self, context, parent)
675 self.model_updated.connect(self.gather_paths, type=Qt.QueuedConnection)
676 self._paths = []
678 def gather_paths(self):
679 context = self.context
680 self._paths = gitcmds.tracked_files(context)
682 def gather_matches(self, case_sensitive):
683 if not self._paths:
684 self.gather_paths()
685 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
686 sort_key=ref_sort_key)
687 paths, dirs = filter_path_matches(self.match_text, self._paths,
688 case_sensitive)
689 has_doubledash = (self.match_text == '--' or
690 self.full_text.startswith('-- ') or
691 ' -- ' in self.full_text)
692 if has_doubledash:
693 refs = []
694 elif refs and paths:
695 paths.insert(0, '--')
697 return (refs, paths, dirs)
700 def bind_lineedit(model, hint=''):
701 """Create a line edit bound against a specific model"""
703 class BoundLineEdit(CompletionLineEdit):
705 def __init__(self, context, hint=hint, parent=None):
706 CompletionLineEdit.__init__(self, context, model,
707 hint=hint, parent=parent)
708 self.context = context
710 return BoundLineEdit
713 # Concrete classes
714 GitLogLineEdit = bind_lineedit(GitLogCompletionModel, hint='<ref>')
715 GitRefLineEdit = bind_lineedit(GitRefCompletionModel, hint='<ref>')
716 GitCheckoutBranchLineEdit = bind_lineedit(GitCheckoutBranchCompletionModel,
717 hint='<branch>')
718 GitCreateBranchLineEdit = bind_lineedit(GitCreateBranchCompletionModel,
719 hint='<branch>')
720 GitBranchLineEdit = bind_lineedit(GitBranchCompletionModel, hint='<branch>')
721 GitRemoteBranchLineEdit = bind_lineedit(GitRemoteBranchCompletionModel,
722 hint='<remote-branch>')
723 GitStatusFilterLineEdit = bind_lineedit(GitStatusFilterCompletionModel,
724 hint='<path>')
725 GitTrackedLineEdit = bind_lineedit(GitTrackedCompletionModel, hint='<path>')
728 class GitDialog(QtWidgets.QDialog):
730 def __init__(self, lineedit, context, title, text, parent, icon=None):
731 QtWidgets.QDialog.__init__(self, parent)
732 self.context = context
733 self.setWindowTitle(title)
734 self.setWindowModality(Qt.WindowModal)
735 self.setMinimumWidth(333)
737 self.label = QtWidgets.QLabel()
738 self.label.setText(title)
739 self.lineedit = lineedit(context)
740 self.ok_button = qtutils.ok_button(text, icon=icon, enabled=False)
741 self.close_button = qtutils.close_button()
743 self.button_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
744 qtutils.STRETCH,
745 self.ok_button, self.close_button)
747 self.main_layout = qtutils.vbox(defs.margin, defs.spacing,
748 self.label, self.lineedit,
749 self.button_layout)
750 self.setLayout(self.main_layout)
752 self.lineedit.textChanged.connect(self.text_changed)
753 self.lineedit.enter.connect(self.accept)
754 qtutils.connect_button(self.ok_button, self.accept)
755 qtutils.connect_button(self.close_button, self.reject)
757 self.setFocusProxy(self.lineedit)
758 self.lineedit.setFocus(True)
760 def text(self):
761 return self.lineedit.text()
763 def text_changed(self, _txt):
764 self.ok_button.setEnabled(bool(self.text()))
766 def set_text(self, ref):
767 self.lineedit.setText(ref)
769 @classmethod
770 def get(cls, context, title, text, parent, default=None, icon=None):
771 dlg = cls(context, title, text, parent, icon=icon)
772 if default:
773 dlg.set_text(default)
775 dlg.show()
777 def show_popup():
778 x = dlg.lineedit.x()
779 y = dlg.lineedit.y() + dlg.lineedit.height()
780 point = QtCore.QPoint(x, y)
781 mapped = dlg.mapToGlobal(point)
782 dlg.lineedit.popup().move(mapped.x(), mapped.y())
783 dlg.lineedit.popup().show()
784 dlg.lineedit.refresh()
785 dlg.lineedit.setFocus(True)
787 QtCore.QTimer().singleShot(100, show_popup)
789 if dlg.exec_() == cls.Accepted:
790 return dlg.text()
791 return None
794 class GitRefDialog(GitDialog):
796 def __init__(self, context, title, text, parent, icon=None):
797 GitDialog.__init__(
798 self, GitRefLineEdit, context, title, text, parent, icon=icon)
801 class GitCheckoutBranchDialog(GitDialog):
803 def __init__(self, context, title, text, parent, icon=None):
804 GitDialog.__init__(self, GitCheckoutBranchLineEdit,
805 context, title, text, parent, icon=icon)
808 class GitBranchDialog(GitDialog):
810 def __init__(self, context, title, text, parent, icon=None):
811 GitDialog.__init__(
812 self, GitBranchLineEdit, context, title, text, parent, icon=icon)
815 class GitRemoteBranchDialog(GitDialog):
817 def __init__(self, context, title, text, parent, icon=None):
818 GitDialog.__init__(self, GitRemoteBranchLineEdit,
819 context, title, text, parent, icon=icon)