qutebrowser/qutebrowser/completion/completer.py
2017-05-09 21:37:03 +02:00

283 lines
11 KiB
Python

# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2014-2017 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Completer attached to a CompletionView."""
from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config
from qutebrowser.commands import cmdutils, runners
from qutebrowser.utils import usertypes, log, utils
from qutebrowser.completion.models import instances, sortfilter
class Completer(QObject):
"""Completer which manages completions in a CompletionView.
Attributes:
_cmd: The statusbar Command object this completer belongs to.
_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.
_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.
"""
def __init__(self, cmd, win_id, parent=None):
super().__init__(parent)
self._win_id = win_id
self._cmd = cmd
self._ignore_change = False
self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.setInterval(0)
self._timer.timeout.connect(self._update_completion)
self._last_cursor_pos = None
self._last_text = None
self._cmd.update_completion.connect(self.schedule_completion_update)
def __repr__(self):
return utils.get_repr(self)
def _model(self):
"""Convenience method to get the current completion model."""
completion = self.parent()
return completion.model()
def _get_completion_model(self, completion, pos_args):
"""Get a completion model based on an enum member.
Args:
completion: A usertypes.Completion member.
pos_args: The positional args entered before the cursor.
Return:
A completion model or None.
"""
if completion == usertypes.Completion.option:
section = pos_args[0]
model = instances.get(completion).get(section)
elif completion == usertypes.Completion.value:
section = pos_args[0]
option = pos_args[1]
try:
model = instances.get(completion)[section][option]
except KeyError:
# No completion model for this section/option.
model = None
else:
model = instances.get(completion)
if model is None:
return None
else:
return sortfilter.CompletionFilterModel(source=model, parent=self)
def _get_new_completion(self, before_cursor, under_cursor):
"""Get a new completion.
Args:
before_cursor: The command chunks before the cursor.
under_cursor: The command chunk under the cursor.
Return:
A completion model.
"""
if '--' in before_cursor or under_cursor.startswith('-'):
# cursor on a flag or after an explicit split (--)
return None
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)
try:
cmd = cmdutils.cmd_dict[before_cursor[0]]
except KeyError:
log.completion.debug("No completion for unknown command: {}"
.format(before_cursor[0]))
return None
argpos = len(before_cursor) - 1
try:
completion = cmd.get_pos_arg_info(argpos).completion
except IndexError:
log.completion.debug("No completion in position {}".format(argpos))
return None
if completion is None:
return None
model = self._get_completion_model(completion, before_cursor[1:])
return model
def _quote(self, s):
"""Quote s if it needs quoting for the commandline.
Note we don't use shlex.quote because that quotes a lot of shell
metachars we don't need to have quoted.
"""
if not s:
return "''"
elif any(c in s for c in ' \'\t\n\\'):
# use single quotes, and put single quotes into double quotes
# the string $'b is then quoted as '$'"'"'b'
return "'" + s.replace("'", "'\"'\"'") + "'"
else:
return s
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())
pos = min(pos, len(text)) # Qt treats 2-byte UTF-16 chars as 2 chars
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:
text: Newly selected text.
"""
if text is None:
return
before, center, after = self._partition()
log.completion.debug("Changing {} to '{}'".format(center, text))
try:
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(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(text, before, after)
@pyqtSlot()
def schedule_completion_update(self):
"""Schedule updating/enabling completion.
For performance reasons we don't want to block here, instead we do this
in the background.
"""
if (self._cmd.cursorPosition() == self._last_cursor_pos and
self._cmd.text() == self._last_text):
log.completion.debug("Ignoring update because there were no "
"changes.")
else:
log.completion.debug("Scheduling completion update.")
self._timer.start()
self._last_cursor_pos = self._cmd.cursorPosition()
self._last_text = self._cmd.text()
@pyqtSlot()
def _update_completion(self):
"""Check if completions are available and activate them."""
if self._ignore_change:
log.completion.debug("Ignoring completion update because "
"ignore_change is True.")
self._ignore_change = False
return
completion = self.parent()
if self._cmd.prefix() != ':':
# This is a search or gibberish, so we don't need to complete
# anything (yet)
# FIXME complete searches
# https://github.com/qutebrowser/qutebrowser/issues/32
completion.set_model(None)
return
before_cursor, pattern, after_cursor = self._partition()
log.completion.debug("Updating completion: {} {} {}".format(
before_cursor, pattern, after_cursor))
pattern = pattern.strip("'\"")
model = self._get_new_completion(before_cursor, 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 _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) 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.
"""
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)
self._cmd.setCursorPosition(pos)
self._cmd.setFocus()
self._cmd.show_cmd.emit()