core: add list2cmdline() wrapper
[git-cola.git] / cola / widgets / completion.py
blob52cfe9a68ac39b09da7323fc59fc95a1ae84dabd
1 from __future__ import division, absolute_import, unicode_literals
3 import re
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 from cola import core
11 from cola import gitcmds
12 from cola import icons
13 from cola import qtutils
14 from cola import utils
15 from cola.models import main
16 from cola.widgets import defs
17 from cola.widgets import text
18 from cola.compat import ustr
21 UPDATE_SIGNAL = 'update()'
24 class CompletionLineEdit(text.HintedLineEdit):
25 """An lineedit with advanced completion abilities"""
27 # Activation keys will cause a selected completion item to be chosen
28 ACTIVATION_KEYS = (Qt.Key_Return, Qt.Key_Enter)
30 # Navigation keys trigger signals that widgets can use for customization
31 NAVIGATION_KEYS = {
32 Qt.Key_Return: 'return()',
33 Qt.Key_Enter: 'enter()',
34 Qt.Key_Up: 'up()',
35 Qt.Key_Down: 'down()',
38 def __init__(self, model_factory, hint='', parent=None):
39 text.HintedLineEdit.__init__(self, hint=hint, parent=parent)
40 # Tracks when the completion popup was active during key events
41 self._was_visible = False
42 # The most recently selected completion item
43 self._selection = None
45 # Create a completion model
46 completion_model = model_factory(self)
47 completer = Completer(completion_model, self)
48 completer.setWidget(self)
49 self._completer = completer
50 self._completion_model = completion_model
52 # The delegate highlights matching completion text in the popup widget
53 self._delegate = HighlightDelegate(self)
54 completer.popup().setItemDelegate(self._delegate)
56 self.connect(self, SIGNAL('textChanged(QString)'), self._text_changed)
58 self.connect(self._completer, SIGNAL('activated(QString)'),
59 self.choose_completion)
61 self.connect(self._completion_model, SIGNAL('updated()'),
62 self._completions_updated, Qt.QueuedConnection)
64 self.connect(self, SIGNAL('destroyed(QObject*)'), self.dispose)
66 def __del__(self):
67 self.dispose()
69 def dispose(self, *args):
70 self._completer.dispose()
72 def was_visible(self):
73 """Was the popup visible during the last keypress event?"""
74 return self._was_visible
76 def completion_selection(self):
77 """Return the last completion's selection"""
78 return self._selection
80 def complete(self):
81 """Trigger the completion popup to appear and offer completions"""
82 self._completer.complete()
84 def refresh(self):
85 """Refresh the completion model"""
86 return self._completer.model().update()
88 def popup(self):
89 """Return the completer's popup"""
90 return self._completer.popup()
92 def _is_case_sensitive(self, text):
93 return bool([char for char in text if char.isupper()])
95 def _text_changed(self, text):
96 match_text = self._last_word()
97 full_text = ustr(text)
98 self._do_text_changed(full_text, match_text)
99 self.complete_last_word()
101 def _do_text_changed(self, full_text, match_text):
102 case_sensitive = self._is_case_sensitive(match_text)
103 if case_sensitive:
104 self._completer.setCaseSensitivity(Qt.CaseSensitive)
105 else:
106 self._completer.setCaseSensitivity(Qt.CaseInsensitive)
107 self._delegate.set_highlight_text(match_text, case_sensitive)
108 self._completer.set_match_text(full_text, match_text, case_sensitive)
110 def update_matches(self):
111 text = self._last_word()
112 case_sensitive = self._is_case_sensitive(text)
113 self._completer.setCompletionPrefix(text)
114 self._completer.model().update_matches(case_sensitive)
116 def choose_completion(self, completion):
118 This is the event handler for the QCompleter.activated(QString) signal,
119 it is called when the user selects an item in the completer popup.
121 completion = ustr(completion)
122 if not completion:
123 self._do_text_changed('', '')
124 return
125 words = self._words()
126 if words and not self._ends_with_whitespace():
127 words.pop()
129 words.append(completion)
130 text = core.list2cmdline(words)
131 self.setText(text)
132 self.emit(SIGNAL('changed()'))
133 self._do_text_changed(text, '')
134 self.popup().hide()
136 def _words(self):
137 return utils.shell_split(self.value())
139 def _ends_with_whitespace(self):
140 return self.value() != self.value().rstrip()
142 def _last_word(self):
143 if self._ends_with_whitespace():
144 return ''
145 words = self._words()
146 if not words:
147 return self.value()
148 if not words[-1]:
149 return ''
150 return words[-1]
152 def event(self, event):
153 if event.type() == QtCore.QEvent.Hide:
154 self.close_popup()
155 return text.HintedLineEdit.event(self, event)
157 def complete_last_word(self):
158 self.update_matches()
159 self.complete()
161 def close_popup(self):
162 if self.popup().isVisible():
163 self.popup().close()
165 def _update_popup_items(self, prefix):
167 Filters the completer's popup items to only show items
168 with the given prefix.
170 self._completer.setCompletionPrefix(prefix)
172 def _completions_updated(self):
173 popup = self.popup()
174 if not popup.isVisible():
175 return
176 # Select the first item
177 idx = self._completion_model.index(0, 0)
178 selection = QtGui.QItemSelection(idx, idx)
179 mode = QtGui.QItemSelectionModel.Select
180 popup.selectionModel().select(selection, mode)
182 def selected_completion(self):
183 popup = self.popup()
184 if not popup.isVisible():
185 return None
186 model = popup.selectionModel()
187 indexes = model.selectedIndexes()
188 if not indexes:
189 return None
190 idx = indexes[0]
191 item = self._completion_model.itemFromIndex(idx)
192 if not item:
193 return
194 return ustr(item.text())
196 # Qt events
197 def keyPressEvent(self, event):
198 self._was_visible = visible = self.popup().isVisible()
199 key = event.key()
200 was_empty = not bool(self.value())
202 if visible:
203 self._selection = self.selected_completion()
204 else:
205 self._selection = None
206 if event.key() in self.ACTIVATION_KEYS:
207 event.accept()
208 return
210 result = text.HintedLineEdit.keyPressEvent(self, event)
212 # Backspace at the beginning of the line should hide the popup
213 if was_empty and visible and key == Qt.Key_Backspace:
214 self.popup().hide()
215 # Clearing a line should always emit a signal
216 is_empty = not bool(self.value())
217 if is_empty:
218 self.emit(SIGNAL('cleared()'))
219 return result
221 def keyReleaseEvent(self, event):
222 """React to release events, handle completion"""
223 key = event.key()
224 visible = self.was_visible()
225 if not visible:
226 # If it's a navigation key then emit a signal
227 try:
228 msg = self.NAVIGATION_KEYS[key]
229 event.accept()
230 self.emit(SIGNAL(msg))
231 return
232 except KeyError:
233 pass
234 # Run the real release event
235 result = text.HintedLineEdit.keyReleaseEvent(self, event)
236 # If the popup was visible and we have a selected popup item
237 # then choose that completion.
238 selection = self.completion_selection()
239 if visible and selection and key in self.ACTIVATION_KEYS:
240 self.choose_completion(selection)
241 self.emit(SIGNAL('activated()'))
242 return
243 return result
246 class GatherCompletionsThread(QtCore.QThread):
248 def __init__(self, model):
249 QtCore.QThread.__init__(self)
250 self.model = model
251 self.case_sensitive = False
253 def run(self):
254 text = None
255 # Loop when the matched text changes between the start and end time.
256 # This happens when gather_matches() takes too long and the
257 # model's match_text changes in-between.
258 while text != self.model.match_text:
259 text = self.model.match_text
260 items = self.model.gather_matches(self.case_sensitive)
262 if text is not None:
263 self.emit(SIGNAL('items_gathered(PyQt_PyObject)'), items)
266 class HighlightDelegate(QtGui.QStyledItemDelegate):
267 """A delegate used for auto-completion to give formatted completion"""
268 def __init__(self, parent=None): # model, parent=None):
269 QtGui.QStyledItemDelegate.__init__(self, parent)
270 self.highlight_text = ''
271 self.case_sensitive = False
273 self.doc = QtGui.QTextDocument()
274 try:
275 self.doc.setDocumentMargin(0)
276 except: # older PyQt4
277 pass
279 def set_highlight_text(self, text, case_sensitive):
280 """Sets the text that will be made bold in the term name when displayed"""
281 self.highlight_text = text
282 self.case_sensitive = case_sensitive
284 def paint(self, painter, option, index):
285 """Overloaded Qt method for custom painting of a model index"""
286 if not self.highlight_text:
287 return QtGui.QStyledItemDelegate.paint(self, painter, option, index)
289 text = ustr(index.data().toPyObject())
290 if self.case_sensitive:
291 html = text.replace(self.highlight_text,
292 '<strong>%s</strong>' % self.highlight_text)
293 else:
294 match = re.match(r'(.*)(%s)(.*)' % re.escape(self.highlight_text),
295 text, re.IGNORECASE)
296 if match:
297 start = match.group(1) or ''
298 middle = match.group(2) or ''
299 end = match.group(3) or ''
300 html = (start + ('<strong>%s</strong>' % middle) + end)
301 else:
302 html = text
303 self.doc.setHtml(html)
305 # Painting item without text, Text Document will paint the text
306 optionV4 = QtGui.QStyleOptionViewItemV4(option)
307 self.initStyleOption(optionV4, index)
308 optionV4.text = QtCore.QString()
310 style = QtGui.QApplication.style()
311 style.drawControl(QtGui.QStyle.CE_ItemViewItem, optionV4, painter)
312 ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
314 # Highlighting text if item is selected
315 if (optionV4.state & QtGui.QStyle.State_Selected):
316 color = optionV4.palette.color(QtGui.QPalette.Active,
317 QtGui.QPalette.HighlightedText)
318 ctx.palette.setColor(QtGui.QPalette.Text, color)
320 # translate the painter to where the text is drawn
321 rect = style.subElementRect(QtGui.QStyle.SE_ItemViewItemText, optionV4)
322 painter.save()
324 start = rect.topLeft() + QtCore.QPoint(3, 0)
325 painter.translate(start)
327 # tell the text document to draw the html for us
328 self.doc.documentLayout().draw(painter, ctx)
329 painter.restore()
332 def ref_sort_key(ref):
333 """Sort key function that causes shorter refs to sort first, but
334 alphabetizes refs of equal length (in order to make local branches sort
335 before remote ones)."""
336 return len(ref), ref
339 class CompletionModel(QtGui.QStandardItemModel):
341 def __init__(self, parent):
342 QtGui.QStandardItemModel.__init__(self, parent)
343 self.match_text = ''
344 self.full_text = ''
345 self.case_sensitive = False
347 self.update_thread = GatherCompletionsThread(self)
348 self.connect(self.update_thread,
349 SIGNAL('items_gathered(PyQt_PyObject)'),
350 self.apply_matches, Qt.QueuedConnection)
352 def update(self):
353 case_sensitive = self.update_thread.case_sensitive
354 self.update_matches(case_sensitive)
356 def set_match_text(self, full_text, match_text, case_sensitive):
357 self.full_text = full_text
358 self.match_text = match_text
359 self.update_matches(case_sensitive)
361 def update_matches(self, case_sensitive):
362 self.case_sensitive = case_sensitive
363 self.update_thread.case_sensitive = case_sensitive
364 if not self.update_thread.isRunning():
365 self.update_thread.start()
367 def gather_matches(self, case_sensitive):
368 return ((), (), set())
370 def apply_matches(self, match_tuple):
371 matched_refs, matched_paths, dirs = match_tuple
372 QStandardItem = QtGui.QStandardItem
374 dir_icon = icons.directory()
375 git_icon = icons.cola()
377 items = []
378 for ref in matched_refs:
379 item = QStandardItem()
380 item.setText(ref)
381 item.setIcon(git_icon)
382 items.append(item)
384 from_filename = icons.from_filename
385 for match in matched_paths:
386 item = QStandardItem()
387 item.setText(match)
388 if match in dirs:
389 item.setIcon(dir_icon)
390 else:
391 item.setIcon(from_filename(match))
392 items.append(item)
394 self.clear()
395 self.invisibleRootItem().appendRows(items)
396 self.emit(SIGNAL('updated()'))
399 def filter_matches(match_text, candidates, case_sensitive,
400 sort_key=lambda x: x):
401 """Filter candidates and return the matches"""
403 if case_sensitive:
404 case_transform = lambda x: x
405 else:
406 case_transform = lambda x: x.lower()
408 if match_text:
409 match_text = case_transform(match_text)
410 matches = [r for r in candidates if match_text in case_transform(r)]
411 else:
412 matches = list(candidates)
414 matches.sort(key=lambda x: sort_key(case_transform(x)))
415 return matches
418 def filter_path_matches(match_text, file_list, case_sensitive):
419 """Return matching completions from a list of candidate files"""
421 files = set(file_list)
422 files_and_dirs = utils.add_parents(files)
423 dirs = files_and_dirs.difference(files)
425 paths = filter_matches(match_text, files_and_dirs, case_sensitive)
426 return (paths, dirs)
429 class Completer(QtGui.QCompleter):
431 def __init__(self, model, parent):
432 QtGui.QCompleter.__init__(self, parent)
433 self._model = model
434 self.setCompletionMode(QtGui.QCompleter.UnfilteredPopupCompletion)
435 self.setCaseSensitivity(Qt.CaseInsensitive)
437 self.connect(model, SIGNAL(UPDATE_SIGNAL),
438 self.update, Qt.QueuedConnection)
439 self.setModel(model)
441 def update(self):
442 self._model.update()
444 def dispose(self):
445 self._model.dispose()
447 def set_match_text(self, full_text, match_text, case_sensitive):
448 self._model.set_match_text(full_text, match_text, case_sensitive)
451 class GitCompletionModel(CompletionModel):
453 def __init__(self, parent):
454 CompletionModel.__init__(self, parent)
455 self.main_model = model = main.model()
456 msg = model.message_updated
457 model.add_observer(msg, self.emit_update)
459 def gather_matches(self, case_sensitive):
460 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
461 sort_key=ref_sort_key)
462 return (refs, (), set())
464 def emit_update(self):
465 try:
466 self.emit(SIGNAL(UPDATE_SIGNAL))
467 except RuntimeError: # C++ object has been deleted
468 self.dispose()
470 def matches(self):
471 return []
473 def dispose(self):
474 self.main_model.remove_observer(self.emit_update)
477 class GitRefCompletionModel(GitCompletionModel):
478 """Completer for branches and tags"""
480 def __init__(self, parent):
481 GitCompletionModel.__init__(self, parent)
483 def matches(self):
484 model = self.main_model
485 return model.local_branches + model.remote_branches + model.tags
488 class GitPotentialBranchCompletionModel(GitCompletionModel):
489 """Completer for branches, tags, and potential branches"""
491 def __init__(self, parent):
492 GitCompletionModel.__init__(self, parent)
494 def matches(self):
495 model = self.main_model
496 remotes = model.remotes
497 remote_branches = model.remote_branches
499 ambiguous = set()
500 allnames = set(model.local_branches)
501 potential = []
503 for remote_branch in remote_branches:
504 branch = gitcmds.strip_remote(remotes, remote_branch)
505 if branch in allnames or branch == remote_branch:
506 ambiguous.add(branch)
507 continue
508 potential.append(branch)
509 allnames.add(branch)
511 potential_branches = [p for p in potential if p not in ambiguous]
513 return (model.local_branches +
514 potential_branches +
515 model.remote_branches +
516 model.tags)
519 class GitBranchCompletionModel(GitCompletionModel):
520 """Completer for remote branches"""
522 def __init__(self, parent):
523 GitCompletionModel.__init__(self, parent)
525 def matches(self):
526 model = self.main_model
527 return model.local_branches
530 class GitRemoteBranchCompletionModel(GitCompletionModel):
531 """Completer for remote branches"""
533 def __init__(self, parent):
534 GitCompletionModel.__init__(self, parent)
536 def matches(self):
537 model = self.main_model
538 return model.remote_branches
541 class GitPathCompletionModel(GitCompletionModel):
542 """Base class for path completion"""
544 def __init__(self, parent):
545 GitCompletionModel.__init__(self, parent)
547 def candidate_paths(self):
548 return []
550 def gather_matches(self, case_sensitive):
551 paths, dirs = filter_path_matches(self.match_text,
552 self.candidate_paths(),
553 case_sensitive)
554 return ((), paths, dirs)
557 class GitStatusFilterCompletionModel(GitPathCompletionModel):
558 """Completer for modified files and folders for status filtering"""
560 def __init__(self, parent):
561 GitPathCompletionModel.__init__(self, parent)
563 def candidate_paths(self):
564 model = self.main_model
565 return (model.staged + model.unmerged +
566 model.modified + model.untracked)
569 class GitTrackedCompletionModel(GitPathCompletionModel):
570 """Completer for tracked files and folders"""
572 def __init__(self, parent):
573 GitPathCompletionModel.__init__(self, parent)
574 self.connect(self, SIGNAL(UPDATE_SIGNAL),
575 self.gather_paths, Qt.QueuedConnection)
576 self._paths = []
577 self._updated = False
579 def gather_paths(self):
580 self._paths = gitcmds.tracked_files()
582 def gather_matches(self, case_sensitive):
583 if not self._paths:
584 self.gather_paths()
586 refs = []
587 paths, dirs = filter_path_matches(self.match_text, self._paths,
588 case_sensitive)
589 return (refs, paths, dirs)
592 class GitLogCompletionModel(GitRefCompletionModel):
593 """Completer for arguments suitable for git-log like commands"""
595 def __init__(self, parent):
596 GitRefCompletionModel.__init__(self, parent)
597 self.connect(self, SIGNAL(UPDATE_SIGNAL),
598 self.gather_paths, Qt.QueuedConnection)
599 self._paths = []
600 self._updated = False
602 def gather_paths(self):
603 self._paths = gitcmds.tracked_files()
605 def gather_matches(self, case_sensitive):
606 if not self._paths:
607 self.gather_paths()
608 refs = filter_matches(self.match_text, self.matches(), case_sensitive,
609 sort_key=ref_sort_key)
610 paths, dirs = filter_path_matches(self.match_text, self._paths,
611 case_sensitive)
612 has_doubledash = (self.match_text == '--' or
613 self.full_text.startswith('-- ') or
614 ' -- ' in self.full_text)
615 if has_doubledash:
616 refs = []
617 elif refs and paths:
618 paths.insert(0, '--')
620 return (refs, paths, dirs)
623 def bind_lineedit(model):
624 """Create a line edit bound against a specific model"""
626 class BoundLineEdit(CompletionLineEdit):
628 def __init__(self, hint='', parent=None):
629 CompletionLineEdit.__init__(self, model,
630 hint=hint, parent=parent)
632 return BoundLineEdit
635 # Concrete classes
636 GitLogLineEdit = bind_lineedit(GitLogCompletionModel)
637 GitRefLineEdit = bind_lineedit(GitRefCompletionModel)
638 GitPotentialBranchLineEdit = bind_lineedit(GitPotentialBranchCompletionModel)
639 GitBranchLineEdit = bind_lineedit(GitBranchCompletionModel)
640 GitRemoteBranchLineEdit = bind_lineedit(GitRemoteBranchCompletionModel)
641 GitStatusFilterLineEdit = bind_lineedit(GitStatusFilterCompletionModel)
642 GitTrackedLineEdit = bind_lineedit(GitTrackedCompletionModel)
645 class GitDialog(QtGui.QDialog):
647 def __init__(self, lineedit, title, button_text, parent, icon=None):
648 QtGui.QDialog.__init__(self, parent)
649 self.setWindowTitle(title)
650 self.setMinimumWidth(333)
652 self.label = QtGui.QLabel()
653 self.label.setText(title)
655 self.lineedit = lineedit()
656 self.setFocusProxy(self.lineedit)
658 if icon is None:
659 icon = icons.ok()
660 self.ok_button = qtutils.create_button(text=button_text, icon=icon)
661 self.close_button = qtutils.close_button()
663 self.button_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
664 qtutils.STRETCH,
665 self.ok_button, self.close_button)
667 self.main_layout = qtutils.vbox(defs.margin, defs.spacing,
668 self.label, self.lineedit,
669 self.button_layout)
670 self.setLayout(self.main_layout)
672 qtutils.connect_button(self.ok_button, self.accept)
673 qtutils.connect_button(self.close_button, self.reject)
675 self.connect(self.lineedit, SIGNAL('textChanged(QString)'),
676 self.text_changed)
677 self.connect(self.lineedit, SIGNAL('return()'), self.accept)
679 self.setWindowModality(Qt.WindowModal)
680 self.ok_button.setEnabled(False)
682 def text(self):
683 return ustr(self.lineedit.text())
685 def text_changed(self, txt):
686 self.ok_button.setEnabled(bool(self.text()))
688 def set_text(self, ref):
689 self.lineedit.setText(ref)
691 @classmethod
692 def get(cls, title, button_text, parent, default=None, icon=None):
693 dlg = cls(title, button_text, parent, icon=icon)
694 if default:
695 dlg.set_text(default)
697 dlg.show()
698 dlg.raise_()
700 def show_popup():
701 x = dlg.lineedit.x()
702 y = dlg.lineedit.y() + dlg.lineedit.height()
703 point = QtCore.QPoint(x, y)
704 mapped = dlg.mapToGlobal(point)
705 dlg.lineedit.popup().move(mapped.x(), mapped.y())
706 dlg.lineedit.popup().show()
707 dlg.lineedit.refresh()
709 QtCore.QTimer().singleShot(0, show_popup)
711 if dlg.exec_() == cls.Accepted:
712 return dlg.text()
713 else:
714 return None
717 class GitRefDialog(GitDialog):
719 def __init__(self, title, button_text, parent, icon=None):
720 GitDialog.__init__(self, GitRefLineEdit,
721 title, button_text, parent, icon=icon)
724 class GitPotentialBranchDialog(GitDialog):
726 def __init__(self, title, button_text, parent, icon=None):
727 GitDialog.__init__(self, GitPotentialBranchLineEdit,
728 title, button_text, parent, icon=icon)
731 class GitBranchDialog(GitDialog):
733 def __init__(self, title, button_text, parent, icon=None):
734 GitDialog.__init__(self, GitBranchLineEdit,
735 title, button_text, parent, icon=icon)
738 class GitRemoteBranchDialog(GitDialog):
740 def __init__(self, title, button_text, parent, icon=None):
741 GitDialog.__init__(self, GitRemoteBranchLineEdit,
742 title, button_text, parent, icon=icon)