1 from __future__
import division
, absolute_import
, unicode_literals
4 from qtpy
import QtCore
6 from qtpy
import QtWidgets
7 from qtpy
.QtCore
import Qt
8 from qtpy
.QtCore
import Signal
11 from .. import gitcmds
13 from .. import qtutils
16 from .text
import HintedLineEdit
19 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 class CompletionLineEdit(HintedLineEdit
):
69 """A lineedit with advanced completion abilities"""
78 # Activation keys will cause a selected completion item to be chosen
79 ACTIVATION_KEYS
= (Qt
.Key_Return
, Qt
.Key_Enter
)
81 # Navigation keys trigger signals that widgets can use for customization
83 Qt
.Key_Return
: 'enter',
84 Qt
.Key_Enter
: 'enter',
89 def __init__(self
, context
, model_factory
, hint
='', parent
=None):
90 HintedLineEdit
.__init
__(self
, context
, hint
, parent
=parent
)
91 # Tracks when the completion popup was active during key events
93 self
.context
= context
94 # The most recently selected completion item
95 self
._selection
= None
97 # Create a completion model
98 completion_model
= model_factory(context
, self
)
99 completer
= Completer(completion_model
, self
)
100 completer
.setWidget(self
)
101 self
._completer
= completer
102 self
._completion
_model
= completion_model
104 # The delegate highlights matching completion text in the popup widget
105 self
._delegate
= HighlightDelegate(self
)
106 completer
.popup().setItemDelegate(self
._delegate
)
108 self
.textChanged
.connect(self
._text
_changed
)
109 self
._completer
.activated
.connect(self
.choose_completion
)
110 self
._completion
_model
.updated
.connect(self
._completions
_updated
,
111 type=Qt
.QueuedConnection
)
112 self
.destroyed
.connect(self
.dispose
)
117 # pylint: disable=unused-argument
118 def dispose(self
, *args
):
119 self
._completer
.dispose()
121 def completion_selection(self
):
122 """Return the last completion's selection"""
123 return self
._selection
126 """Trigger the completion popup to appear and offer completions"""
127 self
._completer
.complete()
130 """Refresh the completion model"""
131 return self
._completer
.model().update()
134 """Return the completer's popup"""
135 return self
._completer
.popup()
137 def _is_case_sensitive(self
, text
):
138 return bool([char
for char
in text
if char
.isupper()])
140 def _text_changed(self
, full_text
):
141 match_text
= self
._last
_word
()
142 self
._do
_text
_changed
(full_text
, match_text
)
143 self
.complete_last_word()
145 def _do_text_changed(self
, full_text
, match_text
):
146 case_sensitive
= self
._is
_case
_sensitive
(match_text
)
148 self
._completer
.setCaseSensitivity(Qt
.CaseSensitive
)
150 self
._completer
.setCaseSensitivity(Qt
.CaseInsensitive
)
151 self
._delegate
.set_highlight_text(match_text
, case_sensitive
)
152 self
._completer
.set_match_text(full_text
, match_text
, case_sensitive
)
154 def update_matches(self
):
155 text
= self
._last
_word
()
156 case_sensitive
= self
._is
_case
_sensitive
(text
)
157 self
._completer
.setCompletionPrefix(text
)
158 self
._completer
.model().update_matches(case_sensitive
)
160 def choose_completion(self
, completion
):
162 This is the event handler for the QCompleter.activated(QString) signal,
163 it is called when the user selects an item in the completer popup.
166 self
._do
_text
_changed
('', '')
168 words
= self
._words
()
169 if words
and not self
._ends
_with
_whitespace
():
172 words
.append(completion
)
173 text
= core
.list2cmdline(words
)
176 self
._do
_text
_changed
(text
, '')
180 return utils
.shell_split(self
.value())
182 def _ends_with_whitespace(self
):
184 return value
!= value
.rstrip()
186 def _last_word(self
):
187 if self
._ends
_with
_whitespace
():
189 words
= self
._words
()
196 def complete_last_word(self
):
197 self
.update_matches()
200 def close_popup(self
):
201 if self
.popup().isVisible():
204 def _completions_updated(self
):
206 if not popup
.isVisible():
208 # Select the first item
209 idx
= self
._completion
_model
.index(0, 0)
210 selection
= QtCore
.QItemSelection(idx
, idx
)
211 mode
= QtCore
.QItemSelectionModel
.Select
212 popup
.selectionModel().select(selection
, mode
)
214 def selected_completion(self
):
215 """Return the selected completion item"""
217 if not popup
.isVisible():
219 model
= popup
.selectionModel()
220 indexes
= model
.selectedIndexes()
224 item
= self
._completion
_model
.itemFromIndex(idx
)
229 def select_completion(self
):
230 """Choose the selected completion option from the completion popup"""
232 visible
= self
.popup().isVisible()
234 selection
= self
.selected_completion()
236 self
.choose_completion(selection
)
241 def event(self
, event
):
242 """Override QWidget::event() for tab completion"""
243 event_type
= event
.type()
245 if (event_type
== QtCore
.QEvent
.KeyPress
246 and event
.key() == Qt
.Key_Tab
247 and self
.select_completion()):
250 # Make sure the popup goes away during teardown
251 if event_type
== QtCore
.QEvent
.Hide
:
254 return super(CompletionLineEdit
, self
).event(event
)
256 def keyPressEvent(self
, event
):
257 """Process completion and navigation events"""
258 super(CompletionLineEdit
, self
).keyPressEvent(event
)
259 visible
= self
.popup().isVisible()
261 # Hide the popup when the field is empty
262 is_empty
= not self
.value()
268 # Activation keys select the completion when pressed and emit the
269 # activated signal. Navigation keys have lower priority, and only
270 # emit when it wasn't already handled as an activation event.
272 if key
in self
.ACTIVATION_KEYS
and visible
:
273 if self
.select_completion():
274 self
.activated
.emit()
277 navigation
= self
.NAVIGATION_KEYS
.get(key
, None)
279 signal
= getattr(self
, navigation
)
283 class GatherCompletionsThread(QtCore
.QThread
):
285 items_gathered
= Signal(object)
287 def __init__(self
, model
):
288 QtCore
.QThread
.__init
__(self
)
290 self
.case_sensitive
= False
300 # Loop when the matched text changes between the start and end time.
301 # This happens when gather_matches() takes too long and the
302 # model's match_text changes in-between.
303 while self
.running
and text
!= self
.model
.match_text
:
304 text
= self
.model
.match_text
305 items
= self
.model
.gather_matches(self
.case_sensitive
)
307 if self
.running
and text
is not None:
308 self
.items_gathered
.emit(items
)
311 class HighlightDelegate(QtWidgets
.QStyledItemDelegate
):
312 """A delegate used for auto-completion to give formatted completion"""
314 def __init__(self
, parent
):
315 QtWidgets
.QStyledItemDelegate
.__init
__(self
, parent
)
317 self
.highlight_text
= ''
318 self
.case_sensitive
= False
320 self
.doc
= QtGui
.QTextDocument()
321 # older PyQt4 does not have setDocumentMargin
322 if hasattr(self
.doc
, 'setDocumentMargin'):
323 self
.doc
.setDocumentMargin(0)
325 def set_highlight_text(self
, text
, case_sensitive
):
326 """Sets the text that will be made bold when displayed"""
327 self
.highlight_text
= text
328 self
.case_sensitive
= case_sensitive
330 def paint(self
, painter
, option
, index
):
331 """Overloaded Qt method for custom painting of a model index"""
332 if not self
.highlight_text
:
333 QtWidgets
.QStyledItemDelegate
.paint(self
, painter
, option
, index
)
336 if self
.case_sensitive
:
337 html
= text
.replace(self
.highlight_text
,
338 '<strong>%s</strong>' % self
.highlight_text
)
340 match
= re
.match(r
'(.*)(%s)(.*)' % re
.escape(self
.highlight_text
),
343 start
= match
.group(1) or ''
344 middle
= match
.group(2) or ''
345 end
= match
.group(3) or ''
346 html
= (start
+ ('<strong>%s</strong>' % middle
) + end
)
349 self
.doc
.setHtml(html
)
351 # Painting item without text, Text Document will paint the text
352 params
= QtWidgets
.QStyleOptionViewItem(option
)
353 self
.initStyleOption(params
, index
)
356 style
= QtWidgets
.QApplication
.style()
357 style
.drawControl(QtWidgets
.QStyle
.CE_ItemViewItem
, params
, painter
)
358 ctx
= QtGui
.QAbstractTextDocumentLayout
.PaintContext()
360 # Highlighting text if item is selected
361 if params
.state
& QtWidgets
.QStyle
.State_Selected
:
362 color
= params
.palette
.color(QtGui
.QPalette
.Active
,
363 QtGui
.QPalette
.HighlightedText
)
364 ctx
.palette
.setColor(QtGui
.QPalette
.Text
, color
)
366 # translate the painter to where the text is drawn
367 item_text
= QtWidgets
.QStyle
.SE_ItemViewItemText
368 rect
= style
.subElementRect(item_text
, params
, self
.widget
)
371 start
= rect
.topLeft() + QtCore
.QPoint(defs
.margin
, 0)
372 painter
.translate(start
)
374 # tell the text document to draw the html for us
375 self
.doc
.documentLayout().draw(painter
, ctx
)
379 def ref_sort_key(ref
):
380 """Sort key function that causes shorter refs to sort first, but
381 alphabetizes refs of equal length (in order to make local branches sort
382 before remote ones)."""
386 class CompletionModel(QtGui
.QStandardItemModel
):
389 items_gathered
= Signal(object)
390 model_updated
= Signal()
392 def __init__(self
, context
, parent
):
393 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
394 self
.context
= context
397 self
.case_sensitive
= False
399 self
.update_thread
= GatherCompletionsThread(self
)
400 self
.update_thread
.items_gathered
.connect(self
.apply_matches
,
401 type=Qt
.QueuedConnection
)
404 case_sensitive
= self
.update_thread
.case_sensitive
405 self
.update_matches(case_sensitive
)
407 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
408 self
.full_text
= full_text
409 self
.match_text
= match_text
410 self
.update_matches(case_sensitive
)
412 def update_matches(self
, case_sensitive
):
413 self
.case_sensitive
= case_sensitive
414 self
.update_thread
.case_sensitive
= case_sensitive
415 if not self
.update_thread
.isRunning():
416 self
.update_thread
.start()
418 # pylint: disable=unused-argument
419 def gather_matches(self
, case_sensitive
):
420 return ((), (), set())
422 def apply_matches(self
, match_tuple
):
423 matched_refs
, matched_paths
, dirs
= match_tuple
424 QStandardItem
= QtGui
.QStandardItem
426 dir_icon
= icons
.directory()
427 git_icon
= icons
.cola()
430 for ref
in matched_refs
:
431 item
= QStandardItem()
433 item
.setIcon(git_icon
)
436 from_filename
= icons
.from_filename
437 for match
in matched_paths
:
438 item
= QStandardItem()
441 item
.setIcon(dir_icon
)
443 item
.setIcon(from_filename(match
))
448 self
.invisibleRootItem().appendRows(items
)
450 except RuntimeError: # C++ object has been deleted
454 self
.update_thread
.dispose()
465 def filter_matches(match_text
, candidates
, case_sensitive
,
466 sort_key
=lambda x
: x
):
467 """Filter candidates and return the matches"""
470 case_transform
= _identity
472 case_transform
= _lower
475 match_text
= case_transform(match_text
)
476 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
478 matches
= list(candidates
)
480 matches
.sort(key
=lambda x
: sort_key(case_transform(x
)))
484 def filter_path_matches(match_text
, file_list
, case_sensitive
):
485 """Return matching completions from a list of candidate files"""
487 files
= set(file_list
)
488 files_and_dirs
= utils
.add_parents(files
)
489 dirs
= files_and_dirs
.difference(files
)
491 paths
= filter_matches(match_text
, files_and_dirs
, case_sensitive
)
495 class Completer(QtWidgets
.QCompleter
):
497 def __init__(self
, model
, parent
):
498 QtWidgets
.QCompleter
.__init
__(self
, parent
)
500 self
.setCompletionMode(QtWidgets
.QCompleter
.UnfilteredPopupCompletion
)
501 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
503 model
.model_updated
.connect(self
.update
, type=Qt
.QueuedConnection
)
510 self
._model
.dispose()
512 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
513 self
._model
.set_match_text(full_text
, match_text
, case_sensitive
)
516 class GitCompletionModel(CompletionModel
):
518 def __init__(self
, context
, parent
):
519 CompletionModel
.__init
__(self
, context
, parent
)
520 self
.main_model
= model
= context
.model
521 msg
= model
.message_updated
522 model
.add_observer(msg
, self
.emit_model_updated
)
524 def gather_matches(self
, case_sensitive
):
525 refs
= filter_matches(self
.match_text
, self
.matches(), case_sensitive
,
526 sort_key
=ref_sort_key
)
527 return (refs
, (), set())
529 def emit_model_updated(self
):
531 self
.model_updated
.emit()
532 except RuntimeError: # C++ object has been deleted
539 super(GitCompletionModel
, self
).dispose()
540 self
.main_model
.remove_observer(self
.emit_model_updated
)
543 class GitRefCompletionModel(GitCompletionModel
):
544 """Completer for branches and tags"""
546 def __init__(self
, context
, parent
):
547 GitCompletionModel
.__init
__(self
, context
, parent
)
550 model
= self
.main_model
551 return model
.local_branches
+ model
.remote_branches
+ model
.tags
554 def find_potential_branches(model
):
555 remotes
= model
.remotes
556 remote_branches
= model
.remote_branches
559 allnames
= set(model
.local_branches
)
562 for remote_branch
in remote_branches
:
563 branch
= gitcmds
.strip_remote(remotes
, remote_branch
)
564 if branch
in allnames
or branch
== remote_branch
:
565 ambiguous
.add(branch
)
567 potential
.append(branch
)
570 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
571 return potential_branches
574 class GitCreateBranchCompletionModel(GitCompletionModel
):
575 """Completer for naming new branches"""
578 model
= self
.main_model
579 potential_branches
= find_potential_branches(model
)
580 return (model
.local_branches
+
585 class GitCheckoutBranchCompletionModel(GitCompletionModel
):
586 """Completer for git checkout <branch>"""
589 model
= self
.main_model
590 potential_branches
= find_potential_branches(model
)
591 return (model
.local_branches
+
593 model
.remote_branches
+
597 class GitBranchCompletionModel(GitCompletionModel
):
598 """Completer for local branches"""
600 def __init__(self
, context
, parent
):
601 GitCompletionModel
.__init
__(self
, context
, parent
)
604 model
= self
.main_model
605 return model
.local_branches
608 class GitRemoteBranchCompletionModel(GitCompletionModel
):
609 """Completer for remote branches"""
611 def __init__(self
, context
, parent
):
612 GitCompletionModel
.__init
__(self
, context
, parent
)
615 model
= self
.main_model
616 return model
.remote_branches
619 class GitPathCompletionModel(GitCompletionModel
):
620 """Base class for path completion"""
622 def __init__(self
, context
, parent
):
623 GitCompletionModel
.__init
__(self
, context
, parent
)
625 def candidate_paths(self
):
628 def gather_matches(self
, case_sensitive
):
629 paths
, dirs
= filter_path_matches(self
.match_text
,
630 self
.candidate_paths(),
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
.main_model
643 return (model
.staged
+ model
.unmerged
+
644 model
.modified
+ model
.untracked
)
647 class GitTrackedCompletionModel(GitPathCompletionModel
):
648 """Completer for tracked files and folders"""
650 def __init__(self
, context
, parent
):
651 GitPathCompletionModel
.__init
__(self
, context
, parent
)
652 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
655 def gather_paths(self
):
656 context
= self
.context
657 self
._paths
= gitcmds
.tracked_files(context
)
659 def gather_matches(self
, case_sensitive
):
664 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
,
666 return (refs
, paths
, dirs
)
669 class GitLogCompletionModel(GitRefCompletionModel
):
670 """Completer for arguments suitable for git-log like commands"""
672 def __init__(self
, context
, parent
):
673 GitRefCompletionModel
.__init
__(self
, context
, parent
)
674 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
677 def gather_paths(self
):
678 context
= self
.context
679 self
._paths
= gitcmds
.tracked_files(context
)
681 def gather_matches(self
, case_sensitive
):
684 refs
= filter_matches(self
.match_text
, self
.matches(), case_sensitive
,
685 sort_key
=ref_sort_key
)
686 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
,
688 has_doubledash
= (self
.match_text
== '--' or
689 self
.full_text
.startswith('-- ') or
690 ' -- ' in self
.full_text
)
694 paths
.insert(0, '--')
696 return (refs
, paths
, dirs
)
699 def bind_lineedit(model
, hint
=''):
700 """Create a line edit bound against a specific model"""
702 class BoundLineEdit(CompletionLineEdit
):
704 def __init__(self
, context
, hint
=hint
, parent
=None):
705 CompletionLineEdit
.__init
__(self
, context
, model
,
706 hint
=hint
, parent
=parent
)
707 self
.context
= context
713 GitLogLineEdit
= bind_lineedit(GitLogCompletionModel
, hint
='<ref>')
714 GitRefLineEdit
= bind_lineedit(GitRefCompletionModel
, hint
='<ref>')
715 GitCheckoutBranchLineEdit
= bind_lineedit(GitCheckoutBranchCompletionModel
,
717 GitCreateBranchLineEdit
= bind_lineedit(GitCreateBranchCompletionModel
,
719 GitBranchLineEdit
= bind_lineedit(GitBranchCompletionModel
, hint
='<branch>')
720 GitRemoteBranchLineEdit
= bind_lineedit(GitRemoteBranchCompletionModel
,
721 hint
='<remote-branch>')
722 GitStatusFilterLineEdit
= bind_lineedit(GitStatusFilterCompletionModel
,
724 GitTrackedLineEdit
= bind_lineedit(GitTrackedCompletionModel
, hint
='<path>')
727 class GitDialog(QtWidgets
.QDialog
):
729 def __init__(self
, lineedit
, context
, title
, text
, parent
, icon
=None):
730 QtWidgets
.QDialog
.__init
__(self
, parent
)
731 self
.context
= context
732 self
.setWindowTitle(title
)
733 self
.setWindowModality(Qt
.WindowModal
)
734 self
.setMinimumWidth(333)
736 self
.label
= QtWidgets
.QLabel()
737 self
.label
.setText(title
)
738 self
.lineedit
= lineedit(context
)
739 self
.ok_button
= qtutils
.ok_button(text
, icon
=icon
, enabled
=False)
740 self
.close_button
= qtutils
.close_button()
742 self
.button_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
744 self
.ok_button
, self
.close_button
)
746 self
.main_layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
747 self
.label
, self
.lineedit
,
749 self
.setLayout(self
.main_layout
)
751 self
.lineedit
.textChanged
.connect(self
.text_changed
)
752 self
.lineedit
.enter
.connect(self
.accept
)
753 qtutils
.connect_button(self
.ok_button
, self
.accept
)
754 qtutils
.connect_button(self
.close_button
, self
.reject
)
756 self
.setFocusProxy(self
.lineedit
)
757 self
.lineedit
.setFocus(True)
760 return self
.lineedit
.text()
762 def text_changed(self
, _txt
):
763 self
.ok_button
.setEnabled(bool(self
.text()))
765 def set_text(self
, ref
):
766 self
.lineedit
.setText(ref
)
769 def get(cls
, context
, title
, text
, parent
, default
=None, icon
=None):
770 dlg
= cls(context
, title
, text
, parent
, icon
=icon
)
772 dlg
.set_text(default
)
778 y
= dlg
.lineedit
.y() + dlg
.lineedit
.height()
779 point
= QtCore
.QPoint(x
, y
)
780 mapped
= dlg
.mapToGlobal(point
)
781 dlg
.lineedit
.popup().move(mapped
.x(), mapped
.y())
782 dlg
.lineedit
.popup().show()
783 dlg
.lineedit
.refresh()
784 dlg
.lineedit
.setFocus(True)
786 QtCore
.QTimer().singleShot(100, show_popup
)
788 if dlg
.exec_() == cls
.Accepted
:
793 class GitRefDialog(GitDialog
):
795 def __init__(self
, context
, title
, text
, parent
, icon
=None):
797 self
, GitRefLineEdit
, context
, title
, text
, parent
, icon
=icon
)
800 class GitCheckoutBranchDialog(GitDialog
):
802 def __init__(self
, context
, title
, text
, parent
, icon
=None):
803 GitDialog
.__init
__(self
, GitCheckoutBranchLineEdit
,
804 context
, title
, text
, parent
, icon
=icon
)
807 class GitBranchDialog(GitDialog
):
809 def __init__(self
, context
, title
, text
, parent
, icon
=None):
811 self
, GitBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
)
814 class GitRemoteBranchDialog(GitDialog
):
816 def __init__(self
, context
, title
, text
, parent
, icon
=None):
817 GitDialog
.__init
__(self
, GitRemoteBranchLineEdit
,
818 context
, title
, text
, parent
, icon
=icon
)