Merge branch 'rcorre-completion_split'

This commit is contained in:
Florian Bruhin 2016-09-15 16:46:16 +02:00
commit 42aa4deecd
5 changed files with 279 additions and 355 deletions

View File

@ -174,15 +174,6 @@ class CommandRunner(QObject):
count = None count = None
return (count, cmdstr) return (count, cmdstr)
def _parse_fallback(self, text, count, keep):
"""Parse the given commandline without a valid command."""
if keep:
cmdstr, sep, argstr = text.partition(' ')
cmdline = [cmdstr, sep] + argstr.split()
else:
cmdline = text.split()
return ParseResult(cmd=None, args=None, cmdline=cmdline, count=count)
def parse(self, text, *, fallback=False, keep=False): def parse(self, text, *, fallback=False, keep=False):
"""Split the commandline text into command and arguments. """Split the commandline text into command and arguments.
@ -210,7 +201,9 @@ class CommandRunner(QObject):
if not fallback: if not fallback:
raise cmdexc.NoSuchCommandError( raise cmdexc.NoSuchCommandError(
'{}: no such command'.format(cmdstr)) '{}: no such command'.format(cmdstr))
return self._parse_fallback(text, count, keep) cmdline = split.split(text, keep=keep)
return ParseResult(cmd=None, args=None, cmdline=cmdline,
count=count)
args = self._split_args(cmd, argstr, keep) args = self._split_args(cmd, argstr, keep)
if keep and args: if keep and args:

View File

