From f3894916a94b2e737049a57562be96fe0b2d936c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:50:42 -0400 Subject: [PATCH] feat(web): show which plugins use each font; warn before deleting one (#619) * feat(web): show which plugins use each font, warn before deleting one The Fonts tab lists font files from the web process's own scan, and the plugins that register fonts run in the display process, so the tab had no way to say whether a font was in use before deleting it. The display service now publishes {catalog key: [plugin ids]} to the shared cache (font_usage_snapshot, src/font_usage.py), built from the loaded plugins' FontManager.register_manager_font() registrations. A daemon thread checks every 10 s and writes only when the usage changed (plus a daily refresh so cache cleanup cannot expire it); it never raises. Families, aliases (press_start, four_by_six, ...) and paths are resolved through FontManager's catalog to the file stem the Fonts tab keys rows by; fonts outside assets/fonts are left out. Unloading a plugin drops its registrations (new FontManager.forget_manager_fonts). GET /api/v3/fonts/catalog merges used_by into each row per request (the 5-minute scan cache is copied, never edited): a list of plugin ids, or null when the display service has not reported. The tab shows a Used by column ("unknown" / "-" / ids, rendered as text) and deleting an in-use font names the plugins in the confirmation, from a fresh read. The server still refuses only system fonts. Catalog fetches bypass the browser's 5-second API cache, which otherwise served the pre-delete list. Co-Authored-By: Claude Opus 5.5 * fix: call forget_manager_fonts through a hasattr check pylint can follow getattr(..., None) then callable() is fine at runtime, but pylint's E1102 ("not callable") can't see through it, and Codacy fails the check on it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 7 + docs/FONT_MANAGER.md | 32 +- docs/REST_API_REFERENCE.md | 26 +- src/display_controller.py | 7 + src/font_manager.py | 20 +- src/font_usage.py | 272 ++++++++++ src/plugin_system/plugin_manager.py | 7 + test/test_font_usage.py | 484 ++++++++++++++++++ web_interface/blueprints/api_v3/fonts.py | 44 +- .../templates/v3/partials/fonts.html | 118 ++++- 10 files changed, 995 insertions(+), 22 deletions(-) create mode 100644 src/font_usage.py create mode 100644 test/test_font_usage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afa4095..f136d44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ accepts both, but the store flags the old spelling as deprecated app's location in the web UI now actually clears it; the save used to drop the blank field, so the old value stayed. +- The web UI's Fonts tab has a **Used by** column: the loaded plugins that + registered each font with `FontManager.register_manager_font()`, published + by the display service to the shared cache (`src/font_usage.py`) and merged + into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin + uses now names those plugins in the confirmation (it is not blocked). + `FontManager.forget_manager_fonts()` is new; unloading a plugin calls it. + Deprecated, removed in 3.7.0 (each logs a warning on first use; see `docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in core, the monorepo or the registry's third-party plugins calls them: diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 1acf32f1..0d552687 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -173,8 +173,10 @@ font = self.font_manager.resolve_font( > install directory. > > The web UI's **Fonts** tab lists, uploads, previews and deletes the -> font files in `assets/fonts/`. It does not show fonts registered -> through `register_manager_font()` and has no override editor (the +> font files in `assets/fonts/`. Its **Used by** column shows which +> loaded plugins registered each file through `register_manager_font()` +> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it +> warns before deleting one of them. It has no override editor (the > override panels and `/api/v3/fonts/overrides` endpoints were removed). > The programmatic override workflow in > [Manual Font Overrides](#manual-font-overrides) below still works. @@ -276,6 +278,31 @@ Place font files in `assets/fonts/` directory: - Font family name is derived from filename (without extension) - Will be automatically discovered on next initialization +## Font usage in the web UI + +The web interface runs in its own process and has no FontManager, so the +display service publishes which plugin uses which font +(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it: + +- **Source**: `register_manager_font()` registrations of the loaded + plugins. `get_font()` and `resolve_font()` do not know the calling plugin + and are not counted, and neither is a plugin that opens a font file + directly with PIL — register the fonts your plugin draws with if you want + them listed. +- **Names**: a family, alias (`press_start`, `four_by_six`, + `five_by_seven`, `tom_thumb`) or path is resolved through + `font_catalog` to the file it loads and reported under that file's name + without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`, + `tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside + `assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families + that resolve to nothing are left out. +- **When**: a daemon thread started once plugins have loaded checks every + 10 seconds and writes the `font_usage_snapshot` cache key only when the + usage changed (and once a day, so the cache's cleanup never expires it). + Unloading a plugin drops its registrations (`forget_manager_fonts`). +- **Unknown**: until the display service has published, the column reads + "unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`. + ## Performance Monitoring ```python @@ -374,6 +401,7 @@ self.font = self.font_manager.resolve_font( ### FontManager Methods - `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage +- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads) - `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support - `get_font(family, size_px)` - Get font directly (bypasses overrides) - `measure_text(text, font)` - Measure text dimensions diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 2c8f1e67..8406403e 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1489,23 +1489,37 @@ Delete a stored backup. Fonts in `assets/fonts/`, keyed by file name without extension. +`used_by` lists the plugins that use the font, as the display service last +reported: the loaded plugins that registered it through +`FontManager.register_manager_font()`, under whatever family, alias +(`press_start`, `four_by_six`, ...) or path they gave. It is `[]` when no +loaded plugin registered it and `null` when the display service has not +reported yet (`font_usage.available` is then `false`). A plugin that opens a +font file directly is not counted, so `[]` does not prove a font is unused. +The file scan is cached for 5 minutes; `used_by` is read on every request. + **Response**: ```json { "status": "success", "data": { "catalog": { - "press_start": { - "filename": "press_start.ttf", + "PressStart2P-Regular": { + "filename": "PressStart2P-Regular.ttf", "family_name": "Press Start 2P", "display_name": "Press Start 2P", - "path": "assets/fonts/press_start.ttf", + "path": "assets/fonts/PressStart2P-Regular.ttf", "type": "ttf", "is_system": true, "scalable": true, "native_size": null, - "metadata": { ... } + "metadata": { ... }, + "used_by": ["calendar", "hello-world"] } + }, + "font_usage": { + "available": true, + "generated_at": "2026-09-23T10:00:00" } } } @@ -1560,7 +1574,9 @@ Upload a custom font file. It is saved as `assets/fonts/`. **DELETE** `/api/v3/fonts/` Delete an uploaded font (`` is the file name without -extension). System fonts answer `403`. +extension). System fonts answer `403`. A font a plugin uses is not refused; +the Fonts tab names those plugins (the catalog's `used_by`) in its +confirmation first. ### Font Preview diff --git a/src/display_controller.py b/src/display_controller.py index 1385cb82..42201c73 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -406,6 +406,11 @@ class DisplayController: logger.exception("Plugin system initialization failed") self.plugin_manager = None + # The web UI's Fonts tab ("Used by") reads what this publishes. + from src.font_usage import start_font_usage_publisher + self._font_usage_publisher = start_font_usage_publisher( + self.cache_manager, self.font_manager, self.plugin_manager) + # Display rotation state self.current_mode_index = 0 self.current_display_mode = None @@ -3341,6 +3346,8 @@ class DisplayController: self.config_service.shutdown() except Exception as e: logger.warning("Error shutting down config service: %s", e) + if getattr(self, '_font_usage_publisher', None) is not None: + self._font_usage_publisher.stop() logger.info("Cleaning up display controller...") if hasattr(self, 'display_manager'): self.display_manager.cleanup() diff --git a/src/font_manager.py b/src/font_manager.py index a6927c1e..eb6f72b1 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -78,6 +78,10 @@ class FontManager: # Manager font registration - NEW for manager-centric model self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}} self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count} + # Bumped when a manager's registered families change (not when one + # re-registers what it already had), so src/font_usage.py can tell + # "nothing new" without rebuilding its snapshot. + self.manager_fonts_version = 0 # Dynamic font loading self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts" @@ -156,7 +160,10 @@ class FontManager: } if color: font_spec["color"] = color - + + previous = self.manager_fonts[manager_id].get(element_key) + if previous is None or previous.get("family") != family: + self.manager_fonts_version += 1 self.manager_fonts[manager_id][element_key] = font_spec # Track usage in detected_fonts @@ -168,6 +175,17 @@ class FontManager: logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px") + def forget_manager_fonts(self, manager_id: str) -> None: + """Drop every registration ``manager_id`` made. Called by core when a + plugin is unloaded, so a reloaded plugin starts from what its new + instance registers and the web UI's Fonts tab stops listing it.""" + removed = self.manager_fonts.pop(manager_id, None) + for element_key, spec in list(self.detected_fonts.items()): + if spec.get("manager_id") == manager_id: + self.detected_fonts.pop(element_key, None) + if removed: + self.manager_fonts_version += 1 + @deprecated("3.7.0") def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]: """ diff --git a/src/font_usage.py b/src/font_usage.py new file mode 100644 index 00000000..646e6b5b --- /dev/null +++ b/src/font_usage.py @@ -0,0 +1,272 @@ +"""Which plugins use which font, published for the web interface. + +The display service and the web interface are separate processes. Plugins +run, and register their fonts with ``FontManager.register_manager_font()``, +in the display service only; the web interface's Fonts tab lists the files in +``assets/fonts/`` from its own scan and has no FontManager to ask. So the +display service publishes a small snapshot to the shared cache directory -- +the same channel, and the same file permissions, as ``display_current_state`` +and ``plugin_error_snapshot``: files are 0660 and carry the cache directory's +group, so root writes and the web user reads. + + FONT_USAGE_KEY written by the display service only + +The snapshot:: + + {"generated_at": "2026-09-23T10:00:00", + "fonts": {"PressStart2P-Regular": ["calendar", "hello-world"], + "4x6-font": ["calendar"]}} + +``fonts`` is keyed the way ``GET /api/v3/fonts/catalog`` keys its rows: the +file name in ``assets/fonts/`` without its extension, as it is on disk. A +plugin names a font by FontManager family ("press_start", "5x7", "6x13b"), by +alias, or by path; :func:`catalog_key_for` resolves each through FontManager's +own catalog to the file it loads and keys it by that file's stem. Fonts that +live outside ``assets/fonts/`` (a plugin's own ``plugin_id::family`` fonts) +and families FontManager cannot resolve are left out: the Fonts tab has no +row for them. + +Only registrations are counted. ``get_font()`` does not know which plugin is +calling it, and many plugins open font files directly with PIL, so a font no +plugin registered may still be in use; the web interface says so. +""" + +import logging +import os +import threading +import time +from datetime import datetime +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +FONT_USAGE_KEY = "font_usage_snapshot" + +#: How often the display service checks for a change. A check compares two +#: integers and a set of plugin ids; the snapshot is rebuilt and written only +#: when one of them moved, so plugins that register fonts every frame (the +#: countdown plugin registers per countdown at render time) cost nothing. +TICK_INTERVAL = 10.0 + +#: Rewrite an unchanged snapshot this often anyway. The cache's disk cleanup +#: deletes entries older than its default retention (30 days), and a display +#: service that runs longer than that without a font change would otherwise +#: turn the Fonts tab back to "unknown". +REFRESH_INTERVAL = 24 * 3600.0 + +FONT_EXTENSIONS = ('.ttf', '.otf', '.bdf') + +# Bounds on what the web interface accepts from the snapshot file. +_MAX_FONTS = 1000 +_MAX_PLUGINS_PER_FONT = 200 +_MAX_ID_CHARS = 200 + + +def _fonts_dir() -> str: + from src.common.font_layout import resolve_asset_path + return resolve_asset_path("assets/fonts") + + +def _same_dir(a: str, b: str) -> bool: + try: + return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b)) + except (OSError, ValueError): + return False + + +def catalog_key_for(family: Any, font_catalog: Dict[str, str], + fonts_dir: Optional[str] = None) -> Optional[str]: + """The Fonts-tab catalog key for a family a plugin registered, or None. + + Resolution, in order: + + 1. ``family`` as a FontManager family -- a scanned file ("5x7", + "pressstart2p-regular"), an alias ("press_start", "four_by_six", + "five_by_seven", "tom_thumb"), or a plugin's ``plugin_id::family``. + FontManager keys scanned files in lower case, so the lookup falls back + to ``family.lower()``. + 2. ``family`` as a file name or path ("5x7.bdf", + "assets/fonts/4x6-font.ttf", an absolute path). + + The key is the resolved file's name without its extension, and only for a + file directly in ``assets/fonts/`` -- anything else has no catalog row. + """ + if not isinstance(family, str): + return None + name = family.strip() + if not name: + return None + fonts_dir = fonts_dir or _fonts_dir() + + path = font_catalog.get(name) or font_catalog.get(name.lower()) + if not path: + if not name.lower().endswith(FONT_EXTENSIONS): + return None + path = name + directory, filename = os.path.split(str(path)) + stem, ext = os.path.splitext(filename) + if not stem or ext.lower() not in FONT_EXTENSIONS: + return None + if directory: + if not os.path.isabs(directory): + from src.common.font_layout import resolve_asset_path + directory = resolve_asset_path(directory) + if not _same_dir(directory, fonts_dir): + return None + return stem + + +def build_font_usage(font_manager: Any, + plugin_ids: Optional[FrozenSet[str]] = None, + fonts_dir: Optional[str] = None) -> Dict[str, List[str]]: + """``{catalog key: sorted plugin ids}`` from FontManager's registrations. + + ``plugin_ids`` limits it to loaded plugins: a plugin whose constructor + registered fonts and which then failed validation is not using them. + """ + fonts_dir = fonts_dir or _fonts_dir() + # list() copies in one step under the GIL; register_manager_font() runs + # on the render thread while this runs on the publisher's. + registrations = list(getattr(font_manager, 'manager_fonts', {}).items()) + font_catalog = dict(getattr(font_manager, 'font_catalog', {})) + usage: Dict[str, set] = {} + for manager_id, elements in registrations: + if not isinstance(manager_id, str): + continue + if plugin_ids is not None and manager_id not in plugin_ids: + continue + for spec in list(elements.values()) if isinstance(elements, dict) else (): + family = spec.get('family') if isinstance(spec, dict) else None + key = catalog_key_for(family, font_catalog, fonts_dir) + if key is not None: + usage.setdefault(key, set()).add(manager_id) + return {key: sorted(ids) for key, ids in sorted(usage.items())} + + +class FontUsagePublisher: + """Publishes the display service's font usage to the shared cache. + + tick() is the whole job; start() calls it from a daemon thread every + TICK_INTERVAL seconds. Nothing here raises: a failure is logged at debug + and retried on the next tick. + """ + + def __init__(self, cache_manager: Any, font_manager: Any, plugin_manager: Any = None, + clock: Callable[[], float] = time.monotonic) -> None: + self.cache_manager = cache_manager + self.font_manager = font_manager + self.plugin_manager = plugin_manager + self._clock = clock + self._signature: Optional[Tuple[Any, ...]] = None + # None forces a first write, replacing whatever a previous run of the + # service left in the cache with this run's usage. + self._published: Optional[Dict[str, List[str]]] = None + self._published_at: Optional[float] = None + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def _loaded_plugin_ids(self) -> Optional[FrozenSet[str]]: + plugins = getattr(self.plugin_manager, 'plugins', None) + if not isinstance(plugins, dict): + return None + return frozenset(list(plugins)) + + def tick(self) -> bool: + """Publish if the usage changed. True if a snapshot was written.""" + with self._lock: + try: + plugin_ids = self._loaded_plugin_ids() + signature = ( + getattr(self.font_manager, 'manager_fonts_version', None), + getattr(self.font_manager, 'cache_generation', None), + plugin_ids, + ) + now = self._clock() + refresh_due = (self._published_at is not None + and now - self._published_at >= REFRESH_INTERVAL) + if signature == self._signature and not refresh_due: + return False + usage = build_font_usage(self.font_manager, plugin_ids) + if usage == self._published and not refresh_due: + self._signature = signature + return False + self.cache_manager.set(FONT_USAGE_KEY, { + 'generated_at': datetime.now().isoformat(timespec='seconds'), + 'fonts': usage, + }) + self._signature = signature + self._published = usage + self._published_at = now + return True + except Exception as err: # never let reporting break the display + logger.debug("Could not publish font usage: %s", err, exc_info=True) + return False + + def start(self, interval: float = TICK_INTERVAL) -> None: + """Tick now, then from a daemon thread until stop(). No-op while running.""" + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + + def run() -> None: + self.tick() + while not self._stop.wait(interval): + self.tick() + + self._thread = threading.Thread(target=run, name="font-usage-publisher", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + + +def start_font_usage_publisher(cache_manager: Any, font_manager: Any, + plugin_manager: Any) -> Optional[FontUsagePublisher]: + """Start publishing font usage for the web interface. Never raises. + + Call from the display service, once its plugins are loaded. + """ + try: + if cache_manager is None or font_manager is None or plugin_manager is None: + return None + publisher = FontUsagePublisher(cache_manager, font_manager, plugin_manager) + publisher.start() + return publisher + except Exception as err: + logger.warning("Font usage reporting to the web interface is unavailable: %s", err) + return None + + +# --- Reading side (web interface) ------------------------------------------- + +def read_font_usage(cache_manager: Any) -> Optional[Dict[str, Any]]: + """The display service's latest snapshot, or None if it has not published. + + memory_ttl=0: the display service writes this key, so only the file is + current. The contents are checked and bounded -- they arrive from another + process through a shared directory. + """ + try: + snapshot = cache_manager.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0) + except Exception as err: + logger.debug("Could not read font usage: %s", err, exc_info=True) + return None + if not isinstance(snapshot, dict) or not isinstance(snapshot.get('fonts'), dict): + return None + fonts: Dict[str, List[str]] = {} + for key, ids in list(snapshot['fonts'].items())[:_MAX_FONTS]: + if not isinstance(key, str) or not isinstance(ids, list): + continue + clean = sorted({i[:_MAX_ID_CHARS] for i in ids[:_MAX_PLUGINS_PER_FONT] + if isinstance(i, str) and i}) + if clean: + fonts[key[:_MAX_ID_CHARS]] = clean + generated_at = snapshot.get('generated_at') + return { + 'generated_at': generated_at if isinstance(generated_at, str) else None, + 'fonts': fonts, + } diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 98b1ee6c..27502f02 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -624,6 +624,13 @@ class PluginManager: # Delegate sub-module and cached-module cleanup to the loader self.plugin_loader.unregister_plugin_modules(plugin_id) + # Its font registrations go with it (the Fonts tab's "Used by"). + try: + if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'): + self.font_manager.forget_manager_fonts(plugin_id) + except Exception as e: + self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e) + # Update state self.state_manager.set_state(plugin_id, PluginState.UNLOADED) self.state_manager.clear_state(plugin_id) diff --git a/test/test_font_usage.py b/test/test_font_usage.py new file mode 100644 index 00000000..8b4ddffa --- /dev/null +++ b/test/test_font_usage.py @@ -0,0 +1,484 @@ +"""The Fonts tab's "Used by" column, across the two services. + +Plugins register fonts with FontManager in the display service; the web +interface lists font files from its own scan and has no FontManager. The +display side publishes {catalog key: [plugin ids]} to the shared cache +(src/font_usage.py) and GET /api/v3/fonts/catalog merges it in per request. + +As in test_error_snapshot_cross_process.py, the two services are two +CacheManagers over one temporary directory. +""" +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.cache_manager import CacheManager # noqa: E402 +from src.font_manager import FontManager # noqa: E402 +from src import font_usage # noqa: E402 +from src.font_usage import ( # noqa: E402 + FONT_USAGE_KEY, REFRESH_INTERVAL, FontUsagePublisher, build_font_usage, + catalog_key_for, read_font_usage, start_font_usage_publisher, +) +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + +REPO_FONTS = Path(__file__).resolve().parent.parent / "assets" / "fonts" + + +class FakeClock: + def __init__(self): + self.now = 1000.0 + + def __call__(self): + return self.now + + +@pytest.fixture(scope="module") +def fm(): + return FontManager({}) + + +@pytest.fixture +def shared_cache(tmp_path, monkeypatch): + """Two cache managers over one directory: the display's and the web's.""" + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(cache_dir)) + display_cache, web_cache = CacheManager(), CacheManager() + yield display_cache, web_cache + display_cache.stop_cleanup_thread() + web_cache.stop_cleanup_thread() + + +@pytest.fixture +def display(shared_cache): + """A FontManager, the loaded-plugin table and a publisher over them.""" + display_cache, _ = shared_cache + font_manager = FontManager({}) + plugin_manager = SimpleNamespace(plugins={}) + clock = FakeClock() + publisher = FontUsagePublisher(display_cache, font_manager, plugin_manager, clock=clock) + return font_manager, plugin_manager, publisher, clock + + +def _load(font_manager, plugin_manager, plugin_id, *families): + """What a plugin's constructor does, then the plugin manager.""" + for i, family in enumerate(families): + font_manager.register_manager_font(plugin_id, f"{plugin_id}.e{i}", family, 8) + plugin_manager.plugins[plugin_id] = object() + + +def _published(web_cache): + return web_cache.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0) + + +# --- Normalisation to the catalog's keys ------------------------------------ + +class TestCatalogKeys: + """The web catalog keys a font by its file name without the extension, + as on disk. Plugins name fonts by FontManager family, alias or path.""" + + @pytest.mark.parametrize("family, key", [ + ("press_start", "PressStart2P-Regular"), + ("four_by_six", "4x6-font"), + ("five_by_seven", "5x7"), + ("tom_thumb", "tom-thumb"), + ]) + def test_aliases(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + @pytest.mark.parametrize("family, key", [ + ("5x7", "5x7"), + ("4x6", "4x6"), # the BDF, not 4x6-font.ttf + ("6x13b", "6x13B"), # FontManager lower-cases stems + ("6x13B", "6x13B"), + ("PressStart2P-Regular", "PressStart2P-Regular"), + ("5by7.regular", "5by7.regular"), # a dot inside the stem + ]) + def test_scanned_families_keep_the_file_names_case(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + @pytest.mark.parametrize("family, key", [ + ("assets/fonts/4x6-font.ttf", "4x6-font"), + ("5x7.bdf", "5x7"), + (str(REPO_FONTS / "MatrixChunky8.bdf"), "MatrixChunky8"), + ]) + def test_paths(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + def test_absolute_path_through_the_catalog(self, fm, tmp_path): + catalog = {"custom": str(tmp_path / "Custom-Face.ttf")} + assert catalog_key_for("custom", catalog, fonts_dir=str(tmp_path)) == "Custom-Face" + + def test_a_font_outside_assets_fonts_has_no_row(self, fm, tmp_path): + # A plugin's own font ("plugin::family") lives in its directory; a + # file of the same name there is not the catalog's 5x7. + catalog = {"weather::5x7": str(tmp_path / "5x7.bdf")} + assert catalog_key_for("weather::5x7", catalog) is None + assert catalog_key_for(str(tmp_path / "5x7.bdf"), fm.font_catalog) is None + assert catalog_key_for("plugins/x/fonts/5x7.bdf", fm.font_catalog) is None + + @pytest.mark.parametrize("family", ["no_such_font", "", " ", None, 8, "notafont.txt"]) + def test_unresolvable(self, fm, family): + assert catalog_key_for(family, fm.font_catalog) is None + + def test_build_merges_aliases_and_names_of_one_file(self, fm): + font_manager = FontManager({}) + font_manager.register_manager_font("calendar", "calendar.title", "press_start", 8) + font_manager.register_manager_font("weather", "weather.t", "PressStart2P-Regular", 16) + font_manager.register_manager_font("weather", "weather.c", "four_by_six", 8) + font_manager.register_manager_font("weather", "weather.x", "no_such_font", 8) + assert build_font_usage(font_manager) == { + "4x6-font": ["weather"], + "PressStart2P-Regular": ["calendar", "weather"], + } + + +# --- Display side ----------------------------------------------------------- + +class TestPublishing: + def test_publishes_after_plugins_load(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six", "press_start") + _load(font_manager, plugin_manager, "of-the-day", "press_start") + assert publisher.tick() is True + snapshot = _published(web_cache) + assert snapshot["fonts"] == { + "4x6-font": ["calendar"], + "PressStart2P-Regular": ["calendar", "of-the-day"], + } + assert snapshot["generated_at"] + + def test_a_plugin_that_failed_to_load_is_not_listed(self, display, shared_cache): + # Its constructor registered fonts, then validation failed, so it + # never reached plugin_manager.plugins. + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + font_manager.register_manager_font("broken", "broken.x", "press_start", 8) + _load(font_manager, plugin_manager, "calendar", "four_by_six") + publisher.tick() + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_load_and_unload_later_republish(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six") + publisher.tick() + + _load(font_manager, plugin_manager, "weather", "four_by_six", "five_by_seven") + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == { + "4x6-font": ["calendar", "weather"], "5x7": ["weather"]} + + # What PluginManager.unload_plugin does. + del plugin_manager.plugins["weather"] + font_manager.forget_manager_fonts("weather") + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_first_tick_replaces_a_previous_runs_snapshot(self, display, shared_cache): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, {"generated_at": "old", "fonts": {"5x7": ["gone"]}}) + _, _, publisher, _ = display + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {} + + def test_nothing_is_written_when_nothing_changed(self, display): + font_manager, plugin_manager, publisher, clock = display + _load(font_manager, plugin_manager, "countdown", "press_start") + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + with patch.object(font_usage, "build_font_usage", + wraps=font_usage.build_font_usage) as build: + for _ in range(20): + # The countdown plugin registers per countdown at render time. + font_manager.register_manager_font("countdown", "countdown.e0", "press_start", 8) + clock.now += 5 + assert publisher.tick() is False + build.assert_not_called() + publisher.cache_manager.set.assert_not_called() + + def test_a_registration_that_leaves_usage_unchanged_is_not_written(self, display): + font_manager, plugin_manager, publisher, _ = display + _load(font_manager, plugin_manager, "countdown", "press_start") + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + # A new element, same font: the version moves, the usage does not. + font_manager.register_manager_font("countdown", "countdown.c2.value", "press_start", 12) + assert publisher.tick() is False + publisher.cache_manager.set.assert_not_called() + + def test_an_unchanged_snapshot_is_refreshed_daily(self, display): + # The cache's disk cleanup deletes entries older than 30 days. + _, _, publisher, clock = display + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + clock.now += REFRESH_INTERVAL - 1 + assert publisher.tick() is False + clock.now += 1 + assert publisher.tick() is True + publisher.cache_manager.set.assert_called_once() + + +class TestFailureIsolation: + def test_a_failing_cache_write_does_not_raise_and_is_retried(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six") + real = publisher.cache_manager + publisher.cache_manager = MagicMock() + publisher.cache_manager.set.side_effect = PermissionError("read-only cache") + assert publisher.tick() is False + publisher.cache_manager = real + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_a_broken_font_manager_does_not_raise(self, shared_cache): + display_cache, _ = shared_cache + broken = SimpleNamespace(manager_fonts=None, font_catalog={}) + publisher = FontUsagePublisher(display_cache, broken, SimpleNamespace(plugins={})) + assert publisher.tick() is False + + def test_start_never_raises(self): + assert start_font_usage_publisher(MagicMock(), MagicMock(), None) is None + with patch.object(font_usage.FontUsagePublisher, "start", + side_effect=RuntimeError("no threads")): + assert start_font_usage_publisher(MagicMock(), MagicMock(), MagicMock()) is None + + def test_start_publishes_from_a_thread(self, shared_cache, monkeypatch): + display_cache, web_cache = shared_cache + font_manager = FontManager({}) + plugin_manager = SimpleNamespace(plugins={}) + _load(font_manager, plugin_manager, "calendar", "press_start") + publisher = start_font_usage_publisher(display_cache, font_manager, plugin_manager) + try: + import time + deadline = time.monotonic() + 5 + while _published(web_cache) is None and time.monotonic() < deadline: + time.sleep(0.02) + assert _published(web_cache)["fonts"] == {"PressStart2P-Regular": ["calendar"]} + finally: + publisher.stop() + + +class TestFontManagerRegistrations: + def test_version_moves_only_when_a_family_changes(self): + font_manager = FontManager({}) + v0 = font_manager.manager_fonts_version + font_manager.register_manager_font("p", "p.a", "press_start", 8) + v1 = font_manager.manager_fonts_version + font_manager.register_manager_font("p", "p.a", "press_start", 10) # size only + assert font_manager.manager_fonts_version == v1 > v0 + font_manager.register_manager_font("p", "p.a", "four_by_six", 8) + assert font_manager.manager_fonts_version > v1 + + def test_forget_drops_every_registration_of_that_manager(self): + font_manager = FontManager({}) + font_manager.register_manager_font("p", "p.a", "press_start", 8) + font_manager.register_manager_font("q", "q.a", "press_start", 8) + version = font_manager.manager_fonts_version + font_manager.forget_manager_fonts("p") + assert "p" not in font_manager.manager_fonts + assert "p.a" not in font_manager.detected_fonts + assert "q" in font_manager.manager_fonts and "q.a" in font_manager.detected_fonts + assert font_manager.manager_fonts_version > version + font_manager.forget_manager_fonts("never-registered") # no error + + def test_unload_plugin_forgets_its_fonts(self, tmp_path): + from src.plugin_system.plugin_manager import PluginManager + font_manager = FontManager({}) + pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None, + display_manager=None, cache_manager=None, + font_manager=font_manager) + try: + font_manager.register_manager_font("calendar", "calendar.t", "press_start", 8) + pm.plugins["calendar"] = SimpleNamespace() + assert pm.unload_plugin("calendar") is True + assert "calendar" not in font_manager.manager_fonts + finally: + pm.stop_update_worker() + + def test_unload_survives_a_font_manager_that_raises(self, tmp_path): + from src.plugin_system.plugin_manager import PluginManager + font_manager = MagicMock() + font_manager.forget_manager_fonts.side_effect = RuntimeError("boom") + pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None, + display_manager=None, cache_manager=None, + font_manager=font_manager) + try: + pm.plugins["calendar"] = SimpleNamespace() + assert pm.unload_plugin("calendar") is True + finally: + pm.stop_update_worker() + + +def test_display_controller_starts_the_publisher(test_display_controller, mock_cache_manager): + publisher = test_display_controller._font_usage_publisher + assert isinstance(publisher, FontUsagePublisher) + assert publisher.plugin_manager is test_display_controller.plugin_manager + publisher.tick() + assert "fonts" in mock_cache_manager._memory_cache[FONT_USAGE_KEY] + + +# --- Reading side ----------------------------------------------------------- + +class TestReading: + def test_nothing_published(self, shared_cache): + _, web_cache = shared_cache + assert read_font_usage(web_cache) is None + + @pytest.mark.parametrize("junk", [ + "a string", {"fonts": "nope"}, {"generated_at": "x"}, [1, 2], + ]) + def test_a_malformed_snapshot_reads_as_unknown(self, shared_cache, junk): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, junk) + assert read_font_usage(web_cache) is None + + def test_bad_entries_are_dropped(self, shared_cache): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, {"generated_at": 5, "fonts": { + "5x7": ["b", "a", 3, None, "", "a"], "4x6": "calendar", "tom-thumb": [], + }}) + assert read_font_usage(web_cache) == {"generated_at": None, "fonts": {"5x7": ["a", "b"]}} + + def test_a_cache_that_raises_reads_as_unknown(self): + cache = MagicMock() + cache.get.side_effect = OSError("gone") + assert read_font_usage(cache) is None + + +# --- Web side --------------------------------------------------------------- + +@pytest.fixture +def web(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811 + """The real blueprint over a scratch assets/fonts and the shared cache.""" + _, web_cache = shared_cache + api_v3_module.api_v3.cache_manager = web_cache + fonts_dir = tmp_path / "root" / "assets" / "fonts" + fonts_dir.mkdir(parents=True) + for name in ("PressStart2P-Regular.ttf", "5x7.bdf", "my-custom.ttf"): + (fonts_dir / name).write_bytes(b"not really a font") + from web_interface.cache import delete_cached + delete_cached("fonts_catalog") + with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path / "root"): + yield api_v3_client, web_cache, fonts_dir + delete_cached("fonts_catalog") + + +def _catalog(client): + response = client.get("/api/v3/fonts/catalog") + assert response.status_code == 200, response.get_data(as_text=True) + return response.get_json()["data"] + + +class TestCatalogEndpoint: + def test_unknown_until_the_display_service_reports(self, web): + client, _, _ = web + data = _catalog(client) + assert data["font_usage"] == {"available": False, "generated_at": None} + assert {key: info["used_by"] for key, info in data["catalog"].items()} == { + "PressStart2P-Regular": None, "5x7": None, "my-custom": None} + + def test_usage_is_merged_into_the_rows(self, web): + client, web_cache, _ = web + web_cache.set(FONT_USAGE_KEY, {"generated_at": "2026-09-23T10:00:00", "fonts": { + "PressStart2P-Regular": ["calendar", "clock-simple"], + "my-custom": ["football-scoreboard"], + "not-in-the-catalog": ["weather"], + }}) + data = _catalog(client) + assert data["font_usage"] == {"available": True, "generated_at": "2026-09-23T10:00:00"} + rows = data["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "clock-simple"] + assert rows["my-custom"]["used_by"] == ["football-scoreboard"] + assert rows["5x7"]["used_by"] == [] + assert "not-in-the-catalog" not in rows + # The existing fields are still there. + assert rows["5x7"]["type"] == "bdf" and rows["5x7"]["is_system"] is True + + def test_keys_match_case_insensitively(self, web): + client, web_cache, _ = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {"pressstart2p-regular": ["calendar"], + "PressStart2P-Regular": ["weather"]}}) + rows = _catalog(client)["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "weather"] + + def test_the_scan_stays_cached_while_usage_is_read_per_request(self, web): + client, web_cache, _ = web + _catalog(client) # fills the 5-minute cache + from web_interface.blueprints.api_v3 import fonts as fonts_module + with patch.object(fonts_module.os, "listdir", + side_effect=AssertionError("rescanned assets/fonts")): + web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["weather"]}}) + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["weather"] + web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["calendar"]}}) + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar"] + # ...and usage never leaks into the cached scan. + from web_interface.cache import get_cached + cached = get_cached("fonts_catalog", ttl_seconds=300) + assert cached and all("used_by" not in info for info in cached.values()) + + def test_end_to_end_from_the_display_publisher(self, web, display): + client, _, fonts_dir = web + font_manager, plugin_manager, publisher, _ = display + _load(font_manager, plugin_manager, "calendar", "press_start", "five_by_seven") + publisher.tick() + rows = _catalog(client)["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar"] + assert rows["5x7"]["used_by"] == ["calendar"] + # A later change reaches the web process too: it reads the file, not + # the copy its own memory tier kept from the first read. + _load(font_manager, plugin_manager, "weather", "five_by_seven") + publisher.tick() + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar", "weather"] + + +class TestDeleteInUseFont: + """The UI warns with the catalog's used_by; the server does not block.""" + + def test_the_catalog_names_the_plugins_and_delete_still_works(self, web): + client, web_cache, fonts_dir = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {"my-custom": ["football-scoreboard", "clock-simple"]}}) + assert _catalog(client)["catalog"]["my-custom"]["used_by"] == [ + "clock-simple", "football-scoreboard"] + response = client.delete("/api/v3/fonts/my-custom") + assert response.status_code == 200, response.get_json() + assert not (fonts_dir / "my-custom.ttf").exists() + assert "my-custom" not in _catalog(client)["catalog"] + + def test_system_fonts_are_still_refused(self, web): + client, web_cache, fonts_dir = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {}}) + assert client.delete("/api/v3/fonts/5x7").status_code == 403 + assert (fonts_dir / "5x7.bdf").exists() + + +class TestFontsTemplate: + """fonts.html renders usage with DOM text APIs and warns before delete.""" + + TEMPLATE = (Path(__file__).resolve().parent.parent / "web_interface" / "templates" + / "v3" / "partials" / "fonts.html").read_text(encoding="utf-8") + + def test_used_by_is_never_written_as_html(self): + assert "usedSpan.textContent = font.usedBy.join(', ')" in self.TEMPLATE + assert "usedSpan.innerHTML" not in self.TEMPLATE + + def test_delete_confirms_with_the_plugins(self): + assert "Used by: ${usedBy.join(', ')}" in self.TEMPLATE + assert "confirm(deleteFontConfirmMessage(fontFamily, usedBy))" in self.TEMPLATE + + def test_catalog_fetches_bypass_the_browser_cache(self): + # /api/v3 GETs carry max-age=5. The usage check just before a delete + # would otherwise be the copy the post-delete reload is served, and + # the deleted font would stay listed. + fetches = [line for line in self.TEMPLATE.splitlines() + if "fetch(" in line and "/api/v3/fonts/catalog" in line] + assert len(fetches) == 2 + assert all("cache: 'no-store'" in line for line in fetches) diff --git a/web_interface/blueprints/api_v3/fonts.py b/web_interface/blueprints/api_v3/fonts.py index db0d7d5a..2da6d753 100644 --- a/web_interface/blueprints/api_v3/fonts.py +++ b/web_interface/blueprints/api_v3/fonts.py @@ -9,6 +9,46 @@ from web_interface.blueprints.api_v3 import ( ) +def _catalog_response(catalog): + """The catalog response, with each font's ``used_by`` merged in now. + + Usage comes from the display service (src/font_usage.py) and changes + independently of the files, so it is read per request and never stored + in the 5-minute ``fonts_catalog`` cache: entries are copied, not edited. + ``used_by`` is a list of plugin ids, empty when no loaded plugin + registered the font, and None when the display service has not reported. + """ + from src.font_usage import read_font_usage + from web_interface.blueprints.api_v3.display import _cache_manager + try: + usage = read_font_usage(_cache_manager()) + except Exception: + logger.debug("[FontCatalog] Could not read font usage", exc_info=True) + usage = None + + used_by = {} + if usage is not None: + # The snapshot keys fonts by file stem, as the catalog does; match + # case-insensitively too, since FontManager lower-cases families. + by_lower = {key.lower(): key for key in catalog} + for key, plugin_ids in usage['fonts'].items(): + row = key if key in catalog else by_lower.get(key.lower()) + if row is not None: + used_by.setdefault(row, set()).update(plugin_ids) + + merged = { + key: dict(info, used_by=(sorted(used_by.get(key, ())) if usage is not None else None)) + for key, info in catalog.items() + } + return jsonify({'status': 'success', 'data': { + 'catalog': merged, + 'font_usage': { + 'available': usage is not None, + 'generated_at': usage['generated_at'] if usage is not None else None, + }, + }}) + + @api_v3.route('/fonts/catalog', methods=['GET']) def get_fonts_catalog(): """Get fonts catalog""" @@ -18,7 +58,7 @@ def get_fonts_catalog(): from web_interface.cache import get_cached, set_cached cached_result = get_cached('fonts_catalog', ttl_seconds=300) if cached_result is not None: - return jsonify({'status': 'success', 'data': {'catalog': cached_result}}) + return _catalog_response(cached_result) except ImportError: # Cache not available, continue without caching get_cached = None @@ -116,7 +156,7 @@ def get_fonts_catalog(): except Exception: logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True) - return jsonify({'status': 'success', 'data': {'catalog': catalog}}) + return _catalog_response(catalog) except Exception as e: logger.error("%s failed", request.path, exc_info=True) return jsonify({'status': 'error', diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index 3994bd5d..703f3165 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -9,10 +9,11 @@

Available Font Families

-
+
Loading...
-

All available font families in the system

+

All available font families in the system. +

@@ -299,8 +300,11 @@ async function loadFontData() { try { // Use absolute URLs to ensure they work when loaded via HTMX + // no-store: /api/v3 GETs are browser-cacheable for 5 s, and this + // reload follows an upload or delete (or the usage check before one), + // so a cached copy would show the list as it was before the change. const [catalogRes, tokensRes] = await Promise.all([ - fetch(`/api/v3/fonts/catalog`), + fetch(`/api/v3/fonts/catalog`, { cache: 'no-store' }), fetch(`/api/v3/fonts/tokens`) ]); @@ -325,6 +329,7 @@ async function loadFontData() { // Update both window properties and local references window.fontCatalog = catalogData.data.catalog || {}; + window.fontUsage = catalogData.data.font_usage || { available: false, generated_at: null }; window.fontTokens = tokensData.data.tokens || {}; // Update local variable references @@ -355,10 +360,29 @@ async function loadFontData() { } } +// The plugin ids that use a font, as the catalog reports them: an array +// (possibly empty) when the display service has reported, null when not. +function fontUsedBy(fontInfo) { + if (!fontInfo || typeof fontInfo !== 'object') return null; + return Array.isArray(fontInfo.used_by) + ? fontInfo.used_by.filter(id => typeof id === 'string') + : null; +} + function updateAvailableFontsDisplay() { const container = document.getElementById('available-fonts'); if (!container) return; + const usage = window.fontUsage || {}; + const note = document.getElementById('font-usage-note'); + if (note) { + const reported = usage.generated_at + ? ` (reported ${String(usage.generated_at).replace('T', ' ')})` : ''; + note.textContent = usage.available + ? `"Used by" lists the running plugins that registered the font with the display service${reported}. A plugin that opens a font file directly is not listed.` + : '"Used by" is unknown: the display service has not reported which fonts its plugins use. Start it, or reload this tab a few seconds after it starts.'; + } + if (Object.keys(fontCatalog).length === 0) { container.innerHTML = '
No fonts available
'; return; @@ -371,18 +395,34 @@ function updateAvailableFontsDisplay() { const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : ''; // Use is_system flag from API (single source of truth) const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false; - return { name, filename, displayName, fontType, fontPath, isSystem }; + const usedBy = fontUsedBy(fontInfo); + return { name, filename, displayName, fontType, fontPath, isSystem, usedBy }; }).sort((a, b) => a.displayName.localeCompare(b.displayName)); - // Build list using DOM APIs to prevent XSS + // Build list using DOM APIs to prevent XSS (plugin ids are untrusted too) container.innerHTML = ''; + + const header = document.createElement('div'); + header.className = 'flex items-center gap-2 pb-1 mb-1 border-b border-gray-600 text-xs uppercase text-gray-400'; + const fontHead = document.createElement('span'); + fontHead.className = 'flex-1 min-w-0'; + fontHead.textContent = 'Font'; + const usedHead = document.createElement('span'); + usedHead.className = 'flex-1 min-w-0'; + usedHead.textContent = 'Used by'; + const actionHead = document.createElement('span'); + actionHead.className = 'w-24 flex-shrink-0'; + header.append(fontHead, usedHead, actionHead); + container.appendChild(header); + fontEntries.forEach(font => { const row = document.createElement('div'); - row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0'; + row.className = 'flex items-start gap-2 py-1 border-b border-gray-700 last:border-0'; const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate flex-1'; + nameSpan.className = 'truncate flex-1 min-w-0'; nameSpan.textContent = font.displayName; + nameSpan.title = font.filename; if (font.fontType) { const typeSpan = document.createElement('span'); @@ -393,29 +433,83 @@ function updateAvailableFontsDisplay() { row.appendChild(nameSpan); + // Wraps rather than truncates: the list is what a delete is weighed + // against, and a title tooltip is out of reach on a phone. + const usedSpan = document.createElement('span'); + usedSpan.className = 'flex-1 min-w-0 break-words'; + if (font.usedBy === null) { + usedSpan.classList.add('text-gray-500', 'italic'); + usedSpan.textContent = 'unknown'; + usedSpan.title = 'The display service has not reported which fonts its plugins use'; + } else if (font.usedBy.length === 0) { + usedSpan.classList.add('text-gray-500'); + usedSpan.textContent = '—'; + usedSpan.title = 'No running plugin registered this font'; + } else { + usedSpan.classList.add('text-gray-300'); + usedSpan.textContent = font.usedBy.join(', '); + } + row.appendChild(usedSpan); + + const actionCell = document.createElement('span'); + actionCell.className = 'w-24 flex-shrink-0 text-right'; if (font.isSystem) { const systemBadge = document.createElement('span'); - systemBadge.className = 'text-gray-600 text-xs ml-2'; + systemBadge.className = 'text-gray-600 text-xs'; systemBadge.textContent = '[system]'; - row.appendChild(systemBadge); + actionCell.appendChild(systemBadge); } else { const deleteBtn = document.createElement('button'); - deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2'; + deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs'; deleteBtn.title = 'Delete font'; deleteBtn.textContent = '[delete]'; deleteBtn.dataset.fontName = font.name; deleteBtn.addEventListener('click', function() { deleteFont(this.dataset.fontName); }); - row.appendChild(deleteBtn); + actionCell.appendChild(deleteBtn); } + row.appendChild(actionCell); container.appendChild(row); }); } +// Which plugins use a font now. Re-reads the catalog so the warning reflects +// the display service's latest report rather than the one the tab opened +// with; falls back to that one if the request fails. +async function currentFontUsedBy(fontName) { + const own = (obj) => obj && typeof obj === 'object' + && Object.prototype.hasOwnProperty.call(obj, fontName); + try { + const response = await fetch('/api/v3/fonts/catalog', { cache: 'no-store' }); + if (response.ok) { + const catalog = (await response.json())?.data?.catalog; + if (own(catalog)) return fontUsedBy(catalog[fontName]); + } + } catch (error) { + console.warn('Could not refresh font usage before delete:', error); + } + return own(fontCatalog) ? fontUsedBy(fontCatalog[fontName]) : null; +} + +// The confirm() text for deleting a font. Plain text: confirm() renders no +// HTML, so a plugin id cannot inject markup here. +function deleteFontConfirmMessage(fontFamily, usedBy) { + if (Array.isArray(usedBy) && usedBy.length > 0) { + return `Font "${fontFamily}" is in use.\n\nUsed by: ${usedBy.join(', ')}.\n\n` + + 'Delete anyway? This action cannot be undone.'; + } + const base = `Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`; + if (usedBy === null) { + return `${base}\n\nWhich plugins use it is unknown: the display service has not reported.`; + } + return base; +} + async function deleteFont(fontFamily) { - if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) { + const usedBy = await currentFontUsedBy(fontFamily); + if (!confirm(deleteFontConfirmMessage(fontFamily, usedBy))) { return; }