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 matched_refs
, matched_paths
, dirs
= match_tuple
434 QStandardItem
= QtGui
.QStandardItem
436 dir_icon
= icons
.directory()
437 git_icon
= icons
.cola()
440 for ref
in matched_refs
:
441 item
= QStandardItem()
443 item
.setIcon(git_icon
)
446 from_filename
= icons
.from_filename
447 for match
in matched_paths
:
448 item
= QStandardItem()
451 item
.setIcon(dir_icon
)
453 item
.setIcon(from_filename(match
))
456 # Results from background tasks can arrive after the widget has been destroyed.
457 utils
.catch_runtime_error(self
.set_items
, items
)
459 def set_items(self
, items
):
460 """Clear the widget and add items to the model"""
462 self
.invisibleRootItem().appendRows(items
)
466 self
.update_thread
.dispose()
477 def filter_matches(match_text
, candidates
, case_sensitive
, sort_key
=lambda x
: x
):
478 """Filter candidates and return the matches"""
480 case_transform
= _identity
482 case_transform
= _lower
485 match_text
= case_transform(match_text
)
486 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
488 matches
= list(candidates
)
490 matches
.sort(key
=lambda x
: sort_key(case_transform(x
)))
494 def filter_path_matches(match_text
, file_list
, case_sensitive
):
495 """Return matching completions from a list of candidate files"""
496 files
= set(file_list
)
497 files_and_dirs
= utils
.add_parents(files
)
498 dirs
= files_and_dirs
.difference(files
)
500 paths
= filter_matches(match_text
, files_and_dirs
, case_sensitive
)
504 class Completer(QtWidgets
.QCompleter
):
505 def __init__(self
, model
, parent
):
506 QtWidgets
.QCompleter
.__init
__(self
, parent
)
508 self
.setCompletionMode(QtWidgets
.QCompleter
.UnfilteredPopupCompletion
)
509 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
511 model
.model_updated
.connect(self
.update
, type=Qt
.QueuedConnection
)
518 self
._model
.dispose()
520 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
521 self
._model
.set_match_text(full_text
, match_text
, case_sensitive
)
524 class GitCompletionModel(CompletionModel
):
525 def __init__(self
, context
, parent
):
526 CompletionModel
.__init
__(self
, context
, parent
)
527 self
.context
= context
528 context
.model
.updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
530 def gather_matches(self
, case_sensitive
):
531 refs
= filter_matches(
532 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
534 return (refs
, (), set())
536 # pylint: disable=no-self-use
541 class GitRefCompletionModel(GitCompletionModel
):
542 """Completer for branches and tags"""
544 def __init__(self
, context
, parent
):
545 GitCompletionModel
.__init
__(self
, context
, parent
)
546 context
.model
.refs_updated
.connect(self
.model_updated
, type=Qt
.QueuedConnection
)
549 model
= self
.context
.model
550 return model
.local_branches
+ model
.remote_branches
+ model
.tags
553 def find_potential_branches(model
):
554 remotes
= model
.remotes
555 remote_branches
= model
.remote_branches
558 allnames
= set(model
.local_branches
)
561 for remote_branch
in remote_branches
:
562 branch
= gitcmds
.strip_remote(remotes
, remote_branch
)
563 if branch
in allnames
or branch
== remote_branch
:
564 ambiguous
.add(branch
)
566 potential
.append(branch
)
569 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
570 return potential_branches
573 class GitCreateBranchCompletionModel(GitCompletionModel
):
574 """Completer for naming new branches"""
577 model
= self
.context
.model
578 potential_branches
= find_potential_branches(model
)
579 return model
.local_branches
+ potential_branches
+ model
.tags
582 class GitCheckoutBranchCompletionModel(GitCompletionModel
):
583 """Completer for git checkout <branch>"""
586 model
= self
.context
.model
587 potential_branches
= find_potential_branches(model
)
591 + model
.remote_branches
596 class GitBranchCompletionModel(GitCompletionModel
):
597 """Completer for local branches"""
599 def __init__(self
, context
, parent
):
600 GitCompletionModel
.__init
__(self
, context
, parent
)
603 model
= self
.context
.model
604 return model
.local_branches
607 class GitRemoteBranchCompletionModel(GitCompletionModel
):
608 """Completer for remote branches"""
610 def __init__(self
, context
, parent
):
611 GitCompletionModel
.__init
__(self
, context
, parent
)
614 model
= self
.context
.model
615 return model
.remote_branches
618 class GitPathCompletionModel(GitCompletionModel
):
619 """Base class for path completion"""
621 def __init__(self
, context
, parent
):
622 GitCompletionModel
.__init
__(self
, context
, parent
)
624 # pylint: disable=no-self-use
625 def candidate_paths(self
):
628 def gather_matches(self
, case_sensitive
):
629 paths
, dirs
= filter_path_matches(
630 self
.match_text
, self
.candidate_paths(), 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
.context
.model
643 return model
.staged
+ model
.unmerged
+ model
.modified
+ model
.untracked
646 class GitTrackedCompletionModel(GitPathCompletionModel
):
647 """Completer for tracked files and folders"""
649 def __init__(self
, context
, parent
):
650 GitPathCompletionModel
.__init
__(self
, context
, parent
)
651 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
654 def gather_paths(self
):
655 context
= self
.context
656 self
._paths
= gitcmds
.tracked_files(context
)
658 def gather_matches(self
, case_sensitive
):
663 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
664 return (refs
, paths
, dirs
)
667 class GitLogCompletionModel(GitRefCompletionModel
):
668 """Completer for arguments suitable for git-log like commands"""
670 def __init__(self
, context
, parent
):
671 GitRefCompletionModel
.__init
__(self
, context
, parent
)
672 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
674 self
._model
= context
.model
676 def gather_paths(self
):
677 """Gather paths and store them in the model"""
678 if not self
._model
.cfg
.get(prefs
.AUTOCOMPLETE_PATHS
, True):
681 context
= self
.context
682 self
._paths
= gitcmds
.tracked_files(context
)
684 def gather_matches(self
, case_sensitive
):
685 """Filter paths and refs to find matching entries"""
688 refs
= filter_matches(
689 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
691 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
693 self
.match_text
== '--'
694 or self
.full_text
.startswith('-- ')
695 or ' -- ' in self
.full_text
700 paths
.insert(0, '--')
702 return (refs
, paths
, dirs
)
705 def bind_lineedit(model
, hint
=''):
706 """Create a line edit bound against a specific model"""
708 class BoundLineEdit(CompletionLineEdit
):
709 def __init__(self
, context
, hint
=hint
, parent
=None):
710 CompletionLineEdit
.__init
__(self
, context
, model
, hint
=hint
, parent
=parent
)
711 self
.context
= context
717 GitLogLineEdit
= bind_lineedit(GitLogCompletionModel
, hint
='<ref>')
718 GitRefLineEdit
= bind_lineedit(GitRefCompletionModel
, hint
='<ref>')
719 GitCheckoutBranchLineEdit
= bind_lineedit(
720 GitCheckoutBranchCompletionModel
, hint
='<branch>'
722 GitCreateBranchLineEdit
= bind_lineedit(GitCreateBranchCompletionModel
, hint
='<branch>')
723 GitBranchLineEdit
= bind_lineedit(GitBranchCompletionModel
, hint
='<branch>')
724 GitRemoteBranchLineEdit
= bind_lineedit(
725 GitRemoteBranchCompletionModel
, hint
='<remote-branch>'
727 GitStatusFilterLineEdit
= bind_lineedit(GitStatusFilterCompletionModel
, hint
='<path>')
728 GitTrackedLineEdit
= bind_lineedit(GitTrackedCompletionModel
, hint
='<path>')
731 class GitDialog(QtWidgets
.QDialog
):
732 def __init__(self
, lineedit
, context
, title
, text
, parent
, icon
=None):
733 QtWidgets
.QDialog
.__init
__(self
, parent
)
734 self
.context
= context
735 self
.setWindowTitle(title
)
736 self
.setWindowModality(Qt
.WindowModal
)
737 self
.setMinimumWidth(333)
739 self
.label
= QtWidgets
.QLabel()
740 self
.label
.setText(title
)
741 self
.lineedit
= lineedit(context
)
742 self
.ok_button
= qtutils
.ok_button(text
, icon
=icon
, enabled
=False)
743 self
.close_button
= qtutils
.close_button()
745 self
.button_layout
= qtutils
.hbox(
753 self
.main_layout
= qtutils
.vbox(
754 defs
.margin
, defs
.spacing
, self
.label
, self
.lineedit
, self
.button_layout
756 self
.setLayout(self
.main_layout
)
758 self
.lineedit
.textChanged
.connect(self
.text_changed
)
759 self
.lineedit
.enter
.connect(self
.accept
)
760 qtutils
.connect_button(self
.ok_button
, self
.accept
)
761 qtutils
.connect_button(self
.close_button
, self
.reject
)
763 self
.setFocusProxy(self
.lineedit
)
764 self
.lineedit
.setFocus()
767 return self
.lineedit
.text()
769 def text_changed(self
, _txt
):
770 self
.ok_button
.setEnabled(bool(self
.text()))
772 def set_text(self
, ref
):
773 self
.lineedit
.setText(ref
)
776 def get(cls
, context
, title
, text
, parent
, default
=None, icon
=None):
777 dlg
= cls(context
, title
, text
, parent
, icon
=icon
)
779 dlg
.set_text(default
)
785 y
= dlg
.lineedit
.y() + dlg
.lineedit
.height()
786 point
= QtCore
.QPoint(x
, y
)
787 mapped
= dlg
.mapToGlobal(point
)
788 dlg
.lineedit
.popup().move(mapped
.x(), mapped
.y())
789 dlg
.lineedit
.popup().show()
790 dlg
.lineedit
.refresh()
791 dlg
.lineedit
.setFocus()
793 QtCore
.QTimer().singleShot(100, show_popup
)
795 if dlg
.exec_() == cls
.Accepted
:
800 class GitRefDialog(GitDialog
):
801 def __init__(self
, context
, title
, text
, parent
, icon
=None):
803 self
, GitRefLineEdit
, context
, title
, text
, parent
, icon
=icon
807 class GitCheckoutBranchDialog(GitDialog
):
808 def __init__(self
, context
, title
, text
, parent
, icon
=None):
810 self
, GitCheckoutBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
814 class GitBranchDialog(GitDialog
):
815 def __init__(self
, context
, title
, text
, parent
, icon
=None):
817 self
, GitBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
821 class GitRemoteBranchDialog(GitDialog
):
822 def __init__(self
, context
, title
, text
, parent
, icon
=None):
824 self
, GitRemoteBranchLineEdit
, context
, title
, text
, parent
, icon
=icon