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
303 # The C++ object may have already been deleted by python while
304 # the application is tearing down. This is fine.
310 # Loop when the matched text changes between the start and end time.
311 # This happens when gather_matches() takes too long and the
312 # model's match_text changes in-between.
313 while self
.running
and text
!= self
.model
.match_text
:
314 text
= self
.model
.match_text
315 items
= self
.model
.gather_matches(self
.case_sensitive
)
317 if self
.running
and text
is not None:
318 self
.items_gathered
.emit(items
)
321 class HighlightDelegate(QtWidgets
.QStyledItemDelegate
):
322 """A delegate used for auto-completion to give formatted completion"""
324 def __init__(self
, parent
):
325 QtWidgets
.QStyledItemDelegate
.__init
__(self
, parent
)
327 self
.highlight_text
= ''
328 self
.case_sensitive
= False
330 self
.doc
= QtGui
.QTextDocument()
331 # older PyQt4 does not have setDocumentMargin
332 if hasattr(self
.doc
, 'setDocumentMargin'):
333 self
.doc
.setDocumentMargin(0)
335 def set_highlight_text(self
, text
, case_sensitive
):
336 """Sets the text that will be made bold when displayed"""
337 self
.highlight_text
= text
338 self
.case_sensitive
= case_sensitive
340 def paint(self
, painter
, option
, index
):
341 """Overloaded Qt method for custom painting of a model index"""
342 if not self
.highlight_text
:
343 QtWidgets
.QStyledItemDelegate
.paint(self
, painter
, option
, index
)
346 if self
.case_sensitive
:
348 self
.highlight_text
, '<strong>%s</strong>' % self
.highlight_text
352 r
'(.*)(%s)(.*)' % re
.escape(self
.highlight_text
), text
, re
.IGNORECASE
355 start
= match
.group(1) or ''
356 middle
= match
.group(2) or ''
357 end
= match
.group(3) or ''
358 html
= start
+ ('<strong>%s</strong>' % middle
) + end
361 self
.doc
.setHtml(html
)
363 # Painting item without text, Text Document will paint the text
364 params
= QtWidgets
.QStyleOptionViewItem(option
)
365 self
.initStyleOption(params
, index
)
368 style
= QtWidgets
.QApplication
.style()
369 style
.drawControl(QtWidgets
.QStyle
.CE_ItemViewItem
, params
, painter
)
370 ctx
= QtGui
.QAbstractTextDocumentLayout
.PaintContext()
372 # Highlighting text if item is selected
373 if params
.state
& QtWidgets
.QStyle
.State_Selected
:
374 color
= params
.palette
.color(
375 QtGui
.QPalette
.Active
, QtGui
.QPalette
.HighlightedText
377 ctx
.palette
.setColor(QtGui
.QPalette
.Text
, color
)
379 # translate the painter to where the text is drawn
380 item_text
= QtWidgets
.QStyle
.SE_ItemViewItemText
381 rect
= style
.subElementRect(item_text
, params
, self
.widget
)
384 start
= rect
.topLeft() + QtCore
.QPoint(defs
.margin
, 0)
385 painter
.translate(start
)
387 # tell the text document to draw the html for us
388 self
.doc
.documentLayout().draw(painter
, ctx
)
392 def ref_sort_key(ref
):
393 """Sort key function that causes shorter refs to sort first, but
394 alphabetizes refs of equal length (in order to make local branches sort
395 before remote ones)."""
399 class CompletionModel(QtGui
.QStandardItemModel
):
402 items_gathered
= Signal(object)
403 model_updated
= Signal()
405 def __init__(self
, context
, parent
):
406 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
407 self
.context
= context
410 self
.case_sensitive
= False
412 self
.update_thread
= GatherCompletionsThread(self
)
413 self
.update_thread
.items_gathered
.connect(
414 self
.apply_matches
, type=Qt
.QueuedConnection
418 case_sensitive
= self
.update_thread
.case_sensitive
419 self
.update_matches(case_sensitive
)
421 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
422 self
.full_text
= full_text
423 self
.match_text
= match_text
424 self
.update_matches(case_sensitive
)
426 def update_matches(self
, case_sensitive
):
427 self
.case_sensitive
= case_sensitive
428 self
.update_thread
.case_sensitive
= case_sensitive
429 if not self
.update_thread
.isRunning():
430 self
.update_thread
.start()
432 # pylint: disable=unused-argument,no-self-use
433 def gather_matches(self
, case_sensitive
):
434 return ((), (), set())
436 def apply_matches(self
, match_tuple
):
437 matched_refs
, matched_paths
, dirs
= match_tuple
438 QStandardItem
= QtGui
.QStandardItem
440 dir_icon
= icons
.directory()
441 git_icon
= icons
.cola()
444 for ref
in matched_refs
:
445 item
= QStandardItem()
447 item
.setIcon(git_icon
)
450 from_filename
= icons
.from_filename
451 for match
in matched_paths
:
452 item
= QStandardItem()
455 item
.setIcon(dir_icon
)
457 item
.setIcon(from_filename(match
))
462 self
.invisibleRootItem().appendRows(items
)
464 except RuntimeError: # C++ object has been deleted
468 self
.update_thread
.dispose()
479 def filter_matches(match_text
, candidates
, case_sensitive
, sort_key
=lambda x
: x
):
480 """Filter candidates and return the matches"""
483 case_transform
= _identity
485 case_transform
= _lower
488 match_text
= case_transform(match_text
)
489 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
491 matches
= list(candidates
)
493 matches
.sort(key
=lambda x
: sort_key(case_transform(x
)))
497 def filter_path_matches(match_text
, file_list
, case_sensitive
):
498 """Return matching completions from a list of candidate files"""
500 files
= set(file_list
)
501 files_and_dirs
= utils
.add_parents(files
)
502 dirs
= files_and_dirs
.difference(files
)
504 paths
= filter_matches(match_text
, files_and_dirs
, case_sensitive
)
508 class Completer(QtWidgets
.QCompleter
):
509 def __init__(self
, model
, parent
):
510 QtWidgets
.QCompleter
.__init
__(self
, parent
)
512 self
.setCompletionMode(QtWidgets
.QCompleter
.UnfilteredPopupCompletion
)
513 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
515 model
.model_updated
.connect(self
.update
, type=Qt
.QueuedConnection
)
522 self
._model
.dispose()
524 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
525 self
._model
.set_match_text(full_text
, match_text
, case_sensitive
)
528 class GitCompletionModel(CompletionModel
):
529 def __init__(self
, context
, parent
):
530 CompletionModel
.__init
__(self
, context
, parent
)
531 self
.context
= context
532 context
.model
.updated
.connect(self
.model_updated
)
534 def gather_matches(self
, case_sensitive
):
535 refs
= filter_matches(
536 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
538 return (refs
, (), set())
540 # pylint: disable=no-self-use
545 class GitRefCompletionModel(GitCompletionModel
):
546 """Completer for branches and tags"""
548 def __init__(self
, context
, parent
):
549 GitCompletionModel
.__init
__(self
, context
, parent
)
550 context
.model
.refs_updated
.connect(self
.model_updated
)
553 model
= self
.context
.model
554 return model
.local_branches
+ model
.remote_branches
+ model
.tags
557 def find_potential_branches(model
):
558 remotes
= model
.remotes
559 remote_branches
= model
.remote_branches
562 allnames
= set(model
.local_branches
)
565 for remote_branch
in remote_branches
:
566 branch
= gitcmds
.strip_remote(remotes
, remote_branch
)
567 if branch
in allnames
or branch
== remote_branch
:
568 ambiguous
.add(branch
)
570 potential
.append(branch
)
573 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
574 return potential_branches
577 class GitCreateBranchCompletionModel(GitCompletionModel
):
578 """Completer for naming new branches"""
581 model
= self
.context
.model
582 potential_branches
= find_potential_branches(model
)
583 return model
.local_branches
+ potential_branches
+ model
.tags
586 class GitCheckoutBranchCompletionModel(GitCompletionModel
):
587 """Completer for git checkout <branch>"""
590 model
= self
.context
.model
591 potential_branches
= find_potential_branches(model
)
595 + model
.remote_branches
600 class GitBranchCompletionModel(GitCompletionModel
):
601 """Completer for local branches"""
603 def __init__(self
, context
, parent
):
604 GitCompletionModel
.__init
__(self
, context
, parent
)
607 model
= self
.context
.model
608 return model
.local_branches
611 class GitRemoteBranchCompletionModel(GitCompletionModel
):
612 """Completer for remote branches"""
614 def __init__(self
, context
, parent
):
615 GitCompletionModel
.__init
__(self
, context
, parent
)
618 model
= self
.context
.model
619 return model
.remote_branches
622 class GitPathCompletionModel(GitCompletionModel
):
623 """Base class for path completion"""
625 def __init__(self
, context
, parent
):
626 GitCompletionModel
.__init
__(self
, context
, parent
)
628 # pylint: disable=no-self-use
629 def candidate_paths(self
):
632 def gather_matches(self
, case_sensitive
):
633 paths
, dirs
= filter_path_matches(
634 self
.match_text
, self
.candidate_paths(), case_sensitive
636 return ((), paths
, dirs
)
639 class GitStatusFilterCompletionModel(GitPathCompletionModel
):
640 """Completer for modified files and folders for status filtering"""
642 def __init__(self
, context
, parent
):
643 GitPathCompletionModel
.__init
__(self
, context
, parent
)
645 def candidate_paths(self
):
646 model
= self
.context
.model
647 return model
.staged
+ model
.unmerged
+ model
.modified
+ model
.untracked
650 class GitTrackedCompletionModel(GitPathCompletionModel
):
651 """Completer for tracked files and folders"""
653 def __init__(self
, context
, parent
):
654 GitPathCompletionModel
.__init
__(self
, context
, parent
)
655 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
658 def gather_paths(self
):
659 context
= self
.context
660 self
._paths
= gitcmds
.tracked_files(context
)
662 def gather_matches(self
, case_sensitive
):
667 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
668 return (refs
, paths
, dirs
)
671 class GitLogCompletionModel(GitRefCompletionModel
):
672 """Completer for arguments suitable for git-log like commands"""
674 def __init__(self
, context
, parent
):
675 GitRefCompletionModel
.__init
__(self
, context
, parent
)
676 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
678 self
._model
= context
.model
680 def gather_paths(self
):
681 if not self
._model
.cfg
.get(prefs
.AUTOCOMPLETE_PATHS
, True):
684 context
= self
.context
685 self
._paths
= gitcmds
.tracked_files(context
)
687 def gather_matches(self
, case_sensitive
):
690 refs
= filter_matches(
691 self
.match_text
, self
.matches(), case_sensitive
, sort_key
=ref_sort_key
693 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
, case_sensitive
)
695 self
.match_text
== '--'
696 or self
.full_text
.startswith('-- ')
697 or ' -- ' in self
.full_text
702 paths
.insert(0, '--')
704 return (refs
, paths
, dirs
)
707 def bind_lineedit(model
, hint
=''):
708 """Create a line edit bound against a specific model"""
710 class BoundLineEdit(CompletionLineEdit
):
711 def __init__(self
, context
, hint
=hint
, parent
=None):
712 CompletionLineEdit
.__init
__(self
, context
, model
, hint
=hint
, parent
=parent
)
713 self
.context
= context
719 GitLogLineEdit
= bind_lineedit(GitLogCompletionModel
, hint
='<ref>')
720 GitRefLineEdit
= bind_lineedit(GitRefCompletionModel
, hint
='<ref>')
721 GitCheckoutBranchLineEdit
= bind_lineedit(
722 GitCheckoutBranchCompletionModel
, hint
='<branch>'
724 GitCreateBranchLineEdit
= bind_lineedit(GitCreateBranchCompletionModel
, hint
='<branch>')
725 GitBranchLineEdit
= bind_lineedit(GitBranchCompletionModel
, hint
='<branch>')
726 GitRemoteBranchLineEdit
= bind_lineedit(
727 GitRemoteBranchCompletionModel
, hint
='<remote-branch>'
729 GitStatusFilterLineEdit
= bind_lineedit(GitStatusFilterCompletionModel
, hint
='<path>')
730 GitTrackedLineEdit
= bind_lineedit(GitTrackedCompletionModel
, hint
='<path>')
733 class GitDialog(QtWidgets
.QDialog
):
734 def __init__(self
, lineedit
, context
, title
, text
, parent
, icon
=None):
735 QtWidgets
.QDialog
.__init
__(self
, parent
)
736 self
.context
= context
737 self
.setWindowTitle(title
)
738 self
.setWindowModality(Qt
.WindowModal
)
739 self
.setMinimumWidth(333)
741 self
.label
= QtWidgets
.QLabel()
742 self
.label
.setText(title
)
743 self
.lineedit
= lineedit(context
)
744 self
.ok_button
= qtutils
.ok_button(text
, icon
=icon
, enabled
=False)
745 self
.close_button
= qtutils
.close_button()
747 self
.button_layout
= qtutils
.hbox(
755 self
.main_layout
= qtutils
.vbox(
756 defs
.margin
, defs
.spacing
, self
.label
, self
.lineedit
, self
.button_layout
758 self
.setLayout(self
.main_layout
)
760 self
.lineedit
.textChanged
.connect(self
.text_changed
)
761 self
.lineedit
.enter
.connect(self
.accept
)
762 qtutils
.connect_button(self
.ok_button
, self
.accept
)
763 qtutils
.connect_button(self
.close_button
, self
.reject
)
765 self
.setFocusProxy(self
.lineedit
)
766 self
.lineedit
.setFocus()
769 return self
.lineedit
.text()
771 def text_changed(self
, _txt
):
772 self
.ok_button
.setEnabled(bool(self
.text()))
774 def set_text(self
, ref
):
775 self
.lineedit
.setText(ref
)
778 def get(cls
, context
, title
, text
, parent
, default
=None, icon
=None):
779 dlg
= cls(context
, title
, text
, parent
, icon
=icon
)
781 dlg
.set_text(default
)
787 y
= dlg
.lineedit
.y() + dlg
.lineedit
.height()
788 point
= QtCore
.QPoint(x
, y
)
789 mapped
= dlg
.mapToGlobal(point
)
790 dlg
.lineedit
.popup().move(mapped
.x(), mapped
.y())
791 dlg
.lineedit
.popup().show()
792 dlg
.lineedit
.refresh()
793 dlg
.lineedit
.setFocus()
795 QtCore
.QTimer().singleShot(100, show_popup
)
797 if dlg
.exec_() == cls
.Accepted
:
802 class GitRefDialog(GitDialog
):
803 def __init__(self
, context
, title
, text
, parent
, icon
=None):
805 self
, GitRefLineEdit
, context
, title
, text
, parent
, icon
=icon
809 class GitCheckoutBranchDialog(GitDialog
):
810 def __init__(self
, context
, title
, text
, parent
, icon
=None):
812 self
, GitCheckoutBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
816 class GitBranchDialog(GitDialog
):
817 def __init__(self
, context
, title
, text
, parent
, icon
=None):
819 self
, GitBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
823 class GitRemoteBranchDialog(GitDialog
):
824 def __init__(self
, context
, title
, text
, parent
, icon
=None):
826 self
, GitRemoteBranchLineEdit
, context
, title
, text
, parent
, icon
=icon