diff --git a/.gitignore b/.gitignore index 52c3978..9753d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,11 @@ dist/ # Docs doc/build/ +# Sphinx/sphinx-gallery/autosummary output generated into the source tree +doc/source/examples/ +doc/source/api/ +doc/source/reference/generated/ +doc/source/sg_execution_times.rst # Editors .idea/ diff --git a/README.md b/README.md index 7879fef..9fe94d1 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,49 @@ Call `path.done()` to check progress without blocking, or `path.result()` to wai explicitly. Pass `download(background=False)` to block and get the path as a plain string instead. -The download location defaults to `~/em_database` and can be changed either for the -session or through the `EM_DATABASE_DATA_DIR` environment variable: +## Settings + +Configuration lives in a live, matplotlib-`rcParams`-style object, +`em_database.settings`, seeded at import from `~/.em_database/settings.yaml`. +Change it in memory for an immediate effect, and persist it to remember the +choice across sessions: ```python import em_database -em_database.set_data_dir("/path/to/somewhere") -em_database.get_data_dir() -em_database.reset_data_dir() +em_database.settings["data_dir"] = "/big/disk/em_data" # takes effect now +em_database.settings.save() # remember it next time ``` +In Jupyter you can also edit them interactively — `display(em_database.settings)` +renders a panel to set (and save) the data directory directly. + +The data directory defaults to `~/em_database`. Convenience helpers wrap the +common case — `set_data_dir` persists by default: + +```python +em_database.get_data_dir() # current location +em_database.set_data_dir("/big/disk/em_data") # set + persist +em_database.set_data_dir("/scratch", persist=False) # one-off, in-memory only +em_database.reset_data_dir() # back to the default, forget the choice +``` + +No environment variable is needed. Only values you explicitly set are written to +the file, so the default is never frozen in — it keeps being obeyed even if it +changes. For a one-off override (e.g. CI) the legacy `EM_DATABASE_DATA_DIR` is +still honored, and `EM_DATABASE_CONFIG` relocates the settings file. + +### Shared, system-wide data + +Datasets can be installed once for every user. `download()` and `filepath()` +look in the shared/system locations **first**, then your own data directory, and +only download (into your directory) if the file is nowhere to be found — so a +shared copy is reused instead of refetched. Shared locations come from the +`EM_DATABASE_SHARED_DIR` environment variable (an `os.pathsep`-separated list), a +`shared_data_dirs` list in your settings, or a system config file +(`/etc/em_database/settings.yaml`, or `%PROGRAMDATA%\em_database\settings.yaml` +on Windows). + ## Adding a dataset Datasets are described by a YAML file in `em_database/datasets/`, one entry per file, diff --git a/doc/source/_static/custom.css b/doc/source/_static/custom.css new file mode 100644 index 0000000..8d7cd79 --- /dev/null +++ b/doc/source/_static/custom.css @@ -0,0 +1,194 @@ +/* custom.css - Catppuccin Mocha theming for the Sphinx (pydata) pages + * + * The landing / All Data / Add Dataset pages are generated as self-contained + * app pages (see em_database/_build_docs.py). The Examples gallery and API + * reference are rendered by pydata_sphinx_theme; this file recolors that theme + * to match, so the whole site reads as one dark Catppuccin "app". It maps the + * Catppuccin palette onto pydata's --pst-color-* variables and restyles the + * top navbar to echo the app header. + */ + +:root { + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --ctp-crust: #11111b; + --ctp-surface0: #313244; + --ctp-surface1: #45475a; + --ctp-surface2: #585b70; + --ctp-overlay: #2a2a3c; + --ctp-text: #cdd6f4; + --ctp-subtext: #a6adc8; + --ctp-muted: #7f849c; + --ctp-blue: #89b4fa; + --ctp-mauve: #cba6f7; + --ctp-green: #a6e3a1; + --ctp-red: #f38ba8; +} + +/* Map Catppuccin onto pydata's tokens. Apply to the dark theme (the default), + * and also to the base :root so the palette holds before the theme attribute is + * stamped on / if the visitor forces light. */ +:root, +html[data-theme="dark"], +html[data-theme="light"] { + --pst-color-primary: var(--ctp-blue); + --pst-color-primary-highlight: var(--ctp-mauve); + --pst-color-secondary: var(--ctp-mauve); + --pst-color-secondary-highlight: var(--ctp-blue); + --pst-color-background: var(--ctp-base); + --pst-color-on-background: var(--ctp-mantle); + --pst-color-surface: var(--ctp-surface0); + --pst-color-on-surface: var(--ctp-text); + --pst-color-text-base: var(--ctp-text); + --pst-color-text-muted: var(--ctp-subtext); + --pst-color-heading-color: var(--ctp-text); + --pst-color-border: var(--ctp-surface0); + --pst-color-shadow: rgba(0, 0, 0, 0.45); + --pst-color-link: var(--ctp-blue); + --pst-color-link-hover: var(--ctp-mauve); + --pst-color-inline-code: var(--ctp-mauve); + --pst-color-inline-code-links: var(--ctp-blue); + --pst-color-target: rgba(137, 180, 250, 0.18); + --pst-color-accent: var(--ctp-mauve); + --pst-color-info: var(--ctp-blue); + --pst-color-success: var(--ctp-green); + --pst-color-warning: var(--ctp-yellow, #f9e2af); + --pst-color-danger: var(--ctp-red); + color-scheme: dark; +} + +body { + background-color: var(--ctp-base); + color: var(--ctp-text); +} + +/* Header: neutralize pydata's navbar shell so the app nav (rendered by the + * overridden sections/header.html) IS the visible bar - identical to the + * generated app pages. */ +#pst-header.bd-header, +.bd-header.navbar { + background: none !important; + border: 0 !important; + box-shadow: none !important; + padding: 0 !important; + display: block !important; + min-height: 0 !important; +} +.app-nav { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + padding: 10px 22px; background: linear-gradient(#1c1c2b, var(--ctp-mantle)); + border-bottom: 1px solid var(--ctp-surface0); box-shadow: 0 2px 16px rgba(0, 0, 0, 0.35); +} +.app-brand { + font-size: 16px; font-weight: 800; letter-spacing: 0.2px; margin-right: auto; + display: flex; align-items: center; color: var(--ctp-text); +} +.app-brand:hover { text-decoration: none; } +.app-navlink { + font-size: 14px; font-weight: 600; color: var(--ctp-subtext); + padding: 7px 15px; border-radius: 999px; border: 1px solid transparent; +} +.app-navlink:hover { color: var(--ctp-text); background: var(--ctp-overlay); text-decoration: none; } +.app-navlink.active { + color: var(--ctp-blue); background: rgba(137, 180, 250, 0.14); + border-color: rgba(137, 180, 250, 0.4); +} + +/* Sidebars + content surfaces. */ +.bd-sidebar-primary, +.bd-sidebar-secondary { + background-color: var(--ctp-mantle); + border-color: var(--ctp-surface0); +} +.bd-main .bd-content { color: var(--ctp-text); } + +/* Gradient page titles, matching the app hero. */ +.bd-article-container h1 { + background: linear-gradient(135deg, #89b4fa, #cba6f7); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 800; +} + +/* Code + admonitions. */ +code, pre, .highlight { + background-color: var(--ctp-crust) !important; +} +.highlight pre { color: var(--ctp-text); } + +/* Cards (sphinx-design) + gallery thumbnails. */ +.sd-card, +.sphx-glr-thumbcontainer { + background-color: var(--ctp-mantle) !important; + border: 1px solid var(--ctp-surface0) !important; + border-radius: 10px; +} +.sd-card:hover, +.sphx-glr-thumbcontainer:hover { + border-color: var(--ctp-blue) !important; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); +} + +/* Buttons / search. */ +.bd-search input, +input[type="search"] { + background-color: var(--ctp-crust); + color: var(--ctp-text); + border: 1px solid var(--ctp-surface0); +} + +/* Simple + full width: no sidebars, no breadcrumbs, edge-to-edge content, and a + * larger base font so the Examples/API pages match the app pages and use the + * space they are given. */ +html { font-size: 17px; } +.bd-sidebar-primary, +.bd-sidebar-secondary, +.bd-breadcrumbs, +.prev-next-footer { display: none !important; } +.bd-container, +.bd-page-width, +.bd-main .bd-content, +.bd-main .bd-content .bd-article-container, +.bd-article-container > article { + max-width: 100% !important; +} +.bd-main { margin: 0 !important; } +.bd-article-container { padding: 10px 40px 48px !important; } +.bd-content, .bd-article-container p, .bd-content li { font-size: 1rem; line-height: 1.6; } +.bd-article-container h1 { font-size: 2.4rem; } +/* Gallery: cards stretch to fill the row instead of clustering top-left. */ +.sphx-glr-thumbnails { + display: grid !important; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 18px; + margin: 8px 0 24px; +} +.sphx-glr-thumbcontainer { + width: auto !important; + min-height: 0 !important; + margin: 0 !important; + padding: 12px !important; +} +.sphx-glr-thumbnail-title { font-size: 1rem !important; } + +/* Restyle the (green, monospace) download banners into app pill buttons. */ +.sphx-glr-footer { display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-start; margin-top: 20px; } +.sphx-glr-download { margin: 0 !important; } +.sphx-glr-download a { + background: var(--ctp-surface0) !important; + background-image: none !important; + color: var(--ctp-blue) !important; + border: 1px solid var(--ctp-surface1) !important; + border-radius: 8px !important; + box-shadow: none !important; + font-family: inherit !important; + font-size: 0.9rem !important; + font-weight: 600 !important; + padding: 10px 16px !important; +} +.sphx-glr-download a:hover { border-color: var(--ctp-blue) !important; background: var(--ctp-overlay) !important; } +.sphx-glr-signature { opacity: 0.45; font-size: 0.8rem; } + +/* Trim the footer to the copyright line only. */ +.bd-footer .bd-footer__inner { justify-content: flex-start; } diff --git a/doc/source/_templates/sections/header.html b/doc/source/_templates/sections/header.html new file mode 100644 index 0000000..0551174 --- /dev/null +++ b/doc/source/_templates/sections/header.html @@ -0,0 +1,11 @@ +{# Override pydata's header so the Sphinx pages (Examples / API) get the exact + same top bar as the generated app pages (see _top_nav in _build_docs.py). + Links use pathto() so they resolve from any page depth; the active pill is + chosen from the current pagename. #} + diff --git a/doc/source/add_dataset.rst b/doc/source/add_dataset.rst new file mode 100644 index 0000000..a52acd5 --- /dev/null +++ b/doc/source/add_dataset.rst @@ -0,0 +1,15 @@ +:html_theme.sidebar_secondary.remove: + +Add Dataset +=========== + +A submission form that opens a pre-filled GitHub pull request adding your +dataset's YAML file. + +.. note:: + + This page is rendered as a self-contained form during the build (see + ``build-finished`` in ``conf.py``); this text is a fallback that only shows if + that generation step fails. You can also submit a dataset via the + `new-dataset issue form + `_. diff --git a/doc/source/all_data.rst b/doc/source/all_data.rst new file mode 100644 index 0000000..3e5dabb --- /dev/null +++ b/doc/source/all_data.rst @@ -0,0 +1,12 @@ +:html_theme.sidebar_secondary.remove: + +All Data +======== + +The full, widget-styled dataset browser. + +.. note:: + + This page is rendered as a self-contained, widget-styled browser during the + build (see ``build-finished`` in ``conf.py``); this text is a fallback that + only shows if that generation step fails. diff --git a/doc/source/conf.py b/doc/source/conf.py index 0a0984a..a6cc0f0 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -6,7 +6,14 @@ import sys from pathlib import Path # Import and run the build script -from em_database._build_docs import parse_datasets, generate_html_table, generate_browser_html +from em_database._build_docs import ( + parse_datasets, + generate_html_table, + generate_browser_html, + generate_landing_html, + generate_all_data_html, + generate_add_dataset_html, +) # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -32,7 +39,10 @@ ] templates_path = ['_templates'] -exclude_patterns = [] +# intro.rst / datasets.rst are superseded by the generated landing + All Data +# app pages; keep the files but leave them out of the build so they don't warn +# about being orphaned. +exclude_patterns = ['intro.rst', 'datasets.rst'] @@ -41,9 +51,46 @@ html_theme = "pydata_sphinx_theme" html_static_path = ["_static"] +html_css_files = ["custom.css"] master_doc = "index" -html_sidebars = { - "datasets": [] + +# Dark Catppuccin-Mocha by default, to match the generated app pages. +html_context = {"default_mode": "dark"} + +# Top navigation: Examples / API / All Data / Add Dataset. Examples and API are +# Sphinx-generated (sphinx-gallery + autodoc); All Data and Add Dataset are the +# generated app pages, in the toctree so pydata builds correct relative links to +# them from every page (their HTML output is then overwritten with the app page +# in the build-finished hook below). +html_theme_options = { + "logo": {"text": "◆ EM-Database"}, + "navbar_start": ["navbar-logo"], + "navbar_center": ["navbar-nav"], + "navbar_end": ["theme-switcher", "navbar-icon-links"], + "navbar_persistent": [], + "show_prev_next": False, + # Simple: no left sidebar, no right ("Show Source"/on-this-page) sidebar, + # no breadcrumbs; a minimal footer. + "secondary_sidebar_items": {"**": []}, + "footer_start": ["copyright"], + "footer_center": [], + "footer_end": [], + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/CSSFrancis/em_data", + "icon": "fa-brands fa-github", + }, + ], +} + +# No left sidebar anywhere - keep every page a single, full-width column. +html_sidebars = {"**": []} +_unused_sidebars = { + "index": [], + "all_data": [], + "add_dataset": [], + "datasets": [], } def build_datasets_html(app, exception): @@ -65,13 +112,23 @@ def build_datasets_html(app, exception): with output_path.open('w', encoding='utf-8') as f: f.write(html_output) - # The widget-styled browser used on the landing page. - try: - browser_html = generate_browser_html() - (Path(app.outdir) / 'datasets_browser.html').write_text(browser_html, encoding='utf-8') - print("Wrote datasets_browser.html") - except Exception as e: # pragma: no cover - keep the build alive - print(f"Could not build datasets_browser.html: {e}") + # Generated, self-contained Catppuccin "app" pages. Each is written into the + # build output (overwriting the Sphinx-rendered page where names collide: + # index.html, all_data.html, add_dataset.html) so the whole site looks like + # em_database.browse(). Each is guarded so a failure never kills the build. + outdir = Path(app.outdir) + pages = { + 'index.html': generate_landing_html, + 'all_data.html': generate_all_data_html, + 'add_dataset.html': generate_add_dataset_html, + 'datasets_browser.html': generate_browser_html, + } + for filename, generator in pages.items(): + try: + (outdir / filename).write_text(generator(), encoding='utf-8') + print(f"Wrote {filename}") + except Exception as e: # pragma: no cover - keep the build alive + print(f"Could not build {filename}: {e}") def setup(app): app.connect('build-finished', build_datasets_html) diff --git a/doc/source/index.rst b/doc/source/index.rst index 5235120..919e505 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,10 +1,23 @@ +:html_theme.sidebar_secondary.remove: + .. toctree:: - :maxdepth: 1 :hidden: - Introduction - Reference - Datasets Examples + API + All Data + Add Dataset + +EM-Database +=========== + +A curated, citable collection of electron microscopy datasets — a couple of +lines of Python from your analysis. + +.. note:: -.. include:: intro.rst \ No newline at end of file + This landing page is rendered as a self-contained, widget-styled app during + the build (see ``build-finished`` in ``conf.py``); this reStructuredText is a + fallback that only shows if that generation step fails. Browse the datasets on + the :doc:`All Data ` page, or add one on :doc:`Add Dataset + `. diff --git a/em_database/__init__.py b/em_database/__init__.py index c9f9cc2..adacee5 100644 --- a/em_database/__init__.py +++ b/em_database/__init__.py @@ -1,19 +1,12 @@ ### Example datasets ### import os +from em_database.config import settings from em_database.downloadable_dataset import DownloadableDataset from em_database._create_stubs import build_docstring from em_database import data __all__ = [] -if "EM_DATABASE_DATA_DIR" not in os.environ: - # set the default dir to User's home directory + "/emdata" - os.environ["EM_DATABASE_DATA_DIR"] = os.path.join( - os.path.expanduser("~"),"em_database" - ) - - - def get_data_dir(): """ Get the directory where example datasets are stored. @@ -23,9 +16,10 @@ def get_data_dir(): str Path to the example datasets directory. """ - return os.environ["EM_DATABASE_DATA_DIR"] + from em_database import config + return config.data_dir() -def set_data_dir(path: str): +def set_data_dir(path: str, persist: bool = True): """ Set the directory where example datasets are stored. @@ -33,16 +27,31 @@ def set_data_dir(path: str): ---------- path : str Path to the desired example datasets directory. + persist : bool, optional + If True (the default), remember the choice across sessions by writing it + to the settings file. Pass False for a one-off, in-memory change. """ - os.environ["EM_DATABASE_DATA_DIR"] = path + settings["data_dir"] = str(path) + if persist: + settings.save() def reset_data_dir(): """ - Reset the example datasets directory to the default location. + Reset the example datasets directory to the default location, clearing any + saved choice. """ - os.environ["EM_DATABASE_DATA_DIR"] = os.path.join( - os.path.expanduser("~"),"em_database" - ) + settings.reset("data_dir") + + +def get_setting(key: str, default=None): + """Read a value from :data:`em_database.settings`.""" + return settings.get(key, default) + +def set_setting(key: str, value, persist: bool = True): + """Set a value in :data:`em_database.settings`, persisting it by default.""" + settings[key] = value + if persist: + settings.save() def browse(**kwargs): @@ -60,7 +69,8 @@ def browse(**kwargs): return _browse(**kwargs) -__all__ = ['get_data_dir', 'set_data_dir', 'reset_data_dir', 'browse', "data"] +__all__ = ['get_data_dir', 'set_data_dir', 'reset_data_dir', + 'get_setting', 'set_setting', 'settings', 'browse', "data"] # Let ``display(em_database)`` render the browser. Reassigning the module's diff --git a/em_database/_build_docs.py b/em_database/_build_docs.py index 5f8a532..843ea4f 100644 --- a/em_database/_build_docs.py +++ b/em_database/_build_docs.py @@ -1,3 +1,4 @@ +import json import yaml from pathlib import Path from collections import defaultdict @@ -451,7 +452,8 @@ def generate_html_table(datasets_by_technique): } function drawTabs() { tabsEl.innerHTML = ""; - ["All"].concat(techniques()).forEach(function (tab) { + var tabList = (typeof TABS !== "undefined" && TABS) ? TABS : techniques(); + ["All"].concat(tabList).forEach(function (tab) { var label = tab === "All" ? "All" : (TAB_LABEL[tab] || tab); var b = el("button", "emdb-tab" + (state.tab === tab ? " active" : ""), esc(label)); b.addEventListener("click", function () { state.tab = tab; drawTabs(); drawList(); }); @@ -527,60 +529,620 @@ def generate_html_table(datasets_by_technique): })(); """ -_BROWSER_PAGE = """ - - -EM Datasets - - -
- - +/* Bigger, more legible text on the website (the notebook widget stays compact) */ +.emdb-search { font-size: 15px; padding: 10px 13px; } +.emdb-tab { font-size: 13.5px; padding: 5px 13px; } +.emdb-count { font-size: 13px; } +.emdb-group-head { font-size: 11.5px; } +.emdb-name { font-size: 14.5px; } +.emdb-meta { font-size: 13px; } +.emdb-glyph { font-size: 12px; } +.emdb-d-title { font-size: 20px; } +.emdb-d-sub { font-size: 13.5px; } +.emdb-d-desc { font-size: 14.5px; line-height: 1.6; max-width: 760px; } +.emdb-kv { font-size: 13.5px; } +.emdb-k { flex-basis: 92px; } +.emdb-code { font-size: 13px; } +.emdb-load-label, .emdb-d-status { font-size: 12px; } +.emdb-copy-btn, .emdb-dl { font-size: 13px; } +/* Keep the metadata a tidy two-column block instead of sprawling edge to edge */ +.emdb-d-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 820px; gap: 6px 30px; } +""" + +# Form styling for the Add Dataset page. +_FORM_CSS = """ +.form-wrap { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 430px); gap: 26px; align-items: start; } +@media (max-width: 900px) { .form-wrap { grid-template-columns: 1fr; } .yaml-side { position: static; } } +.ds-form { display: flex; flex-direction: column; gap: 14px; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +@media (max-width: 560px) { .grid2 { grid-template-columns: 1fr; } } +.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.field label { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--emdb-muted); } +.field .req { color: var(--emdb-red); } +.field-hint { font-size: 11px; color: var(--emdb-subtext); } +.field input, .field textarea, .field select { + width: 100%; box-sizing: border-box; + background: var(--emdb-crust); color: var(--emdb-text); + border: 1px solid var(--emdb-surface0); border-radius: 7px; padding: 8px 10px; + font-size: 13px; font-family: var(--emdb-font); outline: none; +} +.field textarea { min-height: 84px; resize: vertical; line-height: 1.5; } +.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--emdb-blue); } +.field input.invalid, .field textarea.invalid, .field select.invalid { border-color: var(--emdb-red); } +.field-err { font-size: 11px; color: var(--emdb-red); min-height: 13px; } +.section-title { + font-size: 12px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--emdb-subtext); margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--emdb-surface0); +} +#authors { display: flex; flex-direction: column; gap: 12px; } +.author-row { + display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; + background: var(--emdb-mantle); border: 1px solid var(--emdb-surface0); border-radius: 9px; padding: 12px; +} +@media (max-width: 620px) { .author-row { grid-template-columns: 1fr; } } +.btn-ghost { + align-self: flex-start; cursor: pointer; font-weight: 600; font-size: 12px; + background: var(--emdb-surface0); color: var(--emdb-text); + border: 1px solid var(--emdb-surface1); border-radius: 7px; padding: 6px 12px; +} +.btn-ghost:hover { border-color: var(--emdb-blue); color: var(--emdb-blue); } + +.yaml-side { position: sticky; top: 72px; display: flex; flex-direction: column; gap: 12px; } +.yaml-head { + display: flex; align-items: center; justify-content: space-between; + font-size: 11px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--emdb-muted); +} +.yaml-pre { + margin: 0; background: var(--emdb-crust); border: 1px solid var(--emdb-surface0); + border-radius: 10px; padding: 14px; max-height: 440px; overflow: auto; + font-family: var(--emdb-mono); font-size: 12px; color: var(--emdb-text); white-space: pre; +} +.submit-row { display: flex; flex-direction: column; gap: 10px; } +.btn-primary { + cursor: pointer; font-weight: 700; font-size: 14px; text-align: center; + color: var(--emdb-crust); background: linear-gradient(135deg, var(--emdb-blue), var(--emdb-mauve)); + border: 0; border-radius: 9px; padding: 12px 16px; +} +.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; filter: grayscale(0.3); } +.btn-primary:not(:disabled):hover { filter: brightness(1.06); } +.btn-secondary { + text-align: center; font-size: 12px; font-weight: 600; color: var(--emdb-subtext); + border: 1px solid var(--emdb-surface1); border-radius: 8px; padding: 9px 14px; +} +.btn-secondary:hover { color: var(--emdb-text); border-color: var(--emdb-blue); text-decoration: none; } +.copy-mini { + cursor: pointer; font-size: 11px; font-weight: 600; color: var(--emdb-blue); + background: transparent; border: 1px solid var(--emdb-surface1); border-radius: 6px; padding: 2px 9px; +} +.copy-mini:hover { border-color: var(--emdb-blue); background: rgba(137, 180, 250, 0.1); } +.copy-mini.copied { color: var(--emdb-green); border-color: var(--emdb-green); } +.form-note { font-size: 11.5px; color: var(--emdb-subtext); line-height: 1.55; } +.form-note code { font-family: var(--emdb-mono); color: var(--emdb-text); } """ -def generate_browser_html(): - """Generate a self-contained, widget-styled dataset browser for the docs. +def _esc(value) -> str: + """Minimal HTML escaping for text baked into a page at build time.""" + return (str(value).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """).replace("'", "'")) + - Reuses ``em_database/static/browser.css`` and the ``em_database.catalogue`` - data model, so the docs landing page looks and browses like - ``em_database.browse()`` - minus the live downloads (a static site has no - kernel): the details panel offers the copy-to-load snippet and a direct - download link instead. +# Top-nav destinations. Examples and API are Sphinx-generated (sphinx-gallery + +# autodoc); All Data and Add Dataset are the generated app pages. All the +# generated pages sit at the site root, so these relative links resolve the +# same from each of them. +_NAV_LINKS = ( + ("Examples", "examples/index.html"), + ("API", "reference/index.html"), + ("All Data", "all_data.html"), + ("Add Dataset", "add_dataset.html"), +) + + +def _top_nav(active: str = "") -> str: + items = "".join( + '{name}'.format( + cls=" active" if name == active else "", url=url, name=_esc(name)) + for name, url in _NAV_LINKS + ) + return ( + '' + ) + + +def _load_css() -> str: + return (Path(__file__).parent / "static" / "browser.css").read_text(encoding="utf-8") + + +def _app_page(title: str, body: str, active: str = "", extra_css: str = "", + scripts: str = "") -> str: + """Wrap page ``body`` in the self-contained Catppuccin app shell. + + Built by concatenation (not ``str.format``/``%``) so CSS/JS braces need no + escaping. The result references no external hosts - all CSS/JS is inline. """ - import json + return ( + "\n" + '\n' + '\n' + "" + _esc(title) + "\n" + "\n" + "\n\n" + + _top_nav(active) + "\n" + body + "\n" + scripts + "\n" + "\n" + ) + + +def _catalogue_payload(): + """``(payload, tabs)`` - the baked catalogue and the ordered tab list. + Tabs are the canonical techniques (so 4D-STEM / EELS / EDS / EBSD / STEM / + In-situ / Cryo always show, even if a technique currently has no dataset), + followed by any other technique that happens to be present. + """ from em_database import catalogue payload = catalogue.catalogue() - css = (Path(__file__).parent / "static" / "browser.css").read_text(encoding="utf-8") - page = _BROWSER_PAGE.replace("__CSS__", css) - page = page.replace("__DATA__", json.dumps(payload)) - page = page.replace("__JS__", _DOCS_BROWSER_JS) - return page + order = list(catalogue.TECHNIQUE_ORDER) + present = [g["technique"] for g in payload.get("groups", [])] + tabs = order + [t for t in present if t not in order] + return payload, tabs -if __name__ == "__main__": +def _browser_script(payload, tabs) -> str: + """The " + ) + + +def generate_browser_html() -> str: + """Self-contained, widget-styled dataset browser (no nav chrome). + + Kept for backward compatibility (it can be embedded in an ``