diff --git a/doc/_includes/resources_Top-Level_Resources.rst b/doc/_includes/resources_Top-Level_Resources.rst index b69278445..0b4553c3c 100644 --- a/doc/_includes/resources_Top-Level_Resources.rst +++ b/doc/_includes/resources_Top-Level_Resources.rst @@ -1,8 +1,25 @@ This logo of the snake doubles as a quick way to test Arcade's resource handles. -#. Mouse over the copy button (|Example Copy Button|) below -#. It should change color to indicate you've hovered -#. Click to copy +.. raw:: html -Paste in your favorite text editor! +
    +
  1. Look down toward the Arcade logo below until you see the file name
  2. +
  3. Look to the right edge of the file name ('logo.png')
  4. +
  5. There should be a copy button <(
    +
    )
  6. +
  7. Click or tap it.
  8. +
+Click the button or tap it if you're on mobile. Then try pasting in your favorite text editor. It +should look like this:: + + ':resources:/logo.png' + +This string is what Arcade calls a **:ref:`resource handle `**. They let you load +images, sound, and other data without worrying about where exactly data is on a computer. To learn +more, including how to define your own handle prefix, see :ref:`resource_handles`. + +To get started with game code right away, please see the following: + +* :ref:`example-code` +* :ref:`main-page-tutorials` diff --git a/doc/_includes/resources_Video.rst b/doc/_includes/resources_Video.rst new file mode 100644 index 000000000..031fe91a4 --- /dev/null +++ b/doc/_includes/resources_Video.rst @@ -0,0 +1,24 @@ +.. _resources_video: + +Video +----- + +Arcade offers experimental support for video playback through :py:mod:`pyglet` and other libraries. + +.. warning:: These features are works-in-progress! + + Please the following to learn more: + + * :ref:`future_api` + * The undocumented `experimental folder `_ + +To make testing easier, Arcade includes the small video file embedded below. However, runnign the +examples below may require installing both :ref:`guide-supportedmedia-ffmpeg` and additional libraries: + +* The `cv2-based video examples `_ +* The `cv2-based shadertoy example `_ + +The links above use the unstable development branch of Arcade to gain access to the latest :py:mod:`pyglet` +and Arcade features. If you have questions or want to help develop these examples further, we'd love to hear +from you. The Arcade `Discord server `_ and `GitHub repository `_ always welcome +new community members. diff --git a/doc/_static/css/custom.css b/doc/_static/css/custom.css index f633e7e9e..88aae3daa 100644 --- a/doc/_static/css/custom.css +++ b/doc/_static/css/custom.css @@ -262,11 +262,18 @@ table.resource-table td > .resource-thumb.file-icon { .resource-handle { - display: inline-block; + /* Flex props keep this all on one line when the viewport with's small */ + display: inline-flex; + flex-direction: row; + flex-shrink: 0; + + /* Make the button round so it's nice-looking */ border-radius: 0.4em; border: 1px solid rgb(0, 0, 0, 0); + width: fit-content !important; } + .resource-handle:has(button.arcade-ezcopy:hover) { border-color: #54c079; color: #54c079; @@ -314,20 +321,28 @@ table.resource-table td > .resource-thumb.file-icon { .resource-table * > .literal:has(+ button.arcade-ezcopy) { border-radius: 0.4em 0 0 0.4em !important; } -.resource-table .literal + button.arcade-ezcopy { +.resource-table * > .literal + button.arcade-ezcopy { border-radius: 0 0.4em 0.4em 0 !important; } - -.arcade-ezcopy > img { +.arcade-ezcopy > *:is(img, svg) { margin: 0; width: 100%; + max-width: 100%; height: 100%; + max-height: 100%; } .arcade-ezcopy:hover { background-color: #54c079; } +/* Give it some breathing room inside the inline HTML we're using for the moment + # pending: post-3.0 clean-up +*/ +li .arcade-ezcopy.doc-ui-example-dummy { + margin-left: 0.2em; + margin-right: 0.2em; +} table.colorTable { border-width: 1px; diff --git a/doc/_static/icons/tabler/copy.svg b/doc/_static/icons/tabler/copy.svg new file mode 100644 index 000000000..3e71440c8 --- /dev/null +++ b/doc/_static/icons/tabler/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index 11df9852e..4816a97c3 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,23 +1,34 @@ #!/usr/bin/env python """Sphinx configuration file""" from __future__ import annotations + from functools import cache import logging from pathlib import Path -from textwrap import dedent from typing import Any, NamedTuple -import docutils.nodes -import os import re import runpy -import sphinx.ext.autodoc -import sphinx.transforms import sys from docutils import nodes -from docutils.nodes import literal from sphinx.util.docutils import SphinxRole +HERE = Path(__file__).resolve() +REPO_LOCAL_ROOT = HERE.parent.parent +ARCADE_MODULE = REPO_LOCAL_ROOT / "arcade" +UTIL_DIR = REPO_LOCAL_ROOT / "util" + +log = logging.getLogger('conf.py') +logging.basicConfig(level=logging.INFO) + +sys.path.insert(0, str(REPO_LOCAL_ROOT)) +sys.path.insert(0, str(ARCADE_MODULE)) +log.info(f"Inserted elements in system path: First two are now:") +for i in range(2): + log.info(f" {i}: {sys.path[i]!r}") + +from util.doc_helpers.real_filesystem import copy_media + # As of pyglet==2.1.dev7, this is no longer set in pyglet/__init__.py # because Jupyter / IPython always load Sphinx into sys.modules. See # the following for more info: @@ -27,15 +38,7 @@ # --- Pre-processing Tasks -log = logging.getLogger('conf.py') -logging.basicConfig(level=logging.INFO) - -HERE = Path(__file__).resolve() -REPO_LOCAL_ROOT = HERE.parent.parent - -ARCADE_MODULE = REPO_LOCAL_ROOT / "arcade" -UTIL_DIR = REPO_LOCAL_ROOT / "util" - +# Report our diagnostic info log.info(f"Absolute path for our conf.py : {str(HERE)!r}") log.info(f"Absolute path for the repo root : {str(REPO_LOCAL_ROOT)!r}") log.info(f"Absolute path for the arcade module : {str(REPO_LOCAL_ROOT)!r}") @@ -43,28 +46,39 @@ # _temp_version = (REPO_LOCAL_ROOT / "arcade" / "VERSION").read_text().replace("-",'') -sys.path.insert(0, str(REPO_LOCAL_ROOT)) -sys.path.insert(0, str(ARCADE_MODULE)) -log.info(f"Inserted elements in system path: First two are now:") -for i in range(2): - log.info(f" {i}: {sys.path[i]!r}") - # Don't change to # from arcade.version import VERSION # or read the docs build will fail. from version import VERSION # pyright: ignore [reportMissingImports] -log.info(f"Got version {VERSION!r}") +log.info(f" Got version {VERSION!r}") -REPO_URL_BASE="https://github.com/pythonarcade/arcade" -if 'dev' in VERSION: - GIT_REF = 'development' - log.info(f"Got .dev release: using {GIT_REF!r}") -else: + +# Check whether the version ends in an all-digit string +VERSION_PARTS = [] +for part in VERSION.split('.'): + if part.isdigit(): + VERSION_PARTS.append(int(part)) + else: + VERSION_PARTS.append(part) + +print() +if VERSION_PARTS[-1].isdigit(): GIT_REF = VERSION - log.info(f"Got real release: using {GIT_REF!r}") + log.info(" !!!!! APPEARS TO BE A REAL RELEASE !!!!!") +else: + GIT_REF = 'development' + log.info(" - - - Building as a dev release - - -") + +print() +print(f" {GIT_REF=!r}") +print(f" {VERSION=!r}") +print() + # We'll pass this to our generation scripts to initialize their globals +REPO_URL_BASE="https://github.com/pythonarcade/arcade" FMT_URL_REF_BASE=f"{REPO_URL_BASE}/blob/{GIT_REF}" + RESOURCE_GLOBALS = dict( GIT_REF=GIT_REF, BASE_URL_REPO=REPO_URL_BASE, @@ -104,6 +118,22 @@ def run_util(filename, run_name="__main__", init_globals=None): # Run the generate quick API index script run_util('../util/update_quick_index.py') + +src_res_dir = ARCADE_MODULE / 'resources/assets' +out_res_dir = REPO_LOCAL_ROOT / 'build/html/_static/assets' + +# pending: post-3.0 cleanup to find the right source events to make this work? +# if exc or app.builder.format != "html": +# return +# static_dir = (app.outdir / '_static').resolve() +copy_what = { # pending: post-3.0 cleanup to tie this into resource generation correctly + 'sounds': ('*.wav', '*.ogg', '*.mp3'), + 'music': ('*.wav', '*.ogg', '*.mp3'), + 'video': ('*.mp4', '*.webm', ) +} +copy_media(src_res_dir, out_res_dir, copy_what) + + autodoc_inherit_docstrings = False autodoc_default_options = { 'members': True, @@ -136,6 +166,12 @@ def run_util(filename, run_name="__main__", init_globals=None): 'doc.extensions.prettyspecialmethods', # Forker plugin for prettifying special methods ] +# pending: post-3.0 cleanup: +# 1. Setting this breaks the CSS for both the plugin's buttons and our "custom" ones +# 2. Since our custom ones are only on the gui page for now, it's okay +# Note: tabler doesn't require attribution + it's the original theme for this icon set +# copybutton_image_svg = (REPO_LOCAL_ROOT / "doc/_static/icons/tabler/copy.svg").read_text() + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -272,7 +308,6 @@ def run_util(filename, run_name="__main__", init_globals=None): rst_prolog = "\n".join(PROLOG_PARTS) - def strip_init_return_typehint(app, what, name, obj, options, signature, return_annotation): # Prevent a the `-> None` annotation from appearing after classes. # This annotation comes from the `__init__`, but it renders on the class, @@ -281,6 +316,7 @@ def strip_init_return_typehint(app, what, name, obj, options, signature, return_ if what == "class" and return_annotation is None: return (signature, None) + def inspect_docstring_for_member( _app, what: str, @@ -407,7 +443,6 @@ def on_autodoc_process_bases(app, name, obj, options, bases): bases[:] = [base for base in bases if base is not object] - class A(NamedTuple): dirname: str comment: str = "" @@ -439,7 +474,7 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: '/api_docs/resources.html#', page_id]), ) - print("HALP?", locals()) + log.info(" Attempted ResourceRole", locals()) return [node], [] @@ -452,6 +487,7 @@ def setup(app): print(f" {comment}") # Separate stylesheets loosely by category. + # pending: sphinx >= 8.1.4 to remove the sphinx_static_file_temp_fix.py app.add_css_file("css/colors.css") app.add_css_file("css/layout.css") app.add_css_file("css/custom.css") @@ -467,6 +503,8 @@ def setup(app): app.connect('autodoc-process-bases', on_autodoc_process_bases) # app.add_transform(Transform) app.add_role('resource', ResourceRole()) + # Don't do anything that can fail on this event or it'll kill your build hard + # app.connect('build-finished', throws_exception) # ------------------------------------------------------ # Old hacks that breaks the api docs. !!! DO NOT USE !!! diff --git a/util/create_resources_listing.py b/util/create_resources_listing.py index 0aa04010b..1f453ed5f 100644 --- a/util/create_resources_listing.py +++ b/util/create_resources_listing.py @@ -64,6 +64,7 @@ def announce_templating(var_name): MODULE_DIR = Path(__file__).parent.resolve() ARCADE_ROOT = MODULE_DIR.parent RESOURCE_DIR = ARCADE_ROOT / "arcade" / "resources" +ASSET_DIR = RESOURCE_DIR / "assets" DOC_ROOT = ARCADE_ROOT / "doc" INCLUDES_ROOT = DOC_ROOT / "_includes" OUT_FILE = DOC_ROOT / "api_docs" / "resources.rst" @@ -135,7 +136,7 @@ class TableConfigDict(TypedDict): class HeadingConfigDict(TypedDict): - ref_target: NotRequired[str] + ref_target: NotRequired[str | bool] skip: NotRequired[bool] value: NotRequired[str] level: NotRequired[int] @@ -143,7 +144,7 @@ class HeadingConfigDict(TypedDict): class HandleLevelConfigDict(TypedDict): heading: NotRequired[HeadingConfigDict] - include: NotRequired[str] + include: NotRequired[str | bool] list_table: NotRequired[TableConfigDict] @@ -197,6 +198,14 @@ class HandleLevelConfigDict(TypedDict): }, ":resources:/gui_basic_assets/window/": { "heading": {"value": "Window & Panel"} + }, + ":resources:/video/": { + # pending: post-3.0 cleanup # trains are hats + # "heading:": { + # "value": "Video", + # "ref_target": "resources_video" + # }, + "include": "resources_Video.rst" } } @@ -237,7 +246,7 @@ def do_heading( out, relative_heading_level: int, heading_text: str, - ref_target: str | None = None + ref_target: str | bool | None = None ) -> None: """Writes a heading to the output file. @@ -254,8 +263,10 @@ def do_heading( print(f"doing heading: {heading_text!r} {relative_heading_level}") num_headings = len(headings_lookup) + if ref_target is True: + ref_target = f"resources-{heading_text}.rst" if ref_target: - out.write(f".. _{ref_target}:\n\n") + out.write(f".. _{ref_target.lower()}:\n\n") if relative_heading_level >= num_headings: # pending: post-3.0 cleanup @@ -415,16 +426,20 @@ def process_resource_directory(out, dir: Path): # Heading config fetch and write use_level = local_heading_config.get('level', heading_level) - use_target = local_heading_config.get('ref_target', None) use_value = local_heading_config.get('value', None) if use_value is None: use_value = format_title_part(handle_steps_parts[heading_level]) + use_target = local_heading_config.get('ref_target', None) + if isinstance(use_target, bool) and use_target: + use_target = f"resources_{use_value.lower()}" do_heading(out, use_level, use_value, ref_target=use_target) out.write(f"\n.. comment `{handle_step_whole!r}``\n\n") # Include any include .rst # pending: inline via pluginification if include := local_config.get("include", None): + if isinstance(include, bool) and include: + include = f"resources_{use_value}.rst" if isinstance(include, str): include = INCLUDES_ROOT / include log.info(f" INCLUDE: Include resolving to {include})") @@ -461,6 +476,10 @@ def indent( # pending: post-3.0 refactor # why would indent come after the tex return new.getvalue() +# pending: post-3.0 cleanup, I don't have time to make this CSS nice right now. +COPY_BUTTON_PATH = "_static/icons/tabler/copy.svg" +#COPY_BUTTON_RAW = (DOC_ROOT / "_static/icons/tabler/copy.svg").read_text().strip() + "\n" + def html_copyable( value: str, @@ -470,14 +489,14 @@ def html_copyable( if string_quote_char: value = f"{string_quote_char}{value}{string_quote_char}" escaped = html.escape(value) - raw = ( f"\n" f" \n" f" {escaped}\n" f" \n" f" \n" f"\n" f"
\n\n") @@ -693,15 +712,17 @@ def start(): config = MEDIA_EMBED[suffix] kind = config.get('media_kind') mime_suffix = config.get('mime_suffix') - file_path = FMT_URL_REF_EMBED.format(resource_path) - + # file_path = FMT_URL_REF_EMBED.format(resource_path) + rel = path.relative_to(RESOURCE_DIR) + file_path = f"/_static/{str(rel)}" out.write(f" {start()} - .. raw:: html\n\n") out.write(indent( " ", resource_copyable)) out.write(f" .. raw:: html\n\n") out.write(indent(" ", - f"<{kind} class=\"resource-thumb\" controls>\n" + # Using preload="none" is gentler on GitHub and readthedocs + f"<{kind} class=\"resource-thumb\" controls preload=\"none\">\n" f" \n" f"\n\n")) @@ -741,11 +762,25 @@ def resources(): do_heading(out, 0, "Built-In Resources") - out.write("\n\n:resource:`:resources:/gui_basic_assets/window/panel_green.png`\n\n") + # pending: post-3.0 cleanup: get the linking working + # out.write("\n\n:resource:`:resources:/gui_basic_assets/window/panel_green.png`\n\n") # out.write("Linking test: :ref:`resources-gui-basic-assets-window-panel-green-png`.\n") - out.write("Every file below is included when you :ref:`install Arcade `. This includes the images,\n" - "sounds, fonts, and other files to help you get started quickly. You can still download them\n" - "separately, but Arcade's resource handle system will usually be easier.\n") + + out.write("Every file below is included when you :ref:`install Arcade `.\n\n" + "Afterward, you can try running one of Arcade's :py:ref:`examples `,\n" + "such as the one below:\n\n" + ".. code-block:: shell\n" + " :caption: Taken from :ref:`sprite_collect_coins`\n" + "\n" + " python -m arcade.examples.sprite_collect_coins\n" + "\n" + "If the example mini-game runs, every image, sound, font, and example Tiled map below should\n" + "work with zero additional software. You can still download the resources from this page for\n" + "convenience, or visit `Kenney.nl`_ for more permissively licensed game assets.\n" + "\n" + "The one feature which may require additional software is Arcade's experimental video playback\n" + "support. The :ref:`resources_video` section below will explain further.\n") + do_heading(out, 1, "Do I have to credit anyone?") # Injecting the links.rst doesn't seem to be working? out.write("That's a good question and one you should always ask when searching for assets online.\n" @@ -754,7 +789,7 @@ def resources(): "are specifically released under `CC0 `_" " or similar terms.\n") out.write("Most are from `Kenney.nl `_.\n") # pending: post-3.0 cleanup. - + logo = html.escape("'logo.png'") do_heading(out, 1, "How do I use these?") out.write( # '.. |Example Copy Button| raw:: html\n\n' @@ -762,17 +797,17 @@ def resources(): # ' \n\n' # ' \n\n' # + - "Arcade helps save time through **resource handle** strings. These strings start with\n" - "``':resources:'``. After you've installed Arcade, you'll need to:\n\n" - "#. Find the copy button (|Example Copy Button|) after a filename below\n" - "#. Click it to copy the string, such as ``':resources:/logo.png'``\n" - "#. Use the appropriate loading functions to load and display the data\n\n" - "Try it below with the Arcade logo, or see the following to learn more\n:" - "\n\n" - "* :ref:`Sprite Examples ` for example code\n" - "* :ref:`The Platformer Tutorial ` for step-by-step guidance\n" - "* The :ref:`resource_handles` page of the manual covers them in more depth\n" - "\n" + f"Each file preview below has the following items above it:\n\n" + f".. raw:: html\n\n" + f"
    \n" + f"
  1. A file name as a single-quoted string ({logo})
  2. \n" + f"
  3. A copy button to the right of the string (
    " + f"
    )
  4. \n" + f"
\n\n" + + + "Click the button above a preview to copy the **resource handle** string for loading the asset.\n" + "Any image or sound on this page should work after installing Arcade with zero additional dependencies.\n" + "Full example code and manual sections for any relevant functions are linked below." ) out.write("\n") diff --git a/util/doc_helpers/real_filesystem.py b/util/doc_helpers/real_filesystem.py new file mode 100644 index 000000000..911c8083a --- /dev/null +++ b/util/doc_helpers/real_filesystem.py @@ -0,0 +1,119 @@ +""" +Helpers for dealing with the real-world file system. + +""" +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Generator, TypeVar, Hashable, Iterable, Mapping, Sequence, Callable +import logging + +H = TypeVar('H', bound=Hashable) + +FILE = Path(__file__) +REPO_ROOT = Path(__file__).parent.parent.resolve() +log = logging.getLogger(str(FILE.relative_to(REPO_ROOT))) + + +def dest_older(src: Path | str, dest: Path | str) -> bool: + """True if ``dest`` is older than ``src``. + + This works because git does not bother syncing the modified times + on files. It delegates that data to the commit history. + + Args: + src: A str or :py:class:`pathlib.Path`. + dest: A str or :py:class:`pathlib.Path`. + """ + return Path(src).stat().st_mtime > dest.stat().st_mtime + + +def multi_glob( + p: str | Path, + *globs: str, + predicate: Callable[[Path], bool] | None = None +) -> Generator[Path, None, None]: + """Chain multiple :py:class:`pathlib.Path.glob` results into one. + + The + Args: + p: the path to merge glob args for + globs: The glob strings to use. + predicate: An optional filter predicate. + Yields: + A series of paths with possible duplicates. + """ + p = Path(p) + for glob in globs: + if predicate: + yield from filter(predicate, p.glob(glob)) + else: + yield from p.glob(glob) + + +def unique(items: Iterable[H], seen: set | None = None) -> Generator[H, None, None]: + """Filter hashable ``items`` by adding them to a ``seen`` set during iteration. + + Passing a re-used set in allows efficiently visiting nodes. + + Args: + items: An iterable of hashables to reject duplicates from. + seen: specify a set rather than creating a new one for this call. + """ + if seen is None: + seen = set() + for new in (elt for elt in items if elt not in seen): + seen.add(new) + yield new + + +def sync_dir(src_dir: Path, dest_dir: Path, *globs: str, done: set | None = None) -> None: + """Sync a directory's files by using :py:mod:`pathlib` style ``globs``. + + Args: + src_dir: The source directory to read. + dest_dir: Where to sync any globbed files from. + globs: Match these and sync them into ``dest_dir``. + done: Pass a set of visited paths to enforce custom uniqueness. + """ + if not src_dir.is_dir(): + raise ValueError(f"source is not a directory: {src_dir}") + if dest_dir.is_file(): + raise ValueError(f"dest dir is not a directory: {dest_dir}") + for src_file in unique(multi_glob(src_dir, *globs), seen=done): + dest_file = dest_dir / src_file.name + + if not dest_file.exists() or dest_older(src_file, dest_file): + dest_file.parent.mkdir(parents=True, exist_ok=True) + log.info(f' Copying media file {src_file} to {dest_file}') + + shutil.copyfile(src_file, dest_file) + + +def copy_media( + src_root: Path | str, + dest_root: Path | str, + items: Mapping[str | Path, Sequence[str]], + done: set | None = None +) -> None: + """A more configurable version of the file syncing scripts we use. + + Args: + src_root: Where to start looking for matching ``items`` + dest_root: Where to write the new items. + items: A mapping of folder names to glob sequences. + done: A set to use as a uniqueness check, if any. + """ + + if done is None: + done = set() + logging.info("") + for dir_name, sub_items in items.items(): + print(f" Copying... {' '.join(map(repr, sub_items))}...") + + src_sub = (src_root / dir_name).resolve() + dest_sub = dest_root / dir_name + print(" from :", src_sub) + print(" to :", dest_sub) + sync_dir(src_sub, dest_sub, *items, done=done)