diff --git a/README.rst b/README.rst index 687ba6713..9fe3562ae 100644 --- a/README.rst +++ b/README.rst @@ -73,12 +73,21 @@ What's New? in development ^^^^^^^^^^^^^^ +* Improve the search box UX: + - There is now a keyboard shortcut ('Ctrl+K' or 'Cmd+K' on Mac or '/', + but the later is overriden by ReadTheDocs) to focus the search box. + When the search box is focused, you can use the up and down arrow keys to navigate the results, + and press enter to open the selected result. + - If the search box contains "docstring:", the search will be performed in docstrings automatically. + - Use a logical "and" in between search terms by default. Use leading '?' to make a term optional. + (More on this in the embedded help page of the generated documentation.) * Hide sidebar element title when all items under it are private. * Allow suppressing the footer's buildtime altogether with option ``--buildtime=no``. * Add project version on each HTML page. * When an intersphinx inventory file fails to read, exit with code 2 and do not print the whole stack trace by default. The -v flag will log exceptions' tracebacks. + pydoctor 25.10.1 ^^^^^^^^^^^^^^^^ diff --git a/docs/tests/test.py b/docs/tests/test.py index d768e357f..b0ac7fe06 100644 --- a/docs/tests/test.py +++ b/docs/tests/test.py @@ -190,7 +190,7 @@ def test_search(query:str, expected:List[str], order_is_important:bool=True) -> 'pydoctor.factory.Factory.Class', 'pydoctor.model.DocumentableKind.CLASS', 'pydoctor.model.System.Class', - ]) + ], order_is_important=False) to_stan_results = [ 'pydoctor.epydoc.markup.ParsedDocstring.to_stan', diff --git a/pydoctor/templatewriter/search.py b/pydoctor/templatewriter/search.py index fe48a00ee..e16fcf784 100644 --- a/pydoctor/templatewriter/search.py +++ b/pydoctor/templatewriter/search.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path -from typing import Iterator, List, Optional, Tuple, Type, Dict, TYPE_CHECKING +from typing import Iterator, List, Optional, Type, Dict, TYPE_CHECKING import json import attr @@ -63,7 +63,7 @@ class LunrIndexWriter: fields: List[str] _BOOSTS = { - 'name':6, + 'name':3, 'names': 1, 'qname':2, 'docstring':1, @@ -75,14 +75,6 @@ class LunrIndexWriter: _SKIP_PIPELINES = list(_BOOSTS) _SKIP_PIPELINES.remove('docstring') - @staticmethod - def get_ob_boost(ob: model.Documentable) -> int: - # Advantage container types because they hold more informations. - if isinstance(ob, (model.Class, model.Module)): - return 2 - else: - return 1 - def format(self, ob: model.Documentable, field:str) -> Optional[str]: try: return getattr(self, f'format_{field}')(ob) #type:ignore[no-any-return] @@ -110,16 +102,11 @@ def format_docstring(self, ob: model.Documentable) -> Optional[str]: def format_kind(self, ob:model.Documentable) -> str: return epydoc2stan.format_kind(ob.kind) if ob.kind else '' - def get_corpus(self) -> List[Tuple[Dict[str, Optional[str]], Dict[str, int]]]: + def get_corpus(self) -> list[dict[str, str | None]]: return [ - ( - { - f:self.format(ob, f) for f in self.fields - }, - { - "boost": self.get_ob_boost(ob) - } - ) + { + f:self.format(ob, f) for f in self.fields + } for ob in (o for o in self.system.allobjects.values() if o.isVisible) ] @@ -160,12 +147,12 @@ def write_lunr_index(output_dir: Path, system: model.System) -> None: """ LunrIndexWriter(output_dir / "searchindex.json", system=system, - fields=["name", "names", "qname"] + fields=["name", "names", "qname", "kind"] ).write() LunrIndexWriter(output_dir / "fullsearchindex.json", system=system, - fields=["name", "names", "qname", "docstring", "kind"] + fields=["name", "names", "qname", "kind", "docstring",] ).write() diff --git a/pydoctor/templatewriter/summary.py b/pydoctor/templatewriter/summary.py index d24992905..50e3d9ecb 100644 --- a/pydoctor/templatewriter/summary.py +++ b/pydoctor/templatewriter/summary.py @@ -372,16 +372,14 @@ class HelpPage(Page): There is one page per class, module and package. Each page present summary table(s) which feature the members of the object. - Package or Module page - ~~~~~~~~~~~~~~~~~~~~~~~ + **Package or Module page** Each of these pages has two main sections consisting of: - summary tables submodules and subpackages and the members of the module or in the ``__init__.py`` file. - detailed descriptions of function and attribute members. - Class page - ~~~~~~~~~~ + **Class page** Each class has its own separate page. Each of these pages has three main sections consisting of: @@ -392,18 +390,15 @@ class HelpPage(Page): Entries in each of these sections are omitted if they are empty or not applicable. - Module Index - ~~~~~~~~~~~~ + **Module Index** Provides a high level overview of the packages and modules structure. - Class Hierarchy - ~~~~~~~~~~~~~~~ + **Class Hierarchy** Provides a list of classes organized by inheritance structure. Note that ``object`` is ommited. - Index Of Names - ~~~~~~~~~~~~~~ + **Index Of Names** The Index contains an alphabetic index of all objects in the documentation. @@ -411,63 +406,77 @@ class HelpPage(Page): Search ------ - You can search for definitions of modules, packages, classes, functions, methods and attributes. + You can search for definitions of modules, packages, classes, functions, methods and attributes. The shorcut Ctrl+K (or Cmd+K on Mac) focuses the search box. These items can be searched using part or all of the name and/or from their docstrings if "search in docstrings" is enabled. Multiple search terms can be provided separated by whitespace. + + When the search box is focused, you can use the up and down arrow keys to navigate the results, + and press enter to open the selected result. The search is powered by `lunrjs `_. - Indexing - ~~~~~~~~ + **Indexing** By default the search only matches on the name of the object. Enable the full text search in the docstrings with the checkbox option. You can instruct the search to look only in specific fields by passing the field name in the search like ``docstring:term``. - **Possible fields are**: + Possible fields are: - ``name``, the name of the object (example: "MyClassAdapter" or "my_fmin_opti"). - - ``qname``, the fully qualified name of the object (example: "lib.classses.MyClassAdapter"). - - ``names``, the name splitted on camel case or snake case (example: "My Class Adapter" or "my fmin opti") - ``docstring``, the docstring of the object (example: "This is an adapter for HTTP json requests that logs into a file...") - ``kind``, can be one of: $kind_names + - ``qname``, the fully qualified name of the object (example: "lib.classses.MyClassAdapter"). + - ``names``, the name splitted on camel case or snake case (example: "My Class Adapter" or "my fmin opti") - Last two fields are only applicable if "search in docstrings" is enabled. - - Other search features - ~~~~~~~~~~~~~~~~~~~~~ + Field "docstring" is only applicable if "search in docstrings" is enabled. - Term presence. - The default behaviour is to give a better ranking to object matching multiple terms of your query, - but still show entries that matches only one of the two terms. - To change this behavour, you can use the sign ``+``. - - - To indicate a term must exactly match use the plus sing: ``+``. - - To indicate a term must not match use the minus sing: ``-``. + **Term presence** + + By default, multiple terms in the query are combined with logical AND: + all (non-optional) terms must match for a result to be returned. + + You can change how an individual term participates in the query by + prefixing it with one of three modifiers: + + - ``+term``: The '+' prefix indicates an exact/required presence for that term. + When '+' is used the automatic trailing wildcard is suppressed and the + search treats the term as an exact token match (rather than a + prefix/wildcard search). The term must be present for a result to + match. + - ``-term``: The '-' prefix marks the term as an exclusion. Matches that contain + that term are filtered out. Like '+', the '-' prefix suppresses the + automatic trailing wildcard and treats the term as an exact token to + be excluded. + - ``?term``: The '?' prefix marks the term as optional. Optional terms are not + required for a result to match; they are used to increase relevance + if present but do not enforce inclusion. - Wildcards - A trailling wildcard is automatically added to each term of your query if they don't contain an explicit term presence (``+`` or ``-``). - Searching for ``foo`` is the same as searching for ``foo*``. + **Wildcards** + + - By default each plain term (without a presence modifier) gets an + automatic trailing wildcard, so "foo" is treated like "foo*". + In addition to this automatic feature, you can manually add a wildcard + anywhere else in the query. + - If a term is prefixed with '+' or '-' the automatic trailing wildcard + is not added, turning the term into an exact token match/exclusion. + - If a term contains a dot ('.'), a leading wildcard is also added to + enable matching across dotted module/class boundaries. For example, + "model." behaves like "*model.*". - If the query include a dot (``.``), a leading wildcard will to also added, - searching for ``model.`` is the same as ``*model.*`` and ``.model`` is the same as ``*.model*``. - - In addition to this automatic feature, you can manually add a wildcard anywhere else in the query. - - - Query examples - ~~~~~~~~~~~~~~ - - - "doc" matches "pydoctor.model.Documentable" and "pydoctor.model.DocLocation". - - "+doc" matches "pydoctor.model.DocLocation" but won't match "pydoctor.model.Documentable". - - "ensure doc" matches "pydoctor.epydoc2stan.ensure_parsed_docstring" and other object whose matches either "doc" or "ensure". - - "inp str" matches "java.io.InputStream" and other object whose matches either "in" or "str". - - "model." matches everything in the pydoctor.model module. - - ".web.*tag" matches "twisted.web.teplate.Tag" and related. - - "docstring:ansi" matches object whose docstring matches "ansi". + **Examples** + + - ``doc`` -> matches names containing tokens that start with "doc" (equivalent to "doc*"). + - ``ensure doc`` -> matches object whose matches "doc*" and "ensure*". + - ``doc kind:class`` -> matches classes whose matches "doc*". + - ``docstring:ansi`` -> matches object whose docstring matches "ansi*". + - ``+doc`` -> matches only where a token equals "doc" exactly. + - ``-test`` -> excludes any result containing a token equal to "test". + - ``?input ?str`` -> matches results that contain either "input" or "str" but neither is required. + - ``+doc -deprecated ?helper`` -> requires an exact "doc" token, excludes "deprecated", and treats "helper" as optional. ''') def title(self) -> str: diff --git a/pydoctor/themes/base/apidocs.css b/pydoctor/themes/base/apidocs.css index bdb548cd4..7e5a9520f 100644 --- a/pydoctor/themes/base/apidocs.css +++ b/pydoctor/themes/base/apidocs.css @@ -1151,6 +1151,14 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } cursor: pointer; } +.search-result-selected{ + background-color: rgba(109, 161, 219, 0.2)!important; +} + +.search-result-selected > *:first-child{ + background-color: transparent!important; +} + /* Constant values repr */ pre.constant-value { padding: .5em; } .rst-variable-linewrap { color: #604000; font-weight: bold; } diff --git a/pydoctor/themes/base/search.js b/pydoctor/themes/base/search.js index f76b5dd86..148f839d1 100644 --- a/pydoctor/themes/base/search.js +++ b/pydoctor/themes/base/search.js @@ -161,7 +161,7 @@ function _stopSearchingProcess(){ var SEARCH_DEFAULT_DELAY = 150; // in miliseconds var SEARCH_INCREASED_DELAY = 300; // in miliseconds var SEARCH_INDEX_SIZE_TRESH_INCREASE_DELAY = 15; // in MB -var SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE = 25; // in MB +var SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE = 30; // in MB // Search delay depends on index size in MB function _getIndexSizePromise(indexURL){ @@ -202,9 +202,10 @@ function searchAsYouType(){ if (input.value.length>0){ showResultContainer(); } + setStatus("Loading..."); _getIndexSizePromise("searchindex.json").then((indexSizeApprox) => { if (indexSizeApprox > SEARCH_INDEX_SIZE_TRESH_DISABLE_SEARCH_AS_YOU_TYPE){ - // Not searching as we type if "default" index size if greater than 20MB. + // Not searching as we type if "default" index size is greater than a certain treshold. if (input.value.length===0){ // No actual query, this only resets some UI components. launchSearch(); } @@ -364,7 +365,15 @@ function displaySearchResults(_query, documentResults, lunrResults){ } function _isSearchInDocstringsEnabled() { - return searchInDocstringsCheckbox.checked; + if (searchInDocstringsCheckbox.checked){ + return true; + } + if (input.value.startsWith("docstring:") || input.value.indexOf(" docstring:") !== -1){ + searchInDocstringsCheckbox.checked = true; + toggleSearchInDocstrings() + return true; + } + return false; } function toggleSearchInDocstrings() { @@ -390,11 +399,14 @@ input.oninput = (event) => { searchAsYouType(); }, 0); }; -input.onkeyup = (event) => { +input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { - launchSearch(true); + if (_getSelectedSearchResult() === -1){ + // Only manually launch the search when no result is selected. + launchSearch(true); + } } -}; +}); input.onfocus = (event) => { // Ensure the search bar is set-up. // Load fullsearchindex.json, searchindex.json and all-documents.html to have them in the cache asap. @@ -436,6 +448,7 @@ window.addEventListener("click", (event) => { // 2. Show the dropdown if the user clicks inside the search box if (event.target.closest('#search-box')){ if (input.value.length>0){ + _resetSelectedSearchResult(); showResultContainer(); return; } @@ -463,3 +476,93 @@ window.addEventListener("click", (event) => { } } }); + +// Focus on the search bar when the user hit '/' or 'ctrl+k' key, the '/' shortcut is replaced by the default readthedocs search +// box which is not going to include any of the API documentation in its index at the moment (see +// issue #356), this is why we provide another shortcut which is commonly associated to searching. +window.addEventListener('keydown', (event) => { + if((event.key === 'k' && (event.ctrlKey || event.metaKey) && !event.altKey) || ( + event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey)){ + + event.preventDefault(); + input.focus(); + if (input.value.length>0){ + _resetSelectedSearchResult(); + showResultContainer(); + return; + } + } +}); + +function _getSelectedSearchResult(){ + const results = results_list.getElementsByTagName('tr'); + let selectedIndex = -1; + for (let i=0; i= 0){ + const results = results_list.getElementsByTagName('tr'); + results[selectedIndex].classList.remove('search-result-selected'); + } + } + +// When the search box is focused, we can use top and down arrows to navigate the search results +// and use the enter key to open the selected result. +input.addEventListener('keydown', (event) => { + const results = results_list.getElementsByTagName('tr'); + if (results.length === 0){ + return; + } + let selectedIndex = _getSelectedSearchResult(); + if (event.key === 'ArrowDown'){ + // Move selection down + if (selectedIndex >= 0){ + results[selectedIndex].classList.remove('search-result-selected'); + } + + selectedIndex = (selectedIndex + 1) % results.length; + // Ignore private items when the toogle is off + while (document.body.classList.contains('private-hidden') && results[selectedIndex].classList.contains('private')){ + selectedIndex = (selectedIndex + 1) % results.length; + } + + results[selectedIndex].classList.add('search-result-selected'); + results[selectedIndex].scrollIntoView({block: "nearest"}); + event.preventDefault(); + } + else if (event.key === 'ArrowUp'){ + // Move selection up + if (selectedIndex >= 0){ + results[selectedIndex].classList.remove('search-result-selected'); + } + + selectedIndex = (selectedIndex - 1 + results.length) % results.length; + // Ignore private items when the toogle is off + while (document.body.classList.contains('private-hidden') && results[selectedIndex].classList.contains('private')){ + selectedIndex = (selectedIndex - 1 + results.length) % results.length; + } + + results[selectedIndex].classList.add('search-result-selected'); + results[selectedIndex].scrollIntoView({block: "nearest"}); + event.preventDefault(); + } + else if (event.key === 'Enter'){ + // Open selected result + if (selectedIndex >= 0){ + const link = results[selectedIndex].getElementsByTagName('a')[0]; + if (link){ + window.location.href = link.href; + event.preventDefault(); + } + } + } +}); diff --git a/pydoctor/themes/base/searchlib.js b/pydoctor/themes/base/searchlib.js index c1a3995f0..d56bfd846 100644 --- a/pydoctor/themes/base/searchlib.js +++ b/pydoctor/themes/base/searchlib.js @@ -49,46 +49,38 @@ onmessage = (message) => { }); // Auto wilcard feature, see issue https://github.com/twisted/pydoctor/issues/648 - var new_clauses = []; - _query.clauses.forEach(clause => { - if (clause.presence === 1) { // ignore clauses that have explicit presence (+/-) - // Setting clause.wildcard is useless, and clause.wildcard is actually always NONE + let excplicitlyOptional = clause.term.slice(0,1) == '?' && clause.term.length > 1; + if (excplicitlyOptional){ + // Remove leading '?' from term + clause.term = clause.term.slice(1); + } + + if (clause.presence === lunr.Query.presence.OPTIONAL) { // ignore clauses that have explicit presence (+/-) + if (!excplicitlyOptional){ + clause.presence = lunr.Query.presence.REQUIRED + } + // Setting clause.wildcard is useless (but we do it anyway for clarty), // due to https://github.com/olivernn/lunr.js/issues/495 - // But this works... + // But appending to .term works... if (clause.term.slice(-1) != '*'){ - let new_clause = {...clause} - new_clause.term = new_clause.term + '*' - clause.boost = 2 - new_clause.boost = 1 - new_clauses.push(new_clause) + // Adding a trailing wildcard + clause.wildcard = lunr.Query.wildcard.TRAILING + clause.term = clause.term + '*' } - // Adding a leading wildcard if the dot is included as well. - // This should only apply to terms that are applicable to name-like fields. - // so we refer to the default fields if (clause.term.indexOf('.') != -1) { if (clause.term.slice(0,1) != '*'){ - let second_new_clause = {...clause} - second_new_clause.boost = 1 - if (clause.term.slice(0,1) != '.'){ - second_new_clause.term = '.' + second_new_clause.term - } - second_new_clause.term = '*' + second_new_clause.term - if (clause.term.slice(-1) != '*'){ - second_new_clause.term = second_new_clause.term + '*' - } - new_clauses.push(second_new_clause) + // Adding a leading wildcard if the dot is included as well. + clause.wildcard = lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING + clause.term = '*' + clause.term } } } }); - new_clauses.forEach(clause => { - _query.clauses.push(clause) - }); console.log('Parsed query:') - console.dir(_query.clauses) + console.dir(_query) } // Launch the search