Implement a hybrid list/sql completion model.

Now all completion models are of a single type called CompletionModel.
This model combines one or more categories. A category can either be a
ListCategory or a SqlCategory.

This simplifies the API, and will allow the use of models that combine simple
list-based and sql sources. This is important for two reasons:

- Adding searchengines to url completion
- Using an on-disk sqlite database for history, while keeping bookmarks and
  quickmars as text files.
This commit is contained in:
Ryan Roden-Corrent 2017-02-22 22:25:11 -05:00
parent 921211bbaa
commit e3a33ca427
15 changed files with 426 additions and 407 deletions

View File

@ -24,7 +24,7 @@ from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config
from qutebrowser.commands import cmdutils, runners
from qutebrowser.utils import log, utils
from qutebrowser.completion.models import sortfilter, miscmodels
from qutebrowser.completion.models import miscmodels
class Completer(QObject):
@ -62,22 +62,6 @@ class Completer(QObject):
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.
"""
model = completion(*pos_args)
if model is None or hasattr(model, 'set_pattern'):
return model
else:
return sortfilter.CompletionFilterModel(source=model, parent=self)
def _get_new_completion(self, before_cursor, under_cursor):
"""Get a new completion.
@ -96,9 +80,8 @@ class Completer(QObject):
log.completion.debug("After removing flags: {}".format(before_cursor))
if not before_cursor:
# '|' or 'set|'
model = miscmodels.command()
log.completion.debug('Starting command completion')
return sortfilter.CompletionFilterModel(source=model, parent=self)
return miscmodels.command()
try:
cmd = cmdutils.cmd_dict[before_cursor[0]]
except KeyError:
@ -113,7 +96,8 @@ class Completer(QObject):
return None
if completion is None:
return None
model = self._get_completion_model(completion, before_cursor[1:])
model = completion(*before_cursor[1:])
log.completion.debug('Starting {} completion'
.format(completion.__name__))
return model

View File

@ -197,7 +197,7 @@ class CompletionItemDelegate(QStyledItemDelegate):
if index.parent().isValid():
pattern = index.model().pattern
columns_to_filter = index.model().srcmodel.columns_to_filter
columns_to_filter = index.model().columns_to_filter
if index.column() in columns_to_filter and pattern:
repl = r'<span class="highlight">\g<0></span>'
text = re.sub(re.escape(pattern).replace(r'\ ', r'|'),

View File

@ -299,7 +299,7 @@ class CompletionView(QTreeView):
if pattern is not None:
model.set_pattern(pattern)
self._column_widths = model.srcmodel.column_widths
self._column_widths = model.column_widths
self._resize_columns()
self._maybe_update_geometry()
@ -368,7 +368,7 @@ class CompletionView(QTreeView):
"""Delete the current completion item."""
if not self.currentIndex().isValid():
raise cmdexc.CommandError("No item selected!")
if self.model().srcmodel.delete_cur_item is None:
if self.model().delete_cur_item is None:
raise cmdexc.CommandError("Cannot delete this item.")
else:
self.model().srcmodel.delete_cur_item(self)
self.model().delete_cur_item(self)

View File

@ -1,105 +0,0 @@
# 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/>.
"""The base completion model for completion in the command line.
Module attributes:
Role: An enum of user defined model roles.
"""
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QStandardItemModel, QStandardItem
class CompletionModel(QStandardItemModel):
"""A simple QStandardItemModel adopted for completions.
Used for showing completions later in the CompletionView. Supports setting
marks and adding new categories/items easily.
Attributes:
column_widths: The width percentages of the columns used in the
completion view.
"""
def __init__(self, *, column_widths=(30, 70, 0), columns_to_filter=None,
delete_cur_item=None, parent=None):
super().__init__(parent)
self.setColumnCount(3)
self.columns_to_filter = columns_to_filter or [0]
self.column_widths = column_widths
self.delete_cur_item = delete_cur_item
def new_category(self, name):
"""Add a new category to the model.
Args:
name: The name of the category to add.
Return:
The created QStandardItem.
"""
cat = QStandardItem(name)
self.appendRow(cat)
return cat
def new_item(self, cat, name, desc='', misc=None):
"""Add a new item to a category.
Args:
cat: The parent category.
name: The name of the item.
desc: The description of the item.
misc: Misc text to display.
Return:
A (nameitem, descitem, miscitem) tuple.
"""
assert not isinstance(name, int)
assert not isinstance(desc, int)
assert not isinstance(misc, int)
nameitem = QStandardItem(name)
descitem = QStandardItem(desc)
miscitem = QStandardItem(misc)
cat.appendRow([nameitem, descitem, miscitem])
def flags(self, index):
"""Return the item flags for index.
Override QAbstractItemModel::flags.
Args:
index: The QModelIndex to get item flags for.
Return:
The item flags, or Qt.NoItemFlags on error.
"""
if not index.isValid():
return
if index.parent().isValid():
# item
return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
Qt.ItemNeverHasChildren)
else:
# category
return Qt.NoItemFlags

