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 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 self
.textChanged
.connect(self
._text
_changed
)
113 self
._completer
.activated
.connect(self
.choose_completion
)
114 self
._completion
_model
.updated
.connect(self
._completions
_updated
,
115 type=Qt
.QueuedConnection
)
116 self
.destroyed
.connect(self
.dispose
)
121 # pylint: disable=unused-argument
122 def dispose(self
, *args
):
123 self
._completer
.dispose()
125 def completion_selection(self
):
126 """Return the last completion's selection"""
127 return self
._selection
130 """Trigger the completion popup to appear and offer completions"""
131 self
._completer
.complete()
134 """Refresh the completion model"""
135 return self
._completer
.model().update()
138 """Return the completer's popup"""
139 return self
._completer
.popup()
141 def _text_changed(self
, full_text
):
142 match_text
= self
._last
_word
()
143 self
._do
_text
_changed
(full_text
, match_text
)
144 self
.complete_last_word()
146 def _do_text_changed(self
, full_text
, match_text
):
147 case_sensitive
= _is_case_sensitive(match_text
)
149 self
._completer
.setCaseSensitivity(Qt
.CaseSensitive
)
151 self
._completer
.setCaseSensitivity(Qt
.CaseInsensitive
)
152 self
._delegate
.set_highlight_text(match_text
, case_sensitive
)
153 self
._completer
.set_match_text(full_text
, match_text
, case_sensitive
)
155 def update_matches(self
):
156 text
= self
._last
_word
()
157 case_sensitive
= _is_case_sensitive(text
)
158 self
._completer
.setCompletionPrefix(text
)
159 self
._completer
.model().update_matches(case_sensitive
)
161 def choose_completion(self
, completion
):
163 This is the event handler for the QCompleter.activated(QString) signal,
164 it is called when the user selects an item in the completer popup.
167 self
._do
_text
_changed
('', '')
169 words
= self
._words
()
170 if words
and not self
._ends
_with
_whitespace
():
173 words
.append(completion
)
174 text
= core
.list2cmdline(words
)
177 self
._do
_text
_changed
(text
, '')
181 return utils
.shell_split(self
.value())
183 def _ends_with_whitespace(self
):
185 return value
!= value
.rstrip()
187 def _last_word(self
):
188 if self
._ends
_with
_whitespace
():
190 words
= self
._words
()
197 def complete_last_word(self
):
198 self
.update_matches()
201 def close_popup(self
):
202 if self
.popup().isVisible():
205 def _completions_updated(self
):
207 if not popup
.isVisible():
209 # Select the first item
210 idx
= self
._completion
_model
.index(0, 0)
211 selection
= QtCore
.QItemSelection(idx
, idx
)
212 mode
= QtCore
.QItemSelectionModel
.Select
213 popup
.selectionModel().select(selection
, mode
)
215 def selected_completion(self
):
216 """Return the selected completion item"""
218 if not popup
.isVisible():
220 model
= popup
.selectionModel()
221 indexes
= model
.selectedIndexes()
225 item
= self
._completion
_model
.itemFromIndex(idx
)
230 def select_completion(self
):
231 """Choose the selected completion option from the completion popup"""
233 visible
= self
.popup().isVisible()
235 selection
= self
.selected_completion()
237 self
.choose_completion(selection
)
242 def event(self
, event
):
243 """Override QWidget::event() for tab completion"""
244 event_type
= event
.type()
246 if (event_type
== QtCore
.QEvent
.KeyPress
247 and event
.key() == Qt
.Key_Tab
248 and self
.select_completion()):
251 # Make sure the popup goes away during teardown
252 if event_type
== QtCore
.QEvent
.Hide
:
255 return super(CompletionLineEdit
, self
).event(event
)
257 def keyPressEvent(self
, event
):
258 """Process completion and navigation events"""
259 super(CompletionLineEdit
, self
).keyPressEvent(event
)
260 visible
= self
.popup().isVisible()
262 # Hide the popup when the field is empty
263 is_empty
= not self
.value()
269 # Activation keys select the completion when pressed and emit the
270 # activated signal. Navigation keys have lower priority, and only
271 # emit when it wasn't already handled as an activation event.
273 if key
in self
.ACTIVATION_KEYS
and visible
:
274 if self
.select_completion():
275 self
.activated
.emit()
278 navigation
= self
.NAVIGATION_KEYS
.get(key
, None)
280 signal
= getattr(self
, navigation
)
284 class GatherCompletionsThread(QtCore
.QThread
):
286 items_gathered
= Signal(object)
288 def __init__(self
, model
):
289 QtCore
.QThread
.__init
__(self
)
291 self
.case_sensitive
= False
301 # Loop when the matched text changes between the start and end time.
302 # This happens when gather_matches() takes too long and the
303 # model's match_text changes in-between.
304 while self
.running
and text
!= self
.model
.match_text
:
305 text
= self
.model
.match_text
306 items
= self
.model
.gather_matches(self
.case_sensitive
)
308 if self
.running
and text
is not None:
309 self
.items_gathered
.emit(items
)
312 class HighlightDelegate(QtWidgets
.QStyledItemDelegate
):
313 """A delegate used for auto-completion to give formatted completion"""
315 def __init__(self
, parent
):
316 QtWidgets
.QStyledItemDelegate
.__init
__(self
, parent
)
318 self
.highlight_text
= ''
319 self
.case_sensitive
= False
321 self
.doc
= QtGui
.QTextDocument()
322 # older PyQt4 does not have setDocumentMargin
323 if hasattr(self
.doc
, 'setDocumentMargin'):
324 self
.doc
.setDocumentMargin(0)
326 def set_highlight_text(self
, text
, case_sensitive
):
327 """Sets the text that will be made bold when displayed"""
328 self
.highlight_text
= text
329 self
.case_sensitive
= case_sensitive
331 def paint(self
, painter
, option
, index
):
332 """Overloaded Qt method for custom painting of a model index"""
333 if not self
.highlight_text
:
334 QtWidgets
.QStyledItemDelegate
.paint(self
, painter
, option
, index
)
337 if self
.case_sensitive
:
338 html
= text
.replace(self
.highlight_text
,
339 '<strong>%s</strong>' % self
.highlight_text
)
341 match
= re
.match(r
'(.*)(%s)(.*)' % re
.escape(self
.highlight_text
),
344 start
= match
.group(1) or ''
345 middle
= match
.group(2) or ''
346 end
= match
.group(3) or ''
347 html
= (start
+ ('<strong>%s</strong>' % middle
) + end
)
350 self
.doc
.setHtml(html
)
352 # Painting item without text, Text Document will paint the text
353 params
= QtWidgets
.QStyleOptionViewItem(option
)
354 self
.initStyleOption(params
, index
)
357 style
= QtWidgets
.QApplication
.style()
358 style
.drawControl(QtWidgets
.QStyle
.CE_ItemViewItem
, params
, painter
)
359 ctx
= QtGui
.QAbstractTextDocumentLayout
.PaintContext()
361 # Highlighting text if item is selected
362 if params
.state
& QtWidgets
.QStyle
.State_Selected
:
363 color
= params
.palette
.color(QtGui
.QPalette
.Active
,
364 QtGui
.QPalette
.HighlightedText
)
365 ctx
.palette
.setColor(QtGui
.QPalette
.Text
, color
)
367 # translate the painter to where the text is drawn
368 item_text
= QtWidgets
.QStyle
.SE_ItemViewItemText
369 rect
= style
.subElementRect(item_text
, params
, self
.widget
)
372 start
= rect
.topLeft() + QtCore
.QPoint(defs
.margin
, 0)
373 painter
.translate(start
)
375 # tell the text document to draw the html for us
376 self
.doc
.documentLayout().draw(painter
, ctx
)
380 def ref_sort_key(ref
):
381 """Sort key function that causes shorter refs to sort first, but
382 alphabetizes refs of equal length (in order to make local branches sort
383 before remote ones)."""
387 class CompletionModel(QtGui
.QStandardItemModel
):
390 items_gathered
= Signal(object)
391 model_updated
= Signal()
393 def __init__(self
, context
, parent
):
394 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
395 self
.context
= context
398 self
.case_sensitive
= False
400 self
.update_thread
= GatherCompletionsThread(self
)
401 self
.update_thread
.items_gathered
.connect(self
.apply_matches
,
402 type=Qt
.QueuedConnection
)
405 case_sensitive
= self
.update_thread
.case_sensitive
406 self
.update_matches(case_sensitive
)
408 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
409 self
.full_text
= full_text
410 self
.match_text
= match_text
411 self
.update_matches(case_sensitive
)
413 def update_matches(self
, case_sensitive
):
414 self
.case_sensitive
= case_sensitive
415 self
.update_thread
.case_sensitive
= case_sensitive
416 if not self
.update_thread
.isRunning():
417 self
.update_thread
.start()
419 # pylint: disable=unused-argument
420 def gather_matches(self
, case_sensitive
):
421 return ((), (), set())
423 def apply_matches(self
, match_tuple
):
424 matched_refs
, matched_paths
, dirs
= match_tuple
425 QStandardItem
= QtGui
.QStandardItem
427 dir_icon
= icons
.directory()
428 git_icon
= icons
.cola()
431 for ref
in matched_refs
:
432 item
= QStandardItem()
434 item
.setIcon(git_icon
)
437 from_filename
= icons
.from_filename
438 for match
in matched_paths
:
439 item
= QStandardItem()
442 item
.setIcon(dir_icon
)
444 item
.setIcon(from_filename(match
))
449 self
.invisibleRootItem().appendRows(items
)
451 except RuntimeError: # C++ object has been deleted
455 self
.update_thread
.dispose()
466 def filter_matches(match_text
, candidates
, case_sensitive
,
467 sort_key
=lambda x
: x
):
468 """Filter candidates and return the matches"""
471 case_transform
= _identity
473 case_transform
= _lower
476 match_text
= case_transform(match_text
)
477 matches
= [r
for r
in candidates
if match_text
in case_transform(r
)]
479 matches
= list(candidates
)
481 matches
.sort(key
=lambda x
: sort_key(case_transform(x
)))
485 def filter_path_matches(match_text
, file_list
, case_sensitive
):
486 """Return matching completions from a list of candidate files"""
488 files
= set(file_list
)
489 files_and_dirs
= utils
.add_parents(files
)
490 dirs
= files_and_dirs
.difference(files
)
492 paths
= filter_matches(match_text
, files_and_dirs
, case_sensitive
)
496 class Completer(QtWidgets
.QCompleter
):
498 def __init__(self
, model
, parent
):
499 QtWidgets
.QCompleter
.__init
__(self
, parent
)
501 self
.setCompletionMode(QtWidgets
.QCompleter
.UnfilteredPopupCompletion
)
502 self
.setCaseSensitivity(Qt
.CaseInsensitive
)
504 model
.model_updated
.connect(self
.update
, type=Qt
.QueuedConnection
)
511 self
._model
.dispose()
513 def set_match_text(self
, full_text
, match_text
, case_sensitive
):
514 self
._model
.set_match_text(full_text
, match_text
, case_sensitive
)
517 class GitCompletionModel(CompletionModel
):
519 def __init__(self
, context
, parent
):
520 CompletionModel
.__init
__(self
, context
, parent
)
521 self
.main_model
= model
= context
.model
522 msg
= model
.message_updated
523 model
.add_observer(msg
, self
.emit_model_updated
)
525 def gather_matches(self
, case_sensitive
):
526 refs
= filter_matches(self
.match_text
, self
.matches(), case_sensitive
,
527 sort_key
=ref_sort_key
)
528 return (refs
, (), set())
530 def emit_model_updated(self
):
532 self
.model_updated
.emit()
533 except RuntimeError: # C++ object has been deleted
540 super(GitCompletionModel
, self
).dispose()
541 self
.main_model
.remove_observer(self
.emit_model_updated
)
544 class GitRefCompletionModel(GitCompletionModel
):
545 """Completer for branches and tags"""
547 def __init__(self
, context
, parent
):
548 GitCompletionModel
.__init
__(self
, context
, parent
)
551 model
= self
.main_model
552 return model
.local_branches
+ model
.remote_branches
+ model
.tags
555 def find_potential_branches(model
):
556 remotes
= model
.remotes
557 remote_branches
= model
.remote_branches
560 allnames
= set(model
.local_branches
)
563 for remote_branch
in remote_branches
:
564 branch
= gitcmds
.strip_remote(remotes
, remote_branch
)
565 if branch
in allnames
or branch
== remote_branch
:
566 ambiguous
.add(branch
)
568 potential
.append(branch
)
571 potential_branches
= [p
for p
in potential
if p
not in ambiguous
]
572 return potential_branches
575 class GitCreateBranchCompletionModel(GitCompletionModel
):
576 """Completer for naming new branches"""
579 model
= self
.main_model
580 potential_branches
= find_potential_branches(model
)
581 return (model
.local_branches
+
586 class GitCheckoutBranchCompletionModel(GitCompletionModel
):
587 """Completer for git checkout <branch>"""
590 model
= self
.main_model
591 potential_branches
= find_potential_branches(model
)
592 return (model
.local_branches
+
594 model
.remote_branches
+
598 class GitBranchCompletionModel(GitCompletionModel
):
599 """Completer for local branches"""
601 def __init__(self
, context
, parent
):
602 GitCompletionModel
.__init
__(self
, context
, parent
)
605 model
= self
.main_model
606 return model
.local_branches
609 class GitRemoteBranchCompletionModel(GitCompletionModel
):
610 """Completer for remote branches"""
612 def __init__(self
, context
, parent
):
613 GitCompletionModel
.__init
__(self
, context
, parent
)
616 model
= self
.main_model
617 return model
.remote_branches
620 class GitPathCompletionModel(GitCompletionModel
):
621 """Base class for path completion"""
623 def __init__(self
, context
, parent
):
624 GitCompletionModel
.__init
__(self
, context
, parent
)
626 def candidate_paths(self
):
629 def gather_matches(self
, case_sensitive
):
630 paths
, dirs
= filter_path_matches(self
.match_text
,
631 self
.candidate_paths(),
633 return ((), paths
, dirs
)
636 class GitStatusFilterCompletionModel(GitPathCompletionModel
):
637 """Completer for modified files and folders for status filtering"""
639 def __init__(self
, context
, parent
):
640 GitPathCompletionModel
.__init
__(self
, context
, parent
)
642 def candidate_paths(self
):
643 model
= self
.main_model
644 return (model
.staged
+ model
.unmerged
+
645 model
.modified
+ model
.untracked
)
648 class GitTrackedCompletionModel(GitPathCompletionModel
):
649 """Completer for tracked files and folders"""
651 def __init__(self
, context
, parent
):
652 GitPathCompletionModel
.__init
__(self
, context
, parent
)
653 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
656 def gather_paths(self
):
657 context
= self
.context
658 self
._paths
= gitcmds
.tracked_files(context
)
660 def gather_matches(self
, case_sensitive
):
665 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
,
667 return (refs
, paths
, dirs
)
670 class GitLogCompletionModel(GitRefCompletionModel
):
671 """Completer for arguments suitable for git-log like commands"""
673 def __init__(self
, context
, parent
):
674 GitRefCompletionModel
.__init
__(self
, context
, parent
)
675 self
.model_updated
.connect(self
.gather_paths
, type=Qt
.QueuedConnection
)
678 def gather_paths(self
):
679 context
= self
.context
680 self
._paths
= gitcmds
.tracked_files(context
)
682 def gather_matches(self
, case_sensitive
):
685 refs
= filter_matches(self
.match_text
, self
.matches(), case_sensitive
,
686 sort_key
=ref_sort_key
)
687 paths
, dirs
= filter_path_matches(self
.match_text
, self
._paths
,
689 has_doubledash
= (self
.match_text
== '--' or
690 self
.full_text
.startswith('-- ') or
691 ' -- ' in self
.full_text
)
695 paths
.insert(0, '--')
697 return (refs
, paths
, dirs
)
700 def bind_lineedit(model
, hint
=''):
701 """Create a line edit bound against a specific model"""
703 class BoundLineEdit(CompletionLineEdit
):
705 def __init__(self
, context
, hint
=hint
, parent
=None):
706 CompletionLineEdit
.__init
__(self
, context
, model
,
707 hint
=hint
, parent
=parent
)
708 self
.context
= context
714 GitLogLineEdit
= bind_lineedit(GitLogCompletionModel
, hint
='<ref>')
715 GitRefLineEdit
= bind_lineedit(GitRefCompletionModel
, hint
='<ref>')
716 GitCheckoutBranchLineEdit
= bind_lineedit(GitCheckoutBranchCompletionModel
,
718 GitCreateBranchLineEdit
= bind_lineedit(GitCreateBranchCompletionModel
,
720 GitBranchLineEdit
= bind_lineedit(GitBranchCompletionModel
, hint
='<branch>')
721 GitRemoteBranchLineEdit
= bind_lineedit(GitRemoteBranchCompletionModel
,
722 hint
='<remote-branch>')
723 GitStatusFilterLineEdit
= bind_lineedit(GitStatusFilterCompletionModel
,
725 GitTrackedLineEdit
= bind_lineedit(GitTrackedCompletionModel
, hint
='<path>')
728 class GitDialog(QtWidgets
.QDialog
):
730 def __init__(self
, lineedit
, context
, title
, text
, parent
, icon
=None):
731 QtWidgets
.QDialog
.__init
__(self
, parent
)
732 self
.context
= context
733 self
.setWindowTitle(title
)
734 self
.setWindowModality(Qt
.WindowModal
)
735 self
.setMinimumWidth(333)
737 self
.label
= QtWidgets
.QLabel()
738 self
.label
.setText(title
)
739 self
.lineedit
= lineedit(context
)
740 self
.ok_button
= qtutils
.ok_button(text
, icon
=icon
, enabled
=False)
741 self
.close_button
= qtutils
.close_button()
743 self
.button_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
745 self
.ok_button
, self
.close_button
)
747 self
.main_layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
,
748 self
.label
, self
.lineedit
,
750 self
.setLayout(self
.main_layout
)
752 self
.lineedit
.textChanged
.connect(self
.text_changed
)
753 self
.lineedit
.enter
.connect(self
.accept
)
754 qtutils
.connect_button(self
.ok_button
, self
.accept
)
755 qtutils
.connect_button(self
.close_button
, self
.reject
)
757 self
.setFocusProxy(self
.lineedit
)
758 self
.lineedit
.setFocus(True)
761 return self
.lineedit
.text()
763 def text_changed(self
, _txt
):
764 self
.ok_button
.setEnabled(bool(self
.text()))
766 def set_text(self
, ref
):
767 self
.lineedit
.setText(ref
)
770 def get(cls
, context
, title
, text
, parent
, default
=None, icon
=None):
771 dlg
= cls(context
, title
, text
, parent
, icon
=icon
)
773 dlg
.set_text(default
)
779 y
= dlg
.lineedit
.y() + dlg
.lineedit
.height()
780 point
= QtCore
.QPoint(x
, y
)
781 mapped
= dlg
.mapToGlobal(point
)
782 dlg
.lineedit
.popup().move(mapped
.x(), mapped
.y())
783 dlg
.lineedit
.popup().show()
784 dlg
.lineedit
.refresh()
785 dlg
.lineedit
.setFocus(True)
787 QtCore
.QTimer().singleShot(100, show_popup
)
789 if dlg
.exec_() == cls
.Accepted
:
794 class GitRefDialog(GitDialog
):
796 def __init__(self
, context
, title
, text
, parent
, icon
=None):
798 self
, GitRefLineEdit
, context
, title
, text
, parent
, icon
=icon
)
801 class GitCheckoutBranchDialog(GitDialog
):
803 def __init__(self
, context
, title
, text
, parent
, icon
=None):
804 GitDialog
.__init
__(self
, GitCheckoutBranchLineEdit
,
805 context
, title
, text
, parent
, icon
=icon
)
808 class GitBranchDialog(GitDialog
):
810 def __init__(self
, context
, title
, text
, parent
, icon
=None):
812 self
, GitBranchLineEdit
, context
, title
, text
, parent
, icon
=icon
)
815 class GitRemoteBranchDialog(GitDialog
):
817 def __init__(self
, context
, title
, text
, parent
, icon
=None):
818 GitDialog
.__init
__(self
, GitRemoteBranchLineEdit
,
819 context
, title
, text
, parent
, icon
=icon
)