1 from __future__
import division
, absolute_import
, unicode_literals
5 from PyQt4
import QtCore
6 from PyQt4
import QtGui
7 from PyQt4
.QtCore
import Qt
8 from PyQt4
.QtCore
import SIGNAL
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
32 Qt
.Key_Return
: 'return()',
33 Qt
.Key_Enter
: 'enter()',
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
)
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
81 """Trigger the completion popup to appear and offer completions"""
82 self
._completer
.complete()
85 """Refresh the completion model"""
86 return self
._completer
.model().update()
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
)
104 self
._completer
.setCaseSensitivity(Qt
.CaseSensitive
)
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
)
123 self
._do
_text
_changed
('', '')
125 words
= self
._words
()
126 if words
and not self
._ends
_with
_whitespace
():
129 words
.append(completion
)
130 text
= core
.list2cmdline(words
)
132 self
.emit(SIGNAL('changed()'))
133 self
._do
_text
_changed
(text
, '')
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
():
145 words
= self
._words
()
152 def event(self
, event
):
153 if event
.type() == QtCore
.QEvent
.Hide
:
155 return text
.HintedLineEdit
.event(self
, event
)
157 def complete_last_word(self
):
158 self
.update_matches()
161 def close_popup(self
):
162 if self
.popup().isVisible():
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
):
174 if not popup
.isVisible():
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
):
184 if not popup
.isVisible():
186 model
= popup
.selectionModel()
187 indexes
= model
.selectedIndexes()
191 item
= self
._completion
_model
.itemFromIndex(idx
)
194 return ustr(item
.text())
197 def keyPressEvent(self
, event
):
198 self
._was
_visible
= visible
= self
.popup().isVisible()
200 was_empty
= not bool(self
.value())
203 self
._selection
= self
.selected_completion()
205 self
._selection
= None
206 if event
.key() in self
.ACTIVATION_KEYS
:
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
:
215 # Clearing a line should always emit a signal
216 is_empty
= not bool(self
.value())
218 self
.emit(SIGNAL('cleared()'))
221 def keyReleaseEvent(self
, event
):
222 """React to release events, handle completion"""
224 visible
= self
.was_visible()
226 # If it's a navigation key then emit a signal
228 msg
= self
.NAVIGATION_KEYS
[key
]
230 self
.emit(SIGNAL(msg
))
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()'))
246 class GatherCompletionsThread(QtCore
.QThread
):
248 def __init__(self
, model
):
249 QtCore
.QThread
.__init
__(self
)
251 self
.case_sensitive
= False
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
)
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()
275 self
.doc
.setDocumentMargin(0)
276 except: # older PyQt4
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
)
294 match
= re
.match(r
'(.*)(%s)(.*)' % re
.escape(self
.highlight_text
),
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
)
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
)
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
)
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)."""
339 class CompletionModel(QtGui
.QStandardItemModel
):
341 def __init__(self
, parent
):
342 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
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
)
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()
378 for ref
in matched_refs
:
379 item
= QStandardItem()
381 item
.setIcon(git_icon
)
384 from_filename
= icons
.from_filename
385 for match
in matched_paths
:
386 item
= QStandardItem()
389 item
.setIcon(dir_icon
)
391 item
.setIcon(from_filename(match
))
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"""
404 case_transform
= lambda x
: x
406 case_transform
= lambda x
: x
.lower()
409 match_text
= case_transform(match_text
)
410 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
412 matches
= list(candidates
)
414 matches
.sort(key
=lambda x
: sort_key(case_transform(x
)))
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
)
429 class Completer(QtGui
.QCompleter
):
431 def __init__(self
, model
, parent
):
432 QtGui
.QCompleter
.__init
__(self
, parent
)
434 self
.setCompletionMode(QtGui
.QCompleter
.UnfilteredPopupCompletion
)
435 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
437 self
.connect(model
, SIGNAL(UPDATE_SIGNAL
),
438 self
.update
, Qt
.QueuedConnection
)
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
):
466 self
.emit(SIGNAL(UPDATE_SIGNAL
))
467 except RuntimeError: # C++ object has been deleted
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
)
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
)
495 model
= self
.main_model
496 remotes
= model
.remotes
497 remote_branches
= model
.remote_branches
500 allnames
= set(model
.local_branches
)
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
)
508 potential
.append(branch
)
511 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
513 return (model
.local_branches
+
515 model
.remote_branches
+
519 class GitBranchCompletionModel(GitCompletionModel
):
520 """Completer for remote branches"""
522 def __init__(self
, parent
):
523 GitCompletionModel
.__init
__(self
, parent
)
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
)
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
):
550 def gather_matches(self
, case_sensitive
):
551 paths
, dirs
= filter_path_matches(self
.match_text
,
552 self
.candidate_paths(),
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
)
577 self
._updated
= False
579 def gather_paths(self
):
580 self
._paths
= gitcmds
.tracked_files()
582 def gather_matches(self
, case_sensitive
):
587 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
,
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
)
600 self
._updated
= False
602 def gather_paths(self
):
603 self
._paths
= gitcmds
.tracked_files()
605 def gather_matches(self
, case_sensitive
):
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
,
612 has_doubledash
= (self
.match_text
== '--' or
613 self
.full_text
.startswith('-- ') or
614 ' -- ' in self
.full_text
)
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
)
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
)
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
,
665 self
.ok_button
, self
.close_button
)
667 self
.main_layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
668 self
.label
, self
.lineedit
,
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)'),
677 self
.connect(self
.lineedit
, SIGNAL('return()'), self
.accept
)
679 self
.setWindowModality(Qt
.WindowModal
)
680 self
.ok_button
.setEnabled(False)
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
)
692 def get(cls
, title
, button_text
, parent
, default
=None, icon
=None):
693 dlg
= cls(title
, button_text
, parent
, icon
=icon
)
695 dlg
.set_text(default
)
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
:
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
)