View File

@ -1,6 +1,6 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
@ -17,65 +17,29 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""A completion model backed by SQL tables."""
"""A model that proxies access to one or more completion categories."""
import re
from PyQt5.QtCore import Qt, QModelIndex, QAbstractItemModel
from PyQt5.QtSql import QSqlQueryModel
from qutebrowser.utils import log, qtutils
from qutebrowser.misc import sql
from qutebrowser.completion.models import sortfilter, listcategory, sqlcategory
class _SqlCompletionCategory(QSqlQueryModel):
"""Wraps a SqlQuery for use as a completion category."""
class CompletionModel(QAbstractItemModel):
def __init__(self, name, *, sort_by, sort_order, select, where,
columns_to_filter, parent=None):
super().__init__(parent=parent)
self.tablename = name
"""A model that proxies access to one or more completion categories.
query = sql.run_query('select * from {} limit 1'.format(name))
self._fields = [query.record().fieldName(i) for i in columns_to_filter]
querystr = 'select {} from {} where ('.format(select, name)
# the incoming pattern will have literal % and _ escaped with '\'
# we need to tell sql to treat '\' as an escape character
querystr += ' or '.join("{} like ? escape '\\'".format(f)
for f in self._fields)
querystr += ')'
if where:
querystr += ' and ' + where
if sort_by:
assert sort_order == 'asc' or sort_order == 'desc'
querystr += ' order by {} {}'.format(sort_by, sort_order)
self._querystr = querystr
self.set_pattern('%')
def set_pattern(self, pattern):
query = sql.run_query(self._querystr, [pattern for _ in self._fields])
self.setQuery(query)
class SqlCompletionModel(QAbstractItemModel):
"""A sqlite-based model that provides data for the CompletionView.
This model is a wrapper around one or more sql tables. The tables are all
stored in a single database in qutebrowser's cache directory.
Top level indices represent categories, each of which is backed by a single
table. Child indices represent rows of those tables.
Top level indices represent categories.
Child indices represent rows of those tables.
Attributes:
column_widths: The width percentages of the columns used in the
completion view.
columns_to_filter: A list of indices of columns to apply the filter to.
pattern: Current filter pattern, used for highlighting.
_categories: The category tables.
_categories: The sub-categories.
"""
def __init__(self, *, column_widths=(30, 70, 0), columns_to_filter=None,
@ -84,7 +48,6 @@ class SqlCompletionModel(QAbstractItemModel):
self.columns_to_filter = columns_to_filter or [0]
self.column_widths = column_widths
self._categories = []
self.srcmodel = self # TODO: dummy for compat with old API
self.pattern = ''
self.delete_cur_item = delete_cur_item
@ -94,7 +57,7 @@ class SqlCompletionModel(QAbstractItemModel):
Args:
idx: A QModelIndex
Returns:
A _SqlCompletionCategory if the index points at one, else None
A category if the index points at one, else None
"""
# items hold an index to the parent category in their internalPointer
# categories have an empty internalPointer
@ -102,7 +65,19 @@ class SqlCompletionModel(QAbstractItemModel):
return self._categories[index.row()]
return None
def new_category(self, name, *, select='*', where=None, sort_by=None,
def add_list(self, name, items):
"""Add a list of items as a completion category.
Args:
name: Title of the category.
items: List of tuples.
"""
cat = listcategory.ListCategory(name, items, parent=self)
filtermodel = sortfilter.CompletionFilterModel(cat,
self.columns_to_filter)
self._categories.append(filtermodel)
def add_sqltable(self, name, *, select='*', where=None, sort_by=None,
sort_order=None):
"""Create a new completion category and add it to this model.
@ -112,13 +87,11 @@ class SqlCompletionModel(QAbstractItemModel):
where: An optional clause to filter out some rows.
sort_by: The name of the field to sort by, or None for no sorting.
sort_order: Either 'asc' or 'desc', if sort_by is non-None
Return: A new CompletionCategory.
"""
cat = _SqlCompletionCategory(name, parent=self, sort_by=sort_by,
sort_order=sort_order,
select=select, where=where,
columns_to_filter=self.columns_to_filter)
cat = sqlcategory.SqlCategory(name, parent=self, sort_by=sort_by,
sort_order=sort_order,
select=select, where=where,
columns_to_filter=self.columns_to_filter)
self._categories.append(cat)
def data(self, index, role=Qt.DisplayRole):
@ -135,11 +108,11 @@ class SqlCompletionModel(QAbstractItemModel):
return None
if not index.parent().isValid():
if index.column() == 0:
return self._categories[index.row()].tablename
return self._categories[index.row()].name
else:
table = self._categories[index.parent().row()]
idx = table.index(index.row(), index.column())
return table.data(idx)
cat = self._categories[index.parent().row()]
idx = cat.index(index.row(), index.column())
return cat.data(idx)
def flags(self, index):
"""Return the item flags for index.
@ -171,7 +144,7 @@ class SqlCompletionModel(QAbstractItemModel):
if parent.isValid():
if parent.column() != 0:
return QModelIndex()
# store a pointer to the parent table in internalPointer
# store a pointer to the parent category in internalPointer
return self.createIndex(row, col, self._categories[parent.row()])
return self.createIndex(row, col, None)
@ -183,11 +156,11 @@ class SqlCompletionModel(QAbstractItemModel):
Args:
index: The QModelIndex to get the parent index for.
"""
parent_table = index.internalPointer()
if not parent_table:
parent_cat = index.internalPointer()
if not parent_cat:
# categories have no parent
return QModelIndex()
row = self._categories.index(parent_table)
row = self._categories.index(parent_cat)
return self.createIndex(row, 0, None)
def rowCount(self, parent=QModelIndex()):
@ -209,24 +182,24 @@ class SqlCompletionModel(QAbstractItemModel):
return 3
def canFetchMore(self, parent):
"""Override to forward the call to the tables."""
"""Override to forward the call to the categories."""
cat = self._cat_from_idx(parent)
if cat:
return cat.canFetchMore()
return cat.canFetchMore(parent)
return False
def fetchMore(self, parent):
"""Override to forward the call to the tables."""
"""Override to forward the call to the categories."""
cat = self._cat_from_idx(parent)
if cat:
cat.fetchMore()
cat.fetchMore(parent)
def count(self):
"""Return the count of non-category items."""
return sum(t.rowCount() for t in self._categories)
def set_pattern(self, pattern):
"""Set the filter pattern for all category tables.
"""Set the filter pattern for all categories.
This will apply to the fields indicated in columns_to_filter.
@ -236,19 +209,13 @@ class SqlCompletionModel(QAbstractItemModel):
log.completion.debug("Setting completion pattern '{}'".format(pattern))
# TODO: should pattern be saved in the view layer instead?
self.pattern = pattern
# escape to treat a user input % or _ as a literal, not a wildcard
pattern = pattern.replace('%', '\\%')
pattern = pattern.replace('_', '\\_')
# treat spaces as wildcards to match any of the typed words
pattern = re.sub(r' +', '%', pattern)
pattern = '%{}%'.format(pattern)
for cat in self._categories:
cat.set_pattern(pattern)
def first_item(self):
"""Return the index of the first child (non-category) in the model."""
for row, table in enumerate(self._categories):
if table.rowCount() > 0:
for row, cat in enumerate(self._categories):
if cat.rowCount() > 0:
parent = self.index(row, 0)
index = self.index(0, 0, parent)
qtutils.ensure_valid(index)
@ -257,18 +224,11 @@ class SqlCompletionModel(QAbstractItemModel):
def last_item(self):
"""Return the index of the last child (non-category) in the model."""
for row, table in reversed(list(enumerate(self._categories))):
childcount = table.rowCount()
for row, cat in reversed(list(enumerate(self._categories))):
childcount = cat.rowCount()
if childcount > 0:
parent = self.index(row, 0)
index = self.index(childcount - 1, 0, parent)
qtutils.ensure_valid(index)
return index
return QModelIndex()
class SqlException(Exception):
"""Raised on an error interacting with the SQL database."""
pass

View File

@ -20,17 +20,16 @@
"""Functions that return config-related completion models."""
from qutebrowser.config import configdata, configexc
from qutebrowser.completion.models import base
from qutebrowser.completion.models import completionmodel
from qutebrowser.utils import objreg
def section():
"""A CompletionModel filled with settings sections."""
model = base.CompletionModel(column_widths=(20, 70, 10))
cat = model.new_category("Sections")
for name in configdata.DATA:
desc = configdata.SECTION_DESC[name].splitlines()[0].strip()
model.new_item(cat, name, desc)
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
sections = ((name, configdata.SECTION_DESC[name].splitlines()[0].strip())
for name in configdata.DATA)
model.add_list("Sections", sections)
return model
@ -40,12 +39,12 @@ def option(sectname):
Args:
sectname: The name of the config section this model shows.
"""
model = base.CompletionModel(column_widths=(20, 70, 10))
cat = model.new_category(sectname)
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
try:
sectdata = configdata.DATA[sectname]
except KeyError:
return None
options = []
for name in sectdata:
try:
desc = sectdata.descriptions[name]
@ -57,7 +56,8 @@ def option(sectname):
desc = desc.splitlines()[0]
config = objreg.get('config')
val = config.get(sectname, name, raw=True)
model.new_item(cat, name, desc, val)
options.append((name, desc, val))
model.add_list(sectname, options)
return model
@ -68,16 +68,16 @@ def value(sectname, optname):
sectname: The name of the config section this model shows.
optname: The name of the config option this model shows.
"""
model = base.CompletionModel(column_widths=(20, 70, 10))
cur_cat = model.new_category("Current/Default")
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
config = objreg.get('config')
try:
val = config.get(sectname, optname, raw=True) or '""'
current = config.get(sectname, optname, raw=True) or '""'
except (configexc.NoSectionError, configexc.NoOptionError):
return None
model.new_item(cur_cat, val, "Current value")
default_value = configdata.DATA[sectname][optname].default() or '""'
model.new_item(cur_cat, default_value, "Default value")
default = configdata.DATA[sectname][optname].default() or '""'
if hasattr(configdata.DATA[sectname], 'valtype'):
# Same type for all values (ValueList)
vals = configdata.DATA[sectname].valtype.complete()
@ -87,8 +87,9 @@ def value(sectname, optname):
"sections, but {} is not!".format(sectname))
# Different type for each value (KeyValue)
vals = configdata.DATA[sectname][optname].typ.complete()
model.add_list("Current/Default", [(current, "Current value"),
(default, "Default value")])
if vals is not None:
cat = model.new_category("Completions")
for (val, desc) in vals:
model.new_item(cat, val, desc)
model.add_list("Completions", vals)
return model

View File

@ -0,0 +1,66 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# 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/>.
"""The base completion model for completion in the command line.
Module attributes:
Role: An enum of user defined model roles.
"""
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QStandardItem, QStandardItemModel
class ListCategory(QStandardItemModel):
"""Expose a list of items as a category for the CompletionModel."""
def __init__(self, name, items, parent=None):
super().__init__(parent)
self.name = name
# self.setColumnCount(3) TODO needed?
# TODO: batch insert?
# TODO: can I just insert a tuple instead of a list?
for item in items:
self.appendRow([QStandardItem(x) for x in item])
def flags(self, index):
"""Return the item flags for index.
Override QAbstractItemModel::flags.
Args:
index: The QModelIndex to get item flags for.
Return:
The item flags, or Qt.NoItemFlags on error.
"""
if not index.isValid():
return
if index.parent().isValid():
# item
return (Qt.ItemIsEnabled | Qt.ItemIsSelectable |
Qt.ItemNeverHasChildren)
else:
# category
return Qt.NoItemFlags
def set_pattern(self, pattern):
pass

View File

@ -22,30 +22,24 @@
from qutebrowser.config import config, configdata
from qutebrowser.utils import objreg, log, qtutils
from qutebrowser.commands import cmdutils
from qutebrowser.completion.models import base, sqlmodel
from qutebrowser.completion.models import completionmodel
def command():
"""A CompletionModel filled with non-hidden commands and descriptions."""
model = base.CompletionModel(column_widths=(20, 60, 20))
model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
cmdlist = _get_cmd_completions(include_aliases=True, include_hidden=False)
cat = model.new_category("Commands")
for (name, desc, misc) in cmdlist:
model.new_item(cat, name, desc, misc)
model.add_list("Commands", cmdlist)
return model
def helptopic():
"""A CompletionModel filled with help topics."""
model = base.CompletionModel()
model = completionmodel.CompletionModel()
cmdlist = _get_cmd_completions(include_aliases=False, include_hidden=True,
prefix=':')
cat = model.new_category("Commands")
for (name, desc, misc) in cmdlist:
model.new_item(cat, name, desc, misc)
cat = model.new_category("Settings")
settings = []
for sectname, sectdata in configdata.DATA.items():
for optname in sectdata:
try:
@ -57,34 +51,35 @@ def helptopic():
else:
desc = desc.splitlines()[0]
name = '{}->{}'.format(sectname, optname)
model.new_item(cat, name, desc)
settings.append((name, desc))
model.add_list("Commands", cmdlist)
model.add_list("Settings", settings)
return model
def quickmark():
"""A CompletionModel filled with all quickmarks."""
model = base.CompletionModel()
model = sqlmodel.SqlCompletionModel(column_widths=(30, 70, 0))
model.new_category('Quickmarks')
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
model.add_sqltable('Quickmarks')
return model
def bookmark():
"""A CompletionModel filled with all bookmarks."""
model = base.CompletionModel()
model = sqlmodel.SqlCompletionModel(column_widths=(30, 70, 0))
model.new_category('Bookmarks')
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
model.add_sqltable('Bookmarks')
return model
def session():
"""A CompletionModel filled with session names."""
model = base.CompletionModel()
cat = model.new_category("Sessions")
model = completionmodel.CompletionModel()
try:
for name in objreg.get('session-manager').list_sessions():
if not name.startswith('_'):
model.new_item(cat, name)
manager = objreg.get('session-manager')
sessions = ((name,) for name in manager.list_sessions()
if not name.startswith('_'))
model.add_list("Sessions", sessions)
except OSError:
log.completion.exception("Failed to list sessions!")
return model
@ -111,7 +106,7 @@ def buffer():
window=int(win_id))
tabbed_browser.on_tab_close_requested(int(tab_index) - 1)
model = base.CompletionModel(
model = completionmodel.CompletionModel(
column_widths=(6, 40, 54),
delete_cur_item=delete_buffer,
columns_to_filter=[idx_column, url_column, text_column])
@ -121,12 +116,13 @@ def buffer():
window=win_id)
if tabbed_browser.shutting_down:
continue
c = model.new_category("{}".format(win_id))
tabs = []
for idx in range(tabbed_browser.count()):
tab = tabbed_browser.widget(idx)
model.new_item(c, "{}/{}".format(win_id, idx + 1),
tab.url().toDisplayString(),
tabbed_browser.page_title(idx))
tabs.append(("{}/{}".format(win_id, idx + 1),
tab.url().toDisplayString(),
tabbed_browser.page_title(idx)))
model.add_list("{}".format(win_id), tabs)
return model
@ -137,11 +133,9 @@ def bind(_key):
_key: the key being bound.
"""
# TODO: offer a 'Current binding' completion based on the key.
model = base.CompletionModel(column_widths=(20, 60, 20))
model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
cmdlist = _get_cmd_completions(include_hidden=True, include_aliases=True)
cat = model.new_category("Commands")
for (name, desc, misc) in cmdlist:
model.new_item(cat, name, desc, misc)
model.add_list("Commands", cmdlist)
return model

View File

@ -41,12 +41,14 @@ class CompletionFilterModel(QSortFilterProxyModel):
a long time for some reason.
"""
def __init__(self, source, parent=None):
def __init__(self, source, columns_to_filter, parent=None):
super().__init__(parent)
super().setSourceModel(source)
self.srcmodel = source
self.pattern = ''
self.pattern_re = None
self.columns_to_filter = columns_to_filter
self.name = source.name
def set_pattern(self, val):
"""Setter for pattern.
@ -64,41 +66,6 @@ class CompletionFilterModel(QSortFilterProxyModel):
sortcol = 0
self.sort(sortcol)
def count(self):
"""Get the count of non-toplevel items currently visible.
Note this only iterates one level deep, as we only need root items
(categories) and children (items) in our model.
"""
count = 0
for i in range(self.rowCount()):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
count += self.rowCount(cat)
return count
def first_item(self):
"""Return the first item in the model."""
for i in range(self.rowCount()):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
if cat.model().hasChildren(cat):
index = self.index(0, 0, cat)
qtutils.ensure_valid(index)
return index
return QModelIndex()
def last_item(self):
"""Return the last item in the model."""
for i in range(self.rowCount() - 1, -1, -1):
cat = self.index(i, 0)
qtutils.ensure_valid(cat)
if cat.model().hasChildren(cat):
index = self.index(self.rowCount(cat) - 1, 0, cat)
qtutils.ensure_valid(index)
return index
return QModelIndex()
def setSourceModel(self, model):
"""Override QSortFilterProxyModel's setSourceModel to clear pattern."""
log.completion.debug("Setting source model: {}".format(model))
@ -119,10 +86,10 @@ class CompletionFilterModel(QSortFilterProxyModel):
True if self.pattern is contained in item, or if it's a root item
(category). False in all other cases
"""
if parent == QModelIndex() or not self.pattern:
if not self.pattern:
return True
for col in self.srcmodel.columns_to_filter:
for col in self.columns_to_filter:
idx = self.srcmodel.index(row, col, parent)
if not idx.isValid(): # pragma: no cover
# this is a sanity check not hit by any test case

View File

@ -0,0 +1,64 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# 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/>.
"""A completion model backed by SQL tables."""
import re
from PyQt5.QtSql import QSqlQueryModel
from qutebrowser.misc import sql
class SqlCategory(QSqlQueryModel):
"""Wraps a SqlQuery for use as a completion category."""
def __init__(self, name, *, sort_by, sort_order, select, where,
columns_to_filter, parent=None):
super().__init__(parent=parent)
self.name = name
query = sql.run_query('select * from {} limit 1'.format(name))
self._fields = [query.record().fieldName(i) for i in columns_to_filter]
querystr = 'select {} from {} where ('.format(select, name)
# the incoming pattern will have literal % and _ escaped with '\'
# we need to tell sql to treat '\' as an escape character
querystr += ' or '.join("{} like ? escape '\\'".format(f)
for f in self._fields)
querystr += ')'
if where:
querystr += ' and ' + where
if sort_by:
assert sort_order == 'asc' or sort_order == 'desc'
querystr += ' order by {} {}'.format(sort_by, sort_order)
self._querystr = querystr
self.set_pattern('')
def set_pattern(self, pattern):
# escape to treat a user input % or _ as a literal, not a wildcard
pattern = pattern.replace('%', '\\%')
pattern = pattern.replace('_', '\\_')
# treat spaces as wildcards to match any of the typed words
pattern = re.sub(r' +', '%', pattern)
pattern = '%{}%'.format(pattern)
query = sql.run_query(self._querystr, [pattern for _ in self._fields])
self.setQuery(query)

View File

@ -19,7 +19,7 @@
"""Function to return the url completion model for the `open` command."""
from qutebrowser.completion.models import sqlmodel
from qutebrowser.completion.models import completionmodel
from qutebrowser.config import config
from qutebrowser.utils import qtutils, log, objreg
@ -61,14 +61,16 @@ def url():
Used for the `open` command.
"""
model = sqlmodel.SqlCompletionModel(column_widths=(40, 50, 10),
columns_to_filter=[_URLCOL, _TEXTCOL],
delete_cur_item=_delete_url)
model = completionmodel.CompletionModel(
column_widths=(40, 50, 10),
columns_to_filter=[_URLCOL, _TEXTCOL],
delete_cur_item=_delete_url)
timefmt = config.get('completion', 'timestamp-format')
select_time = "strftime('{}', atime, 'unixepoch')".format(timefmt)
model.new_category('Quickmarks', select='url, name')
model.new_category('Bookmarks')
model.new_category('History',
model.add_sqltable('Quickmarks', select='url, name')
model.add_sqltable('Bookmarks')
model.add_sqltable('History',
sort_order='desc', sort_by='atime',
select='url, title, {}'.format(select_time),
where='not redirect')

View File

@ -0,0 +1,146 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2017 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# 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/>.
"""Tests for CompletionModel."""
import pytest
from qutebrowser.completion.models import completionmodel, sortfilter
def _create_model(data, filter_cols=None):
"""Create a completion model populated with the given data.
data: A list of lists, where each sub-list represents a category, each
tuple in the sub-list represents an item, and each value in the
tuple represents the item data for that column
filter_cols: Columns to filter, or None for default.
"""
model = completionmodel.CompletionModel(columns_to_filter=filter_cols)
for catdata in data:
model.add_list('', catdata)
return model
def _extract_model_data(model):
"""Express a model's data as a list for easier comparison.
Return: A list of lists, where each sub-list represents a category, each
tuple in the sub-list represents an item, and each value in the
tuple represents the item data for that column
"""
data = []
for i in range(0, model.rowCount()):
cat_idx = model.index(i, 0)
row = []
for j in range(0, model.rowCount(cat_idx)):
row.append((model.data(cat_idx.child(j, 0)),
model.data(cat_idx.child(j, 1)),
model.data(cat_idx.child(j, 2))))
data.append(row)
return data
@pytest.mark.parametrize('tree, first, last', [
([[('Aa',)]], 'Aa', 'Aa'),
([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]],
'Aa', 'Ca'),
([[], [('Ba',)]], 'Ba', 'Ba'),
([[], [], [('Ca',)]], 'Ca', 'Ca'),
([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], [], []], 'Aa', 'Aa'),
([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'),
([[], []], None, None),
])
def test_first_last_item(tree, first, last):
"""Test that first() and last() return indexes to the first and last items.
Args:
tree: Each list represents a completion category, with each string
being an item under that category.
first: text of the first item
last: text of the last item
"""
model = _create_model(tree)
assert model.data(model.first_item()) == first
assert model.data(model.last_item()) == last
@pytest.mark.parametrize('tree, expected', [
([[('Aa',)]], 1),
([[('Aa',)], [('Ba',)]], 2),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6),
([[], [('Ba',)]], 1),
([[], [], [('Ca',)]], 1),
([[], [], [('Ca',), ('Cb',)]], 2),
([[('Aa',)], []], 1),
([[('Aa',)], []], 1),
([[('Aa',)], [], []], 1),
([[('Aa',)], [], [('Ca',)]], 2),
])
def test_count(tree, expected):
model = _create_model(tree)
assert model.count() == expected
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
('foo', [0],
[[('foo', '', ''), ('bar', '', '')]],
[[('foo', '', '')]]),
('foo', [0],
[[('foob', '', ''), ('fooc', '', ''), ('fooa', '', '')]],
[[('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]]),
('foo', [0],
[[('foo', '', '')], [('bar', '', '')]],
[[('foo', '', '')], []]),
# prefer foobar as it starts with the pattern
('foo', [0],
[[('barfoo', '', ''), ('foobar', '', '')]],
[[('foobar', '', ''), ('barfoo', '', '')]]),
# however, don't rearrange categories
('foo', [0],
[[('barfoo', '', '')], [('foobar', '', '')]],
[[('barfoo', '', '')], [('foobar', '', '')]]),
('foo', [1],
[[('foo', 'bar', ''), ('bar', 'foo', '')]],
[[('bar', 'foo', '')]]),
('foo', [0, 1],
[[('foo', 'bar', ''), ('bar', 'foo', ''), ('bar', 'bar', '')]],
[[('foo', 'bar', ''), ('bar', 'foo', '')]]),
('foo', [0, 1, 2],
[[('foo', '', ''), ('bar', '')]],
[[('foo', '', '')]]),
])
def test_set_pattern(pattern, filter_cols, before, after):
"""Validate the filtering and sorting results of set_pattern."""
# TODO: just test that it calls the mock on its child categories
model = _create_model(before, filter_cols)
model.set_pattern(pattern)
actual = _extract_model_data(model)
assert actual == after

View File

@ -25,7 +25,7 @@ import pytest
from PyQt5.QtGui import QStandardItem, QColor
from qutebrowser.completion import completionwidget
from qutebrowser.completion.models import base, sortfilter
from qutebrowser.completion.models import completionmodel
from qutebrowser.commands import cmdexc
@ -72,28 +72,25 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
@pytest.fixture
def simplemodel(completionview):
"""A filter model wrapped around a completion model with one item."""
model = base.CompletionModel()
cat = QStandardItem()
cat.appendRow(QStandardItem('foo'))
model.appendRow(cat)
return sortfilter.CompletionFilterModel(model, parent=completionview)
"""A completion model with one item."""
model = completionmodel.CompletionModel()
model.add_list('', [('foo'),])
return model
def test_set_model(completionview):
"""Ensure set_model actually sets the model and expands all categories."""
model = base.CompletionModel()
filtermodel = sortfilter.CompletionFilterModel(model)
model = completionmodel.CompletionModel()
for i in range(3):
model.appendRow(QStandardItem(str(i)))
completionview.set_model(filtermodel)
assert completionview.model() is filtermodel
model.add_list(str(i), [('foo',)])
completionview.set_model(model)
assert completionview.model() is model
for i in range(model.rowCount()):
assert completionview.isExpanded(filtermodel.index(i, 0))
assert completionview.isExpanded(model.index(i, 0))
def test_set_pattern(completionview):
model = sortfilter.CompletionFilterModel(base.CompletionModel())
model = completionmodel.CompletionModel()
model.set_pattern = unittest.mock.Mock()
completionview.set_model(model, 'foo')
model.set_pattern.assert_called_with('foo')
@ -159,15 +156,10 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot):
successive movement. None implies no signal should be
emitted.
"""
model = base.CompletionModel()
model = completionmodel.CompletionModel()
for catdata in tree:
cat = QStandardItem()
model.appendRow(cat)
for name in catdata:
cat.appendRow(QStandardItem(name))
filtermodel = sortfilter.CompletionFilterModel(model,
parent=completionview)
completionview.set_model(filtermodel)
model.add_list('', (x,) for x in catdata)
completionview.set_model(model)
for entry in expected:
if entry is None:
with qtbot.assertNotEmitted(completionview.selection_changed):
@ -187,10 +179,8 @@ def test_completion_item_focus_no_model(which, completionview, qtbot):
"""
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
model = base.CompletionModel()
filtermodel = sortfilter.CompletionFilterModel(model,
parent=completionview)
completionview.set_model(filtermodel)
model = completionmodel.CompletionModel()
completionview.set_model(model)
completionview.set_model(None)
with qtbot.assertNotEmitted(completionview.selection_changed):
completionview.completion_item_focus(which)
@ -211,16 +201,12 @@ def test_completion_show(show, rows, quick_complete, completionview,
config_stub.data['completion']['show'] = show
config_stub.data['completion']['quick-complete'] = quick_complete
model = base.CompletionModel()
model = completionmodel.CompletionModel()
for name in rows:
cat = QStandardItem()
model.appendRow(cat)
cat.appendRow(QStandardItem(name))
filtermodel = sortfilter.CompletionFilterModel(model,
parent=completionview)
model.add_list('', [(name,)])
assert not completionview.isVisible()
completionview.set_model(filtermodel)
completionview.set_model(model)
assert completionview.isVisible() == (show == 'always' and len(rows) > 0)
completionview.completion_item_focus('next')
expected = (show != 'never' and len(rows) > 0 and

View File

@ -228,12 +228,12 @@ def test_help_completion(qtmodeltester, monkeypatch, stubs, key_config_stub):
(':hide', '', ''),
],
"Settings": [
('general->time', 'Is an illusion.', ''),
('general->volume', 'Goes to 11', ''),
('ui->gesture', 'Waggle your hands to control qutebrowser', ''),
('ui->mind', 'Enable mind-control ui (experimental)', ''),
('ui->voice', 'Whether to respond to voice commands', ''),
('searchengines->DEFAULT', '', ''),
('general->time', 'Is an illusion.', None),
('general->volume', 'Goes to 11', None),
('ui->gesture', 'Waggle your hands to control qutebrowser', None),
('ui->mind', 'Enable mind-control ui (experimental)', None),
('ui->voice', 'Whether to respond to voice commands', None),
('searchengines->DEFAULT', '', None),
]
})
@ -342,7 +342,9 @@ def test_session_completion(qtmodeltester, session_manager_stub):
qtmodeltester.check(model)
_check_completions(model, {
"Sessions": [('default', '', ''), ('1', '', ''), ('2', '', '')]
"Sessions": [('default', None, None),
('1', None, None),
('2', None, None)]
})
@ -406,9 +408,9 @@ def test_setting_section_completion(qtmodeltester, monkeypatch, stubs):
_check_completions(model, {
"Sections": [
('general', 'General/miscellaneous options.', ''),
('ui', 'General options related to the user interface.', ''),
('searchengines', 'Definitions of search engines ...', ''),
('general', 'General/miscellaneous options.', None),
('ui', 'General options related to the user interface.', None),
('searchengines', 'Definitions of search engines ...', None),
]
})
@ -462,12 +464,12 @@ def test_setting_value_completion(qtmodeltester, monkeypatch, stubs,
_check_completions(model, {
"Current/Default": [
('0', 'Current value', ''),
('11', 'Default value', ''),
('0', 'Current value', None),
('11', 'Default value', None),
],
"Completions": [
('0', '', ''),
('11', '', ''),
('0', '', None),
('11', '', None),
]
})

View File

@ -21,9 +21,10 @@
import pytest
from qutebrowser.completion.models import base, sortfilter
from qutebrowser.completion.models import listcategory, sortfilter
# TODO: merge listcategory and sortfilter
def _create_model(data):
"""Create a completion model populated with the given data.
@ -31,11 +32,9 @@ def _create_model(data):
tuple in the sub-list represents an item, and each value in the
tuple represents the item data for that column
"""
model = base.CompletionModel()
model = completionmodel.CompletionModel()
for catdata in data:
cat = model.new_category('')
for itemdata in catdata:
model.new_item(cat, *itemdata)
cat = model.add_list(itemdata)
return model
@ -72,7 +71,7 @@ def _extract_model_data(model):
('4', 'blah', False),
])
def test_filter_accepts_row(pattern, data, expected):
source_model = base.CompletionModel()
source_model = completionmodel.CompletionModel()
cat = source_model.new_category('test')
source_model.new_item(cat, data)
@ -86,35 +85,6 @@ def test_filter_accepts_row(pattern, data, expected):
assert row_count == (1 if expected else 0)
@pytest.mark.parametrize('tree, first, last', [
([[('Aa',)]], 'Aa', 'Aa'),
([[('Aa',)], [('Ba',)]], 'Aa', 'Ba'),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]],
'Aa', 'Ca'),
([[], [('Ba',)]], 'Ba', 'Ba'),
([[], [], [('Ca',)]], 'Ca', 'Ca'),
([[], [], [('Ca',), ('Cb',)]], 'Ca', 'Cb'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], []], 'Aa', 'Aa'),
([[('Aa',)], [], []], 'Aa', 'Aa'),
([[('Aa',)], [], [('Ca',)]], 'Aa', 'Ca'),
([[], []], None, None),
])
def test_first_last_item(tree, first, last):
"""Test that first() and last() return indexes to the first and last items.
Args:
tree: Each list represents a completion category, with each string
being an item under that category.
first: text of the first item
last: text of the last item
"""
model = _create_model(tree)
filter_model = sortfilter.CompletionFilterModel(model)
assert filter_model.data(filter_model.first_item()) == first
assert filter_model.data(filter_model.last_item()) == last
def test_set_source_model():
"""Ensure setSourceModel sets source_model and clears the pattern."""
model1 = base.CompletionModel()
@ -131,24 +101,6 @@ def test_set_source_model():
assert not filter_model.pattern
@pytest.mark.parametrize('tree, expected', [
([[('Aa',)]], 1),
([[('Aa',)], [('Ba',)]], 2),
([[('Aa',), ('Ab',), ('Ac',)], [('Ba',), ('Bb',)], [('Ca',)]], 6),
([[], [('Ba',)]], 1),
([[], [], [('Ca',)]], 1),
([[], [], [('Ca',), ('Cb',)]], 2),
([[('Aa',)], []], 1),
([[('Aa',)], []], 1),
([[('Aa',)], [], []], 1),
([[('Aa',)], [], [('Ca',)]], 2),
])
def test_count(tree, expected):
model = _create_model(tree)
filter_model = sortfilter.CompletionFilterModel(model)
assert filter_model.count() == expected
@pytest.mark.parametrize('pattern, filter_cols, before, after', [
('foo', [0],
[[('foo', '', ''), ('bar', '', '')]],