+
+
diff --git a/automation/boundaries/reveals.py b/automation/boundaries/reveals.py
index 76516d47e..ca9a3ca55 100644
--- a/automation/boundaries/reveals.py
+++ b/automation/boundaries/reveals.py
@@ -12,22 +12,26 @@
class FileManagerStandIn:
- """Stands in for the desktop's file manager, noting every path the application asks it to show.
+ """Stands in for the desktop's file manager and its handler of files, noting every path the
+ application hands them.
The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file
- manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads
- the search path, so the application's own reveal code runs and reaches them: the first names a
- file manager they stand for, and the others write the path they were given to ``log``, one line
- each.
+ manager, or ``xdg-open``, to show a path, and it asks ``xdg-open`` to open a page in the
+ browser. Programs of those names lie in ``folder``, which leads the search path, so the
+ application's own code runs and reaches them: the first names a file manager they stand for,
+ the file manager writes the path it was given to ``shown_log``, and ``xdg-open`` writes a folder
+ there and a file to ``opened_log``, one line each.
"""
def __init__(
self,
folder: Path,
- log: Path,
+ shown_log: Path,
+ opened_log: Path,
) -> None:
self.folder = folder
- self._log = log
+ self._shown_log = shown_log
+ self._opened_log = opened_log
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Lays the programs in the folder and puts the folder first on the search path."""
@@ -44,15 +48,30 @@ def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
def shown(self) -> Tuple[Path, ...]:
"""Every path the file manager was asked to show, in order."""
- if not self._log.exists():
- return ()
+ return _noted(self._shown_log)
- return tuple(Path(line) for line in self._log.read_text().splitlines())
+ def opened(self) -> Tuple[Path, ...]:
+ """Every file handed to the desktop's program for its kind, in order."""
+ return _noted(self._opened_log)
def _programs(self) -> Mapping[str, str]:
- noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n'
+ noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._shown_log}"; done\n'
+ sorting = (
+ "#!/bin/sh\n"
+ 'for path in "$@"; do\n'
+ f' if [ -d "$path" ]; then log="{self._shown_log}"; else log="{self._opened_log}"; fi\n'
+ ' printf \'%s\\n\' "$path" >> "$log"\n'
+ "done\n"
+ )
return {
"xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n",
FILE_MANAGER: noting,
- "xdg-open": noting,
+ "xdg-open": sorting,
}
+
+
+def _noted(log: Path) -> Tuple[Path, ...]:
+ if not log.exists():
+ return ()
+
+ return tuple(Path(line) for line in log.read_text().splitlines())
diff --git a/automation/paths.py b/automation/paths.py
index 17ee9a728..50acd520a 100644
--- a/automation/paths.py
+++ b/automation/paths.py
@@ -13,3 +13,4 @@
REPORTS_FILE: Final[str] = "reports.jsonl"
NO_BUS_FILE: Final[str] = "no-bus"
FAILURE_SCREENSHOT: Final[str] = "failure.png"
+SHIPPED_DOCUMENTATION_FOLDER: Final[str] = "shipped-documentation"
diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py
index 49729e3d5..7862d8299 100644
--- a/automation/plugin/fixtures.py
+++ b/automation/plugin/fixtures.py
@@ -28,17 +28,19 @@
screen_size,
)
from automation.holds.base import Holds
-from automation.paths import FAILURE_SCREENSHOT
+from automation.paths import FAILURE_SCREENSHOT, SHIPPED_DOCUMENTATION_FOLDER
from automation.plugin.constants import DISPLAY_KEY
from automation.render_thread import QueueRenderThread
from automation.screen import Screen
from automation.worlds.home import World, lived_in_world
from sampletones_application.application import Application
from sampletones_application.config.profile import UserProfile
+from sampletones_shared.documentation.site import DocumentationSite
ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0
STAND_IN_PROGRAMS: Final[str] = "programs"
REVEALS_FILE: Final[str] = "reveals.log"
+OPENS_FILE: Final[str] = "opens.log"
DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey()
@@ -87,7 +89,7 @@ def screen_boundaries_fixture(
errors = ErrorRecords()
errors.install()
artifacts = Path(os.environ[ARTIFACTS_VARIABLE])
- file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE)
+ file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE, artifacts / OPENS_FILE)
file_manager.install(monkeypatch)
spawns = SpawnGuard()
spawns.install()
@@ -125,6 +127,7 @@ def screen_application_fixture(
world.write(profile)
application = Application(
profile=profile,
+ documentation=DocumentationSite(Path.home() / SHIPPED_DOCUMENTATION_FOLDER),
reconstruction_path=startup.reconstruction,
project_path=startup.project,
)
diff --git a/automation/screen.py b/automation/screen.py
index 850b48345..6b0e89e2a 100644
--- a/automation/screen.py
+++ b/automation/screen.py
@@ -68,6 +68,7 @@
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_BUTTON,
+ TAG_GLOBAL_DIALOG_DOCUMENTATION,
TAG_GLOBAL_DIALOG_ERROR,
TAG_GLOBAL_DIALOG_FILE_NOT_FOUND,
TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT,
@@ -144,6 +145,7 @@ def __init__(
self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND)
self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT)
self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN)
+ self.documentation_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_DOCUMENTATION)
def sound_settled(self) -> bool:
"""Whether the open reconstruction's sound stands landed, which a box or an edit sends to the worker."""
@@ -282,6 +284,11 @@ def revealed(self) -> Tuple[Path, ...]:
"""Every path the application asked the desktop's file manager to show, in order."""
return self._file_manager.shown()
+ def opened(self) -> Tuple[Path, ...]:
+ """Every file the application handed to the desktop's program for its kind, such as a page for
+ the browser, in order."""
+ return self._file_manager.opened()
+
def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]:
"""Every highlight standing on a table, with the color the application laid it in."""
return self._highlights.standing()
diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md
index f1e09d9fc..2b70e8b9e 100644
--- a/docs/development/application/screens.md
+++ b/docs/development/application/screens.md
@@ -202,8 +202,9 @@ how a menu popup's width is measured.
browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and
so do the stand-ins below.
- **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that
- file manager and `xdg-open` note each path they are asked to show. The application's own reveal code
- runs, and `screen.revealed()` reads what it showed.
+ file manager and `xdg-open` note each path they are handed. The application's own code runs:
+ `screen.revealed()` reads the folders and files it asked the file manager to show, and `screen.opened()`
+ the files `xdg-open` handed to their program, such as the documentation's page for the browser.
- **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request
a title bar's close button sends.
- **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and
diff --git a/docs/development/assets.md b/docs/development/assets.md
index 68b4b298e..4374a74fe 100644
--- a/docs/development/assets.md
+++ b/docs/development/assets.md
@@ -1,7 +1,8 @@
# Assets
This document governs `assets/`, the checkout unit that makes the repository's own content: the icon suite
-the application ships, the demo tree, and the pictures of the application the guide and the README show.
+the application ships, the HTML documentation it opens, the demo tree, and the pictures of the application
+the guide and the README show.
Consult it before adding something the repository keeps that a person would
otherwise draw, record or arrange by hand, and before changing how one of these is made. What a checkout
unit is, and what it may import, is in [tooling](tooling.md).
@@ -28,6 +29,9 @@ same hook. A maker whose output depends on the machine's arithmetic, such as a c
`build/` and is run by hand. A maker whose output may differ by a pixel between machines, as a software
renderer's does, is run by hand too, and the pull request shows what changed.
+The documentation site is the one output the history leaves out. It repeats `docs/` in another form, so
+`docs/` stays the single copy and every wheel build derives the site afresh ([documentation](#documentation)).
+
## The makers
### Icons
@@ -40,6 +44,28 @@ from, `src/sampletones_assets/icons/`. `make icons` writes them, and `python -m
them elsewhere. The whole suite is committed, so every wheel, bundle and test run finds the icons where
they lie. Pillow rasterizes the suite; it is a development dependency, so the maker runs in a checkout alone.
+### Documentation
+
+`assets/documentation/` writes the user's documentation as HTML pages in the application's look, the pages
+**Help > Documentation** opens. The Markdown under `docs/` is the specification: the index, the glossary,
+and the guide, concepts, tools, formats and API sections become pages, each at the place its document holds
+in `docs/`, so a picture a page shows keeps its relative link. Each second-level heading becomes one of the
+application's cards, and the index's sections lay out the navigation beside every page. The look is the
+shared web layout ([tooling](tooling.md#pages-a-tool-writes)), in the application's default palette.
+
+The development pages, the changelog and anything else under `docs/` stay online: a link to one leads to
+the copy at the documentation address `pyproject.toml` names. A link to a missing document, heading or
+picture fails the run, so a site that builds holds no broken link. A site is sealed by a stamp written
+last, a hash over every file beside it, which names the pages exactly: the application replaces its copy
+of the pages when the stamp differs, and the self-check holds a build's pages to it.
+
+`make docs` writes the site into `src/sampletones_assets/documentation/`, which the history ignores, so a
+checkout opens its own documentation. `python -m assets.documentation -o DIR` writes it elsewhere; the
+folder must be empty or hold an earlier site, which the run replaces. The wheel's build hook,
+`hatch_build.py`, runs the same maker into a temporary folder for every wheel and carries the site at the
+same package path, so every installation and every bundle built from a wheel opens the pages it was built
+with. An editable install reads the checkout, where `make docs` writes the site.
+
### Demo tree
`assets/demo/` writes a documents folder a person can run the application from, with recordings beside
diff --git a/docs/development/packages.md b/docs/development/packages.md
index 72bd29e9c..85383bf57 100644
--- a/docs/development/packages.md
+++ b/docs/development/packages.md
@@ -44,7 +44,7 @@ graph TD
|---------|---------------|
| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the command type the entry and the tools share |
| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, behavior, deployment, and these boundaries themselves — reached as package data and not by import |
-| `sampletones_assets` | The application icons and the bundled fonts, reached as package data |
+| `sampletones_assets` | The application icons, the bundled fonts and, in a wheel, the HTML documentation, reached as package data |
| `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats |
| `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file |
| `sampletones_application` | The DearPyGui front end |
diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md
index 70ea55b2e..fa0c75f8e 100644
--- a/docs/development/release/dependencies.md
+++ b/docs/development/release/dependencies.md
@@ -42,6 +42,10 @@ The icon suite is generated from a mark declared in `assets/icons/mark/config` a
Pillow rasterizes the suite. It is declared in the `assets` dependency group, which the `dev` group includes. It serves the assets unit alone, which the build environment never installs, and the bundle script passes `--exclude-module PIL` as well. `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is installed, and PyInstaller follows that import into the bundle. The application reads its icons as files, so the exclusion keeps Pillow's extension modules and the imaging libraries that come with them out of every bundle. `scripts/verify_bundle.py` holds the release bundles to it.
+## Documentation
+
+Every wheel build writes the HTML documentation ([assets](../assets.md#documentation)), so the build needs what that maker imports. `markdown-it-py` reads the Markdown, with the tables GitHub draws, and belongs to the `assets` group beside Pillow. The build hook names its own dependencies in `pyproject.toml`: markdown-it-py and the libraries the application's theme and palette code imports, each held to the project's own range, which a test compares. Every wheel is built in an isolated environment that installs them, so the first build on a machine reaches the network. `hatchling` 1.27 is the oldest build backend that reads the project's license fields.
+
## Calibration
The calibration harness scores renders with referees of its own, built on `numpy` and `scipy`. The `calibration` dependency group adds [Zimtohrli](https://github.com/google/zimtohrli), a psychoacoustic model, as a second opinion. PyPI has its wheels for Windows, Intel macOS and x86-64 Linux, and other systems compile it on install. The group stays out of `dev`. `uv sync --group calibration`, with the extras the environment already uses named beside it, installs it. See [Calibration](../../tools/calibration.md).
@@ -84,3 +88,5 @@ Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11
PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained.
The executable links against the glibc of the machine that builds it and runs on that version or newer. A redistributable artifact therefore belongs on the oldest Debian or Ubuntu release being supported, so it runs on every newer one.
+
+The bootloader points `LD_LIBRARY_PATH` at the libraries the bundle carries, and a program the application starts would load them in place of the system's. Every system program the application starts, a file manager, a browser or a dialog tool, therefore receives the reader's own library path, which `program_environment()` in `sampletones_shared/utils/system/programs.py` restores.
diff --git a/docs/development/tooling.md b/docs/development/tooling.md
index 8cad8fd1c..2d7814fd0 100644
--- a/docs/development/tooling.md
+++ b/docs/development/tooling.md
@@ -73,7 +73,7 @@ The Makefile is the developer's index, one line per target. A target names the s
and passes its flag. The `run` and `calibration` targets name the `sampletones` command they start with no
options, and the `tracker-playback` target passes the inputs its command takes: the Bitphase source folder
its `BITPHASE` variable names, the `FamiTracker.exe` its `FAMITRACKER` variable names, and the project
-files in `PROJECT`, if any. The `icons` and `demo` targets run makers of the assets unit. It runs each target it is
+files in `PROJECT`, if any. The `icons`, `docs` and `demo` targets run makers of the assets unit. It runs each target it is
given. `install.sh` and `install.bat` at the root
exist for the double-click path and call the same bundle script.
@@ -127,11 +127,21 @@ A test guards principle 4. It imports the registry in a subprocess and asserts t
registry and package modules of the tools load and no heavy library does. A startup failure in any tool
module would break every invocation, the GUI included.
-A tool that writes a page ships that page's files as they are read. `calibration/board/static/` holds the
-markup, the stylesheet and the script, copied out byte for byte, and the builder writes the palette, the
-faces and the run's own contents beside them. The stylesheet names color tokens and the script names no
-measurement, so a page is edited in place and drawn in the application's palettes. A test holds every
-shipped file to reaching nothing beyond the page, which lets one open from a file.
+### Pages a tool writes
+
+A page the repository writes wears the application's look, read from the application itself.
+`sampletones_tools/web/` resolves the theme set and a palette into *roles*: the ground, a card's header
+strip, the text, the accent, each the color or size the application draws that part with. It writes them
+as one stylesheet of variables beside the fonts the application ships, and the layout's own stylesheets
+name roles alone. A change to a theme or a palette therefore reaches every page the next time it is
+written. The fonts travel inside their stylesheet, which lets a page open from a file wherever it lies.
+A page is read rather than worked in, so it spaces its cards and its prose wider than the application
+does, in multiples of the application's own spacing, and its proportions follow the application.
+
+The calibration board and the documentation site ([assets](assets.md#documentation)) are written this
+way. A page's own files are shipped as they are read: `calibration/board/static/` holds the board's markup,
+its stylesheet and its script, copied out byte for byte beside the layout and the run's own contents. A test
+holds every shipped file to reaching nothing beyond the page.
## The bootstrap scripts
diff --git a/docs/guide/interface.md b/docs/guide/interface.md
index e80918a3f..9f55046d6 100644
--- a/docs/guide/interface.md
+++ b/docs/guide/interface.md
@@ -17,10 +17,11 @@ The **Instructions** tab is optional. Use it to explore single [instructions](..
## The menus
-Two menu items are easy to miss:
+A few menu items are easy to miss:
- **View ▸ Show advanced settings** adds the **Advanced settings** card to the **Main** tab. See [Configuration](configuration.md).
- **Playback ▸ Audio settings...** sets the device you listen through. Its sample rate is separate from **Sample rate** on the **Main** tab, which sets how audio is converted.
+- **Help ▸ Documentation** opens this guide in your web browser, from a copy that comes with the app.
Project properties belong to a project and are covered in the [sequencer guide](sequencer.md).
diff --git a/hatch_build.py b/hatch_build.py
new file mode 100644
index 000000000..12be63614
--- /dev/null
+++ b/hatch_build.py
@@ -0,0 +1,58 @@
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+from typing import Any, Dict, Final
+
+from hatchling.builders.config import BuilderConfig
+from hatchling.builders.hooks.plugin.interface import BuildHookInterface
+
+EDITABLE: Final[str] = "editable"
+FORCE_INCLUDE: Final[str] = "force_include"
+SOURCE_DIRECTORY: Final[str] = "src"
+SITE_DESTINATION: Final[str] = "sampletones_assets/documentation"
+SITE_PREFIX: Final[str] = "sampletones-documentation-"
+
+
+class DocumentationBuildHook(BuildHookInterface[BuilderConfig]):
+ """Writes the user documentation into every wheel, as the pages Help > Documentation opens.
+
+ The maker in ``assets/documentation`` writes the site from the checkout's docs into a
+ temporary folder, which the wheel carries at the assets package's documentation path and
+ which is removed once the wheel is written. An editable install reads the checkout itself,
+ where ``make docs`` writes the same site.
+ """
+
+ PLUGIN_NAME = "custom"
+
+ def initialize(self, version: str, build_data: Dict[str, Any]) -> None:
+ if version == EDITABLE:
+ return
+
+ root = Path(self.root)
+ for entry in (root / SOURCE_DIRECTORY, root):
+ if str(entry) not in sys.path:
+ sys.path.insert(0, str(entry))
+
+ from assets.documentation.build import ( # pylint: disable=import-outside-toplevel
+ SiteRequest,
+ build_site,
+ )
+ from assets.documentation.paths import ( # pylint: disable=import-outside-toplevel
+ DOCS_DIRECTORY,
+ PROJECT_FILE,
+ )
+
+ site = Path(tempfile.mkdtemp(prefix=SITE_PREFIX))
+ build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=site))
+ build_data[FORCE_INCLUDE][str(site)] = SITE_DESTINATION
+
+ def finalize(
+ self,
+ version: str,
+ build_data: Dict[str, Any],
+ artifact_path: str,
+ ) -> None:
+ for source, destination in build_data[FORCE_INCLUDE].items():
+ if destination == SITE_DESTINATION:
+ shutil.rmtree(source)
diff --git a/pyproject.toml b/pyproject.toml
index d7d900362..8e87d292e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,11 +76,12 @@ gpu-cuda11 = [
]
[dependency-groups]
-assets = ["pillow>=11,<13"]
+assets = ["markdown-it-py>=3,<5", "pillow>=11,<13"]
calibration = ["zimtohrli>=0.2.1,<0.3"]
dev = [
{ include-group = "assets" },
"black==26.5.1",
+ "hatchling>=1.27",
"isort==8.0.1",
"mypy==2.1.0",
"pre-commit==4.6.0",
@@ -96,7 +97,7 @@ dev = [
]
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[tool.uv]
@@ -114,6 +115,16 @@ packages = [
"src/sampletones_tools",
]
+[tool.hatch.build.targets.wheel.hooks.custom]
+path = "hatch_build.py"
+dependencies = [
+ "markdown-it-py>=3,<5",
+ "numpy>=2.0,<3",
+ "pydantic>=2.9,<3",
+ "PyYAML>=6.0,<7",
+ "rich>=13.0,<16",
+]
+
[tool.black]
line-length = 120
target-version = ["py312"]
@@ -166,6 +177,7 @@ files = [
"scripts",
"automation",
"assets",
+ "hatch_build.py",
]
exclude = ["tests"]
disallow_subclassing_any = true
diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py
index 9542db5b3..83afcd1a8 100644
--- a/scripts/bootstrap/layout.py
+++ b/scripts/bootstrap/layout.py
@@ -9,6 +9,9 @@
DISTRIBUTION: Final[str] = "bin"
BUNDLES: Final[str] = "bundles"
BUILD_ENVIRONMENT: Final[str] = ".venv-build"
+DOCUMENTATION_PACKAGE_PATH: Final[str] = "sampletones_assets/documentation"
+DOCUMENTATION_SITE: Final[str] = f"{SOURCE_DIRECTORY}/{DOCUMENTATION_PACKAGE_PATH}"
+DOCUMENTATION_FILES: Final[Tuple[str, ...]] = ("index.html", "stamp")
RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py"
GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py"
PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks"
@@ -17,7 +20,15 @@
CUPY_PACKAGES: Final[Tuple[str, ...]] = ("cupy", "cupy_backends", "cupyx")
CUDA_PACKAGE: Final[str] = "nvidia"
GPU_PACKAGES: Final[Tuple[str, ...]] = (*CUPY_PACKAGES, CUDA_PACKAGE)
-CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage")
+CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (
+ DISTRIBUTION,
+ BUNDLES,
+ DOCUMENTATION_SITE,
+ "build",
+ "dist",
+ "htmlcov",
+ ".coverage",
+)
CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",)
CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",)
CACHE_DIRECTORY_SUFFIXES: Final[Tuple[str, ...]] = (".egg-info",)
diff --git a/scripts/bundle.py b/scripts/bundle.py
index caf0cd86b..ead25f2ca 100644
--- a/scripts/bundle.py
+++ b/scripts/bundle.py
@@ -30,6 +30,7 @@
SELF_CHECK: Final[str] = "self-check"
GPU_FLAG: Final[str] = "--gpu"
+DOCUMENTATION_FLAG: Final[str] = "--documentation"
@dataclass(frozen=True)
@@ -139,11 +140,12 @@ def pyinstaller_command(
def self_check_command(launcher: Path, options: BundleOptions) -> Tuple[str, ...]:
- """The launcher's self-check, which a GPU bundle runs with the GPU check that proves it computes on the card."""
+ """The launcher's self-check, held to the documentation the wheel carries, which a GPU bundle runs
+ with the GPU check that proves it computes on the card."""
if options.gpu:
- return (str(launcher), SELF_CHECK, GPU_FLAG)
+ return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG, GPU_FLAG)
- return (str(launcher), SELF_CHECK)
+ return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG)
def remove_previous(distribution: Path, bundling: Bundling, name: str) -> None:
diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py
index f0daba10b..4c9a94889 100644
--- a/scripts/setup_environment.py
+++ b/scripts/setup_environment.py
@@ -13,10 +13,17 @@
from bootstrap.project import DEVELOPMENT_GROUP, read_project
-def setup_commands(extra: Optional[str]) -> List[List[str]]:
+def setup_commands(
+ name: str,
+ extra: Optional[str],
+) -> List[List[str]]:
"""The commands that create the development environment and install the global command.
+ The global command is rebuilt from the checkout on every run, so it carries the checkout's
+ code and documentation as they stand.
+
Args:
+ name: The distribution's name, the package the global command is rebuilt from.
extra: The GPU extra installed with the package, or ``None`` for the CPU backend.
Returns:
@@ -30,12 +37,13 @@ def setup_commands(extra: Optional[str]) -> List[List[str]]:
return [
synchronize,
- ["uv", "tool", "install", "--force", package],
+ ["uv", "tool", "install", "--force", "--reinstall-package", name, package],
]
def set_up_environment(
root: Path,
+ name: str,
platform: Platform,
extra: Optional[str],
*,
@@ -47,6 +55,7 @@ def set_up_environment(
Args:
root: The repository.
+ name: The distribution's name, the package the global command is rebuilt from.
platform: The system the setup runs on, which adds the variables the build needs.
extra: The GPU extra installed with the package, or ``None`` for the CPU backend.
machine: The processor architecture ``platform.machine()`` reports.
@@ -57,7 +66,7 @@ def set_up_environment(
SystemExit: If a command fails.
"""
variables = platform.setup_variables(environment, machine=machine)
- for command in setup_commands(extra):
+ for command in setup_commands(name, extra):
expect_success(runner, command, cwd=root, environment=variables)
@@ -73,10 +82,11 @@ def main(argv: Sequence[str]) -> int:
arguments = parser.parse_args(list(argv))
root = repository_root()
- read_project(root)
+ project = read_project(root)
platform = current_platform()
set_up_environment(
root,
+ project.name,
platform,
gpu_extra(arguments.gpu, platform, os.environ),
machine=running.machine(),
diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py
index c647f43ff..69f577ce1 100644
--- a/scripts/verify_bundle.py
+++ b/scripts/verify_bundle.py
@@ -4,7 +4,15 @@
from pathlib import Path
from typing import Final, List, Mapping, Sequence
-from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, GPU_PACKAGES, NOTICES, repository_root
+from bootstrap.layout import (
+ BUILD_TOOLS,
+ DISTRIBUTION,
+ DOCUMENTATION_FILES,
+ DOCUMENTATION_PACKAGE_PATH,
+ GPU_PACKAGES,
+ NOTICES,
+ repository_root,
+)
from bootstrap.platforms.factory import current_platform
from bootstrap.platforms.protocol import Platform
from bootstrap.processes import Runner, run
@@ -19,6 +27,16 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in NOTICES if not (bundle / name).is_file()]
+def missing_documentation(bundle: Path) -> List[str]:
+ """The files of the documentation site a bundle ships that are absent from its internals.
+
+ The wheel the bundle is built from carries the site at the assets package's path, and the
+ bundle unpacks its data under the same path, where Help > Documentation reads it.
+ """
+ site = bundle / INTERNAL_DIRECTORY / DOCUMENTATION_PACKAGE_PATH
+ return [name for name in DOCUMENTATION_FILES if not (site / name).is_file()]
+
+
def carried_packages(bundle: Path, names: Sequence[str]) -> List[str]:
"""The packages among ``names`` found in a bundle, beside its launcher or among its internals."""
directories = (bundle, bundle / INTERNAL_DIRECTORY)
@@ -53,8 +71,8 @@ def bundle_failures(
) -> List[str]:
"""What keeps the release bundle under ``root`` from shipping, each as a line of its own.
- The bundle ships its notices, carries the application and its CPU runtime dependencies alone,
- and offers a launcher that starts.
+ The bundle ships its notices and its documentation, carries the application and its CPU runtime
+ dependencies alone, and offers a launcher that starts.
Args:
root: The repository the bundle was built in.
@@ -76,6 +94,10 @@ def bundle_failures(
if absent:
failures.append(f"Bundle {bundle} is missing {', '.join(absent)}")
+ pages = missing_documentation(bundle)
+ if pages:
+ failures.append(f"Bundle {bundle} is missing its documentation: {', '.join(pages)}")
+
carried = carried_build_tools(bundle)
if carried:
failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
@@ -96,7 +118,8 @@ def bundle_failures(
def main(argv: Sequence[str]) -> int:
- """Confirms the release bundle ships its notices, holds to them, and that its launcher starts."""
+ """Confirms the release bundle ships its notices and its documentation, holds to the notices, and that its
+ launcher starts."""
parser = argparse.ArgumentParser(description="Verify the release bundle before it is archived.")
parser.parse_args(list(argv))
@@ -107,7 +130,7 @@ def main(argv: Sequence[str]) -> int:
if failures:
return 1
- print("The bundle ships its notices and its launcher starts")
+ print("The bundle ships its notices and its documentation, and its launcher starts")
return 0
diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py
index a059dd93b..2d52d0467 100644
--- a/src/sampletones/__init__.py
+++ b/src/sampletones/__init__.py
@@ -19,7 +19,7 @@
)
from sampletones_core.library import InstructionLibrary
from sampletones_core.reconstructions import Reconstruction, Reconstructor
- from sampletones_shared.application import SAMPLETONES_VERSION as __version__
+ from sampletones_shared.distribution import SAMPLETONES_VERSION as __version__
def __getattr__(name: str) -> Any:
@@ -29,7 +29,7 @@ def __getattr__(name: str) -> Any:
return Config
if name == "__version__":
- from sampletones_shared.application import SAMPLETONES_VERSION
+ from sampletones_shared.distribution import SAMPLETONES_VERSION
return SAMPLETONES_VERSION
diff --git a/src/sampletones/commands/self_check.py b/src/sampletones/commands/self_check.py
index 220b9fa02..bae870cae 100644
--- a/src/sampletones/commands/self_check.py
+++ b/src/sampletones/commands/self_check.py
@@ -6,6 +6,7 @@
NAME: Final[str] = "self-check"
HELP: Final[str] = "verify that this build's imports, bundled resources and configuration files are usable"
GPU_FLAG: Final[str] = "--gpu"
+DOCUMENTATION_FLAG: Final[str] = "--documentation"
def configure(parser: ArgumentParser) -> None:
@@ -14,13 +15,18 @@ def configure(parser: ArgumentParser) -> None:
action="store_true",
help="also verify that this build computes on an NVIDIA graphics card",
)
+ parser.add_argument(
+ DOCUMENTATION_FLAG,
+ action="store_true",
+ help="also verify that this build carries its documentation, as an installed one does",
+ )
def run(arguments: Namespace) -> int:
"""Runs every startup check and answers with the status a packaged build is held to."""
from sampletones.self_check import run_self_check
- return run_self_check(gpu=arguments.gpu)
+ return run_self_check(gpu=arguments.gpu, documentation=arguments.documentation)
SELF_CHECK: Final[Command] = Command(
diff --git a/src/sampletones/dispatcher.py b/src/sampletones/dispatcher.py
index b0d85872a..885e39fd9 100644
--- a/src/sampletones/dispatcher.py
+++ b/src/sampletones/dispatcher.py
@@ -1,8 +1,8 @@
from argparse import ArgumentParser
from typing import Final, Sequence
-from sampletones_shared.application import SAMPLETONES_NAME_VERSION
from sampletones_shared.command import Command
+from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION
PROGRAM: Final[str] = "sampletones"
DESCRIPTION: Final[str] = "SampleToNES turns recordings into NES instruments and plays them back."
diff --git a/src/sampletones/run.py b/src/sampletones/run.py
index a99e233be..cf1543583 100644
--- a/src/sampletones/run.py
+++ b/src/sampletones/run.py
@@ -3,7 +3,8 @@
from sampletones_application.application import Application
from sampletones_application.config.profile import UserProfile
-from sampletones_shared.application import SAMPLETONES_NAME_VERSION
+from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION
+from sampletones_shared.documentation.site import DocumentationSite
from sampletones_shared.logger import logger
@@ -17,6 +18,7 @@ def run_application(
logger.info(SAMPLETONES_NAME_VERSION)
gui = Application(
profile=UserProfile.user(),
+ documentation=DocumentationSite.shipped(),
config_path=config_path,
library_path=library_path,
reconstruction_path=reconstruction_path,
diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py
index 2d612a207..1cbcc937c 100644
--- a/src/sampletones/self_check.py
+++ b/src/sampletones/self_check.py
@@ -156,6 +156,27 @@ def _check_array_backend() -> str:
return describe_array_backend()
+def _check_documentation() -> str:
+ """Reads the documentation the build ships, whose stamp has to match the pages beside it.
+
+ The wheel's build hook writes the pages and seals them, so a build carrying no pages, or pages
+ other than the ones sealed, names that here.
+ """
+ from sampletones_shared.documentation.site import DocumentationSite
+ from sampletones_shared.exceptions import DocumentationUnavailableError
+
+ site = DocumentationSite.shipped()
+ stamp = site.read_stamp()
+ if stamp is None or not site.index.is_file():
+ raise DocumentationUnavailableError(f"No documentation site lies in {site.directory}")
+
+ if stamp != site.content_stamp():
+ raise DocumentationUnavailableError(f"The documentation in {site.directory} differs from its stamp {stamp}")
+
+ pages = sum(1 for _ in site.directory.rglob(f"*{site.index.suffix}"))
+ return f"{pages} pages, stamp {stamp}"
+
+
def _check_gpu_backend() -> str:
"""Computes on the graphics card, which is what a GPU build is held to."""
from sampletones_shared.array import exercise_gpu
@@ -176,29 +197,39 @@ def _check_gpu_backend() -> str:
SelfCheck(name="file dialog backend", run=_check_file_dialog_backend),
SelfCheck(name="array backend", run=_check_array_backend),
)
+DOCUMENTATION_CHECK: Final[SelfCheck] = SelfCheck(name="documentation", run=_check_documentation)
GPU_CHECK: Final[SelfCheck] = SelfCheck(name="gpu backend", run=_check_gpu_backend)
-def checks_for(*, gpu: bool) -> Tuple[SelfCheck, ...]:
- """The checks a build runs: every startup check, and the GPU check where the build carries GPU support."""
- if gpu:
- return (*CHECKS, GPU_CHECK)
-
- return CHECKS
+def checks_for(
+ *,
+ gpu: bool,
+ documentation: bool,
+) -> Tuple[SelfCheck, ...]:
+ """The checks a build runs: every startup check, the documentation check where the build was
+ installed from a wheel, and the GPU check last where the build carries GPU support."""
+ held = ((documentation, DOCUMENTATION_CHECK), (gpu, GPU_CHECK))
+ return (*CHECKS, *(check for wanted, check in held if wanted))
-def run_self_check(*, gpu: bool) -> int:
+def run_self_check(
+ *,
+ gpu: bool,
+ documentation: bool,
+) -> int:
"""Runs every startup check in order and returns the process exit status.
Prints one line per check so a passing run doubles as an inventory of what the build
carries, and stops at the first failure with the offending check named on the standard
- error stream. A packaged build runs this to prove it starts before it is shipped, and a GPU
- bundle runs it with ``gpu`` set to prove it computes on the graphics card.
+ error stream. A packaged build runs this to prove it starts before it is shipped, with
+ ``documentation`` set to prove it carries its pages, and a GPU bundle runs it with ``gpu`` set
+ to prove it computes on the graphics card.
Args:
gpu: Whether the build is held to the GPU backend.
+ documentation: Whether the build is held to carrying its documentation.
"""
- checks = checks_for(gpu=gpu)
+ checks = checks_for(gpu=gpu, documentation=documentation)
for check in checks:
try:
detail = check.run()
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index a37c26ee7..10f7fb7ac 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -18,6 +18,7 @@
from sampletones_application.constants.playback import FollowMode
from sampletones_application.coordinators.config import ConfigCoordinator
from sampletones_application.coordinators.display import DisplayCoordinator
+from sampletones_application.coordinators.documentation import DocumentationCoordinator
from sampletones_application.coordinators.edit.router import EditRouter
from sampletones_application.coordinators.export import (
InstrumentExportCoordinator,
@@ -47,6 +48,7 @@
from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
from sampletones_application.exports import ExportBackends
from sampletones_application.layout import LayoutConfig, load_layout_config
+from sampletones_application.logic.documentation.shelf import DocumentationShelf
from sampletones_application.logic.export import SongExportLogic
from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic
from sampletones_application.logic.export.nsf.logic import NSFExportLogic
@@ -195,12 +197,17 @@
from sampletones_shared.application import (
SAMPLETONES_AUTHOR,
SAMPLETONES_GROUP,
+)
+from sampletones_shared.distribution import (
+ SAMPLETONES_DOCUMENTATION_ADDRESS,
SAMPLETONES_NAME_VERSION,
)
+from sampletones_shared.documentation.site import DocumentationSite
from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.logger import logger
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
+from sampletones_shared.utils.system.opening import select_file_opener
SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}"
SEQUENCER_SAMPLE_ORDINAL_FORMAT: Final[str] = "02X"
@@ -224,6 +231,7 @@ class Application:
def __init__(
self,
profile: UserProfile,
+ documentation: DocumentationSite,
config_path: Optional[Path] = None,
library_path: Optional[Path] = None,
reconstruction_path: Optional[Path] = None,
@@ -498,6 +506,13 @@ def __init__(
dialogs=self.dialogs,
language_manager=self.language_manager,
)
+ self._documentation_coordinator = DocumentationCoordinator(
+ shelf=DocumentationShelf(site=documentation, directory=profile.documentation),
+ opener=select_file_opener(),
+ dialogs=self.dialogs,
+ language_manager=self.language_manager,
+ address=SAMPLETONES_DOCUMENTATION_ADDRESS,
+ )
self._instrument_exports = InstrumentExportCoordinator(
InstrumentExportLogic(
@@ -821,6 +836,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions,
toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories,
toggle_fullscreen=self._shell.toggle_fullscreen,
+ documentation=self._documentation_coordinator.open,
about=self._open_about_dialog,
next_tab=self._next_tab,
previous_tab=self._previous_tab,
diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py
index bb2ed7505..27b955018 100644
--- a/src/sampletones_application/categories/elements/global_.py
+++ b/src/sampletones_application/categories/elements/global_.py
@@ -99,6 +99,7 @@ class MenuElements(AbstractElement):
ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings"
ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings"
GROUP_HELP = "group_help"
+ ITEM_HELP_DOCUMENTATION = "item_help_documentation"
ITEM_HELP_ABOUT = "item_help_about"
TAB_MAIN = "tab_main"
TAB_INSTRUCTIONS = "tab_instructions"
diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py
index c99ff24dd..d05644bc4 100644
--- a/src/sampletones_application/categories/elements/settings.py
+++ b/src/sampletones_application/categories/elements/settings.py
@@ -72,6 +72,7 @@ class KeybindingActionElements(AbstractElement):
TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions"
TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories"
TOGGLE_FULLSCREEN = "toggle_fullscreen"
+ DOCUMENTATION = "documentation"
ABOUT_DIALOG = "about_dialog"
NEXT_TAB = "next_tab"
PREVIOUS_TAB = "previous_tab"
diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py
index 239bfeafa..40a943308 100644
--- a/src/sampletones_application/config/profile.py
+++ b/src/sampletones_application/config/profile.py
@@ -3,13 +3,16 @@
from dataclasses import dataclass
from pathlib import Path
-from sampletones_application.paths import APPLICATION_STATE_PATH
-from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
+from sampletones_shared.paths.user import (
+ APPLICATION_CONFIG_PATH,
+ APPLICATION_STATE_PATH,
+ DOCUMENTATION_COPIES_DIRECTORY,
+)
@dataclass(frozen=True)
class UserProfile:
- """The two files a run keeps its settings and its session in.
+ """The places a run keeps its settings, its session and its copy of the documentation in.
A profile is chosen at startup and travels to the managers that read and write it, which is
what lets a run be pointed at a location of its own.
@@ -17,11 +20,13 @@ class UserProfile:
config: Path
state: Path
+ documentation: Path
@classmethod
def user(cls) -> UserProfile:
- """The profile in the user's configuration directory, which is where a normal run reads."""
+ """The profile in the user's own folders, which is where a normal run reads and writes."""
return cls(
config=APPLICATION_CONFIG_PATH,
state=APPLICATION_STATE_PATH,
+ documentation=DOCUMENTATION_COPIES_DIRECTORY,
)
diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py
index 282f1df89..8a8218a3a 100644
--- a/src/sampletones_application/coordinators/config.py
+++ b/src/sampletones_application/coordinators/config.py
@@ -26,7 +26,7 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_shared.application import SAMPLETONES_VERSION
+from sampletones_shared.distribution import SAMPLETONES_VERSION
from sampletones_shared.logger import logger
from sampletones_shared.paths.extensions import EXT_FILE_JSON
from sampletones_shared.utils.validation import flatten_location
diff --git a/src/sampletones_application/coordinators/documentation.py b/src/sampletones_application/coordinators/documentation.py
new file mode 100644
index 000000000..379e394f5
--- /dev/null
+++ b/src/sampletones_application/coordinators/documentation.py
@@ -0,0 +1,47 @@
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.documentation.shelf import DocumentationShelf
+from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_DOCUMENTATION
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_shared.exceptions import DocumentationUnavailableError
+from sampletones_shared.utils.system.opening import FileOpener
+
+
+class DocumentationCoordinator:
+ """Opens the documentation the installation carries in the reader's browser.
+
+ The shelf places a copy of the pages in the user's data folder, and the opener shows its index
+ in the program the system gives web pages. An installation that came without its pages tells
+ the reader where the documentation lives online, and a copy or a browser that fails is
+ reported as an error.
+ """
+
+ def __init__(
+ self,
+ *,
+ shelf: DocumentationShelf,
+ opener: FileOpener,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ address: str,
+ ) -> None:
+ self._shelf = shelf
+ self._opener = opener
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+ self._address = address
+
+ def open(self) -> None:
+ """Shows the documentation's index in the reader's browser."""
+ try:
+ self._opener.open(self._shelf.place())
+ except DocumentationUnavailableError:
+ self._dialogs.show_info(
+ TAG_GLOBAL_DIALOG_DOCUMENTATION,
+ self._language_manager["global.dialog.template.documentation_unavailable"].format(
+ address=self._address,
+ ),
+ self._language_manager["global.dialog.title.documentation"],
+ modal=True,
+ )
+ except OSError as error:
+ self._dialogs.show_error(error, self._language_manager["global.dialog.message.documentation_not_opened"])
diff --git a/src/sampletones_application/logic/documentation/__init__.py b/src/sampletones_application/logic/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/logic/documentation/shelf.py b/src/sampletones_application/logic/documentation/shelf.py
new file mode 100644
index 000000000..ea414de13
--- /dev/null
+++ b/src/sampletones_application/logic/documentation/shelf.py
@@ -0,0 +1,69 @@
+import os
+import shutil
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.documentation.site import DocumentationSite
+from sampletones_shared.exceptions import DocumentationUnavailableError
+
+HIDDEN_PREFIX: Final[str] = "."
+STAGING_SUFFIX: Final[str] = ".staging"
+RETIRED_SUFFIX: Final[str] = ".retired"
+
+
+class DocumentationShelf:
+ """Keeps a copy of the shipped documentation in the user's data folder, where the browser reads it.
+
+ The shipped pages may live only as long as a run, since a one-file bundle unpacks itself into
+ a temporary folder; the copy outlives the run, so an open page stays readable after the
+ application closes. The copy keeps one address across versions, so a page the reader bookmarked
+ still opens after an update. It carries the stamp of the pages it was copied from, and a
+ different stamp in the installation replaces it whole.
+ """
+
+ def __init__(
+ self,
+ *,
+ site: DocumentationSite,
+ directory: Path,
+ ) -> None:
+ self._site = site
+ self._copy = DocumentationSite(directory)
+
+ def place(self) -> Path:
+ """The index of the copy of the shipped pages, replaced first where it holds other pages.
+
+ The new pages are written beside the copy and swapped in once whole, so the copy is either
+ the earlier pages or the new ones.
+
+ Raises:
+ DocumentationUnavailableError: If the installation carries no sealed site.
+ OSError: If the copy cannot be written.
+ """
+ stamp = self._site.read_stamp()
+ if stamp is None or not self._site.index.is_file():
+ raise DocumentationUnavailableError(f"No documentation site lies in {self._site.directory}")
+
+ if self._copy.read_stamp() != stamp:
+ self._replace()
+
+ return self._copy.index
+
+ def _replace(self) -> None:
+ target = self._copy.directory
+ staging = self._beside(STAGING_SUFFIX)
+ retired = self._beside(RETIRED_SUFFIX)
+ for leftover in (staging, retired):
+ if leftover.exists():
+ shutil.rmtree(leftover)
+
+ shutil.copytree(self._site.directory, staging)
+ if target.exists():
+ os.replace(target, retired)
+
+ os.replace(staging, target)
+ shutil.rmtree(retired, ignore_errors=True)
+
+ def _beside(self, suffix: str) -> Path:
+ target = self._copy.directory
+ return target.with_name(f"{HIDDEN_PREFIX}{target.name}{suffix}")
diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py
index 0ae44e42d..362ab73fd 100644
--- a/src/sampletones_application/paths.py
+++ b/src/sampletones_application/paths.py
@@ -2,7 +2,6 @@
from typing import Final
from sampletones_shared.paths.resources import CONFIG_DIRECTORY
-from sampletones_shared.paths.user import USER_PATH_CONFIG
APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application"
BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior"
@@ -14,4 +13,3 @@
LANG_EN: Final[Path] = LANG_DIRECTORY / "en.yaml"
DEPLOYMENT_CONFIG_PATH: Final[Path] = APPLICATION_DIRECTORY / "deployment.yaml"
-APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml"
diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py
index 7abc0fb12..2ba2153bb 100644
--- a/src/sampletones_application/shell.py
+++ b/src/sampletones_application/shell.py
@@ -112,6 +112,7 @@ class ShortcutBindings:
toggle_auto_expand_favorite_reconstructions: Callback
toggle_auto_expand_favorite_directories: Callback
toggle_fullscreen: Callback
+ documentation: Callback
about: Callback
next_tab: Callback
previous_tab: Callback
@@ -281,6 +282,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback
),
ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories,
ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen,
+ ShortcutId.DOCUMENTATION: bindings.documentation,
ShortcutId.ABOUT_DIALOG: bindings.about,
ShortcutId.NEXT_TAB: bindings.next_tab,
ShortcutId.PREVIOUS_TAB: bindings.previous_tab,
diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py
index 8c56083f9..b2b981788 100644
--- a/src/sampletones_application/tags/general.py
+++ b/src/sampletones_application/tags/general.py
@@ -518,6 +518,12 @@
Widget.DIALOG,
"about",
)
+TAG_GLOBAL_DIALOG_DOCUMENTATION = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.DIALOG,
+ "documentation",
+)
TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py
index 74f737998..02d22a7ec 100644
--- a/src/sampletones_application/ui/menu.py
+++ b/src/sampletones_application/ui/menu.py
@@ -594,6 +594,10 @@ def _create_auto_expand_favorites_menu(self) -> None:
def _create_help_menu(self) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_HELP)):
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.DOCUMENTATION,
+ label=self._label(MenuElements.ITEM_HELP_DOCUMENTATION),
+ )
self._shortcut_manager.add_menu_item(
ShortcutId.ABOUT_DIALOG,
label=self._label(MenuElements.ITEM_HELP_ABOUT),
diff --git a/src/sampletones_application/ui/resources/loader.py b/src/sampletones_application/ui/resources/loader.py
index 327a1fa7c..808e588d4 100644
--- a/src/sampletones_application/ui/resources/loader.py
+++ b/src/sampletones_application/ui/resources/loader.py
@@ -1,11 +1,9 @@
from pathlib import Path
-from typing import Final
from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ASSETS_PACKAGE
from sampletones_shared.types.path import Pathlike
-ASSETS_PACKAGE: Final[str] = "sampletones_assets"
-
class ResourceLoader:
"""Reads the files of one directory of the assets package, where the package lies in every copy."""
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index 5949fa7ab..ef61eb2b5 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Dict, Final, List, Optional, Set
+from typing import Dict, List
from sampletones_application.ui.themes.dpg_constants import (
CATEGORY_MAP,
@@ -16,11 +16,6 @@
ThemeEntryKey,
ThemeItems,
)
-from sampletones_application.ui.themes.spec import (
- ThemeColorEntrySpec,
- ThemeEntrySpec,
- ThemeSpec,
-)
from sampletones_application.ui.themes.style import (
ThemeColor,
ThemeParameter,
@@ -28,37 +23,35 @@
ThemeValue,
)
from sampletones_application.ui.themes.theme import Theme
-from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
from sampletones_application.utils.palette.source import PaletteSource
-from sampletones_shared.paths.extensions import EXT_FILE_YAML
-from sampletones_shared.utils.serialization import load_yaml
-
-_BASE_THEME_NAME: Final[str] = "default"
+from sampletones_application.utils.theme.catalog import ThemeSpecCatalog
+from sampletones_application.utils.theme.spec import (
+ ThemeColorEntrySpec,
+ ThemeEntrySpec,
+ ThemeSpec,
+)
class ThemeLoader:
"""Resolves the theme YAML directory into complete runtime themes.
- Each spec composes on top of its parent (the base theme by default), and every
- enabled-state entry gains a disabled-state counterpart, so a loaded theme fully
- describes both item states and stays authoritative wherever it is bound.
+ The specs come from :class:`ThemeSpecCatalog`, parents first. Each spec composes on top of
+ its parent (the base theme by default), and every enabled-state entry gains a disabled-state
+ counterpart, so a loaded theme fully describes both item states and stays authoritative
+ wherever it is bound.
"""
def __init__(self, theme_directory: Path, palette_source: PaletteSource) -> None:
self._directory = theme_directory
- self._context: Dict[str, PaletteSource] = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
+ self._palette_source = palette_source
def load_all(self) -> List[Theme]:
- specs = self._load_specs()
- name_index = {spec.name: spec for spec in specs}
- self._check_for_cycles(name_index)
- ordered = self._topological_sort(specs, name_index)
-
+ catalog = ThemeSpecCatalog.load(self._directory, self._palette_source)
resolved: Dict[str, ThemeEntries] = {}
themes: List[Theme] = []
- for spec in ordered:
+ for spec in catalog.ordered:
entries = self._spec_to_entries(spec)
- parent = self._effective_parent(spec)
+ parent = spec.parent
merged = {**resolved[parent], **entries} if parent is not None else entries
resolved[spec.name] = merged
items = self._entries_to_items(self._mirror_disabled_entries(merged))
@@ -66,36 +59,6 @@ def load_all(self) -> List[Theme]:
return themes
- def _load_specs(self) -> List[ThemeSpec]:
- specs: List[ThemeSpec] = []
- for path in sorted(self._directory.rglob(f"*{EXT_FILE_YAML}")):
- raw = load_yaml(path)
- if not isinstance(raw, dict):
- raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}")
-
- specs.append(ThemeSpec.model_validate(raw, context=self._context))
-
- return specs
-
- @staticmethod
- def _effective_parent(spec: ThemeSpec) -> Optional[str]:
- """The theme a spec composes on top of.
-
- A spec inherits from its explicit ``extends`` when given, and otherwise from
- the base theme, so every theme carries the shared base's complete component
- set and states only its own overrides. The base theme itself stands alone.
- This makes inheritance the default: a bound item theme always resolves to
- the global base plus its overrides, so every color it omits keeps the
- base's value.
- """
- if spec.extends is not None:
- return spec.extends
-
- if spec.name != _BASE_THEME_NAME:
- return _BASE_THEME_NAME
-
- return None
-
@staticmethod
def _resolve_color_key(key: str, category: str) -> int:
color_map = PLOTS_COLOR_MAP if category == "Plots" else CORE_COLOR_MAP
@@ -238,46 +201,3 @@ def _entries_to_items(entries: ThemeEntries) -> ThemeItems:
ordered = sorted(grouped, key=lambda parameter: parameter.item_type != EVERY_ITEM_TYPE)
return ThemeItems(items={parameter: grouped[parameter] for parameter in ordered})
-
- @classmethod
- def _check_for_cycles(cls, name_index: Dict[str, ThemeSpec]) -> None:
- for name in name_index:
- visited: Set[str] = set()
- current: Optional[str] = name
- while current is not None:
- if current in visited:
- raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}")
-
- visited.add(current)
- spec = name_index.get(current)
- if spec is None:
- raise ValueError(
- f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(name_index)}"
- )
-
- current = cls._effective_parent(spec)
-
- @classmethod
- def _topological_sort(
- cls,
- specs: List[ThemeSpec],
- name_index: Dict[str, ThemeSpec],
- ) -> List[ThemeSpec]:
- ordered: List[ThemeSpec] = []
- visited: Set[str] = set()
-
- def visit(spec: ThemeSpec) -> None:
- if spec.name in visited:
- return
-
- parent = cls._effective_parent(spec)
- if parent is not None:
- visit(name_index[parent])
-
- visited.add(spec.name)
- ordered.append(spec)
-
- for spec in specs:
- visit(spec)
-
- return ordered
diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py
deleted file mode 100644
index 3eeb63711..000000000
--- a/src/sampletones_application/ui/themes/spec.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from __future__ import annotations
-
-from typing import Annotated, List, Literal, Optional, Union
-
-from pydantic import BaseModel, Field
-
-from sampletones_application.utils.palette.colors.written import WrittenColor
-
-
-class ThemeColorEntrySpec(BaseModel, frozen=True):
- type: Literal["color"]
- key: str
- value: WrittenColor
- category: str = "Core"
-
-
-class ThemeStyleEntrySpec(BaseModel, frozen=True):
- type: Literal["style"]
- key: str
- x: float
- y: float = -1.0
- category: str = "Core"
-
-
-ThemeEntrySpec = Annotated[
- Union[ThemeColorEntrySpec, ThemeStyleEntrySpec],
- Field(discriminator="type"),
-]
-
-
-class ThemeComponentSpec(BaseModel, frozen=True):
- item_type: str
- enabled: bool = True
- entries: List[ThemeEntrySpec]
-
-
-class ThemeSpec(BaseModel, frozen=True):
- name: str
- tag: str
- extends: Optional[str] = None
- components: List[ThemeComponentSpec]
diff --git a/src/sampletones_application/utils/file_dialogs/backends/command.py b/src/sampletones_application/utils/file_dialogs/backends/command.py
index 96cd0da8e..2c04ff3df 100644
--- a/src/sampletones_application/utils/file_dialogs/backends/command.py
+++ b/src/sampletones_application/utils/file_dialogs/backends/command.py
@@ -3,6 +3,7 @@
from typing import List, Optional
from sampletones_shared.utils.system.paths import normalize_path
+from sampletones_shared.utils.system.programs import program_environment
def run_dialog_command(command: List[str]) -> Optional[Path]:
@@ -24,5 +25,6 @@ def run_dialog_command(command: List[str]) -> Optional[Path]:
capture_output=True,
text=True,
check=False,
+ env=program_environment(),
)
return normalize_path(result.stdout.strip())
diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py
index 52aa7003d..c5831dc7a 100644
--- a/src/sampletones_application/utils/gui/shortcuts/ids.py
+++ b/src/sampletones_application/utils/gui/shortcuts/ids.py
@@ -100,6 +100,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
ShortcutCategory.APPLICATION,
)
TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION)
+ DOCUMENTATION = ("Documentation", ShortcutCategory.APPLICATION)
ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION)
NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION)
PREVIOUS_TAB = ("PreviousTab", ShortcutCategory.APPLICATION)
diff --git a/src/sampletones_application/utils/theme/__init__.py b/src/sampletones_application/utils/theme/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/utils/theme/catalog.py b/src/sampletones_application/utils/theme/catalog.py
new file mode 100644
index 000000000..9d3719f38
--- /dev/null
+++ b/src/sampletones_application/utils/theme/catalog.py
@@ -0,0 +1,198 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Dict, Iterator, List, Optional, Set, Tuple
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.utils.theme.spec import (
+ EVERY_ITEM_TYPE_NAME,
+ ThemeColorEntrySpec,
+ ThemeEntrySpec,
+ ThemeSpec,
+ ThemeStyleEntrySpec,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+from sampletones_shared.utils.serialization import load_yaml
+
+
+@dataclass(frozen=True)
+class ThemeEntryAddress:
+ """Where one entry of a theme sits: the item type it styles, the state it applies in, its
+ category and its key, all as the theme files spell them."""
+
+ item_type: str
+ enabled: bool
+ category: str
+ key: str
+
+
+ThemeEntries = Dict[ThemeEntryAddress, ThemeEntrySpec]
+
+
+class ThemeSpecCatalog:
+ """Every theme the configuration states, each with the entries it wears once composed.
+
+ A theme composes on top of its parent (:attr:`ThemeSpec.parent`), so a theme's entries are
+ its parent's with its own laid over them, and the specs are kept parents first. A lookup
+ answers what an item of a given type and state draws with: the entry its own component
+ states, the enabled state's entry where the disabled state states none, and the component
+ naming every item type where the item's own type states nothing. That is how DearPyGui
+ resolves a bound theme, so a reader of this catalog sees the value the application draws.
+ """
+
+ def __init__(self, specs: List[ThemeSpec]) -> None:
+ self._index = {spec.name: spec for spec in specs}
+ self._check_parents()
+ self._ordered = self._parents_first(specs)
+ self._entries = self._compose()
+
+ @classmethod
+ def load(
+ cls,
+ directory: Path,
+ palette_source: PaletteSource,
+ ) -> ThemeSpecCatalog:
+ """Every theme file under ``directory``, its colors bound to ``palette_source``.
+
+ Raises:
+ TypeError: If a theme file holds something other than a mapping.
+ ValueError: If a theme extends an unknown theme or the inheritance forms a cycle.
+ """
+ context = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
+ specs: List[ThemeSpec] = []
+ for path in sorted(directory.rglob(f"*{EXT_FILE_YAML}")):
+ raw = load_yaml(path)
+ if not isinstance(raw, dict):
+ raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}")
+
+ specs.append(ThemeSpec.model_validate(raw, context=context))
+
+ return cls(specs)
+
+ @property
+ def ordered(self) -> List[ThemeSpec]:
+ """Every theme, each after the theme it composes on."""
+ return list(self._ordered)
+
+ def entries(self, name: str) -> ThemeEntries:
+ """The entries theme ``name`` wears: its parent's, with its own laid over them.
+
+ Raises:
+ KeyError: If no theme carries that name.
+ """
+ return dict(self._entries[name])
+
+ def color(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> BaseColor:
+ """The color theme ``name`` gives the entry at ``address``.
+
+ Raises:
+ KeyError: If the theme states no color there, for that item type or every item type.
+ """
+ entry = self._resolve(name, address)
+ if not isinstance(entry, ThemeColorEntrySpec):
+ raise KeyError(f"Theme {name!r} states a style, not a color, at {address}")
+
+ return entry.value
+
+ def style(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> Tuple[float, float]:
+ """The style value theme ``name`` gives the entry at ``address``, as its two components.
+
+ Raises:
+ KeyError: If the theme states no style there, for that item type or every item type.
+ """
+ entry = self._resolve(name, address)
+ if not isinstance(entry, ThemeStyleEntrySpec):
+ raise KeyError(f"Theme {name!r} states a color, not a style, at {address}")
+
+ return entry.x, entry.y
+
+ def _resolve(
+ self,
+ name: str,
+ address: ThemeEntryAddress,
+ ) -> ThemeEntrySpec:
+ entries = self._entries[name]
+ for candidate in self._candidates(address):
+ if candidate in entries:
+ return entries[candidate]
+
+ raise KeyError(f"Theme {name!r} states nothing at {address}")
+
+ @staticmethod
+ def _candidates(address: ThemeEntryAddress) -> Iterator[ThemeEntryAddress]:
+ for item_type in dict.fromkeys((address.item_type, EVERY_ITEM_TYPE_NAME)):
+ yield replace(address, item_type=item_type)
+ if not address.enabled:
+ yield replace(address, item_type=item_type, enabled=True)
+
+ def _compose(self) -> Dict[str, ThemeEntries]:
+ composed: Dict[str, ThemeEntries] = {}
+ for spec in self._ordered:
+ own = self._own_entries(spec)
+ parent = spec.parent
+ composed[spec.name] = {**composed[parent], **own} if parent is not None else own
+
+ return composed
+
+ @staticmethod
+ def _own_entries(spec: ThemeSpec) -> ThemeEntries:
+ entries: ThemeEntries = {}
+ for component in spec.components:
+ for entry in component.entries:
+ address = ThemeEntryAddress(
+ item_type=component.item_type,
+ enabled=component.enabled,
+ category=entry.category,
+ key=entry.key,
+ )
+ entries[address] = entry
+
+ return entries
+
+ def _check_parents(self) -> None:
+ for name in self._index:
+ visited: Set[str] = set()
+ current: Optional[str] = name
+ while current is not None:
+ if current in visited:
+ raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}")
+
+ visited.add(current)
+ spec = self._index.get(current)
+ if spec is None:
+ raise ValueError(
+ f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(self._index)}"
+ )
+
+ current = spec.parent
+
+ def _parents_first(self, specs: List[ThemeSpec]) -> List[ThemeSpec]:
+ ordered: List[ThemeSpec] = []
+ visited: Set[str] = set()
+
+ def visit(spec: ThemeSpec) -> None:
+ if spec.name in visited:
+ return
+
+ parent = spec.parent
+ if parent is not None:
+ visit(self._index[parent])
+
+ visited.add(spec.name)
+ ordered.append(spec)
+
+ for spec in specs:
+ visit(spec)
+
+ return ordered
diff --git a/src/sampletones_application/utils/theme/spec.py b/src/sampletones_application/utils/theme/spec.py
new file mode 100644
index 000000000..7421f2ff8
--- /dev/null
+++ b/src/sampletones_application/utils/theme/spec.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from typing import Annotated, Final, List, Literal, Optional, Union
+
+from pydantic import BaseModel, Field
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+BASE_THEME_NAME: Final[str] = "default"
+EVERY_ITEM_TYPE_NAME: Final[str] = "All"
+CORE_CATEGORY: Final[str] = "Core"
+
+
+class ThemeColorEntrySpec(BaseModel, frozen=True):
+ type: Literal["color"]
+ key: str
+ value: WrittenColor
+ category: str = CORE_CATEGORY
+
+
+class ThemeStyleEntrySpec(BaseModel, frozen=True):
+ type: Literal["style"]
+ key: str
+ x: float
+ y: float = -1.0
+ category: str = CORE_CATEGORY
+
+
+ThemeEntrySpec = Annotated[
+ Union[ThemeColorEntrySpec, ThemeStyleEntrySpec],
+ Field(discriminator="type"),
+]
+
+
+class ThemeComponentSpec(BaseModel, frozen=True):
+ item_type: str
+ enabled: bool = True
+ entries: List[ThemeEntrySpec]
+
+
+class ThemeSpec(BaseModel, frozen=True):
+ name: str
+ tag: str
+ extends: Optional[str] = None
+ components: List[ThemeComponentSpec]
+
+ @property
+ def parent(self) -> Optional[str]:
+ """The theme this one composes on top of.
+
+ A theme inherits from its explicit ``extends`` when given, and otherwise from the base
+ theme, so every theme carries the shared base's complete component set and states only
+ its own overrides. The base theme itself stands alone. This makes inheritance the
+ default: a bound item theme always resolves to the global base plus its overrides, so
+ every color it omits keeps the base's value.
+ """
+ if self.extends is not None:
+ return self.extends
+
+ if self.name != BASE_THEME_NAME:
+ return BASE_THEME_NAME
+
+ return None
diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml
index 22be58d6f..27b1f67a3 100644
--- a/src/sampletones_config/keybindings/default.yaml
+++ b/src/sampletones_config/keybindings/default.yaml
@@ -64,6 +64,7 @@ bindings:
ToggleFullscreen: {combination: "F11"}
ToggleAutoExpandFavoriteReconstructions: {combination: ~}
ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ Documentation: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Ctrl+PgDn", field_transparent: true}
PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true}
diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml
index 0adfb26c1..2fdc2e41a 100644
--- a/src/sampletones_config/keybindings/macos.yaml
+++ b/src/sampletones_config/keybindings/macos.yaml
@@ -64,6 +64,7 @@ bindings:
ToggleFullscreen: {combination: "Cmd+Ctrl+F"}
ToggleAutoExpandFavoriteReconstructions: {combination: ~}
ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ Documentation: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true}
PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true}
diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml
index b38e20500..27a6f7690 100644
--- a/src/sampletones_config/lang/en.yaml
+++ b/src/sampletones_config/lang/en.yaml
@@ -48,6 +48,7 @@ global.dialog.title.remove_voice: "Remove voice"
global.dialog.title.change_nes_frequency: "Change NES frequency"
global.dialog.title.frequency_mismatch: "Different NES frequency"
global.dialog.title.about: "About"
+global.dialog.title.documentation: "Documentation"
# Global — Dialog file filters
global.dialog.filter.project: "Project files"
@@ -109,6 +110,7 @@ global.dialog.message.audio_playback_error: "Audio playback error"
global.dialog.message.unexpected_failure: "Something went wrong, and the last action did not finish."
global.dialog.message.no_project_open: "No project is open. Open or create a project to add voices."
global.dialog.message.no_audio_output: "Can't play sound: no audio output is available."
+global.dialog.message.documentation_not_opened: "Can't open the documentation."
global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?"
global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?"
global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?"
@@ -117,6 +119,7 @@ global.dialog.message.about_description: "An application for approximating audio
# Global — Dialog templates
global.dialog.template.time_estimation: " (ETA: {eta_string})"
+global.dialog.template.documentation_unavailable: "This installation came without its documentation. You can read it online at {address}"
global.dialog.template.configuration_recovery_intro: "Your configuration was updated from {source} to version {target}."
global.dialog.template.fps: "{fps:.0f} FPS"
global.dialog.template.about_author: "by {author} / {group}"
@@ -254,6 +257,7 @@ global.menu.label.item_view_auto_expand_favorite_directories: "Directories"
global.menu.label.item_view_display_settings: "Display settings..."
global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..."
global.menu.label.group_help: "Help"
+global.menu.label.item_help_documentation: "Documentation"
global.menu.label.item_help_about: "About"
global.menu.label.tab_main: "Main"
global.menu.label.tab_instructions: "Instructions"
@@ -1016,6 +1020,7 @@ settings.keybindings.label.toggle_advanced_settings: "Advanced settings"
settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions"
settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories"
settings.keybindings.label.toggle_fullscreen: "Fullscreen"
+settings.keybindings.label.documentation: "Documentation"
settings.keybindings.label.about_dialog: "About"
settings.keybindings.label.next_tab: "Next tab"
settings.keybindings.label.previous_tab: "Previous tab"
diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py
index 9f43e9d45..f869f7fd0 100644
--- a/src/sampletones_core/data/metadata.py
+++ b/src/sampletones_core/data/metadata.py
@@ -9,9 +9,9 @@
SAMPLETONES_LIBRARY_DATA_VERSION,
SAMPLETONES_NAME,
SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
- SAMPLETONES_VERSION,
)
from sampletones_shared.deployment.version import compare_versions
+from sampletones_shared.distribution import SAMPLETONES_VERSION
from sampletones_shared.exceptions import InvalidMetadataError
from sampletones_shared.exceptions.version import IncompatibleVersionError
diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py
index 503597228..a2be681c8 100644
--- a/src/sampletones_shared/application.py
+++ b/src/sampletones_shared/application.py
@@ -1,16 +1,13 @@
-from importlib import metadata
from typing import Final
SAMPLETONES_NAME: Final[str] = "SampleToNES"
SAMPLETONES_PACKAGE_NAME: Final[str] = "sampletones"
SAMPLETONES_ENV_PREFIX: Final[str] = "SAMPLETONES_"
-SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME)
SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.1"
SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2"
SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1"
-SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}"
SAMPLETONES_AUTHOR: Final[str] = "Jakim"
SAMPLETONES_GROUP: Final[str] = "Stage Magician"
diff --git a/src/sampletones_shared/distribution.py b/src/sampletones_shared/distribution.py
new file mode 100644
index 000000000..e4ff0e906
--- /dev/null
+++ b/src/sampletones_shared/distribution.py
@@ -0,0 +1,27 @@
+from importlib import metadata
+from typing import Final
+
+from sampletones_shared.application import SAMPLETONES_NAME, SAMPLETONES_PACKAGE_NAME
+
+PROJECT_URL_FIELD: Final[str] = "Project-URL"
+PROJECT_URL_SEPARATOR: Final[str] = ","
+DOCUMENTATION_LABEL: Final[str] = "Documentation"
+
+
+def project_address(label: str) -> str:
+ """The address the installed distribution lists under ``label``, as ``[project.urls]`` names it.
+
+ Raises:
+ KeyError: If the distribution lists no address under that label.
+ """
+ for entry in metadata.metadata(SAMPLETONES_PACKAGE_NAME).get_all(PROJECT_URL_FIELD) or []:
+ name, _, address = str(entry).partition(PROJECT_URL_SEPARATOR)
+ if name.strip() == label:
+ return address.strip()
+
+ raise KeyError(f"The {SAMPLETONES_PACKAGE_NAME} distribution lists no {label} address")
+
+
+SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME)
+SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}"
+SAMPLETONES_DOCUMENTATION_ADDRESS: Final[str] = project_address(DOCUMENTATION_LABEL)
diff --git a/src/sampletones_shared/documentation/__init__.py b/src/sampletones_shared/documentation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_shared/documentation/site.py b/src/sampletones_shared/documentation/site.py
new file mode 100644
index 000000000..5f4fd797e
--- /dev/null
+++ b/src/sampletones_shared/documentation/site.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, Optional
+
+from sampletones_shared.paths.package import package_directory
+from sampletones_shared.paths.resources import ASSETS_PACKAGE
+from sampletones_shared.utils.hashing import calculate_hash
+
+DOCUMENTATION_DIRECTORY: Final[str] = "documentation"
+INDEX_FILE: Final[str] = "index.html"
+STAMP_FILE: Final[str] = "stamp"
+FULL_DIGEST: Final[int] = 64
+ENTRY_SEPARATOR: Final[str] = "\n"
+
+
+@dataclass(frozen=True)
+class DocumentationSite:
+ """The HTML documentation as a folder of pages, and the stamp that names its contents.
+
+ The maker writes the stamp last, from the files beside it, so a site whose stamp matches its
+ contents was written whole. A copy of the site keyed by its stamp is a copy of these exact pages.
+ """
+
+ directory: Path
+
+ @classmethod
+ def shipped(cls) -> DocumentationSite:
+ """The site that ships with the application, inside its assets package."""
+ return cls(package_directory(ASSETS_PACKAGE) / DOCUMENTATION_DIRECTORY)
+
+ @property
+ def index(self) -> Path:
+ return self.directory / INDEX_FILE
+
+ @property
+ def stamp_path(self) -> Path:
+ return self.directory / STAMP_FILE
+
+ def read_stamp(self) -> Optional[str]:
+ """The stamp the site was written with, or ``None`` where no complete site lies."""
+ if not self.stamp_path.is_file():
+ return None
+
+ return self.stamp_path.read_text(encoding="utf-8").strip()
+
+ def content_stamp(self) -> str:
+ """The stamp the site's files earn now: a hash over every file but the stamp, by path and contents."""
+ entries = sorted(
+ f"{path.relative_to(self.directory).as_posix()}:{calculate_hash(path.read_bytes(), length=FULL_DIGEST)}"
+ for path in self.directory.rglob("*")
+ if path.is_file() and path != self.stamp_path
+ )
+ return calculate_hash(ENTRY_SEPARATOR.join(entries))
+
+ def write_stamp(self) -> str:
+ """Seal the site: write the stamp its files earn, and return it."""
+ stamp = self.content_stamp()
+ self.stamp_path.write_text(stamp + ENTRY_SEPARATOR, encoding="utf-8", newline=ENTRY_SEPARATOR)
+ return stamp
diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py
index 6864d640c..d34d0764b 100644
--- a/src/sampletones_shared/exceptions/__init__.py
+++ b/src/sampletones_shared/exceptions/__init__.py
@@ -8,6 +8,7 @@
from .callback import CallbackQueueStop
from .cuda import CuPyNotInstalledWarning, GPUBackendError
from .dialog import FileDialogUnavailableError
+from .documentation import DocumentationUnavailableError
from .instrument import (
IncompatibleInstrumentVersionError,
InstrumentError,
@@ -29,6 +30,7 @@
NoLibraryDataError,
UnhandledLibraryError,
)
+from .opening import FileNotOpenedError
from .operation import OperationCanceled
from .player import (
DriverBuildError,
@@ -69,8 +71,10 @@
"CallbackQueueStop",
"CuPyNotInstalledWarning",
"DeserializationError",
+ "DocumentationUnavailableError",
"DriverBuildError",
"FileDialogUnavailableError",
+ "FileNotOpenedError",
"GPUBackendError",
"IncompatibleInstrumentVersionError",
"IncompatibleLibraryDataVersionError",
diff --git a/src/sampletones_shared/exceptions/documentation.py b/src/sampletones_shared/exceptions/documentation.py
new file mode 100644
index 000000000..b62e5130b
--- /dev/null
+++ b/src/sampletones_shared/exceptions/documentation.py
@@ -0,0 +1,5 @@
+from .base import SampleToNESError
+
+
+class DocumentationUnavailableError(SampleToNESError):
+ """Raised when the installation carries no complete documentation site to open."""
diff --git a/src/sampletones_shared/exceptions/opening.py b/src/sampletones_shared/exceptions/opening.py
new file mode 100644
index 000000000..9fc13005b
--- /dev/null
+++ b/src/sampletones_shared/exceptions/opening.py
@@ -0,0 +1,5 @@
+from .base import SampleToNESError
+
+
+class FileNotOpenedError(SampleToNESError, OSError):
+ """Raised when no program on the system takes a file to show."""
diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py
index 2e415f2ff..00faa138e 100644
--- a/src/sampletones_shared/paths/resources.py
+++ b/src/sampletones_shared/paths/resources.py
@@ -3,6 +3,7 @@
from sampletones_shared.paths.package import package_directory
+ASSETS_PACKAGE: Final[str] = "sampletones_assets"
CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_config")
ICON_DIRECTORY: Final[str] = "icons"
diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py
index 6a34673d3..1d5b6164d 100644
--- a/src/sampletones_shared/paths/user.py
+++ b/src/sampletones_shared/paths/user.py
@@ -17,6 +17,8 @@
PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
+APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml"
+DOCUMENTATION_COPIES_DIRECTORY: Final[Path] = USER_PATH_DATA / "documentation"
PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_shared/utils/system/opening/__init__.py b/src/sampletones_shared/utils/system/opening/__init__.py
new file mode 100644
index 000000000..9fa1e847e
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/__init__.py
@@ -0,0 +1,7 @@
+from .protocol import FileOpener
+from .selection import select_file_opener
+
+__all__ = [
+ "FileOpener",
+ "select_file_opener",
+]
diff --git a/src/sampletones_shared/utils/system/opening/browser.py b/src/sampletones_shared/utils/system/opening/browser.py
new file mode 100644
index 000000000..ef9ca8478
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/browser.py
@@ -0,0 +1,16 @@
+import webbrowser
+from pathlib import Path
+
+from sampletones_shared.exceptions import FileNotOpenedError
+
+
+class BrowserFileOpener:
+ """Shows a file through the standard library's ``webbrowser``, as a ``file://`` link.
+
+ ``webbrowser`` reaches the system's own handler on Windows and macOS and the browsers it finds
+ elsewhere, so it serves every system ``xdg-open`` is missing from.
+ """
+
+ def open(self, path: Path) -> None:
+ if not webbrowser.open(path.resolve().as_uri()):
+ raise FileNotOpenedError(f"No browser took {path}")
diff --git a/src/sampletones_shared/utils/system/opening/protocol.py b/src/sampletones_shared/utils/system/opening/protocol.py
new file mode 100644
index 000000000..dc2f4826b
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/protocol.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+from typing import Protocol
+
+
+class FileOpener(Protocol):
+ """The surface that shows a file in the program the system gives its kind.
+
+ Callers hand a file over through this type, a page to the browser among them, and read alike
+ on every system.
+ """
+
+ def open(self, path: Path) -> None:
+ """Shows ``path`` in the program the system gives its kind.
+
+ Raises:
+ OSError: If no program takes the file.
+ """
diff --git a/src/sampletones_shared/utils/system/opening/selection.py b/src/sampletones_shared/utils/system/opening/selection.py
new file mode 100644
index 000000000..400ece143
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/selection.py
@@ -0,0 +1,20 @@
+import shutil
+
+from sampletones_shared.utils.system.system import System
+
+from .browser import BrowserFileOpener
+from .protocol import FileOpener
+from .xdg_open import XDG_OPEN, XdgOpenFileOpener
+
+
+def select_file_opener() -> FileOpener:
+ """The file opener that fits the running system.
+
+ A Linux desktop with ``xdg-open`` opens a file through it, which hands the file to the
+ desktop's own program for its kind; every other system opens it through the standard library's
+ browser support.
+ """
+ if System.current() == System.LINUX and shutil.which(XDG_OPEN) is not None:
+ return XdgOpenFileOpener()
+
+ return BrowserFileOpener()
diff --git a/src/sampletones_shared/utils/system/opening/xdg_open.py b/src/sampletones_shared/utils/system/opening/xdg_open.py
new file mode 100644
index 000000000..e88e9e19f
--- /dev/null
+++ b/src/sampletones_shared/utils/system/opening/xdg_open.py
@@ -0,0 +1,26 @@
+import subprocess
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.utils.system.programs import program_environment
+
+XDG_OPEN: Final[str] = "xdg-open"
+
+
+class XdgOpenFileOpener:
+ """Shows a file through ``xdg-open``, which hands it to the desktop's program for its kind.
+
+ ``xdg-open`` starts in a session of its own, so the program it starts outlives the
+ application, and it starts with the reader's own library path, so a browser started from a
+ bundle loads the system's libraries.
+ """
+
+ def open(self, path: Path) -> None:
+ subprocess.Popen( # pylint: disable=consider-using-with
+ [XDG_OPEN, str(path)],
+ env=program_environment(),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py
index dc4c3b3e1..7dd75b6ea 100644
--- a/src/sampletones_shared/utils/system/paths.py
+++ b/src/sampletones_shared/utils/system/paths.py
@@ -5,6 +5,7 @@
from sampletones_shared.types.path import GeneralPathlike, Pathlike
+from .programs import program_environment
from .system import System
DEFAULT_MAX_FILENAME_DISPLAY: Final[int] = 48
@@ -292,7 +293,7 @@ def open_directory_in_explorer_linux(path: Path) -> None:
path (Path): The directory or file path to open.
"""
path = path if path.is_dir() else path.parent
- subprocess.run(["xdg-open", str(path)], check=False)
+ subprocess.run(["xdg-open", str(path)], check=False, env=program_environment())
def open_file_in_explorer_linux(path: Path) -> None:
@@ -312,6 +313,7 @@ def open_file_in_explorer_linux(path: Path) -> None:
capture_output=True,
text=True,
check=False,
+ env=program_environment(),
)
if xdg_mime_result.returncode != 0:
@@ -330,7 +332,7 @@ def open_file_in_explorer_linux(path: Path) -> None:
command = file_manager_commands.get(desktop_file)
if command:
- result = subprocess.run(command, check=False, capture_output=True)
+ result = subprocess.run(command, check=False, capture_output=True, env=program_environment())
if result.returncode != 0:
open_directory_in_explorer_linux(path)
else:
@@ -363,14 +365,14 @@ def open_path_in_explorer(path: Pathlike) -> None:
match system:
case System.WINDOWS:
select = "/select," if path.is_file() else ""
- subprocess.run(["explorer", select, path_string], check=False)
+ subprocess.run(["explorer", select, path_string], check=False, env=program_environment())
case System.LINUX:
if path.is_file():
open_file_in_explorer_linux(path)
else:
- subprocess.run(["xdg-open", path_string], check=False)
+ subprocess.run(["xdg-open", path_string], check=False, env=program_environment())
case System.MACOS:
select = "-R" if path.is_file() else ""
- subprocess.run(["open", select, path_string], check=False)
+ subprocess.run(["open", select, path_string], check=False, env=program_environment())
case _:
raise OSError(f"Unsupported operating system: {system}")
diff --git a/src/sampletones_shared/utils/system/programs.py b/src/sampletones_shared/utils/system/programs.py
index 52e39136c..7a058dc7c 100644
--- a/src/sampletones_shared/utils/system/programs.py
+++ b/src/sampletones_shared/utils/system/programs.py
@@ -1,9 +1,14 @@
+import os
import shutil
+import sys
from pathlib import Path
-from typing import Mapping, Optional
+from typing import Dict, Final, Mapping, Optional
from .system import System
+LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH"
+ORIGINAL_LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH_ORIG"
+
def locate_program(program: str) -> Optional[Path]:
"""Where a program is installed, found the way a shell finds it.
@@ -44,3 +49,42 @@ def missing_program_message(
KeyError: If no hint is stated for this system.
"""
return f"{program} is missing: {purpose} ({hints[System.current()]})"
+
+
+def running_frozen() -> bool:
+ """Whether the application runs from a PyInstaller bundle, which marks ``sys`` as frozen."""
+ return bool(getattr(sys, "frozen", False))
+
+
+def reader_environment(
+ environment: Mapping[str, str],
+ *,
+ frozen: bool,
+) -> Dict[str, str]:
+ """The variables ``environment`` holds, with the library path the reader's own session set.
+
+ A PyInstaller bundle points ``LD_LIBRARY_PATH`` at the libraries it carries and keeps the
+ reader's own value in ``LD_LIBRARY_PATH_ORIG``. A frozen run therefore answers the reader's
+ value, or no library path where the reader set none, and any other run answers ``environment``
+ as it stands.
+ """
+ variables = dict(environment)
+ if not frozen:
+ return variables
+
+ original = variables.pop(ORIGINAL_LIBRARY_PATH, None)
+ if original is None:
+ variables.pop(LIBRARY_PATH, None)
+ else:
+ variables[LIBRARY_PATH] = original
+
+ return variables
+
+
+def program_environment() -> Dict[str, str]:
+ """The variables a program of the system starts with: this process's, with the reader's library path.
+
+ A file manager, a browser or a dialog tool started with them loads the system's libraries, in a
+ bundle as in an installed copy.
+ """
+ return reader_environment(os.environ, frozen=running_frozen())
diff --git a/src/sampletones_tools/calibration/board/browser.py b/src/sampletones_tools/calibration/board/browser.py
index 644b60b25..4139f6419 100644
--- a/src/sampletones_tools/calibration/board/browser.py
+++ b/src/sampletones_tools/calibration/board/browser.py
@@ -1,7 +1,7 @@
-import webbrowser
from pathlib import Path
from sampletones_shared.logger import logger
+from sampletones_shared.utils.system.opening import select_file_opener
def open_page(page: Path) -> bool:
@@ -17,8 +17,10 @@ def open_page(page: Path) -> bool:
Returns:
bool: Whether a browser took the page.
"""
- opened = webbrowser.open(page.resolve().as_uri())
- if not opened:
+ try:
+ select_file_opener().open(page.resolve())
+ except OSError:
logger.info("No browser answered; open the page from the link above")
+ return False
- return opened
+ return True
diff --git a/src/sampletones_tools/calibration/board/composition.py b/src/sampletones_tools/calibration/board/composition.py
index 298a3ba9b..a2bb804dd 100644
--- a/src/sampletones_tools/calibration/board/composition.py
+++ b/src/sampletones_tools/calibration/board/composition.py
@@ -6,10 +6,10 @@
from sampletones_shared.utils.system.paths import get_filename
from sampletones_tools.calibration.layout import RECORDINGS_DIRECTORY, combination_name
from sampletones_tools.calibration.renders import RenderRecord, item_directory, variant_directory
+from sampletones_tools.web.roles.table import channel_role
from .clips import ClipStore
from .model import BoardCell, BoardChannel, BoardGroup, BoardPage, BoardRow
-from .palette import channel_token
from .reading import RunReading
NO_CLIP: Final[str] = ""
@@ -19,6 +19,7 @@ def compose_page(
runs: Sequence[RunReading],
store: ClipStore,
title: str,
+ glyph: str,
) -> BoardPage:
"""
Assemble everything a page draws from the runs it reports.
@@ -31,6 +32,7 @@ def compose_page(
runs: The runs the page reports, in the order the page holds them.
store: Where the page finds each clip it plays.
title: The heading the page carries.
+ glyph: The purpose glyph each table's card header carries.
Returns:
The page, ready to be written.
@@ -45,7 +47,8 @@ def compose_page(
return BoardPage(
title=title,
referee=runs[0].referee,
- colors={channel.value: channel_token(channel.value) for channel in ChannelName},
+ glyph=glyph,
+ colors={channel.value: channel_role(channel.value) for channel in ChannelName},
groups=groups,
)
diff --git a/src/sampletones_tools/calibration/board/fonts.py b/src/sampletones_tools/calibration/board/fonts.py
deleted file mode 100644
index c6cb82861..000000000
--- a/src/sampletones_tools/calibration/board/fonts.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from base64 import b64encode
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Final, Tuple
-
-from sampletones_shared.paths.resources import (
- FONT_MONO_REGULAR,
- FONT_SANS_BOLD,
- FONT_SANS_REGULAR,
-)
-
-from .paths import FONTS_DIRECTORY
-
-SANS_FAMILY: Final[str] = "SampleToNES Sans"
-MONO_FAMILY: Final[str] = "SampleToNES Mono"
-
-REGULAR_WEIGHT: Final[int] = 400
-BOLD_WEIGHT: Final[int] = 600
-
-FONT_MEDIA_TYPE: Final[str] = "font/ttf"
-
-
-@dataclass(frozen=True)
-class PageFont:
- """One face the page draws with, and the file it is read from.
-
- Attributes:
- family: The family name the stylesheet asks for.
- weight: The weight the face answers at.
- filename: The face's file in the assets package.
- """
-
- family: str
- weight: int
- filename: str
-
-
-PAGE_FONTS: Final[Tuple[PageFont, ...]] = (
- PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_SANS_REGULAR),
- PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, filename=FONT_SANS_BOLD),
- PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_MONO_REGULAR),
-)
-
-
-def fonts_stylesheet(directory: Path = FONTS_DIRECTORY) -> str:
- """The faces of the application, embedded so the page draws with them wherever it is opened.
-
- A page opened from a file has an origin of its own, and a face carried inside the stylesheet
- reaches it there as surely as it does over the web.
-
- Args:
- directory: The directory the face files are read from.
-
- Returns:
- The stylesheet, one ``@font-face`` rule per face.
-
- Raises:
- FileNotFoundError: If the directory holds no file of a face's name.
- """
- return "\n".join(_face(font, directory) for font in PAGE_FONTS) + "\n"
-
-
-def _face(font: PageFont, directory: Path) -> str:
- encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii")
- return (
- "@font-face {\n"
- f' font-family: "{font.family}";\n'
- f" font-weight: {font.weight};\n"
- " font-style: normal;\n"
- " font-display: block;\n"
- f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");\n'
- "}"
- )
diff --git a/src/sampletones_tools/calibration/board/layout.py b/src/sampletones_tools/calibration/board/layout.py
index e0bb3d52f..baf340c8c 100644
--- a/src/sampletones_tools/calibration/board/layout.py
+++ b/src/sampletones_tools/calibration/board/layout.py
@@ -3,12 +3,10 @@
PAGE_FILE: Final[str] = "index.html"
STYLE_FILE: Final[str] = "style.css"
SCRIPT_FILE: Final[str] = "page.js"
-PALETTE_FILE: Final[str] = "palette.css"
-FONTS_FILE: Final[str] = "fonts.css"
DATA_FILE: Final[str] = "data.js"
PAGE_DIRECTORY: Final[str] = "page"
AUDIO_DIRECTORY: Final[str] = "audio"
SHIPPED_FILES: Final[Tuple[str, ...]] = (PAGE_FILE, STYLE_FILE, SCRIPT_FILE)
-GENERATED_FILES: Final[Tuple[str, ...]] = (PALETTE_FILE, FONTS_FILE, DATA_FILE)
+GENERATED_FILES: Final[Tuple[str, ...]] = (DATA_FILE,)
diff --git a/src/sampletones_tools/calibration/board/model.py b/src/sampletones_tools/calibration/board/model.py
index b8387e5b1..7e82665c8 100644
--- a/src/sampletones_tools/calibration/board/model.py
+++ b/src/sampletones_tools/calibration/board/model.py
@@ -79,7 +79,8 @@ class BoardPage(BaseModel):
Attributes:
title: The heading the page carries.
referee: The name of the referee whose scores the cells report.
- colors: The palette token each channel's color stands under, by channel name.
+ glyph: The purpose glyph each table's card header carries.
+ colors: The role each channel's color stands under, by channel name.
groups: The tables the page holds, one per tab.
"""
@@ -87,5 +88,6 @@ class BoardPage(BaseModel):
title: str
referee: str
+ glyph: str
colors: Dict[str, str]
groups: Tuple[BoardGroup, ...]
diff --git a/src/sampletones_tools/calibration/board/palette.py b/src/sampletones_tools/calibration/board/palette.py
deleted file mode 100644
index 238d0284a..000000000
--- a/src/sampletones_tools/calibration/board/palette.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from typing import Final, Tuple
-
-from sampletones_application.paths import PALETTES_DIRECTORY
-from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME, PaletteCatalog
-from sampletones_application.utils.palette.palette import Palette
-from sampletones_shared.types.application import ColorRGBA
-
-DEFAULT_PALETTE: Final[str] = DEFAULT_PALETTE_NAME
-OPAQUE_ALPHA: Final[int] = 255
-CHANNEL_TOKEN_PREFIX: Final[str] = "channel_"
-
-
-def board_palette(name: str) -> Palette:
- """The palette a page is drawn in, by the name the application knows it under.
-
- Raises:
- KeyError: If the build ships no palette of that name.
- """
- return PaletteCatalog.load(PALETTES_DIRECTORY).get(name)
-
-
-def palette_names() -> Tuple[str, ...]:
- """Every palette name a page may be drawn in, the application's own default among them."""
- return PaletteCatalog.load(PALETTES_DIRECTORY).names
-
-
-def palette_stylesheet(palette: Palette) -> str:
- """The palette's tokens as custom properties, under the names the application states them by.
-
- A token keeps its name from the palette file to the page, so a color the application draws with
- and the color the page draws with are one value read from one place.
-
- Args:
- palette: The palette the page is drawn in.
-
- Returns:
- The stylesheet, one ``:root`` block holding every token.
- """
- declarations = "".join(f" --{token}: {css_color(color)};\n" for token, color in sorted(palette.colors.items()))
- return f":root {{\n{declarations}}}\n"
-
-
-def channel_token(channel: str) -> str:
- """The palette token a channel's color stands under."""
- return f"{CHANNEL_TOKEN_PREFIX}{channel}"
-
-
-def css_color(color: ColorRGBA) -> str:
- """A palette color as CSS writes it: six hexadecimal digits, eight where it is translucent."""
- red, green, blue, alpha = color
- opaque = f"#{red:02x}{green:02x}{blue:02x}"
- return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}"
diff --git a/src/sampletones_tools/calibration/board/paths.py b/src/sampletones_tools/calibration/board/paths.py
index fcd858834..8c56729ef 100644
--- a/src/sampletones_tools/calibration/board/paths.py
+++ b/src/sampletones_tools/calibration/board/paths.py
@@ -4,7 +4,5 @@
from sampletones_shared.paths.package import package_directory
STATIC_PACKAGE: Final[str] = "sampletones_tools.calibration.board.static"
-FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts"
STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE)
-FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE)
diff --git a/src/sampletones_tools/calibration/board/session.py b/src/sampletones_tools/calibration/board/session.py
index d822c6164..03ad5b3fb 100644
--- a/src/sampletones_tools/calibration/board/session.py
+++ b/src/sampletones_tools/calibration/board/session.py
@@ -3,13 +3,15 @@
from pydantic import BaseModel, ConfigDict, Field
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_tools.web.design import ApplicationDesign
+from sampletones_tools.web.writer import write_layout
+
from .assets import write_assets
from .clips import clip_store
from .composition import compose_page
from .data import data_script
-from .fonts import fonts_stylesheet
-from .layout import DATA_FILE, FONTS_FILE, PALETTE_FILE
-from .palette import DEFAULT_PALETTE, board_palette, palette_stylesheet
+from .layout import DATA_FILE, PAGE_DIRECTORY
from .reading import read_runs
PAGE_TITLE: Final[str] = "Calibration"
@@ -29,7 +31,7 @@ class BoardRequest(BaseModel):
runs: List[Path] = Field(min_length=1)
output: Path
- palette: str = DEFAULT_PALETTE
+ palette: str = DEFAULT_PALETTE_NAME
title: str = PAGE_TITLE
@@ -37,8 +39,9 @@ def build_board(request: BoardRequest) -> Path:
"""
Build the page a person listens to a run on, from what the runs already wrote.
- Everything the page needs travels with it: the palette as custom properties, the application's
- own faces inside the stylesheet, and the run's contents as data. A page written into the run it
+ Everything the page needs travels with it: the web layout drawn from the application's own
+ configuration in the requested palette, the application's faces inside a stylesheet, and the
+ run's contents as data. A page written into the run it
reports plays that run's clips where they lie; anywhere else it carries copies, so either page
opens straight from the folder it was written into.
@@ -53,13 +56,8 @@ def build_board(request: BoardRequest) -> Path:
KeyError: If the build ships no palette of the requested name.
"""
runs = read_runs(request.runs)
+ design = ApplicationDesign.load(request.palette)
request.output.mkdir(parents=True, exist_ok=True)
- page = compose_page(runs, clip_store(runs, request.output), request.title)
- return write_assets(
- request.output,
- {
- PALETTE_FILE: palette_stylesheet(board_palette(request.palette)),
- FONTS_FILE: fonts_stylesheet(),
- DATA_FILE: data_script(page),
- },
- )
+ page = compose_page(runs, clip_store(runs, request.output), request.title, design.glyphs.headers.reconstruction)
+ write_layout(request.output / PAGE_DIRECTORY, design)
+ return write_assets(request.output, {DATA_FILE: data_script(page)})
diff --git a/src/sampletones_tools/calibration/board/static/index.html b/src/sampletones_tools/calibration/board/static/index.html
index 8d283fe49..5e0c97440 100644
--- a/src/sampletones_tools/calibration/board/static/index.html
+++ b/src/sampletones_tools/calibration/board/static/index.html
@@ -4,23 +4,42 @@
Calibration
-
+
+
+
+
+
+
+
+
-
-