diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 8569342ec..69b658c98 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -174,15 +174,6 @@ class CommandRunner(QObject): count = None 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): """Split the commandline text into command and arguments. @@ -210,7 +201,9 @@ class CommandRunner(QObject): if not fallback: raise cmdexc.NoSuchCommandError( '{}: 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) if keep and args: diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index 11d9abe0e..ac2a84e6b 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -19,7 +19,7 @@ """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.commands import cmdutils, runners @@ -36,7 +36,6 @@ class Completer(QObject): _ignore_change: Whether to ignore the next completion update. _win_id: The window ID this completer is in. _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 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._cmd = cmd self._ignore_change = False - self._empty_item_idx = None self._timer = QTimer() self._timer.setSingleShot(True) self._timer.setInterval(0) self._timer.timeout.connect(self._update_completion) - self._cursor_part = None self._last_cursor_pos = None self._last_text = None self._cmd.update_completion.connect(self.schedule_completion_update) - self._cmd.textChanged.connect(self._on_text_edited) def __repr__(self): return utils.get_repr(self) @@ -66,23 +62,22 @@ class Completer(QObject): completion = self.parent() 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. Args: completion: A usertypes.Completion member. - parts: The parts currently in the commandline. - cursor_part: The part the cursor is in. + pos_args: The positional args entered before the cursor. Return: A completion model or None. """ if completion == usertypes.Completion.option: - section = parts[cursor_part - 1] + section = pos_args[0] model = instances.get(completion).get(section) elif completion == usertypes.Completion.value: - section = parts[cursor_part - 2] - option = parts[cursor_part - 1] + section = pos_args[0] + option = pos_args[1] try: model = instances.get(completion)[section][option] except KeyError: @@ -96,72 +91,41 @@ class Completer(QObject): else: return sortfilter.CompletionFilterModel(source=model, parent=self) - def _filter_cmdline_parts(self, parts, cursor_part): - """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): + def _get_new_completion(self, before_cursor, under_cursor): """Get a new completion. Args: - parts: The command chunks to get a completion for. - cursor_part: The part the cursor is over currently. + before_cursor: The command chunks before the cursor. + under_cursor: The command chunk under the cursor. Return: A completion model. """ - try: - if parts[cursor_part].startswith('-'): - # 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: + if '--' in before_cursor or under_cursor.startswith('-'): + # cursor on a flag or after an explicit split (--) 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|' model = instances.get(usertypes.Completion.command) return sortfilter.CompletionFilterModel(source=model, parent=self) - # delegate completion to command try: - cmd = cmdutils.cmd_dict[parts[0]] + cmd = cmdutils.cmd_dict[before_cursor[0]] except KeyError: - # entering an unknown command + log.completion.debug("No completion for unknown command: {}" + .format(before_cursor[0])) return None + argpos = len(before_cursor) - 1 try: - idx = cursor_part - 1 - completion = cmd.get_pos_arg_info(idx).completion + completion = cmd.get_pos_arg_info(argpos).completion except IndexError: - # user provided more positional arguments than the command takes + log.completion.debug("No completion in position {}".format(argpos)) return None if completion is None: return None - model = self._get_completion_model(completion, parts, cursor_part) + model = self._get_completion_model(completion, before_cursor[1:]) return model def _quote(self, s): @@ -179,38 +143,68 @@ class Completer(QObject): else: return s - @pyqtSlot(QItemSelection) - def on_selection_changed(self, selected): + def _partition(self): + """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. Called from the views selectionChanged method. Args: - selected: New selection. - _deselected: Previous selection. + text: Newly selected text. """ - indexes = selected.indexes() - if not indexes: + if text is None: return - model = self._model() - data = model.data(indexes[0]) - if data is None: - return - parts = self._split() + before, center, after = self._partition() + log.completion.debug("Changing {} to '{}'".format(center, text)) try: - needs_quoting = cmdutils.cmd_dict[parts[0]].maxsplit is None - except KeyError: - needs_quoting = True - if needs_quoting: - data = self._quote(data) + maxsplit = cmdutils.cmd_dict[before[0]].maxsplit + except (KeyError, IndexError): + maxsplit = None + if maxsplit is None: + text = self._quote(text) + model = self._model() if model.count() == 1 and config.get('completion', 'quick-complete'): # If we only have one item, we want to apply it immediately # 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: log.completion.debug("Will ignore next completion update.") self._ignore_change = True - self._change_completed_part(data) + self._change_completed_part(text, before, after) @pyqtSlot() def schedule_completion_update(self): @@ -232,12 +226,10 @@ class Completer(QObject): @pyqtSlot() def _update_completion(self): """Check if completions are available and activate them.""" - self._update_cursor_part() - parts = self._split() + before_cursor, pattern, after_cursor = self._partition() - log.completion.debug( - "Updating completion - prefix {}, parts {}, cursor_part {}".format( - self._cmd.prefix(), parts, self._cursor_part)) + log.completion.debug("Updating completion: {} {} {}".format( + before_cursor, pattern, after_cursor)) if self._ignore_change: log.completion.debug("Ignoring completion update because " @@ -255,136 +247,35 @@ class Completer(QObject): completion.set_model(None) return - model = self._get_new_completion(parts, self._cursor_part) + pattern = pattern.strip("'\"") + model = self._get_new_completion(before_cursor, pattern) - try: - pattern = parts[self._cursor_part].strip() - except IndexError: - 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)) + log.completion.debug("Setting completion model to {} with pattern '{}'" + .format(model.srcmodel.__class__.__name__ if model else 'None', + pattern)) completion.set_model(model, pattern) - def _split(self, keep=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): + def _change_completed_part(self, newtext, before, after, immediate=False): """Change the part we're currently completing in the commandline. 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 including a trailing space and we shouldn't continue completing the current item. """ - parts = self._split() - log.completion.debug("changing part {} to '{}'".format( - self._cursor_part, newtext)) - try: - parts[self._cursor_part] = newtext - except IndexError: - 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 = self._cmd.prefix() + ' '.join(before + [newtext]) + pos = len(text) + (1 if immediate else 0) + if after: + text += ' ' + ' '.join(after) + elif immediate: + # pad with a space if quick-completing the last entry text += ' ' + log.completion.debug("setting text = '{}', pos = {}".format(text, pos)) self._cmd.setText(text) - log.completion.debug("Placing cursor after '{}'".format(cursor_str)) - log.modes.debug("Completion triggered, focusing {!r}".format(self)) - self._cmd.setCursorPosition(len(cursor_str)) + self._cmd.setCursorPosition(pos) self._cmd.setFocus() 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. diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index d61b32b9c..8eadcc599 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -24,8 +24,7 @@ subclasses to provide completions. """ from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy -from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, - QItemSelection) +from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel from qutebrowser.config import config, style from qutebrowser.completion import completiondelegate @@ -104,7 +103,7 @@ class CompletionView(QTreeView): """ resize_completion = pyqtSignal() - selection_changed = pyqtSignal(QItemSelection) + selection_changed = pyqtSignal(str) def __init__(self, win_id, parent=None): super().__init__(parent) @@ -310,7 +309,11 @@ class CompletionView(QTreeView): if not self._active: return 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): """Extend resizeEvent to adjust column size.""" diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index b29f252e1..9601af57d 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -82,7 +82,7 @@ def instances(monkeypatch): } instances[usertypes.Completion.value] = { 'general': { - 'ignore-case': FakeCompletionModel(usertypes.Completion.value), + 'editor': FakeCompletionModel(usertypes.Completion.value), } } monkeypatch.setattr('qutebrowser.completion.completer.instances', @@ -122,16 +122,12 @@ def cmdutils_patch(monkeypatch, stubs): """docstring.""" pass - cmds = { - 'set': set_command, - 'help': show_help, - 'open': openurl, - 'bind': bind, - 'tab-detach': tab_detach, - } cmd_utils = stubs.FakeCmdUtils({ - name: command.Command(name=name, handler=fn) - for name, fn in cmds.items() + 'set': command.Command(name='set', handler=set_command), + '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) @@ -147,96 +143,146 @@ def _set_cmd_prompt(cmd, txt): cmd.setCursorPosition(txt.index('|')) -def _validate_cmd_prompt(cmd, txt): - """Interpret fake command prompt text using | as the cursor placeholder. - - Args: - cmd: The command prompt object. - txt: The prompt text, using | as a placeholder for the cursor position. - """ - assert cmd.cursorPosition() == txt.index('|') - assert cmd.text() == txt.replace('|', '') - - -@pytest.mark.parametrize('txt, expected', [ - (':nope|', usertypes.Completion.command), - (':nope |', None), - (':set |', usertypes.Completion.section), - (':set gen|', usertypes.Completion.section), - (':set general |', usertypes.Completion.option), - (':set what |', None), - (':set general ignore-case |', usertypes.Completion.value), - (':set general huh |', None), - (':help |', usertypes.Completion.helptopic), - (':help |', usertypes.Completion.helptopic), - (':open |', usertypes.Completion.url), - (':bind |', None), - (':bind |', usertypes.Completion.command), - (':bind foo|', usertypes.Completion.command), - (':bind | foo', None), - (':set| general ', usertypes.Completion.command), - (':|set general ', usertypes.Completion.command), - (':set gene|ral ignore-case', usertypes.Completion.section), - (':|', usertypes.Completion.command), - (': |', 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 |', usertypes.Completion.command), +@pytest.mark.parametrize('txt, kind, pattern', [ + (':nope|', usertypes.Completion.command, 'nope'), + (':nope |', None, ''), + (':set |', usertypes.Completion.section, ''), + (':set gen|', usertypes.Completion.section, 'gen'), + (':set general |', usertypes.Completion.option, ''), + (':set what |', None, ''), + (':set general editor |', usertypes.Completion.value, ''), + (':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'), + (':set general huh |', None, ''), + (':help |', usertypes.Completion.helptopic, ''), + (':help |', usertypes.Completion.helptopic, ''), + (':open |', usertypes.Completion.url, ''), + (':bind |', None, ''), + (':bind |', usertypes.Completion.command, ''), + (':bind foo|', usertypes.Completion.command, 'foo'), + (':bind | foo', None, ''), + (':set| general ', usertypes.Completion.command, 'set'), + (':|set general ', usertypes.Completion.command, 'set'), + (':set gene|ral ignore-case', usertypes.Completion.section, 'general'), + (':|', usertypes.Completion.command, ''), + (': |', 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, ''), + (':tab-detach |', None, ''), + (':bind --mode=caret |', usertypes.Completion.command, ''), pytest.mark.xfail(reason='issue #74')((':bind --mode caret |', - usertypes.Completion.command)), - (':set -t -p |', usertypes.Completion.section), - (':open -- |', None), + usertypes.Completion.command, '')), + (':set -t -p |', usertypes.Completion.section, ''), + (':open -- |', None, ''), + (':gibberish nonesense |', None, ''), ]) -def test_update_completion(txt, expected, status_command_stub, completer_obj, - completion_widget_stub): +def test_update_completion(txt, kind, pattern, status_command_stub, + completer_obj, completion_widget_stub): """Test setting the completion widget's model based on command text.""" # this test uses | as a placeholder for the current cursor position _set_cmd_prompt(status_command_stub, txt) completer_obj.schedule_completion_update() 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 - if expected is None: - assert arg == expected + if kind is None: + assert args[0] is None 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', [ - (':foo |', 'bar', False, 1, ':foo bar|'), - (':foo |', 'bar', True, 2, ':foo bar|'), - (':foo |', 'bar', True, 1, ':foo bar |'), - (':foo | bar', 'baz', False, 1, ':foo baz| bar'), - (':foo |', 'bar baz', True, 1, ":foo 'bar baz' |"), - (':foo |', '', True, 1, ":foo '' |"), - (':foo |', None, True, 1, ":foo |"), +@pytest.mark.parametrize('before, newtxt, after', [ + (':|', 'set', ':set|'), + (':| ', 'set', ':set|'), + (': |', 'set', ':set|'), + (':|set', 'set', ':set|'), + (':|set ', 'set', ':set|'), + (':|se', 'set', ':set|'), + (':|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, - completer_obj, status_command_stub, - completion_widget_stub, config_stub): +def test_on_selection_changed(before, newtxt, after, completer_obj, + config_stub, status_command_stub, + completion_widget_stub): """Test that on_selection_changed modifies the cmd text properly. The | represents the current cursor position in the cmd prompt. 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. """ - config_stub.data['completion']['quick-complete'] = quick_complete 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 - _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.on_selection_changed(selection) - model.data.assert_called_with(indexes[0]) - _validate_cmd_prompt(status_command_stub, after) + assert not completion_widget_stub.set_model.called diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 90be71b02..0f316a928 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -96,64 +96,56 @@ def test_maybe_resize_completion(completionview, config_stub, qtbot): completionview.maybe_resize_completion() -@pytest.mark.parametrize('which, tree, count, expected', [ - ('next', [['Aa']], 1, 'Aa'), - ('prev', [['Aa']], 1, 'Aa'), - ('next', [['Aa'], ['Ba']], 1, 'Aa'), - ('prev', [['Aa'], ['Ba']], 1, 'Ba'), - ('next', [['Aa'], ['Ba']], 2, 'Ba'), - ('prev', [['Aa'], ['Ba']], 2, 'Aa'), - ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'), - ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'), - ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'), - ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'), - ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 1, 'Ca'), - ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Bb'), - ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ac'), - ('next', [[], ['Ba', 'Bb']], 1, 'Ba'), - ('prev', [[], ['Ba', 'Bb']], 1, 'Bb'), - ('next', [[], [], ['Ca', 'Cb']], 1, 'Ca'), - ('prev', [[], [], ['Ca', 'Cb']], 1, 'Cb'), - ('next', [['Aa'], []], 1, 'Aa'), - ('prev', [['Aa'], []], 1, 'Aa'), - ('next', [['Aa'], [], []], 1, 'Aa'), - ('prev', [['Aa'], [], []], 1, 'Aa'), - ('next', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'), - ('prev', [['Aa'], [], ['Ca', 'Cb']], 1, 'Cb'), - ('next', [[]], 1, None), - ('prev', [[]], 1, None), - ('next-category', [['Aa']], 1, 'Aa'), - ('prev-category', [['Aa']], 1, 'Aa'), - ('next-category', [['Aa'], ['Ba']], 1, 'Aa'), - ('prev-category', [['Aa'], ['Ba']], 1, 'Ba'), - ('next-category', [['Aa'], ['Ba']], 2, 'Ba'), - ('prev-category', [['Aa'], ['Ba']], 2, 'Aa'), - ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), - ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), - ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ca'), - ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Aa'), - ('next-category', [[], ['Ba', 'Bb']], 1, 'Ba'), - ('prev-category', [[], ['Ba', 'Bb']], 1, 'Ba'), - ('next-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'), - ('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), +@pytest.mark.parametrize('which, tree, expected', [ + ('next', [['Aa']], ['Aa', None, None]), + ('prev', [['Aa']], ['Aa', None, None]), + ('next', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']), + ('prev', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']), + ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], + ['Aa', 'Ab', 'Ac', 'Ba', 'Bb', 'Ca', 'Aa']), + ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], + ['Ca', 'Bb', 'Ba', 'Ac', 'Ab', 'Aa', 'Ca']), + ('next', [[], ['Ba', 'Bb']], ['Ba', 'Bb', 'Ba']), + ('prev', [[], ['Ba', 'Bb']], ['Bb', 'Ba', 'Bb']), + ('next', [[], [], ['Ca', 'Cb']], ['Ca', 'Cb', 'Ca']), + ('prev', [[], [], ['Ca', 'Cb']], ['Cb', 'Ca', 'Cb']), + ('next', [['Aa'], []], ['Aa', None]), + ('prev', [['Aa'], []], ['Aa', None]), + ('next', [['Aa'], [], []], ['Aa', None]), + ('prev', [['Aa'], [], []], ['Aa', None]), + ('next', [['Aa'], [], ['Ca', 'Cb']], ['Aa', 'Ca', 'Cb', 'Aa']), + ('prev', [['Aa'], [], ['Ca', 'Cb']], ['Cb', 'Ca', 'Aa', 'Cb']), + ('next', [[]], [None, None]), + ('prev', [[]], [None, None]), + ('next-category', [['Aa']], ['Aa', None, None]), + ('prev-category', [['Aa']], ['Aa', None, None]), + ('next-category', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']), + ('prev-category', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']), + ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], + ['Aa', 'Ba', 'Ca', 'Aa']), + ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], + ['Ca', 'Ba', 'Aa', 'Ca']), + ('next-category', [[], ['Ba', 'Bb']], ['Ba', None, None]), + ('prev-category', [[], ['Ba', 'Bb']], ['Ba', None, None]), + ('next-category', [[], [], ['Ca', 'Cb']], ['Ca', None, None]), + ('prev-category', [[], [], ['Ca', 'Cb']], ['Ca', None, None]), + ('next-category', [['Aa'], [], []], ['Aa', None, None]), + ('prev-category', [['Aa'], [], []], ['Aa', None, None]), + ('next-category', [['Aa'], [], ['Ca', 'Cb']], ['Aa', 'Ca', 'Aa']), + ('prev-category', [['Aa'], [], ['Ca', 'Cb']], ['Ca', 'Aa', 'Ca']), + ('next-category', [[]], [None, None]), + ('prev-category', [[]], [None, None]), ]) -def test_completion_item_focus(which, tree, count, expected, completionview, - qtbot): +def test_completion_item_focus(which, tree, expected, completionview, qtbot): """Test that on_next_prev_item moves the selection properly. Args: + which: the direction in which to move the selection. tree: Each list represents a completion category, with each string being an item under that category. - count: Number of times to go forward (or back if negative). - expected: item data that should be selected after going back/forward. + expected: expected argument from on_selection_changed for each + successive movement. None implies no signal should be + emitted. """ model = base.BaseCompletionModel() for catdata in tree: @@ -164,15 +156,14 @@ def test_completion_item_focus(which, tree, count, expected, completionview, filtermodel = sortfilter.CompletionFilterModel(model, parent=completionview) completionview.set_model(filtermodel) - if expected is None: - for _ in range(count): - completionview.completion_item_focus(which) - else: - with qtbot.waitSignal(completionview.selection_changed): - for _ in range(count): + for entry in expected: + if entry is None: + with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) - idx = completionview.selectionModel().currentIndex() - assert filtermodel.data(idx) == expected + else: + 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',