From e3a33ca42744b374f01cfd8792fdac845e3b22e0 Mon Sep 17 00:00:00 2001 From: Ryan Roden-Corrent Date: Wed, 22 Feb 2017 22:25:11 -0500 Subject: [PATCH] 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. --- qutebrowser/completion/completer.py | 24 +-- qutebrowser/completion/completiondelegate.py | 2 +- qutebrowser/completion/completionwidget.py | 6 +- qutebrowser/completion/models/base.py | 105 ------------- .../{sqlmodel.py => completionmodel.py} | 126 ++++++--------- qutebrowser/completion/models/configmodel.py | 37 ++--- qutebrowser/completion/models/listcategory.py | 66 ++++++++ qutebrowser/completion/models/miscmodels.py | 58 ++++--- qutebrowser/completion/models/sortfilter.py | 43 +----- qutebrowser/completion/models/sqlcategory.py | 64 ++++++++ qutebrowser/completion/models/urlmodel.py | 16 +- tests/unit/completion/test_completionmodel.py | 146 ++++++++++++++++++ .../unit/completion/test_completionwidget.py | 52 +++---- tests/unit/completion/test_models.py | 30 ++-- tests/unit/completion/test_sortfilter.py | 58 +------ 15 files changed, 426 insertions(+), 407 deletions(-) delete mode 100644 qutebrowser/completion/models/base.py rename qutebrowser/completion/models/{sqlmodel.py => completionmodel.py} (63%) create mode 100644 qutebrowser/completion/models/listcategory.py create mode 100644 qutebrowser/completion/models/sqlcategory.py create mode 100644 tests/unit/completion/test_completionmodel.py diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index f403354c3..94c7f9352 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -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 diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 1d5dfadf0..776b2164c 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -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'\g<0>' text = re.sub(re.escape(pattern).replace(r'\ ', r'|'), diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 3d2aef27e..f6a980612 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -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) diff --git a/qutebrowser/completion/models/base.py b/qutebrowser/completion/models/base.py deleted file mode 100644 index f5148ff9c..000000000 --- a/qutebrowser/completion/models/base.py +++ /dev/null @@ -1,105 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2017 Florian Bruhin (The Compiler) -# -# 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 . - -"""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 diff --git a/qutebrowser/completion/models/sqlmodel.py b/qutebrowser/completion/models/completionmodel.py similarity index 63% rename from qutebrowser/completion/models/sqlmodel.py rename to qutebrowser/completion/models/completionmodel.py index e3e5a5c5f..b8dcc4601 100644 --- a/qutebrowser/completion/models/sqlmodel.py +++ b/qutebrowser/completion/models/completionmodel.py @@ -1,6 +1,6 @@ # vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: -# Copyright 2016-2017 Ryan Roden-Corrent (rcorre) +# Copyright 2017 Ryan Roden-Corrent (rcorre) # # 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 . -"""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 diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index 7c7e3a0c6..3cb46d42d 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -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 diff --git a/qutebrowser/completion/models/listcategory.py b/qutebrowser/completion/models/listcategory.py new file mode 100644 index 000000000..7b222c3c0 --- /dev/null +++ b/qutebrowser/completion/models/listcategory.py @@ -0,0 +1,66 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# 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 . + +"""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 diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index f5ed72f5d..d07ffaa88 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -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 diff --git a/qutebrowser/completion/models/sortfilter.py b/qutebrowser/completion/models/sortfilter.py index 92d9aeaf1..fb79d002c 100644 --- a/qutebrowser/completion/models/sortfilter.py +++ b/qutebrowser/completion/models/sortfilter.py @@ -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 diff --git a/qutebrowser/completion/models/sqlcategory.py b/qutebrowser/completion/models/sqlcategory.py new file mode 100644 index 000000000..12e0534c1 --- /dev/null +++ b/qutebrowser/completion/models/sqlcategory.py @@ -0,0 +1,64 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# 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 . + +"""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) diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 0b357c3d7..a38e107d2 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -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') diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py new file mode 100644 index 000000000..4c1241d12 --- /dev/null +++ b/tests/unit/completion/test_completionmodel.py @@ -0,0 +1,146 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Ryan Roden-Corrent (rcorre) +# +# 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 . + +"""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 diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index 2c8d96713..3db4dca5a 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -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 diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 224d7ed59..6d68cc8ab 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -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), ] }) diff --git a/tests/unit/completion/test_sortfilter.py b/tests/unit/completion/test_sortfilter.py index 92d644810..7dadaa9dd 100644 --- a/tests/unit/completion/test_sortfilter.py +++ b/tests/unit/completion/test_sortfilter.py @@ -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', '', '')]],