From 1bdfb81e890a784c6de5c232680d9f14bf68180b Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 16:20:02 -0400 Subject: [PATCH 1/2] feat: live reload decks while presenting --- README.md | 9 ++- src/glissade/app.py | 157 ++++++++++++++++++++++++++++++++++++++++++-- src/glissade/cli.py | 5 +- tests/test_app.py | 61 +++++++++++++++++ tests/test_cli.py | 15 ++++- 5 files changed, 240 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7d7a9c1..3f08c9a 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,16 @@ items during the meeting. Those notes autosave per slide and can optionally be shown on the projector as an overlay. The overlay is plain text: line breaks are preserved, and the projector toggles it with L. +`glissade start` also watches your project while it is running. Edit a deck +JSON file, swap an image under `decks/`, or change `themes.json`, and every +connected display and remote refreshes to the newest valid version +automatically. If an edit leaves the deck invalid JSON, Glissade keeps showing +the last good version and logs the reload error instead. + `glissade start --open` opens the deck in your browser once the server is actually accepting connections — put `open = true` in `glissade.toml` to make -that a project's default. +that a project's default. Use `--no-watch` if you want a fixed presentation +that ignores on-disk changes. On Windows the first run may raise a firewall prompt: allow private networks so your phone can reach the remote. `--host 127.0.0.1` avoids it if you only diff --git a/src/glissade/app.py b/src/glissade/app.py index 2658e31..980d1e1 100644 --- a/src/glissade/app.py +++ b/src/glissade/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import json from dataclasses import dataclass, field from pathlib import Path @@ -26,6 +27,11 @@ # and phone browsers dropping it. HEARTBEAT_SECONDS = 15 +# The live server polls the project for changes so authoring can happen with +# the deck open. Stdlib polling keeps the install compiler-free everywhere. +WATCH_INTERVAL_SECONDS = 0.25 +WATCH_DEBOUNCE_SECONDS = 0.15 + def clamp_scale(value: Any, fallback: float = DEFAULT_SCALE) -> float: """Coerce anything to a usable scale factor, rounded to a clean step.""" @@ -145,6 +151,56 @@ def save_live_notes(notes_file: Path, notes: dict[str, dict[str, str]]) -> None: pass +def project_snapshot(project: Project) -> dict[str, tuple[int, int] | None]: + """A cheap fingerprint of the files a live presentation depends on. + + Deck JSON and deck-local media all live under decks/, so watching that tree + catches both rewritten slides and swapped images. The local themes.json is + tracked even when absent, so creating or deleting it also reloads clients. + """ + snapshot: dict[str, tuple[int, int] | None] = {} + + if project.decks_dir.is_dir(): + for path in sorted(project.decks_dir.rglob("*")): + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError: + continue + snapshot[str(path.resolve())] = (stat.st_mtime_ns, stat.st_size) + + local_themes = project.root / "themes.json" + if local_themes.is_file(): + try: + stat = local_themes.stat() + snapshot[str(local_themes.resolve())] = (stat.st_mtime_ns, stat.st_size) + except OSError: + snapshot[str(local_themes.resolve())] = None + else: + snapshot[str(local_themes.resolve())] = None + + return snapshot + + +def discover_live_decks(project: Project) -> list[dict[str, Any]]: + """Read the project's decks, but fail on any broken JSON file. + + `deck_lib.discover()` skips unreadable decks for CLI listing and checking, + which is friendly there. Live reload wants the opposite trade: one half- + written file from an editor or AI should keep the last good presentation on + screen instead of silently swapping to a different subset of decks. + """ + decks = deck_lib.discover(project) + expected = {str(path.resolve()) for path in sorted(project.decks_dir.glob("*.json"))} + seen = {str(Path(deck["path"]).resolve()) for deck in decks} + skipped = sorted(Path(path).name for path in expected - seen) + if skipped: + names = ", ".join(skipped) + raise ValueError(f"can't reload until these deck files parse again: {names}") + return decks + + def render_deck( slides: list[dict[str, Any]], live: bool, @@ -196,6 +252,7 @@ class Presentation: # slides are baked into the page they were served. rev: int = 0 listeners: set[asyncio.Queue] = field(default_factory=set) + reload_error: str = "" def live_note_for(self, n: int | None = None, deck: str | None = None) -> str: deck_id = deck if deck is not None else self.deck @@ -228,6 +285,7 @@ def state(self) -> dict[str, Any]: "min_scale": MIN_SCALE, "max_scale": MAX_SCALE, "all_live_notes": self.live_notes_for_current_deck(), + "reload_error": self.reload_error, } def goto(self, n: int) -> dict[str, Any]: @@ -309,7 +367,14 @@ def publish(self) -> dict[str, Any]: return state -def create_app(project: Project, deck_name: str | None = None) -> FastAPI: +def create_app( + project: Project, + deck_name: str | None = None, + *, + watch: bool = False, + watch_interval: float = WATCH_INTERVAL_SECONDS, + watch_debounce: float = WATCH_DEBOUNCE_SECONDS, +) -> FastAPI: all_decks = deck_lib.discover(project) if not all_decks: raise SystemExit( @@ -327,7 +392,20 @@ def create_app(project: Project, deck_name: str | None = None) -> FastAPI: current = deck_lib.resolve(deck_name or settings["deck"], all_decks) - app = FastAPI(title="Glissade", docs_url=None, redoc_url=None) + @contextlib.asynccontextmanager + async def lifespan(app: FastAPI): + if watch: + app.state.watch_task = asyncio.create_task(watch_project()) + try: + yield + finally: + task = getattr(app.state, "watch_task", None) + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + app = FastAPI(title="Glissade", docs_url=None, redoc_url=None, lifespan=lifespan) # Slides are prepared (media inlined) once per deck and cached, since # base64-encoding images on every request would be wasteful. @@ -361,6 +439,72 @@ def deck_by_id(deck_id: str | None) -> dict[str, Any]: app.state.show = show app.state.decks = all_decks app.state.project = project + app.state.themes = themes + + def reload_project_state() -> dict[str, Any]: + """Reload decks, themes and prepared assets after an on-disk change. + + Reload is atomic from the clients' point of view: either the new files + parse and everyone is told to refresh, or the last good presentation + keeps running and only an error message changes. + """ + nonlocal all_decks, current, themes, theme_ids, deck_ids + + new_decks = discover_live_decks(project) + if not new_decks: + raise ValueError(f"no readable decks found in {project.decks_dir}") + new_themes = load_themes(project) + new_theme_ids = [t["id"] for t in new_themes] + new_deck_ids = [d["id"] for d in new_decks] + selected = show.deck if show.deck in new_deck_ids else None + new_current = deck_lib.resolve(selected, new_decks) + if new_current is None: + raise ValueError(f"no readable decks found in {project.decks_dir}") + + all_decks = new_decks + current = new_current + themes = new_themes + theme_ids = new_theme_ids + deck_ids = new_deck_ids + cache.clear() + + show.deck_ids = new_deck_ids + show.theme_ids = new_theme_ids + show.deck = new_current["id"] + if show.theme not in new_theme_ids: + show.theme = new_theme_ids[0] if new_theme_ids else "paper" + show.total = len(slides_for(new_current)) + show.n = max(1, min(show.total, show.n)) + show.reload_error = "" + show.rev += 1 + show._save() + app.state.decks = all_decks + app.state.themes = themes + return show.publish() + + async def watch_project() -> None: + snapshot = project_snapshot(project) + while True: + await asyncio.sleep(watch_interval) + latest = project_snapshot(project) + if latest == snapshot: + continue + await asyncio.sleep(watch_debounce) + latest = project_snapshot(project) + if latest == snapshot: + continue + snapshot = latest + try: + state = reload_project_state() + deck = deck_by_id(show.deck) + print(f" ↻ reloaded {deck['id']} ({len(deck['slides'])} slides)") + except Exception as exc: + show.reload_error = str(exc) + state = show.publish() + print(f" ! reload failed: {exc}") + app.state.last_reload_state = state + + app.state.reload_project = reload_project_state def switch(deck_id: str) -> dict[str, Any]: """Change the running deck. Bumps `rev` so open displays reload — @@ -385,7 +529,12 @@ async def display() -> HTMLResponse: """The projected deck.""" deck = deck_by_id(show.deck) return no_store( - render_deck(slides_for(deck), live=True, themes=themes, title=deck["title"]) + render_deck( + slides_for(deck), + live=True, + themes=app.state.themes, + title=deck["title"], + ) ) @app.get("/control", response_class=HTMLResponse) @@ -401,7 +550,7 @@ async def api_slides() -> JSONResponse: @app.get("/api/themes") async def api_themes() -> JSONResponse: - return JSONResponse(themes, headers={"Cache-Control": "no-store"}) + return JSONResponse(app.state.themes, headers={"Cache-Control": "no-store"}) @app.get("/api/decks") async def api_decks() -> JSONResponse: diff --git a/src/glissade/cli.py b/src/glissade/cli.py index 559ad45..34eda78 100644 --- a/src/glissade/cli.py +++ b/src/glissade/cli.py @@ -208,7 +208,7 @@ def cmd_start(args) -> int: host = args.host or str(defaults.get("host", "0.0.0.0")) deck = args.deck or defaults.get("deck") - app = create_app(project, deck) + app = create_app(project, deck, watch=not args.no_watch and not args._demo) _warn_if_deck_needs_newer(app.state.decks) show = app.state.show current = next((d for d in app.state.decks if d["id"] == show.deck), None) @@ -607,6 +607,8 @@ def common(sp): sp.add_argument("--port", type=int, default=None, help="port (default 8000)") sp.add_argument("--open", action="store_true", help="open the deck in your browser once the server is up") + sp.add_argument("--no-watch", action="store_true", + help="do not auto-reload when deck files change") sp.set_defaults(func=cmd_start) sp = common(sub.add_parser("build", help="write standalone HTML")) @@ -649,6 +651,7 @@ def common(sp): sp.add_argument("--host", default=None) sp.add_argument("--port", type=int, default=None) sp.add_argument("--open", action="store_true") + sp.add_argument("--no-watch", action="store_true") sp.set_defaults(func=cmd_start, _demo=True, dir=None) return p diff --git a/tests/test_app.py b/tests/test_app.py index f5a5ee0..ccde6dc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,5 @@ +import json + from fastapi.testclient import TestClient from glissade.app import clamp_scale, create_app @@ -57,3 +59,62 @@ def test_app_routes(tmp_path, sample_deck_file): assert notes_data["text"] == "Remember to speak clearly." assert len(notes_data["all"]) == 1 assert notes_data["all"][0] == {"n": 1, "text": "Remember to speak clearly."} + + +def test_reload_project_state_refreshes_slides_and_bumps_rev(tmp_path, sample_deck_file): + project = find_project(tmp_path) + app = create_app(project) + client = TestClient(app) + + before = client.get("/api/state").json() + assert before["rev"] == 0 + assert before["reload_error"] == "" + + deck_path = sample_deck_file + data = json.loads(deck_path.read_text(encoding="utf-8")) + data["slides"][0]["heading"] = "Fresh heading from disk" + data["slides"].append( + { + "title": "New ending", + "layout": "title", + "heading": "Thanks", + "body": "

