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!
+
+
Look down toward the Arcade logo below until you see the file name
+
Look to the right edge of the file name ('logo.png')
+
There should be a copy button <(
+
)
+
Click or tap it.
+
+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"{kind}>\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"
A file name as a single-quoted string ({logo})
\n"
+ f"
A copy button to the right of the string (
"
+ f"
)
\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)