diff --git a/doc/changelog.d/723.added.md b/doc/changelog.d/723.added.md new file mode 100644 index 000000000..4d45f2159 --- /dev/null +++ b/doc/changelog.d/723.added.md @@ -0,0 +1 @@ +Dropdown for navigation bar \ No newline at end of file diff --git a/doc/source/user-guide/options.rst b/doc/source/user-guide/options.rst index 6d055c53e..6897283b7 100644 --- a/doc/source/user-guide/options.rst +++ b/doc/source/user-guide/options.rst @@ -396,4 +396,50 @@ The following images show a sample "What's new" section and sidebar in the chang If you are using both the "whatsnew" and "cheatsheet" options, the "cheatsheet" option will be displayed first in the left navigation pane, followed by the "What's new" section to maintain - sidebar consistency. \ No newline at end of file + sidebar consistency. + +Navigation bar dropdown +------------------------ +This theme supports dropdown navigation bars. The layout is declared using a YAML file contained at any level in the ``doc/source`` directory. +The file path is relative to the ``doc/source`` directory,and must be specified in the ``html_theme_options`` dictionary. + +- ``navigation_yaml_file``: The path to the YAML file containing the navigation structure. + +.. code:: python + + html_theme_options = { + ..., + "navigation_dropdown": { + "layout_file": "navbar.yml", # Relative path to the YAML file + }, + } + +Each entry in the YAML file may include the following fields: + +- **file.** The relative path to the documentation file, based on the doc/source directory. + +- **title.** The text displayed for the link in the dropdown navigation menu. + +- **sections.** A list of nested navigation items. Each section can specify its own file, title, and an optional caption to provide a brief description. + +.. code:: yaml + + - file: api/index + title: "API Reference" + + - file: examples + title: "Examples" + sections: + + - file: examples/sphinx-design.rst + title: "Sphinx Design Examples" + caption: Examples of using Sphinx design features + + - file: examples/nbsphinx + title: "Nbsphinx Examples" + caption: Examples of using Nbsphinx for Jupyter Notebooks + + +.. warning:: + + You must declare the complete layout of the dropdown navigation bar in the YAML file. Sphinx does not resolve it automatically. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index be6868778..ca6b44e84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "Jinja2>=3.1.2", "importlib-metadata>=4.0", "pdf2image>=1.17.0", + "PyYAML==6.0.2", ] [project.optional-dependencies] diff --git a/src/ansys_sphinx_theme/__init__.py b/src/ansys_sphinx_theme/__init__.py index e0e2e8040..a91e34005 100644 --- a/src/ansys_sphinx_theme/__init__.py +++ b/src/ansys_sphinx_theme/__init__.py @@ -35,6 +35,7 @@ from ansys_sphinx_theme.cheatsheet import build_quarto_cheatsheet, cheatsheet_sidebar_pages from ansys_sphinx_theme.extension.linkcode import DOMAIN_KEYS, sphinx_linkcode_resolve from ansys_sphinx_theme.latex import generate_404 +from ansys_sphinx_theme.navbar_dropdown import load_navbar_configuration, update_template_context from ansys_sphinx_theme.search import ( create_search_index, update_search_config, @@ -494,6 +495,7 @@ def setup(app: Sphinx) -> Dict: # Add default HTML configuration setup_default_html_theme_options(app) + load_navbar_configuration(app) # Check for what's new options in the theme configuration whatsnew_file, changelog_file = get_whatsnew_options(app) @@ -519,10 +521,12 @@ def setup(app: Sphinx) -> Dict: app.connect("html-page-context", update_footer_theme) app.connect("html-page-context", fix_edit_html_page_context) app.connect("html-page-context", update_search_sidebar_context) + app.connect("html-page-context", update_template_context) app.connect("build-finished", replace_html_tag) if use_ansys_search: app.connect("build-finished", create_search_index) + return { "version": __version__, "parallel_read_safe": True, diff --git a/src/ansys_sphinx_theme/navbar_dropdown.py b/src/ansys_sphinx_theme/navbar_dropdown.py new file mode 100644 index 000000000..8a7c2c363 --- /dev/null +++ b/src/ansys_sphinx_theme/navbar_dropdown.py @@ -0,0 +1,155 @@ +# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Navigation Dropdown for navigation bar.""" + +import copy +from functools import lru_cache +import pathlib +from typing import TypedDict + +import bs4 +from docutils import nodes +import sphinx +from sphinx.util import logging +from sphinx.util.nodes import make_refnode +import yaml + +logger = logging.getLogger(__name__) + + +class NavEntry(TypedDict, total=False): + """Represents an entry in the navbar configuration.""" + + title: str + caption: str + file: str + link: str + sections: list["NavEntry"] + + +def load_navbar_configuration(app: sphinx.application.Sphinx) -> None: + """Load the navbar configuration from a YAML file for the Sphinx app.""" + navigation_theme_options = app.config.html_theme_options.get("navigation_dropdown", {}) + if not navigation_theme_options or "layout_file" not in navigation_theme_options: + return + + layout_file = navigation_theme_options["layout_file"] + + yaml_path = pathlib.Path(app.srcdir) / layout_file + + try: + yaml_content = yaml_path.read_text(encoding="utf-8") + app.config.navbar_contents = yaml.safe_load(yaml_content) + + except FileNotFoundError: + raise FileNotFoundError( + f"Navbar layout file '{layout_file}' not found in: {yaml_path.parent.resolve()}" + ) + + except yaml.YAMLError as exc: + raise ValueError(f"Failed to parse YAML in '{yaml_path.name}': {exc}") + + +def update_template_context( + app: sphinx, pagename: str, templatename: str, context: dict, doctree: nodes.document | None +) -> None: + """Inject navbar rendering logic into the Sphinx HTML template context.""" + + @lru_cache(maxsize=None) + def render_navbar_links_html() -> bs4.BeautifulSoup: + """Render the navbar content as HTML using the navbar configuration.""" + if not hasattr(app.config, "navbar_contents"): + raise ValueError("Navbar configuration not found. Please define a layout YAML file.") + + nav_root = nodes.container(classes=["navbar-content"]) + nav_root.append(build_navbar_nodes(app.config.navbar_contents)) + rendered = app.builder.render_partial(nav_root)["fragment"] + return add_navbar_chevrons(bs4.BeautifulSoup(rendered, "html.parser")) + + def build_navbar_nodes(entries: list[NavEntry], is_top_level: bool = True) -> nodes.bullet_list: + """Recursively construct docutils nodes for the navbar structure.""" + classes = ["navbar-toplevel"] if is_top_level else ["navbar-sublevel"] + nav_list = nodes.bullet_list(bullet="-", classes=classes) + + for entry in entries: + title = entry.get("title", "") + file = entry.get("file") + link = entry.get("link") + + if file: + ref_node = make_refnode( + app.builder, + context["current_page_name"], + file, + None, + nodes.inline(classes=["navbar-link-title"], text=title), + title, + ) + elif link: + ref_node = nodes.reference("", "", internal=False) + ref_node["refuri"] = link + ref_node["reftitle"] = title + ref_node.append(nodes.inline(classes=["navbar-link-title"], text=title)) + else: + logger.warning( + f"Invalid navbar entry: {entry}. Expected 'file' or 'link'. Skipping." + ) + continue + + if "caption" in entry: + ref_node.append(nodes.Text(entry["caption"])) + + paragraph = nodes.paragraph() + paragraph.append(ref_node) + + container = nodes.container(classes=["ref-container"]) + container.append(paragraph) + + list_item_classes = ["active-link"] if file == pagename else [] + list_item = nodes.list_item(classes=list_item_classes) + list_item.append(container) + + if "sections" in entry: + dropdown = nodes.container(classes=["navbar-dropdown"]) + dropdown.append(build_navbar_nodes(entry["sections"], is_top_level=False)) + list_item.append(dropdown) + + nav_list.append(list_item) + + return nav_list + + context["render_navbar_links_html"] = render_navbar_links_html + + +def add_navbar_chevrons(soup: bs4.BeautifulSoup) -> bs4.BeautifulSoup: + """Add chevron icons to navbar items that have dropdown menus.""" + soup_copy = copy.copy(soup) + + for li in soup_copy.find_all("li", recursive=True): + if li.find("div", class_="navbar-dropdown", recursive=False): + ref_container = li.find("div", class_="ref-container") + if ref_container: + chevron = soup_copy.new_tag("i", attrs={"class": "fa-solid fa-chevron-down"}) + ref_container.append(chevron) + + return soup_copy diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/navbar-nav.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/navbar-nav.html new file mode 100644 index 000000000..aeec614bf --- /dev/null +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/navbar-nav.html @@ -0,0 +1,88 @@ +{% if theme_navigation_dropdown %} + + +{% else %} +{% include "pydata_sphinx_theme/components/navbar-nav.html" %} +{% endif %} \ No newline at end of file diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf index ca54fe33a..aa1aa4d24 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf @@ -22,3 +22,4 @@ whatsnew = use_ansys_search = True search_extra_sources = search_filters = +navigation_dropdown =