Reloaded.

", + "notes": "Wrap up.", + } + ) + deck_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + app.state.reload_project() + + after = client.get("/api/state").json() + assert after["rev"] == 1 + assert after["total"] == 3 + assert after["reload_error"] == "" + + slides = client.get("/api/slides").json() + assert slides[0]["heading"] == "Fresh heading from disk" + assert slides[-1]["heading"] == "Thanks" + + +def test_reload_project_state_keeps_last_good_deck_on_invalid_json(tmp_path, sample_deck_file): + project = find_project(tmp_path) + app = create_app(project) + client = TestClient(app) + + sample_deck_file.write_text("{ not valid json", encoding="utf-8") + + before = client.get("/api/slides").json() + state = client.get("/api/state").json() + assert state["rev"] == 0 + + try: + app.state.reload_project() + except ValueError as exc: + app.state.show.reload_error = str(exc) + app.state.show.publish() + + after = client.get("/api/slides").json() + state = client.get("/api/state").json() + assert after == before + assert state["rev"] == 0 + assert "can't reload until these deck files parse again" in state["reload_error"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 9b2c0d7..a69dc42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -from glissade.cli import main +from glissade.cli import build_parser, main def test_cli_init_and_decks(tmp_path, monkeypatch): @@ -42,3 +42,16 @@ def test_cli_check_command(tmp_path, monkeypatch): # Run glissade check on scaffolded project ret = main(["check"]) assert ret == 0 + + +def test_cli_start_watch_defaults(): + parser = build_parser() + + args = parser.parse_args(["start"]) + assert args.no_watch is False + + args = parser.parse_args(["start", "--no-watch"]) + assert args.no_watch is True + + args = parser.parse_args(["demo"]) + assert args.no_watch is False From afe1c5acdb929b5e2b46e2d650bcba709e03c07e Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 16:29:01 -0400 Subject: [PATCH 2/2] chore: release glissade 0.13.0 --- pyproject.toml | 2 +- src/glissade/__init__.py | 2 +- src/glissade/data/demo/decks/gallery.json | 2 +- src/glissade/data/demo/decks/tour.json | 6 +++--- src/glissade/data/scaffold/AGENTS.md | 2 +- src/glissade/data/schema.json | 2 +- src/glissade/templates/control.html | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 403c6d3..a6af6e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "glissade" -version = "0.12.0" +version = "0.13.0" description = "Presentation decks written as JSON, driven from your phone, built into a single self-contained HTML file" readme = "README.md" requires-python = ">=3.10" diff --git a/src/glissade/__init__.py b/src/glissade/__init__.py index 3bb923f..6c2b36f 100644 --- a/src/glissade/__init__.py +++ b/src/glissade/__init__.py @@ -13,7 +13,7 @@ glissade check validate decks before you rely on them """ -__version__ = "0.12.0" +__version__ = "0.13.0" from .project import ( # noqa: F401 DATA_DIR, diff --git a/src/glissade/data/demo/decks/gallery.json b/src/glissade/data/demo/decks/gallery.json index 5dc68f3..5b99d66 100644 --- a/src/glissade/data/demo/decks/gallery.json +++ b/src/glissade/data/demo/decks/gallery.json @@ -227,7 +227,7 @@ "eyebrow": "Needs live internet", "heading": "An external embed", "body": "If it can't load within six seconds, a QR code appears instead of a blank frame. Press R to retry.", - "notes": "External embed. \"media\": {\"src\": \"https://www.youtube.com/embed/…\"}

Use the /embed/ URL form, not watch?v=.

pixi run build lists every slide like this one." + "notes": "External embed. \"media\": {\"src\": \"https://www.youtube.com/embed/…\"}

Use the /embed/ URL form, not watch?v=.

glissade build lists every slide like this one." }, { "n": 16, diff --git a/src/glissade/data/demo/decks/tour.json b/src/glissade/data/demo/decks/tour.json index f806907..214e5f9 100644 --- a/src/glissade/data/demo/decks/tour.json +++ b/src/glissade/data/demo/decks/tour.json @@ -54,11 +54,11 @@ "heading": "Two commands", "left": { "subheading": "Present", - "body": "

pixi run start

Serves the deck to the projector and a remote to your phone. Prints both addresses and a QR code.

" + "body": "

glissade start

Serves the deck to the projector and a remote to your phone. Prints both addresses and a QR code, then watches the project for deck and theme changes.

" }, "right": { "subheading": "Build", - "body": "

pixi run build

Writes every deck to build/ as a standalone file. No server needed to open it.

" + "body": "

glissade build

Writes every deck to build/ as a standalone file. No server needed to open it.

" }, "notes": "layout: \"two-content\" — two independent regions side by side.

Start is what you use in the room. Build is what you do the night before, so there's a file on the laptop that works even if nothing else does." }, @@ -268,7 +268,7 @@ "eyebrow": "The one thing that needs the network", "heading": "External embeds", "body": "A YouTube URL can't be embedded into a file. If it can't load, this slide shows a QR code instead of a blank frame — press R to retry.", - "notes": "layout: \"media-full\" — media fills the slide, text sits in a gradient panel.

Use the /embed/ URL form, not the watch link.

pixi run build lists every slide like this one, so you know your network dependencies before you're standing in front of a room.

Embeds mount only while their slide is showing — otherwise a video would keep playing, audibly, after you moved on." + "notes": "layout: \"media-full\" — media fills the slide, text sits in a gradient panel.

Use the /embed/ URL form, not the watch link.

glissade build lists every slide like this one, so you know your network dependencies before you're standing in front of a room.

Embeds mount only while their slide is showing — otherwise a video would keep playing, audibly, after you moved on." }, { "n": 18, diff --git a/src/glissade/data/scaffold/AGENTS.md b/src/glissade/data/scaffold/AGENTS.md index 1fc7d19..d554573 100644 --- a/src/glissade/data/scaffold/AGENTS.md +++ b/src/glissade/data/scaffold/AGENTS.md @@ -9,7 +9,7 @@ fastest way to know a deck is sound. Treat a non-zero exit as work unfinished. ```bash glissade check # validate every deck -glissade start # present, with a phone remote +glissade start # present, with a phone remote and live deck reloads glissade build # write standalone HTML to build/ ``` diff --git a/src/glissade/data/schema.json b/src/glissade/data/schema.json index f5bfdf0..4918aa8 100644 --- a/src/glissade/data/schema.json +++ b/src/glissade/data/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://glissade.dev/schema/v1.json", "x-glissade-schema": 1, - "x-glissade-version": "0.12.0", + "x-glissade-version": "0.13.0", "title": "Glissade deck", "description": "A presentation deck. Either an object with metadata and a slides array, or a bare array of slides.", "oneOf": [ diff --git a/src/glissade/templates/control.html b/src/glissade/templates/control.html index ab839cc..f1cceb2 100644 --- a/src/glissade/templates/control.html +++ b/src/glissade/templates/control.html @@ -461,7 +461,7 @@

if(openPanelName !== 'settings') openPanel('settings'); const el = $('sizewarn'); el.textContent = r.status === 404 - ? 'This server was started before text sizing existed — restart it (pixi run start).' + ? 'This server was started before text sizing existed — restart it (glissade start).' : 'Could not reach the server.'; el.classList.add('on'); }