1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
4 from qtpy
import QtCore
6 from qtpy
import QtWidgets
7 from qtpy
.QtCore
import Qt
8 from qtpy
.QtCore
import Signal
10 from ..models
import prefs
12 from .. import gitcmds
14 from .. import qtutils
17 from .text
import HintedLineEdit
20 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
)
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)
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
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"""
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
87 Qt
.Key_Return
: 'enter',
88 Qt
.Key_Enter
: 'enter',
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 # pylint: disable=no-member
113 self
.textChanged
.connect(self
._text
_changed
)
114 self
._completer
.activated
.connect(self
.choose_completion
)
115 self
._completion
_model
.updated
.connect(
116 self
._completions
_updated
, type=Qt
.QueuedConnection
118 self
.destroyed
.connect(self
.dispose
)
123 # pylint: disable=unused-argument
124 def dispose(self
, *args
):
125 self
._completer
.dispose()
127 def completion_selection(self
):
128 """Return the last completion's selection"""
129 return self
._selection
132 """Trigger the completion popup to appear and offer completions"""
133 self
._completer
.complete()
136 """Refresh the completion model"""
137 return self
._completer
.model().update()
140 """Return the completer's popup"""
141 return self
._completer
.popup()
143 def _text_changed(self
, full_text
):
144 match_text
= self
._last
_word
()
145 self
._do
_text
_changed
(full_text
, match_text
)
146 self
.complete_last_word()
148 def _do_text_changed(self
, full_text
, match_text
):
149 case_sensitive
= _is_case_sensitive(match_text
)
151 self
._completer
.setCaseSensitivity(Qt
.CaseSensitive
)
153 self
._completer
.setCaseSensitivity(Qt
.CaseInsensitive
)
154 self
._delegate
.set_highlight_text(match_text
, case_sensitive
)
155 self
._completer
.set_match_text(full_text
, match_text
, case_sensitive
)
157 def update_matches(self
):
158 text
= self
._last
_word
()
159 case_sensitive
= _is_case_sensitive(text
)
160 self
._completer
.setCompletionPrefix(text
)
161 self
._completer
.model().update_matches(case_sensitive
)
163 def choose_completion(self
, completion
):
165 This is the event handler for the QCompleter.activated(QString) signal,
166 it is called when the user selects an item in the completer popup.
169 self
._do
_text
_changed
('', '')
171 words
= self
._words
()
172 if words
and not self
._ends
_with
_whitespace
():
175 words
.append(completion
)
176 text
= core
.list2cmdline(words
)
179 self
._do
_text
_changed
(text
, '')
183 return utils
.shell_split(self
.value())
185 def _ends_with_whitespace(self
):
187 return value
!= value
.rstrip()
189 def _last_word(self
):
190 if self
._ends
_with
_whitespace
():
192 words
= self
._words
()
199 def complete_last_word(self
):
200 self
.update_matches()
203 def close_popup(self
):
204 if self
.popup().isVisible():
207 def _completions_updated(self
):
209 if not popup
.isVisible():
211 # Select the first item
212 idx
= self
._completion
_model
.index(0, 0)
213 selection
= QtCore
.QItemSelection(idx
, idx
)
214 mode
= QtCore
.QItemSelectionModel
.Select
215 popup
.selectionModel().select(selection
, mode
)
217 def selected_completion(self
):
218 """Return the selected completion item"""
220 if not popup
.isVisible():
222 model
= popup
.selectionModel()
223 indexes
= model
.selectedIndexes()
227 item
= self
._completion
_model
.itemFromIndex(idx
)
232 def select_completion(self
):
233 """Choose the selected completion option from the completion popup"""
235 visible
= self
.popup().isVisible()
237 selection
= self
.selected_completion()
239 self
.choose_completion(selection
)
244 def event(self
, event
):
245 """Override QWidget::event() for tab completion"""
246 event_type
= event
.type()
249 event_type
== QtCore
.QEvent
.KeyPress
250 and event
.key() == Qt
.Key_Tab
251 and self
.select_completion()
255 # Make sure the popup goes away during teardown
256 if event_type
== QtCore
.QEvent
.Hide
:
259 return super(CompletionLineEdit
, self
).event(event
)
261 def keyPressEvent(self
, event
):
262 """Process completion and navigation events"""
263 super(CompletionLineEdit
, self
).keyPressEvent(event
)
264 visible
= self
.popup().isVisible()
266 # Hide the popup when the field is empty
267 is_empty
= not self
.value()
273 # Activation keys select the completion when pressed and emit the
274 # activated signal. Navigation keys have lower priority, and only
275 # emit when it wasn't already handled as an activation event.
277 if key
in self
.ACTIVATION_KEYS
and visible
:
278 if self
.select_completion():
279 self
.activated
.emit()
282 navigation
= self
.NAVIGATION_KEYS
.get(key
, None)
284 signal
= getattr(self
, navigation
)
288 class GatherCompletionsThread(QtCore
.QThread
):
290 items_gathered
= Signal(object)
292 def __init__(self
, model
):
293 QtCore
.QThread
.__init
__(self
)
295 self
.case_sensitive
= False
300 utils
.catch_runtime_error(self
.wait
)
306 # Loop when the matched text changes between the start and end time.
307 # This happens when gather_matches() takes too long and the
308 # model's match_text changes in-between.
309 while self
.running
and text
!= self
.model
.match_text
:
310 text
= self
.model
.match_text
311 items
= self
.model
.gather_matches(self
.case_sensitive
)
313 if self
.running
and text
is not None:
314 self
.items_gathered
.emit(items
)
317 class HighlightDelegate(QtWidgets
.QStyledItemDelegate
):
318 """A delegate used for auto-completion to give formatted completion"""
320 def __init__(self
, parent
):
321 QtWidgets
.QStyledItemDelegate
.__init
__(self
, parent
)
323 self
.highlight_text
= ''
324 self
.case_sensitive
= False
326 self
.doc
= QtGui
.QTextDocument()
327 # older PyQt4 does not have setDocumentMargin
328 if hasattr(self
.doc
, 'setDocumentMargin'):
329 self
.doc
.setDocumentMargin(0)
331 def set_highlight_text(self
, text
, case_sensitive
):
332 """Sets the text that will be made bold when displayed"""
333 self
.highlight_text
= text
334 self
.case_sensitive
= case_sensitive
336 def paint(self
, painter
, option
, index
):
337 """Overloaded Qt method for custom painting of a model index"""
338 if not self
.highlight_text
:
339 QtWidgets
.QStyledItemDelegate
.paint(self
, painter
, option
, index
)
342 if self
.case_sensitive
:
344 self
.highlight_text
, '<strong>%s</strong>' % self
.highlight_text
348 r
'(.*)(%s)(.*)' % re
.escape(self
.highlight_text
), text
, re
.IGNORECASE
351 start
= match
.group(1) or ''
352 middle
= match
.group(2) or ''
353 end
= match
.group(3) or ''
354 html
= start
+ ('<strong>%s</strong>' % middle
) + end
357 self
.doc
.setHtml(html
)
359 # Painting item without text, Text Document will paint the text
360 params
= QtWidgets
.QStyleOptionViewItem(option
)
361 self
.initStyleOption(params
, index
)
364 style
= QtWidgets
.QApplication
.style()
365 style
.drawControl(QtWidgets
.QStyle
.CE_ItemViewItem
, params
, painter
)
366 ctx
= QtGui
.QAbstractTextDocumentLayout
.PaintContext()
368 # Highlighting text if item is selected
369 if params
.state
& QtWidgets
.QStyle
.State_Selected
:
370 color
= params
.palette
.color(
371 QtGui
.QPalette
.Active
, QtGui
.QPalette
.HighlightedText
373 ctx
.palette
.setColor(QtGui
.QPalette
.Text
, color
)
375 # translate the painter to where the text is drawn
376 item_text
= QtWidgets
.QStyle
.SE_ItemViewItemText
377 rect
= style
.subElementRect(item_text
, params
, self
.widget
)
380 start
= rect
.topLeft() + QtCore
.QPoint(defs
.margin
, 0)
381 painter
.translate(start
)
383 # tell the text document to draw the html for us
384 self
.doc
.documentLayout().draw(painter
, ctx
)
388 def ref_sort_key(ref
):
389 """Sort key function that causes shorter refs to sort first, but
390 alphabetizes refs of equal length (in order to make local branches sort
391 before remote ones)."""
395 class CompletionModel(QtGui
.QStandardItemModel
):
398 items_gathered
= Signal(object)
399 model_updated
= Signal()
401 def __init__(self
, context
, parent
):
402 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
403 self
.context
= context
406 self
.case_sensitive
= False
408 self
.update_thread
= GatherCompletionsThread(self
)
409 self
.update_thread
.items_gathered
.connect(
410 self
.apply_matches
, type=Qt
.QueuedConnection
414 case_sensitive
= self
.update_thread
.case_sensitive
415 self
.update_matches(case_sensitive
)
417 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
418 self
.full_text
= full_text
419 self
.match_text
= match_text
420 self
.update_matches(case_sensitive
)
422 def update_matches(self
, case_sensitive
):
423 self
.case_sensitive
= case_sensitive
424 self
.update_thread
.case_sensitive
= case_sensitive
425 if not self
.update_thread
.isRunning():
426 self
.update_thread
.start()
428 # pylint: disable=unused-argument,no-self-use
429 def gather_matches(self
, case_sensitive
):
430 return ((), (), set())
432 def apply_matches(self
, match_tuple
):
433 """Build widgets for all of the matching items"""
435 # Results from background tasks can arrive after the widget has been destroyed.
436 utils
.catch_runtime_error(self
.set_items
, [])
438 matched_refs
, matched_paths
, dirs
= match_tuple
439 QStandardItem
= QtGui
.QStandardItem
441 dir_icon
= icons
.directory()
442 git_icon
= icons
.cola()
445 for ref
in matched_refs
:
446 item
= QStandardItem()
448 item
.setIcon(git_icon
)
451 from_filename
= icons
.from_filename
452 for match
in matched_paths
:
453 item
= QStandardItem()
456 item
.setIcon(dir_icon
)
458 item
.setIcon(from_filename(match
))
461 # Results from background tasks can arrive after the widget has been destroyed.
462 utils
.catch_runtime_error(self
.set_items
, items
)
464 def set_items(self
, items
):
465 """Clear the widget and add items to the model"""
467 self
.invisibleRootItem().appendRows(items
)
471 self
.update_thread
.dispose()
482 def filter_matches(match_text
, candidates
, case_sensitive
, sort_key
=None):
483 """Filter candidates and return the matches"""
485 case_transform
= _identity
487 case_transform
= _lower
490 match_text
= case_transform(match_text
)
491 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
493 matches
= list(candidates
)
499 matches
.sort(key
=sort_key
)
502 matches
.sort(key
=_lower
)
504 matches
.sort(key
=lambda x
: sort_key(_lower(x
)))
508 def filter_path_matches(match_text
, file_list
, case_sensitive
):
509 """Return matching completions from a list of candidate files"""
510 files
= set(file_list
)
511 files_and_dirs
= utils
.add_parents(files
)
512 dirs
= files_and_dirs
.difference(files
)
514 paths
= filter_matches(match_text
, files_and_dirs
, case_sensitive
)
518 class Completer(QtWidgets
.QCompleter
):
519 def __init__(self
, model
, parent
):
520 QtWidgets
.QCompleter
.__init
__(self
, parent
)
522 self
.setCompletionMode(QtWidgets
.QCompleter
.UnfilteredPopupCompletion
)
523 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
525 model
.model_updated
.connect(self
.update
, type=Qt
.QueuedConnection
)
532 self
._model
.dispose()
534 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
535 self
._model
.set_match_text(full_text
, match_text
, case_sensitive
)
538 class GitCompletionModel(CompletionModel
):
539 def __init__(self
, context
, parent
):
540 CompletionModel
.__init
__(self
, context
, parent
)
541 self
.context
= context
542 context
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
544 def gather_matches(self
, case_sensitive
):
545 refs
= filter_matches(
546 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
548 return (refs
, (), set())
550 # pylint: disable=no-self-use
555 class GitRefCompletionModel(GitCompletionModel
):
556 """Completer for branches and tags"""
558 def __init__(self
, context
, parent
):
559 GitCompletionModel
.__init
__(self
, context
, parent
)
560 context
.model
.refs_updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
563 model
= self
.context
.model
564 return model
.local_branches
+ model
.remote_branches
+ model
.tags
567 def find_potential_branches(model
):
568 remotes
= model
.remotes
569 remote_branches
= model
.remote_branches
572 allnames
= set(model
.local_branches
)
575 for remote_branch
in remote_branches
:
576 branch
= gitcmds
.strip_remote(remotes
, remote_branch
)
577 if branch
in allnames
or branch
== remote_branch
:
578 ambiguous
.add(branch
)
580 potential
.append(branch
)
583 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
584 return potential_branches
587 class GitCreateBranchCompletionModel(GitCompletionModel
):
588 """Completer for naming new branches"""
591 model
= self
.context
.model
592 potential_branches
= find_potential_branches(model
)
593 return model
.local_branches
+ potential_branches
+ model
.tags
596 class GitCheckoutBranchCompletionModel(GitCompletionModel
):
597 """Completer for git checkout <branch>"""
600 model
= self
.context
.model
601 potential_branches
= find_potential_branches(model
)
605 + model
.remote_branches
610 class GitBranchCompletionModel(GitCompletionModel
):
611 """Completer for local branches"""
613 def __init__(self
, context
, parent
):
614 GitCompletionModel
.__init
__(self
, context
, parent
)
617 model
= self
.context
.model
618 return model
.local_branches
621 class GitRemoteBranchCompletionModel(GitCompletionModel
):
622 """Completer for remote branches"""
624 def __init__(self
, context
, parent
):
625 GitCompletionModel
.__init
__(self
, context
, parent
)
628 model
= self
.context
.model
629 return model
.remote_branches
632 class GitPathCompletionModel(GitCompletionModel
):
633 """Base class for path completion"""
635 def __init__(self
, context
, parent
):
636 GitCompletionModel
.__init
__(self
, context
, parent
)
638 # pylint: disable=no-self-use
639 def candidate_paths(self
):
642 def gather_matches(self
, case_sensitive
):
643 paths
, dirs
= filter_path_matches(
644 self
.match_text
, self
.candidate_paths(), case_sensitive
646 return ((), paths
, dirs
)
649 class GitStatusFilterCompletionModel(GitPathCompletionModel
):
650 """Completer for modified files and folders for status filtering"""
652 def __init__(self
, context
, parent
):
653 GitPathCompletionModel
.__init
__(self
, context
, parent
)
655 def candidate_paths(self
):
656 model
= self
.context
.model
657 return model
.staged
+ model
.unmerged
+ model
.modified
+ model
.untracked
660 class GitTrackedCompletionModel(GitPathCompletionModel
):
661 """Completer for tracked files and folders"""
663 def __init__(self
, context
, parent
):
664 GitPathCompletionModel
.__init
__(self
, context
, parent
)
665 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
668 def gather_paths(self
):
669 context
= self
.context
670 self
._paths
= gitcmds
.tracked_files(context
)
672 def gather_matches(self
, case_sensitive
):
677 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
678 return (refs
, paths
, dirs
)
681 class GitLogCompletionModel(GitRefCompletionModel
):
682 """Completer for arguments suitable for git-log like commands"""
684 def __init__(self
, context
, parent
):
685 GitRefCompletionModel
.__init
__(self
, context
, parent
)
686 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
688 self
._model
= context
.model
690 def gather_paths(self
):
691 """Gather paths and store them in the model"""
692 if not self
._model
.cfg
.get(prefs
.AUTOCOMPLETE_PATHS
, True):
695 context
= self
.context
696 self
._paths
= gitcmds
.tracked_files(context
)
698 def gather_matches(self
, case_sensitive
):
699 """Filter paths and refs to find matching entries"""
702 refs
= filter_matches(
703 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
705 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
707 self
.match_text
== '--'
708 or self
.full_text
.startswith('-- ')
709 or ' -- ' in self
.full_text
714 paths
.insert(0, '--')
716 return (refs
, paths
, dirs
)
719 def bind_lineedit(model
, hint
=''):
720 """Create a line edit bound against a specific model"""
722 class BoundLineEdit(CompletionLineEdit
):
723 def __init__(self
, context
, hint
=hint
, parent
=None):
724 CompletionLineEdit
.__init
__(self
, context
, model
, hint
=hint
, parent
=parent
)
725 self
.context
= context
731 GitLogLineEdit
= bind_lineedit(GitLogCompletionModel
, hint
='<ref>')
732 GitRefLineEdit
= bind_lineedit(GitRefCompletionModel
, hint
='<ref>')
733 GitCheckoutBranchLineEdit
= bind_lineedit(
734 GitCheckoutBranchCompletionModel
, hint
='<branch>'
736 GitCreateBranchLineEdit
= bind_lineedit(GitCreateBranchCompletionModel
, hint
='<branch>')
737 GitBranchLineEdit
= bind_lineedit(GitBranchCompletionModel
, hint
='<branch>')
738 GitRemoteBranchLineEdit
= bind_lineedit(
739 GitRemoteBranchCompletionModel
, hint
='<remote-branch>'
741 GitStatusFilterLineEdit
= bind_lineedit(GitStatusFilterCompletionModel
, hint
='<path>')
742 GitTrackedLineEdit
= bind_lineedit(GitTrackedCompletionModel
, hint
='<path>')
745 class GitDialog(QtWidgets
.QDialog
):
746 def __init__(self
, lineedit
, context
, title
, text
, parent
, icon
=None):
747 QtWidgets
.QDialog
.__init
__(self
, parent
)
748 self
.context
= context
749 self
.setWindowTitle(title
)
750 self
.setWindowModality(Qt
.WindowModal
)
751 self
.setMinimumWidth(333)
753 self
.label
= QtWidgets
.QLabel()
754 self
.label
.setText(title
)
755 self
.lineedit
= lineedit(context
)
756 self
.ok_button
= qtutils
.ok_button(text
, icon
=icon
, enabled
=False)
757 self
.close_button
= qtutils
.close_button()
759 self
.button_layout
= qtutils
.hbox(
767 self
.main_layout
= qtutils
.vbox(
768 defs
.margin
, defs
.spacing
, self
.label
, self
.lineedit
, self
.button_layout
770 self
.setLayout(self
.main_layout
)
772 self
.lineedit
.textChanged
.connect(self
.text_changed
)
773 self
.lineedit
.enter
.connect(self
.accept
)
774 qtutils
.connect_button(self
.ok_button
, self
.accept
)
775 qtutils
.connect_button(self
.close_button
, self
.reject
)
777 self
.setFocusProxy(self
.lineedit
)
778 self
.lineedit
.setFocus()
781 return self
.lineedit
.text()
783 def text_changed(self
, _txt
):
784 self
.ok_button
.setEnabled(bool(self
.text()))
786 def set_text(self
, ref
):
787 self
.lineedit
.setText(ref
)
790 def get(cls
, context
, title
, text
, parent
, default
=None, icon
=None):
791 dlg
= cls(context
, title
, text
, parent
, icon
=icon
)
793 dlg
.set_text(default
)
799 y
= dlg
.lineedit
.y() + dlg
.lineedit
.height()
800 point
= QtCore
.QPoint(x
, y
)
801 mapped
= dlg
.mapToGlobal(point
)
802 dlg
.lineedit
.popup().move(mapped
.x(), mapped
.y())
803 dlg
.lineedit
.popup().show()
804 dlg
.lineedit
.refresh()
805 dlg
.lineedit
.setFocus()
807 QtCore
.QTimer().singleShot(100, show_popup
)
809 if dlg
.exec_() == cls
.Accepted
:
814 class GitRefDialog(GitDialog
):
815 def __init__(self
, context
, title
, text
, parent
, icon
=None):
817 self
, GitRefLineEdit
, context
, title
, text
, parent
, icon
=icon
821 class GitCheckoutBranchDialog(GitDialog
):
822 def __init__(self
, context
, title
, text
, parent
, icon
=None):
824 self
, GitCheckoutBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
828 class GitBranchDialog(GitDialog
):
829 def __init__(self
, context
, title
, text
, parent
, icon
=None):
831 self
, GitBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
835 class GitRemoteBranchDialog(GitDialog
):
836 def __init__(self
, context
, title
, text
, parent
, icon
=None):
838 self
, GitRemoteBranchLineEdit
, context
, title
, text
, parent
, icon
=icon