@ -19,7 +19,7 @@
"""Completer attached to a CompletionView.""" """Completer attached to a CompletionView."""
from PyQt5.QtCore import pyqtSlot, QObject, QTimer, QItemSelection from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config from qutebrowser.config import config
from qutebrowser.commands import cmdutils, runners from qutebrowser.commands import cmdutils, runners
@ -36,7 +36,6 @@ class Completer(QObject):
_ignore_change: Whether to ignore the next completion update. _ignore_change: Whether to ignore the next completion update.
_win_id: The window ID this completer is in. _win_id: The window ID this completer is in.
_timer: The timer used to trigger the completion update. _timer: The timer used to trigger the completion update.
_cursor_part: The cursor part index for the next completion update.
_last_cursor_pos: The old cursor position so we avoid double completion _last_cursor_pos: The old cursor position so we avoid double completion
updates. updates.
_last_text: The old command text so we avoid double completion updates. _last_text: The old command text so we avoid double completion updates.
@ -47,16 +46,13 @@ class Completer(QObject):
self._win_id = win_id self._win_id = win_id
self._cmd = cmd self._cmd = cmd
self._ignore_change = False self._ignore_change = False
self._empty_item_idx = None
self._timer = QTimer() self._timer = QTimer()
self._timer.setSingleShot(True) self._timer.setSingleShot(True)
self._timer.setInterval(0) self._timer.setInterval(0)
self._timer.timeout.connect(self._update_completion) self._timer.timeout.connect(self._update_completion)
self._cursor_part = None
self._last_cursor_pos = None self._last_cursor_pos = None
self._last_text = None self._last_text = None
self._cmd.update_completion.connect(self.schedule_completion_update) self._cmd.update_completion.connect(self.schedule_completion_update)
self._cmd.textChanged.connect(self._on_text_edited)
def __repr__(self): def __repr__(self):
return utils.get_repr(self) return utils.get_repr(self)
@ -66,23 +62,22 @@ class Completer(QObject):
completion = self.parent() completion = self.parent()
return completion.model() return completion.model()
def _get_completion_model(self, completion, parts, cursor_part): def _get_completion_model(self, completion, pos_args):
"""Get a completion model based on an enum member. """Get a completion model based on an enum member.
Args: Args:
completion: A usertypes.Completion member. completion: A usertypes.Completion member.
parts: The parts currently in the commandline. pos_args: The positional args entered before the cursor.
cursor_part: The part the cursor is in.
Return: Return:
A completion model or None. A completion model or None.
""" """
if completion == usertypes.Completion.option: if completion == usertypes.Completion.option:
section = parts[cursor_part - 1] section = pos_args[0]
model = instances.get(completion).get(section) model = instances.get(completion).get(section)
elif completion == usertypes.Completion.value: elif completion == usertypes.Completion.value:
section = parts[cursor_part - 2] section = pos_args[0]
option = parts[cursor_part - 1] option = pos_args[1]
try: try:
model = instances.get(completion)[section][option] model = instances.get(completion)[section][option]
except KeyError: except KeyError:
@ -96,72 +91,41 @@ class Completer(QObject):
else: else:
return sortfilter.CompletionFilterModel(source=model, parent=self) return sortfilter.CompletionFilterModel(source=model, parent=self)
def _filter_cmdline_parts(self, parts, cursor_part): def _get_new_completion(self, before_cursor, under_cursor):
"""Filter a list of commandline parts to exclude flags.
Args:
parts: A list of parts.
cursor_part: The index of the part the cursor is over.
Return:
A (parts, cursor_part) tuple with the modified values.
"""
if parts == ['']:
# Empty commandline, i.e. only :.
return [''], 0
filtered_parts = []
for i, part in enumerate(parts):
if part == '--':
break
elif part.startswith('-'):
if cursor_part >= i:
cursor_part -= 1
else:
filtered_parts.append(part)
return filtered_parts, cursor_part
def _get_new_completion(self, parts, cursor_part):
"""Get a new completion. """Get a new completion.
Args: Args:
parts: The command chunks to get a completion for. before_cursor: The command chunks before the cursor.
cursor_part: The part the cursor is over currently. under_cursor: The command chunk under the cursor.
Return: Return:
A completion model. A completion model.
""" """
try: if '--' in before_cursor or under_cursor.startswith('-'):
if parts[cursor_part].startswith('-'): # cursor on a flag or after an explicit split (--)
# cursor on a flag
return
except IndexError:
pass
log.completion.debug("Before filtering flags: parts {}, cursor_part "
"{}".format(parts, cursor_part))
parts, cursor_part = self._filter_cmdline_parts(parts, cursor_part)
log.completion.debug("After filtering flags: parts {}, cursor_part "
"{}".format(parts, cursor_part))
if not parts:
return None return None
if cursor_part == 0: log.completion.debug("Before removing flags: {}".format(before_cursor))
before_cursor = [x for x in before_cursor if not x.startswith('-')]
log.completion.debug("After removing flags: {}".format(before_cursor))
if not before_cursor:
# '|' or 'set|' # '|' or 'set|'
model = instances.get(usertypes.Completion.command) model = instances.get(usertypes.Completion.command)
return sortfilter.CompletionFilterModel(source=model, parent=self) return sortfilter.CompletionFilterModel(source=model, parent=self)
# delegate completion to command
try: try:
cmd = cmdutils.cmd_dict[parts[0]] cmd = cmdutils.cmd_dict[before_cursor[0]]
except KeyError: except KeyError:
# entering an unknown command log.completion.debug("No completion for unknown command: {}"
.format(before_cursor[0]))
return None return None
argpos = len(before_cursor) - 1
try: try:
idx = cursor_part - 1 completion = cmd.get_pos_arg_info(argpos).completion
completion = cmd.get_pos_arg_info(idx).completion
except IndexError: except IndexError:
# user provided more positional arguments than the command takes log.completion.debug("No completion in position {}".format(argpos))
return None return None
if completion is None: if completion is None:
return None return None
model = self._get_completion_model(completion, parts, cursor_part) model = self._get_completion_model(completion, before_cursor[1:])
return model return model
def _quote(self, s): def _quote(self, s):
@ -179,38 +143,68 @@ class Completer(QObject):
else: else:
return s return s
@pyqtSlot(QItemSelection) def _partition(self):
def on_selection_changed(self, selected): """Divide the commandline text into chunks around the cursor position.
Return:
([parts_before_cursor], 'part_under_cursor', [parts_after_cursor])
"""
text = self._cmd.text()[len(self._cmd.prefix()):]
if not text or not text.strip():
# Only ":", empty part under the cursor with nothing before/after
return [], '', []
runner = runners.CommandRunner(self._win_id)
result = runner.parse(text, fallback=True, keep=True)
parts = [x for x in result.cmdline if x]
pos = self._cmd.cursorPosition() - len(self._cmd.prefix())
log.completion.debug('partitioning {} around position {}'.format(parts,
pos))
for i, part in enumerate(parts):
pos -= len(part)
if pos <= 0:
if part[pos-1:pos+1].isspace():
# cursor is in a space between two existing words
parts.insert(i, '')
prefix = [x.strip() for x in parts[:i]]
center = parts[i].strip()
# strip trailing whitepsace included as a separate token
postfix = [x.strip() for x in parts[i+1:] if not x.isspace()]
log.completion.debug(
"partitioned: {} '{}' {}".format(prefix, center, postfix))
return prefix, center, postfix
@pyqtSlot(str)
def on_selection_changed(self, text):
"""Change the completed part if a new item was selected. """Change the completed part if a new item was selected.
Called from the views selectionChanged method. Called from the views selectionChanged method.
Args: Args:
selected: New selection. text: Newly selected text.
_deselected: Previous selection.
""" """
indexes = selected.indexes() if text is None:
if not indexes:
return return
model = self._model() before, center, after = self._partition()
data = model.data(indexes[0]) log.completion.debug("Changing {} to '{}'".format(center, text))
if data is None:
return
parts = self._split()
try: try:
needs_quoting = cmdutils.cmd_dict[parts[0]].maxsplit is None maxsplit = cmdutils.cmd_dict[before[0]].maxsplit
except KeyError: except (KeyError, IndexError):
needs_quoting = True maxsplit = None
if needs_quoting: if maxsplit is None:
data = self._quote(data) text = self._quote(text)
model = self._model()
if model.count() == 1 and config.get('completion', 'quick-complete'): if model.count() == 1 and config.get('completion', 'quick-complete'):
# If we only have one item, we want to apply it immediately # If we only have one item, we want to apply it immediately
# and go on to the next part. # and go on to the next part.
self._change_completed_part(data, immediate=True) self._change_completed_part(text, before, after, immediate=True)
if maxsplit is not None and maxsplit < len(before):
# If we are quick-completing the part after maxsplit, don't
# keep offering completions (see issue #1519)
self._ignore_change = True
else: else:
log.completion.debug("Will ignore next completion update.") log.completion.debug("Will ignore next completion update.")
self._ignore_change = True self._ignore_change = True
self._change_completed_part(data) self._change_completed_part(text, before, after)
@pyqtSlot() @pyqtSlot()
def schedule_completion_update(self): def schedule_completion_update(self):
@ -232,12 +226,10 @@ class Completer(QObject):
@pyqtSlot() @pyqtSlot()
def _update_completion(self): def _update_completion(self):
"""Check if completions are available and activate them.""" """Check if completions are available and activate them."""
self._update_cursor_part() before_cursor, pattern, after_cursor = self._partition()
parts = self._split()
log.completion.debug( log.completion.debug("Updating completion: {} {} {}".format(
"Updating completion - prefix {}, parts {}, cursor_part {}".format( before_cursor, pattern, after_cursor))
self._cmd.prefix(), parts, self._cursor_part))
if self._ignore_change: if self._ignore_change:
log.completion.debug("Ignoring completion update because " log.completion.debug("Ignoring completion update because "
@ -255,136 +247,35 @@ class Completer(QObject):
completion.set_model(None) completion.set_model(None)
return return
model = self._get_new_completion(parts, self._cursor_part) pattern = pattern.strip("'\"")
model = self._get_new_completion(before_cursor, pattern)
try: log.completion.debug("Setting completion model to {} with pattern '{}'"
pattern = parts[self._cursor_part].strip() .format(model.srcmodel.__class__.__name__ if model else 'None',
except IndexError: pattern))
pattern = ''
if model is None:
log.completion.debug("No completion model for {}.".format(parts))
else:
log.completion.debug(
"New completion for {}: {}, with pattern '{}'".format(
parts, model.srcmodel.__class__.__name__, pattern))
completion.set_model(model, pattern) completion.set_model(model, pattern)
def _split(self, keep=False): def _change_completed_part(self, newtext, before, after, immediate=False):
"""Get the text split up in parts.
Args:
keep: Whether to keep special chars and whitespace.
aliases: Whether to resolve aliases.
"""
text = self._cmd.text()[len(self._cmd.prefix()):]
if not text:
# When only ":" is entered, we already have one imaginary part,
# which just is empty at the moment.
return ['']
if not text.strip():
# Text is only whitespace so we treat this as a single element with
# the whitespace.
return [text]
runner = runners.CommandRunner(self._win_id)
result = runner.parse(text, fallback=True, keep=keep)
parts = result.cmdline
if self._empty_item_idx is not None:
log.completion.debug("Empty element queued at {}, "
"inserting.".format(self._empty_item_idx))
parts.insert(self._empty_item_idx, '')
#log.completion.debug("Splitting '{}' -> {}".format(text, parts))
return parts
@pyqtSlot()
def _update_cursor_part(self):
"""Get the part index of the commandline where the cursor is over."""
cursor_pos = self._cmd.cursorPosition()
snippet = slice(cursor_pos - 1, cursor_pos + 1)
spaces = self._cmd.text()[snippet] == ' '
cursor_pos -= len(self._cmd.prefix())
parts = self._split(keep=True)
log.completion.vdebug(
"text: {}, parts: {}, cursor_pos after removing prefix '{}': "
"{}".format(self._cmd.text(), parts, self._cmd.prefix(),
cursor_pos))
skip = 0
for i, part in enumerate(parts):
log.completion.vdebug("Checking part {}: {!r}".format(i, parts[i]))
if not part:
skip += 1
continue
if cursor_pos <= len(part):
# foo| bar
self._cursor_part = i - skip
if spaces:
self._empty_item_idx = i - skip
else:
self._empty_item_idx = None
log.completion.vdebug("cursor_pos {} <= len(part) {}, "
"setting cursor_part {} - {} (skip), "
"empty_item_idx {}".format(
cursor_pos, len(part), i, skip,
self._empty_item_idx))
break
cursor_pos -= len(part)
log.completion.vdebug(
"Removing len({!r}) -> {} from cursor_pos -> {}".format(
part, len(part), cursor_pos))
else:
if i == 0:
# Initial `:` press without any text.
self._cursor_part = 0
else:
self._cursor_part = i - skip
if spaces:
self._empty_item_idx = i - skip
else:
self._empty_item_idx = None
log.completion.debug("cursor_part {}, spaces {}".format(
self._cursor_part, spaces))
return
def _change_completed_part(self, newtext, immediate=False):
"""Change the part we're currently completing in the commandline. """Change the part we're currently completing in the commandline.
Args: Args:
text: The text to set (string). text: The text to set (string) for the token under the cursor.
before: Commandline tokens before the token under the cursor.
after: Commandline tokens after the token under the cursor.
immediate: True if the text should be completed immediately immediate: True if the text should be completed immediately
including a trailing space and we shouldn't continue including a trailing space and we shouldn't continue
completing the current item. completing the current item.
""" """
parts = self._split() text = self._cmd.prefix() + ' '.join(before + [newtext])
log.completion.debug("changing part {} to '{}'".format( pos = len(text) + (1 if immediate else 0)
self._cursor_part, newtext)) if after:
try: text += ' ' + ' '.join(after)
parts[self._cursor_part] = newtext elif immediate:
except IndexError: # pad with a space if quick-completing the last entry
parts.append(newtext)
# We want to place the cursor directly after the part we just changed.
cursor_str = self._cmd.prefix() + ' '.join(
parts[:self._cursor_part + 1])
if immediate:
# If we should complete immediately, we want to move the cursor by
# one more char, to get to the next field.
cursor_str += ' '
text = self._cmd.prefix() + ' '.join(parts)
if immediate and self._cursor_part == len(parts) - 1:
# If we should complete immediately and we're completing the last
# part in the commandline, we automatically add a space.
text += ' ' text += ' '
log.completion.debug("setting text = '{}', pos = {}".format(text, pos))
self._cmd.setText(text) self._cmd.setText(text)
log.completion.debug("Placing cursor after '{}'".format(cursor_str)) self._cmd.setCursorPosition(pos)
log.modes.debug("Completion triggered, focusing {!r}".format(self))
self._cmd.setCursorPosition(len(cursor_str))
self._cmd.setFocus() self._cmd.setFocus()
self._cmd.show_cmd.emit() self._cmd.show_cmd.emit()
@pyqtSlot()
def _on_text_edited(self):
"""Reset _empty_item_idx if text was edited."""
self._empty_item_idx = None
# We also want to update the cursor part and emit _update_completion
# here, but that's already done for us by cursorPositionChanged
# anyways, so we don't need to do it twice.

View File

@ -24,8 +24,7 @@ subclasses to provide completions.
""" """
from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy
from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel
QItemSelection)
from qutebrowser.config import config, style from qutebrowser.config import config, style
from qutebrowser.completion import completiondelegate from qutebrowser.completion import completiondelegate
@ -104,7 +103,7 @@ class CompletionView(QTreeView):
""" """
resize_completion = pyqtSignal() resize_completion = pyqtSignal()
selection_changed = pyqtSignal(QItemSelection) selection_changed = pyqtSignal(str)
def __init__(self, win_id, parent=None): def __init__(self, win_id, parent=None):
super().__init__(parent) super().__init__(parent)
@ -310,7 +309,11 @@ class CompletionView(QTreeView):
if not self._active: if not self._active:
return return
super().selectionChanged(selected, deselected) super().selectionChanged(selected, deselected)
self.selection_changed.emit(selected) indexes = selected.indexes()
if not indexes:
return
data = self.model().data(indexes[0])
self.selection_changed.emit(data)
def resizeEvent(self, e): def resizeEvent(self, e):
"""Extend resizeEvent to adjust column size.""" """Extend resizeEvent to adjust column size."""

View File

@ -82,7 +82,7 @@ def instances(monkeypatch):
} }
instances[usertypes.Completion.value] = { instances[usertypes.Completion.value] = {
'general': { 'general': {
'ignore-case': FakeCompletionModel(usertypes.Completion.value), 'editor': FakeCompletionModel(usertypes.Completion.value),
} }
} }
monkeypatch.setattr('qutebrowser.completion.completer.instances', monkeypatch.setattr('qutebrowser.completion.completer.instances',
@ -122,16 +122,12 @@ def cmdutils_patch(monkeypatch, stubs):
"""docstring.""" """docstring."""
pass pass
cmds = {
'set': set_command,
'help': show_help,
'open': openurl,
'bind': bind,
'tab-detach': tab_detach,
}
cmd_utils = stubs.FakeCmdUtils({ cmd_utils = stubs.FakeCmdUtils({
name: command.Command(name=name, handler=fn) 'set': command.Command(name='set', handler=set_command),
for name, fn in cmds.items() 'help': command.Command(name='help', handler=show_help),
'open': command.Command(name='open', handler=openurl, maxsplit=0),
'bind': command.Command(name='bind', handler=bind),
'tab-detach': command.Command(name='tab-detach', handler=tab_detach),
}) })
monkeypatch.setattr('qutebrowser.completion.completer.cmdutils', cmd_utils) monkeypatch.setattr('qutebrowser.completion.completer.cmdutils', cmd_utils)
@ -147,96 +143,146 @@ def _set_cmd_prompt(cmd, txt):
cmd.setCursorPosition(txt.index('|')) cmd.setCursorPosition(txt.index('|'))
def _validate_cmd_prompt(cmd, txt): @pytest.mark.parametrize('txt, kind, pattern', [
"""Interpret fake command prompt text using | as the cursor placeholder. (':nope|', usertypes.Completion.command, 'nope'),
(':nope |', None, ''),
Args: (':set |', usertypes.Completion.section, ''),
cmd: The command prompt object. (':set gen|', usertypes.Completion.section, 'gen'),
txt: The prompt text, using | as a placeholder for the cursor position. (':set general |', usertypes.Completion.option, ''),
""" (':set what |', None, ''),
assert cmd.cursorPosition() == txt.index('|') (':set general editor |', usertypes.Completion.value, ''),
assert cmd.text() == txt.replace('|', '') (':set general editor gv|', usertypes.Completion.value, 'gv'),
(':set general editor "gvim -f"|', usertypes.Completion.value, 'gvim -f'),
(':set general editor "gvim |', usertypes.Completion.value, 'gvim'),
@pytest.mark.parametrize('txt, expected', [ (':set general huh |', None, ''),
(':nope|', usertypes.Completion.command), (':help |', usertypes.Completion.helptopic, ''),
(':nope |', None), (':help |', usertypes.Completion.helptopic, ''),
(':set |', usertypes.Completion.section), (':open |', usertypes.Completion.url, ''),
(':set gen|', usertypes.Completion.section), (':bind |', None, ''),
(':set general |', usertypes.Completion.option), (':bind <c-x> |', usertypes.Completion.command, ''),
(':set what |', None), (':bind <c-x> foo|', usertypes.Completion.command, 'foo'),
(':set general ignore-case |', usertypes.Completion.value), (':bind <c-x>| foo', None, '<c-x>'),
(':set general huh |', None), (':set| general ', usertypes.Completion.command, 'set'),
(':help |', usertypes.Completion.helptopic), (':|set general ', usertypes.Completion.command, 'set'),
(':help |', usertypes.Completion.helptopic), (':set gene|ral ignore-case', usertypes.Completion.section, 'general'),
(':open |', usertypes.Completion.url), (':|', usertypes.Completion.command, ''),
(':bind |', None), (': |', usertypes.Completion.command, ''),
(':bind <c-x> |', usertypes.Completion.command), ('/|', None, ''),
(':bind <c-x> foo|', usertypes.Completion.command), (':open -t|', None, ''),
(':bind <c-x>| foo', None), (':open --tab|', None, ''),
(':set| general ', usertypes.Completion.command), (':open -t |', usertypes.Completion.url, ''),
(':|set general ', usertypes.Completion.command), (':open --tab |', usertypes.Completion.url, ''),
(':set gene|ral ignore-case', usertypes.Completion.section), (':open | -t', usertypes.Completion.url, ''),
(':|', usertypes.Completion.command), (':tab-detach |', None, ''),
(': |', usertypes.Completion.command), (':bind --mode=caret <c-x> |', usertypes.Completion.command, ''),
('/|', None),
(':open -t|', None),
(':open --tab|', None),
(':open -t |', usertypes.Completion.url),
(':open --tab |', usertypes.Completion.url),
(':open | -t', usertypes.Completion.url),
(':--foo --bar |', None),
(':tab-detach |', None),
(':bind --mode=caret <c-x> |', usertypes.Completion.command),
pytest.mark.xfail(reason='issue #74')((':bind --mode caret <c-x> |', pytest.mark.xfail(reason='issue #74')((':bind --mode caret <c-x> |',
usertypes.Completion.command)), usertypes.Completion.command, '')),
(':set -t -p |', usertypes.Completion.section), (':set -t -p |', usertypes.Completion.section, ''),
(':open -- |', None), (':open -- |', None, ''),
(':gibberish nonesense |', None, ''),
]) ])
def test_update_completion(txt, expected, status_command_stub, completer_obj, def test_update_completion(txt, kind, pattern, status_command_stub,
completion_widget_stub): completer_obj, completion_widget_stub):
"""Test setting the completion widget's model based on command text.""" """Test setting the completion widget's model based on command text."""
# this test uses | as a placeholder for the current cursor position # this test uses | as a placeholder for the current cursor position
_set_cmd_prompt(status_command_stub, txt) _set_cmd_prompt(status_command_stub, txt)
completer_obj.schedule_completion_update() completer_obj.schedule_completion_update()
assert completion_widget_stub.set_model.call_count == 1 assert completion_widget_stub.set_model.call_count == 1
arg = completion_widget_stub.set_model.call_args[0][0] args = completion_widget_stub.set_model.call_args[0]
# the outer model is just for sorting; srcmodel is the completion model # the outer model is just for sorting; srcmodel is the completion model
if expected is None: if kind is None:
assert arg == expected assert args[0] is None
else: else:
assert arg.srcmodel.kind == expected assert args[0].srcmodel.kind == kind
assert args[1] == pattern
@pytest.mark.parametrize('before, newtxt, quick_complete, count, after', [ @pytest.mark.parametrize('before, newtxt, after', [
(':foo |', 'bar', False, 1, ':foo bar|'), (':|', 'set', ':set|'),
(':foo |', 'bar', True, 2, ':foo bar|'), (':| ', 'set', ':set|'),
(':foo |', 'bar', True, 1, ':foo bar |'), (': |', 'set', ':set|'),
(':foo | bar', 'baz', False, 1, ':foo baz| bar'), (':|set', 'set', ':set|'),
(':foo |', 'bar baz', True, 1, ":foo 'bar baz' |"), (':|set ', 'set', ':set|'),
(':foo |', '', True, 1, ":foo '' |"), (':|se', 'set', ':set|'),
(':foo |', None, True, 1, ":foo |"), (':|se ', 'set', ':set|'),
(':s|e', 'set', ':set|'),
(':se|', 'set', ':set|'),
(':|se fonts', 'set', ':set| fonts'),
(':set |', 'fonts', ':set fonts|'),
(':set |', 'fonts', ':set fonts|'),
(':set --temp |', 'fonts', ':set --temp fonts|'),
(':set |fo', 'fonts', ':set fonts|'),
(':set f|o', 'fonts', ':set fonts|'),
(':set fo|', 'fonts', ':set fonts|'),
(':set fonts |', 'hints', ':set fonts hints|'),
(':set fonts |nt', 'hints', ':set fonts hints|'),
(':set fonts n|t', 'hints', ':set fonts hints|'),
(':set fonts nt|', 'hints', ':set fonts hints|'),
(':set | hints', 'fonts', ':set fonts| hints'),
(':set | hints', 'fonts', ':set fonts| hints'),
(':set |fo hints', 'fonts', ':set fonts| hints'),
(':set f|o hints', 'fonts', ':set fonts| hints'),
(':set fo| hints', 'fonts', ':set fonts| hints'),
(':set fonts hints |', 'Comic Sans', ":set fonts hints 'Comic Sans'|"),
(":set fonts hints 'Comic Sans'|", '12px Hack',
":set fonts hints '12px Hack'|"),
(":set fonts hints 'Comic| Sans'", '12px Hack',
":set fonts hints '12px Hack'|"),
# open has maxsplit=0, so treat the last two tokens as one and don't quote
(':open foo bar|', 'baz', ':open baz|'),
(':open foo| bar', 'baz', ':open baz|'),
]) ])
def test_on_selection_changed(before, newtxt, count, quick_complete, after, def test_on_selection_changed(before, newtxt, after, completer_obj,
completer_obj, status_command_stub, config_stub, status_command_stub,
completion_widget_stub, config_stub): completion_widget_stub):
"""Test that on_selection_changed modifies the cmd text properly. """Test that on_selection_changed modifies the cmd text properly.
The | represents the current cursor position in the cmd prompt. The | represents the current cursor position in the cmd prompt.
If quick-complete is True and there is only 1 completion (count == 1), If quick-complete is True and there is only 1 completion (count == 1),
then we expect a space to be appended after the current word. then we expect a space to be appended after the current word.
""" """
config_stub.data['completion']['quick-complete'] = quick_complete
model = unittest.mock.Mock() model = unittest.mock.Mock()
model.data = unittest.mock.Mock(return_value=newtxt)
model.count = unittest.mock.Mock(return_value=count)
indexes = [unittest.mock.Mock()]
selection = unittest.mock.Mock()
selection.indexes = unittest.mock.Mock(return_value=indexes)
completion_widget_stub.model.return_value = model completion_widget_stub.model.return_value = model
_set_cmd_prompt(status_command_stub, before)
# schedule_completion_update is needed to pick up the cursor position def check(quick_complete, count, expected_txt, expected_pos):
config_stub.data['completion']['quick-complete'] = quick_complete
model.count = lambda: count
_set_cmd_prompt(status_command_stub, before)
completer_obj.on_selection_changed(newtxt)
assert status_command_stub.text() == expected_txt
assert status_command_stub.cursorPosition() == expected_pos
after_pos = after.index('|')
after_txt = after.replace('|', '')
check(False, 1, after_txt, after_pos)
check(True, 2, after_txt, after_pos)
# quick-completing a single item should move the cursor ahead by 1 and add
# a trailing space if at the end of the cmd string
after_pos += 1
if after_pos > len(after_txt):
after_txt += ' '
check(True, 1, after_txt, after_pos)
def test_quickcomplete_flicker(status_command_stub, completer_obj,
completion_widget_stub, config_stub):
"""Validate fix for #1519: bookmark-load background highlighting quirk.
For commands like bookmark-load and open with maxsplit=0, a commandline
that looks like ':open someurl |' is considered to be completing the first
arg with pattern 'someurl ' (note trailing whitespace). As this matches the
one completion available, it keeps the completionmenu open.
This test validates that the completion model is not re-set after we
quick-complete an entry after maxsplit.
"""
model = unittest.mock.Mock()
model.count = unittest.mock.Mock(return_value=1)
completion_widget_stub.model.return_value = model
config_stub.data['completion']['quick-complete'] = True
_set_cmd_prompt(status_command_stub, ':open |')
completer_obj.on_selection_changed('http://example.com')
completer_obj.schedule_completion_update() completer_obj.schedule_completion_update()
completer_obj.on_selection_changed(selection) assert not completion_widget_stub.set_model.called
model.data.assert_called_with(indexes[0])
_validate_cmd_prompt(status_command_stub, after)

View File

@ -96,64 +96,56 @@ def test_maybe_resize_completion(completionview, config_stub, qtbot):
completionview.maybe_resize_completion() completionview.maybe_resize_completion()
@pytest.mark.parametrize('which, tree, count, expected', [ @pytest.mark.parametrize('which, tree, expected', [
('next', [['Aa']], 1, 'Aa'), ('next', [['Aa']], ['Aa', None, None]),
('prev', [['Aa']], 1, 'Aa'), ('prev', [['Aa']], ['Aa', None, None]),
('next', [['Aa'], ['Ba']], 1, 'Aa'), ('next', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']),
('prev', [['Aa'], ['Ba']], 1, 'Ba'), ('prev', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']),
('next', [['Aa'], ['Ba']], 2, 'Ba'), ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']],
('prev', [['Aa'], ['Ba']], 2, 'Aa'), ['Aa', 'Ab', 'Ac', 'Ba', 'Bb', 'Ca', 'Aa']),
('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'), ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']],
('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'), ['Ca', 'Bb', 'Ba', 'Ac', 'Ab', 'Aa', 'Ca']),
('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'), ('next', [[], ['Ba', 'Bb']], ['Ba', 'Bb', 'Ba']),
('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'), ('prev', [[], ['Ba', 'Bb']], ['Bb', 'Ba', 'Bb']),
('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 1, 'Ca'), ('next', [[], [], ['Ca', 'Cb']], ['Ca', 'Cb', 'Ca']),
('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Bb'), ('prev', [[], [], ['Ca', 'Cb']], ['Cb', 'Ca', 'Cb']),
('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ac'), ('next', [['Aa'], []], ['Aa', None]),
('next', [[], ['Ba', 'Bb']], 1, 'Ba'), ('prev', [['Aa'], []], ['Aa', None]),
('prev', [[], ['Ba', 'Bb']], 1, 'Bb'), ('next', [['Aa'], [], []], ['Aa', None]),
('next', [[], [], ['Ca', 'Cb']], 1, 'Ca'), ('prev', [['Aa'], [], []], ['Aa', None]),
('prev', [[], [], ['Ca', 'Cb']], 1, 'Cb'), ('next', [['Aa'], [], ['Ca', 'Cb']], ['Aa', 'Ca', 'Cb', 'Aa']),
('next', [['Aa'], []], 1, 'Aa'), ('prev', [['Aa'], [], ['Ca', 'Cb']], ['Cb', 'Ca', 'Aa', 'Cb']),
('prev', [['Aa'], []], 1, 'Aa'), ('next', [[]], [None, None]),
('next', [['Aa'], [], []], 1, 'Aa'), ('prev', [[]], [None, None]),
('prev', [['Aa'], [], []], 1, 'Aa'), ('next-category', [['Aa']], ['Aa', None, None]),
('next', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'), ('prev-category', [['Aa']], ['Aa', None, None]),
('prev', [['Aa'], [], ['Ca', 'Cb']], 1, 'Cb'), ('next-category', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']),
('next', [[]], 1, None), ('prev-category', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']),
('prev', [[]], 1, None), ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']],
('next-category', [['Aa']], 1, 'Aa'), ['Aa', 'Ba', 'Ca', 'Aa']),
('prev-category', [['Aa']], 1, 'Aa'), ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']],
('next-category', [['Aa'], ['Ba']], 1, 'Aa'), ['Ca', 'Ba', 'Aa', 'Ca']),
('prev-category', [['Aa'], ['Ba']], 1, 'Ba'), ('next-category', [[], ['Ba', 'Bb']], ['Ba', None, None]),
('next-category', [['Aa'], ['Ba']], 2, 'Ba'), ('prev-category', [[], ['Ba', 'Bb']], ['Ba', None, None]),
('prev-category', [['Aa'], ['Ba']], 2, 'Aa'), ('next-category', [[], [], ['Ca', 'Cb']], ['Ca', None, None]),
('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), ('prev-category', [[], [], ['Ca', 'Cb']], ['Ca', None, None]),
('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), ('next-category', [['Aa'], [], []], ['Aa', None, None]),
('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ca'), ('prev-category', [['Aa'], [], []], ['Aa', None, None]),
('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Aa'), ('next-category', [['Aa'], [], ['Ca', 'Cb']], ['Aa', 'Ca', 'Aa']),
('next-category', [[], ['Ba', 'Bb']], 1, 'Ba'), ('prev-category', [['Aa'], [], ['Ca', 'Cb']], ['Ca', 'Aa', 'Ca']),
('prev-category', [[], ['Ba', 'Bb']], 1, 'Ba'), ('next-category', [[]], [None, None]),
('next-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'), ('prev-category', [[]], [None, None]),
('prev-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'),
('next-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'),
('prev-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'),
('next-category', [['Aa'], [], []], 1, 'Aa'),
('prev-category', [['Aa'], [], []], 1, 'Aa'),
('next-category', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'),
('prev-category', [['Aa'], [], ['Ca', 'Cb']], 1, 'Ca'),
('next-category', [[]], 1, None),
('prev-category', [[]], 1, None),
]) ])
def test_completion_item_focus(which, tree, count, expected, completionview, def test_completion_item_focus(which, tree, expected, completionview, qtbot):
qtbot):
"""Test that on_next_prev_item moves the selection properly. """Test that on_next_prev_item moves the selection properly.
Args: Args:
which: the direction in which to move the selection.
tree: Each list represents a completion category, with each string tree: Each list represents a completion category, with each string
being an item under that category. being an item under that category.
count: Number of times to go forward (or back if negative). expected: expected argument from on_selection_changed for each
expected: item data that should be selected after going back/forward. successive movement. None implies no signal should be
emitted.
""" """
model = base.BaseCompletionModel() model = base.BaseCompletionModel()
for catdata in tree: for catdata in tree:
@ -164,15 +156,14 @@ def test_completion_item_focus(which, tree, count, expected, completionview,
filtermodel = sortfilter.CompletionFilterModel(model, filtermodel = sortfilter.CompletionFilterModel(model,
parent=completionview) parent=completionview)
completionview.set_model(filtermodel) completionview.set_model(filtermodel)
if expected is None: for entry in expected:
for _ in range(count): if entry is None:
completionview.completion_item_focus(which) with qtbot.assertNotEmitted(completionview.selection_changed):
else:
with qtbot.waitSignal(completionview.selection_changed):
for _ in range(count):
completionview.completion_item_focus(which) completionview.completion_item_focus(which)
idx = completionview.selectionModel().currentIndex() else:
assert filtermodel.data(idx) == expected with qtbot.waitSignal(completionview.selection_changed) as sig:
completionview.completion_item_focus(which)
assert sig.args == [entry]
@pytest.mark.parametrize('which', ['next', 'prev', 'next-category', @pytest.mark.parametrize('which', ['next', 'prev', 'next-category',