diff --git a/SESSION_RESYNC_PLAN.md b/SESSION_RESYNC_PLAN.md new file mode 100644 index 00000000..f1d73432 --- /dev/null +++ b/SESSION_RESYNC_PLAN.md @@ -0,0 +1,550 @@ +# Session resync — surviving a renderer loss + +**Status:** proposal, with **Phase 1 complete and measured** (§10). Nothing +behavioural implemented — only the lifecycle diagnostics in +`electron/src/main/index.ts` (`4e1af8b`, logging only) and the two Phase 1 +probes. Phase 2 onwards is unbuilt and awaiting the decisions in §5. + +**Symptom.** Close a Mac laptop lid, reopen it, and every plot window is gone +from the workspace. The app still works — you just re-load the data. + +--- + +## 1. What is already established + +Two facts from the code rule out the obvious suspects before any reproduction. + +**The Python backend is alive.** Originally inferred: `runner.ts` never +respawns it (on `close` it sets `proc = null` and every later `sendAction` +no-ops), so if it had died, re-loading data afterwards would be impossible. +**Since confirmed directly — the Python process is still running after a real +Mac lid-close** (§10.4). Python survives the sleep with its signal trees +intact. + +**The workspace is renderer-only state.** The window list lives in +`SpyDEContext`'s `windows: Map`, in memory. Nothing +persists it and nothing rebuilds it from the backend. So the backend can be +holding every signal while the workspace shows nothing — exactly the reported +symptom, including "just re-load the data". + +**Therefore:** the renderer lost its state while Python kept running. Whether +the renderer *process* died (crash / GPU-process death on resume) or it survived +and something reset the state is what the diagnostics answer. **That distinction +changes the trigger, not this plan** — a workspace that cannot be rebuilt from +the backend is a latent bug under any renderer loss, including a devtools reload +or an `npm run dev` hot restart. + +## 2. Why it happens: an asymmetry inside SpyDE + +The Report Builder already solves this problem. `report_state` is a **full +document snapshot** — `emit_state()` is called 41 times across the report +handlers, and the renderer *mirrors* it wholesale rather than accumulating +deltas. That is why a report survives things the workspace doesn't. + +The window layer never got that treatment. It is built up from incremental +messages (`figure`, `window_title`, `window_visibility`, `window_computing`, +`window_closed`) with **no snapshot** and **no way for the renderer to ask**. +The backend has `_reemit_signal_tree`, but it is per-tree, internally triggered, +and only re-sends the workflow tree — not the windows or their figures. + +This is the standard split-process pattern (JupyterLab kernels outliving the +browser; VS Code's extension host surviving Reload Window). SpyDE has the right +architecture and implements the resync in one layer out of two. + +## 3. Proposed design + +**Make the backend authoritative for the workspace, and add a resync +handshake.** Not new architecture — the window layer behaving like the report +layer. + +### 3.1 `session_state` — a snapshot verb + +A new action, mirroring `report_state`: + +``` +renderer boots ──▶ sendAction('session_state') + │ +backend ◀───────────────┘ + └─▶ for every live plot: re-emit the messages that construct it +``` + +The renderer already reconstructs a window purely from messages it receives, so +the backend does not need a new *description* format — it needs to **replay the +messages it already sends**. That is the cheap part, and it is why this is +tractable. + +### 3.2 What must be replayed, per window + +From `SpyDEWindow` / `SpyDEFigure`: + +| field | source | difficulty | +|---|---|---| +| `windowId`, `title`, `isNavigator`, `visible` | `session._plots` | trivial | +| `toolbarActions` | toolbar YAML + gating | already re-derived per window | +| `figures[].figId`, `filePath`, `title` | `figure_registry` | **see 3.3** | +| `aspect` | plot's current data shape | trivial | +| `view` / `viewLabel` / `strainComponents` | plot state | moderate | +| signal tree | `_reemit_signal_tree` | **already exists** | + +### 3.3 The part that is not free + +A window is not a box. Each carries a live anyplotlib figure that the renderer +mounts as an **iframe pointing at an HTML file on disk** (`SpyDEFigure.filePath`, +set from the `figure` message's `file_url`). + +Three questions decide the real cost, and I do not yet know the answers: + +1. **Do those HTML files still exist after a resume?** If `figure_registry`'s + keep-alive holds them, a resync is close to re-sending the same `figure` + messages with the same paths. If they are temp files that get cleaned, every + figure must be re-rendered. +2. **Is the figure's live state recoverable?** Contrast, colormap, zoom and + selector positions ride in the iframe via `awi_state`. `replayState` / + `latestStates` already exists to re-push state into a freshly-mounted iframe + — that machinery is the reason this may be much cheaper than it looks, and it + is the first thing to verify. +3. **What about in-flight compute?** A window mid-`window_computing` needs a + defined resync state. Simplest defensible answer: resync shows the last good + frame, and any genuinely in-flight future either completes into the rebuilt + window or is dropped. + +## 4. Phasing + +**Phase 0 — confirm the trigger (blocked on you).** One lid-close with the +lifecycle logging, to see whether `render-process-gone` / `child-process-gone` +fires. Does not change the plan; does tell us whether a *recovery* path is also +needed (a dead GPU process may need a WebGPU re-init, not just a resync). + +**Phase 1 — the probe.** Answer 3.3's three questions with a throwaway +experiment: reload the renderer (devtools reload) with data loaded, and see how +much of a window can be reconstructed from what the backend still holds. This is +the honest scoping step — everything after it is guesswork until it is done. + +**Phase 2 — `session_state` for windows.** Backend replays construction +messages for every live plot; renderer requests it on boot when it has no +windows. Ship this alone: it fixes the reported symptom. + +**Phase 3 — figure state.** Re-push `awi_state` per figure so contrast/zoom/ +selectors come back, not just the windows. + +**Phase 4 — geometry (optional, needs a decision).** See §5. + +## 5. Decisions I need from you + +1. **Geometry.** Should resync restore window *positions and sizes*, or + re-create the windows and let the MDI lay them out fresh? Restoring geometry + means the renderer must report layout back to the backend continuously (the + backend does not know where windows are) — that is a real new channel, and I + would default to **fresh layout** unless you want otherwise. +2. **Scope of "authoritative".** Phase 2 makes the backend able to *describe* + the workspace. Should it also become the source of truth for window + open/close, or stay a mirror the renderer can rebuild from? Mirror is much + less invasive. +3. **Trigger.** Resync automatically on renderer boot, or a visible "Restore + session" affordance? Automatic is better UX; explicit is safer while the path + is new and cannot silently double-create windows. + +## 6. Explicitly out of scope + +- Persisting the workspace to disk across an app *quit* (that is session + restore, a different feature — §2's pattern 2). +- Reconnecting or restarting the Dask cluster — but **not because it is + unaffected**. See §8: it is a genuinely open question and probably a second, + independent bug. +- Any change to the navigator read path, chunking, or signal-tree internals. + +## 7. Cost + +Phase 1 is under a day and is what makes the rest estimable. Phase 2 is the bulk +and is bounded by 3.3's answers — cheap if the figure HTML and `awi_state` +survive, considerably more if every figure must re-render. **I would not commit +to a Phase 2 estimate before Phase 1 runs.** + +--- + +## 8. Correction: does the Dask cluster survive? + +An earlier draft of this document asserted that the cluster survived, "which is +why re-loading data works". **That claim was wrong and is withdrawn.** Neither +observed symptom tells us anything about the cluster. + +**Re-loading data working proves nothing.** `Session._await_dask()` gates every +load on `self._dask_ready`, which is a `threading.Event` — a **one-shot latch**. +It is `set()` once when the cluster first comes up and is cleared in exactly one +place in the codebase (`compute_config.py`, on an explicit user-driven cluster +restart). Nothing clears it when a cluster *dies*. So after a resume, a load +sails straight through the gate and proceeds against a **dead client**, exactly +as it would against a live one. The load succeeding is evidence about the latch, +not about the cluster. + +**The dashboard disappearing proves nothing either.** `dashboardUrl` lives in +the *same* `SpyDEContext` React state as `windows`. Whatever loses the workspace +loses the dashboard link with it — one state object, one loss. (It is also the +field that commit `6731786` just fixed for an unrelated reason: a late `ready` +message with no dashboard field was overwriting it. Same field, different bug.) + +So there are now **two independent unknowns**, and the two symptoms are +consistent with either or both: + +| | cluster alive | cluster dead | +|---|---|---| +| **renderer state lost** | windows + dashboard link both gone; compute still fine | windows + dashboard gone; compute silently broken | +| **renderer state kept** | not what is observed | dashboard link would persist but be dead | + +### 8.1 The latent bug, independent of sleep + +**Nothing in SpyDE detects cluster death.** There is no liveness check, no +heartbeat consumed, and no path that re-arms `_dask_ready`. Whenever a cluster +dies for any reason, the app keeps accepting loads and computes against a dead +client instead of waiting, restarting, or saying so. Sleep may simply be the +easiest way to trigger it. + +This is worth fixing on its own merits and is **separable** from the resync +work. It is also the more dangerous of the two: a vanished window is obvious, +whereas a silently dead compute backend is not. + +### 8.2 How to actually tell + +Cheap discriminators, in order of effort: + +1. **Look for the workers.** After a resume, `ps` for the `dask-worker` / + spawned Python children (macOS: `pgrep -fl distributed`). Present and + parented correctly → the cluster lived. +2. **Ask the backend, not the UI.** The dashboard *link* is renderer state; the + cluster is not. A backend-side probe (`client.scheduler_info()` in the + console cell, or a log line on `power:resume`) answers the question directly + without going through the state that we already know gets lost. +3. **Try a compute, not a load.** A load passes the latch either way. Something + that actually round-trips through the scheduler is the real test. + +### 8.3 Consequence for this plan + +Phase 0 grows one question: *is the cluster alive after resume?* — answered by +8.2, not by the UI. If it is dead, that is a second workstream (detect death, +re-arm the gate, restart or report) that this document does **not** currently +scope, and it should not be folded into the resync work. + +--- + +## 9. Second workstream: cluster liveness + +Scoped here at your request. **Separate from the resync work above** — different +trigger, different code, different failure mode — and shipped independently. +Fold them together only if §8.2 shows they share a root cause. + +### 9.1 The gap is DETECTION, not recovery + +Recovery already exists and is proven in production code. `compute_config.py`'s +restart path does exactly the right three things: + +```python +session._dask_ready.clear() # loads wait instead of racing a dead client +session.dask_manager.restart(n_workers, threads_per_worker) +# … _on_dask_ready re-opens the gate when the new cluster registers +``` + +That is the whole recovery primitive, already written, already used. What is +missing is **anything that calls it when a cluster dies on its own**. Today the +only trigger is a user changing compute settings. + +So the work is: notice, then reuse. + +### 9.2 Where detection could live + +| option | how | cost | verdict | +|---|---|---|---| +| **Poll the scheduler** | periodic `client.scheduler_info()` on a worker thread | one timer, no new deps | plausible default; must not run on the asyncio main thread | +| **Consume what already exists** | `DaskStatsSampler` (backend/dask_stats.py) already samples worker CPU/mem for the StatusBar HUD — a sampler that starts failing IS the signal | near-zero: the loop exists | **preferred if its failure mode is distinguishable** — verify before committing | +| **distributed's own callbacks** | scheduler/client status hooks | least polling | needs a check that they fire for the death modes we care about (sleep, OOM-kill, worker loss ≠ scheduler loss) | +| **On `power:resume` only** | probe once after a wake | trivial | too narrow — this bug is not sleep-specific | + +`DaskStatsSampler` is the interesting one: there is already a loop talking to the +cluster on a cadence, so a liveness signal may cost nothing beyond reading its +failures. **Check that first.** + +### 9.3 Policy: what to do once it is noticed + +Three defensible behaviours, in increasing ambition: + +1. **Report.** Clear the gate, emit an error, let the user restart the cluster + from the existing compute-settings UI. Smallest change; makes a silent + failure loud, which is the actual harm. +2. **Restart automatically, once.** Call the §9.1 primitive; emit status while it + comes back. Good UX, but see the traps. +3. **Restart and resubmit.** Re-run whatever was in flight. **Not recommended** — + see 9.6. + +I would ship (1), then (2) behind the same status-bar surface the HUD already +owns. + +### 9.4 Phasing + +- **9-A — measure.** Answer §8.2: does the cluster actually die on a Mac + lid-close? If it does not, this workstream is still worth doing (nothing + detects death from *any* cause) but drops in priority. +- **9-B — detect.** Whichever of 9.2 survives inspection. Ship it as a log line + + status message only; no behaviour change. Confirms the detector fires when + it should and, more importantly, does **not** fire when it shouldn't. +- **9-C — re-arm the gate.** On detection, `_dask_ready.clear()`. This alone + converts "silently computes against a dead client" into "waits, then times out + with a real message" — a large improvement for a tiny diff. +- **9-D — restart.** Wire the existing primitive to the detector. + +### 9.5 Decisions needed + +1. **Auto-restart, or report and let the user act?** (9.3 (1) vs (2).) +2. **What counts as "dead"?** A lost *worker* is not a lost *cluster* — + `distributed` replaces workers routinely, and treating that as death would + restart the cluster under normal operation. The detector must distinguish + scheduler loss from worker churn, and that distinction is the whole risk. +3. **Behaviour mid-compute.** If detection fires while a long compute is in + flight, does it cancel and restart, or wait? + +### 9.6 Traps + +- **A false positive is worse than the bug.** Restarting a healthy cluster + cancels in-flight work. Whatever detector is chosen needs a consecutive-failure + threshold, not a single miss — and 9.5(2) is why. +- **Do not probe from the asyncio main thread.** The existing code is careful + about this (`_await_dask` documents "never call on the main asyncio thread"); + a liveness probe has the same constraint. +- **Do not auto-resubmit.** SpyDE's computes are not all idempotent, results + land through `_dispatch_to_main` into plot state, and a resubmit racing a + rebuilt window is a much harder bug than the one being fixed. +- **Threaded mode has no cluster.** `ComputeBackend` runs threaded by default in + some configurations and `SPYDE_NO_DASK=1` sets the gate pre-opened; the + detector must no-op there rather than reporting a permanently dead cluster. +- Nothing here touches the navigator read path, chunking, or the signal tree. + +--- + +## 10. Phase 1 results — MEASURED, not assumed + +Phase 1 is done. Two probes, both committed and re-runnable: + +- `electron/tests/resume_probe.spec.ts` — drives the real app. +- `spyde/tests/migrated/test_dask_ready_latch.py` — characterises the load gate. + +A lid-close cannot be driven from CI, so the app probe uses the faithful proxy: +**reload the renderer with data loaded**. That destroys the renderer's in-memory +React state exactly as a renderer-process death would, while leaving Python and +its cluster untouched — the situation §1 reasons about. + +### 10.1 What survives a renderer loss + +``` +BEFORE windows=2 iframes=2 dashboardLink=true figureFiles=2 +AFTER windows=0 iframes=0 dashboardLink=false figureFilesStillOnDisk=2/2 +BACKEND exited=false logGrew=true +RELOAD worked=true windows=2 +``` + +| thing | survives? | consequence | +|---|---|---| +| Plot windows | **NO** — 2 → 0 | §1's premise is confirmed, not assumed | +| Figure iframes | **NO** — 2 → 0 | follows from the windows | +| Dask dashboard link | **NO** | confirms §8: same React state as `windows`, so it proves nothing about the cluster | +| Python backend | **YES** — never exited, log kept growing | resync has something to resync FROM | +| **Figure HTML files on disk** | **YES — 2/2** | **the Phase 2 cost driver, and it is the cheap answer** | +| Usability | **YES** — re-loading data rebuilt both windows | matches the reported "it still works" | + +### 10.2 The important one + +**The figure HTML files outlive the renderer.** Figures are served over the +custom `spyde-fig://figures/` protocol, which resolves to a real file in +the OS tmpdir (`main/index.ts` `resolveFigPath`); the *main* process writes +them, so a renderer loss does not touch them. + +That answers §3.3 Q1 and puts Phase 2 on its cheap branch: a resync can re-send +the **same `file_url`s** and the iframes remount from files that are already +there. No figure re-render, no backend recompute. §3.3 Q2 (`awi_state` replay) +is now the only significant unknown left. + +### 10.3 The gate is a latch — confirmed + +`test_dask_ready_latch.py` pins three things: + +- `_await_dask()` returns immediately from a pure `Event` read; **no cluster is + consulted**. +- With the client torn out from under the session — the state a *death* leaves, + as opposed to a *restart* — the gate **still opens instantly**. A load + proceeds against a dead client and cannot tell. +- Structurally, there is **exactly one** `_dask_ready.clear()` in the codebase + (the user-driven restart in `compute_config.py`), and **no** liveness path of + any shape. + +So "re-loading data works" is evidence about the latch, not the cluster. §8's +withdrawal holds, and §9's premise is now measured rather than argued. + +### 10.4 Corroborated on a real Mac lid-close + +The probe uses a proxy, so its "backend survives" result could in principle have +been an artefact of reloading the renderer rather than genuinely sleeping the +machine. It is not: **on the real Mac lid-close, the Python process is still +running** (observed directly, 2026-08-05). + +That closes the loop on §1. The backend surviving is no longer an inference from +"re-loading data works" — it is observed on the actual failure, on the actual +platform, and it agrees with the proxy. The proxy is therefore a sound stand-in +for this bug, and Phase 2 has something real to resync from. + +### 10.5 What is STILL open + +**A running Python process is not a healthy cluster.** The Dask workers are +*separate child processes* of the backend (and the scheduler is its own +endpoint), so "the backend is alive" says nothing about whether the workers +survived the sleep. §8's two unknowns collapse to one, not zero: + +| question | status | +|---|---| +| Does the backend survive a Mac sleep? | **answered — yes**, observed directly | +| Do the Dask workers / scheduler survive it? | **still open** — needs §8.2 | + +And §10.3 is exactly why the app cannot answer the second one for us: the gate +is a latch, so a load succeeds either way. The discriminators in §8.2 are still +the only way to tell — look for the worker processes (`pgrep -fl distributed`), +probe from the backend rather than the UI, and run a *compute* rather than a +load. + +So: the workspace loss is now fully explained and measured. The cluster question +is untouched by that explanation and remains §9's to answer. + +### 10.6 Effect on the plan + +- §1 premise: **confirmed by measurement, and corroborated on a real Mac + lid-close** (§10.4). +- Phase 1: **complete.** Its two probes stay in the tree as characterisation + tests — `test_dask_ready_latch.py` is written to FAIL when a liveness detector + is added, which is the point. +- Phase 2: **cheaper than feared** — re-send existing `file_url`s. +- Phase 3 (`awi_state` replay) is now the main remaining unknown. + +--- + +## 11. Phase 0 results — and a hypothesis that did NOT survive + +Reported from a real Mac lid-close (2026-08-05): + +| observation | result | +|---|---| +| Dask worker count, before vs after | **unchanged** | +| A real distributed compute after resume | **worked** | +| `render-process-gone` | **did not fire** | +| `child-process-gone` | **did not fire** | + +### 11.1 The cluster survives + +Settled, and by the right test — a compute that round-trips through the +scheduler, not a load that the §10.3 latch would wave through regardless. + +**§9 drops in priority.** It does *not* go away: nothing detects cluster death +from *any* cause, and §10.3 shows the app would keep queueing work against a +dead cluster silently. That is still a real latent bug. It is just no longer +implicated in *this* one. + +### 11.2 The leading hypothesis is now in doubt + +§1 concluded the renderer lost its state, and the natural mechanism was the +renderer *process* being recreated. **Neither process-gone event fired**, which +argues against exactly that. + +If the renderer process genuinely survived, then something inside the app +**cleared** the workspace — and that is a different, more specific, and probably +much cheaper bug than the one this document scopes. Candidates worth eliminating +before building anything: + +- the renderer received `window_closed` (or equivalent) for every window; +- a React remount high enough in the tree to reset `SpyDEContext`; +- an error boundary or a thrown render that reset state without killing the + process. + +### 11.3 The control question — unresolved + +**Did `[spyde lifecycle …] power:suspend` / `power:resume` print at all?** + +This is the difference between two very different conclusions: + +- **They printed, process-gone did not** → real finding. The renderer survived, + §11.2 applies, and there is a root cause still to find. +- **Nothing printed** → the diagnostics were not live (packaged build, stale + `out/`, or the run predating `4e1af8b`), and the test is **inconclusive** — + absence of evidence, not evidence of absence. + +Until that is answered, §11.2 is a hypothesis, not a result. + +### 11.4 What this changes about "good to go" + +The resync work in §3 is still the right *robustness* fix and is unaffected by +any of this — a workspace that cannot be rebuilt from the backend is a bug under +any renderer loss (devtools reload, hot restart, future crash), which §10.1 +measured directly. + +But if §11.2 holds, resync would be **papering over** a specific state-clearing +bug rather than fixing it, and that bug would still be there afterwards — +clearing the workspace on every future occurrence, just with a recovery path +behind it. Worth one round of §11.3 before committing to Phase 2. + +--- + +## 12. The control is answered — and it reopens the diagnosis + +Log from the real lid-close: + +``` +[spyde lifecycle 2026-08-05T12:28:01.801Z] power:lock-screen +[spyde lifecycle 2026-08-05T12:30:41.274Z] power:resume +[spyde lifecycle 2026-08-05T12:30:41.441Z] power:unlock-screen +``` + +**The diagnostics were live.** `power:resume` printed, so the absence of +`render-process-gone` / `child-process-gone` is a genuine negative, not a dead +listener. §11.2 is now a **finding**: the renderer process survived. + +### 12.1 Which means the workspace was not necessarily "lost" + +`SpyDEProvider`'s state is a `useReducer` initialised once (`SpyDEContext.tsx` +:805). Nothing in the reducer clears `windows` wholesale. With the process alive, +that leaves only two mechanisms — and they are **very** different jobs: + +**A. The page reloaded.** A navigation resets the React tree and every Map with +it. Would appear in the log as `renderer-navigation` and +`renderer-did-finish-load` around `power:resume`. If this is it, §3's resync is +the right fix and Phase 2 proceeds. + +**B. The windows were never lost — they are off-screen or unrendered.** +`MDIArea` positions subwindows at **absolute pixel coordinates** derived from +`areaRef.clientWidth/clientHeight` (`MDIArea.tsx` :134, :211) and caches them in +`placedRef` (:113, :230). A Mac lid-close routinely changes the display +configuration; if the area measures 0 — or much smaller — during resume, cached +placements can land outside the visible region. The windows would still be in +state, still streaming, and simply **not where you can see them**. + +If B is the mechanism, this is a *layout* bug, and the fix is re-clamping +placements on an area-size change — dramatically smaller than §3, and it would +make the resync work unnecessary for THIS symptom (though still correct for +genuine renderer loss, per §10.1). + +### 12.2 The two-second discriminator + +`MDIArea` keeps hidden windows **listed in the top bar** (:108 — a hidden window +is `display:none` and still enumerated). + +So, next time it happens: **does the window bar still list the windows?** + +| top bar | mechanism | fix | +|---|---|---| +| Still lists them | **B** — state intact, they are off-screen / hidden | re-clamp placements on resize; small | +| Empty too | **A** — state genuinely gone | §3 resync; Phase 2 as scoped | + +Second confirmation for A, from the same log you already have: whether +`renderer-navigation` / `renderer-did-finish-load` appear near `power:resume`. +Those lines are already being written — they just were not in the excerpt. + +### 12.3 Status + +**Phase 2 is on hold.** Not because the resync is wrong — §10.1 measured the +workspace dying under a plain renderer reload, so it remains a real robustness +gap — but because building it now risks papering over a much cheaper layout bug +while leaving the actual cause in place. + +One look at the window bar decides it. diff --git a/doc/presentations/README.md b/doc/presentations/README.md new file mode 100644 index 00000000..bfa1c518 --- /dev/null +++ b/doc/presentations/README.md @@ -0,0 +1,116 @@ +# Presentations + +Talks about SpyDE, authored **in** SpyDE — each one is a real `.spyde-report` +presentation document, not a PDF. + +| file | what it is | length | +|---|---|---| +| `spyde-overview.spyde-report` | "SpyDE — an overview": where the project came from, the HyperSpy stack it builds on, the Electron/anyplotlib architecture, the case for open and reproducible analysis, and where it is going | 21 slides, ~12.4 min | + +## Opening a deck + +1. Launch SpyDE (`cd electron && npm run dev`, or a released build). +2. Open the **Report** sidebar — the panel toggle at the top right of the app bar. +3. **Open**, and pick the `.spyde-report` file. +4. **Present** for the full-screen deck. + +In Present mode: `→` / `Space` / `PageDown` advance, `←` / `PageUp` go back, +`Home` / `End` jump to the ends, `Esc` exits. **`S` toggles the presenter view** — +the current slide, the next one, the speaker notes, and a timer. A presentation +clicker sends arrow / page keys, so it works without extra setup. + +Every slide carries speaker notes. They are visible only in the presenter view, +never to the audience and never in an exported deck. + +## Editing + +The deck is ordinary content — open it and edit the slides in the sidebar like +any report. Slides can be reordered by dragging their grips, and +`Export ▾` writes static HTML, interactive HTML, PDF, or a markdown folder. + +`spyde-overview.spyde-report` is **generated**, so the durable source is the +script: + +```bash +python doc/presentations/build_spyde_overview.py +``` + +Three tables in `build_spyde_overview.py` are what you edit: + +| table | what it controls | +|---|---| +| `SPEAKER` | name, role, affiliation, email, **venue and date**. Feeds the title card, the closing card and the footer bar, so booking a new venue is one edit. Leave `venue`/`date` blank and the line is omitted rather than left dangling. | +| `THEME` | the deck's look — background, text, muted, accent, font stack, logo, footer. Written into the document's `theme:` front matter, so it travels with the file. | +| `SLIDES` | the talk: text, layout, speaker notes, per-slide time budget. | + +The script prints the slide count, the total time budget and the resolved theme +on every rebuild, and **fails the build** if the budget leaves the ~11–13.5 min +slot the deck targets — better to find that here than on stage. + +If you edit the deck in the app instead and save over the file, the script +becomes stale — that is fine, but say so in the commit. + +Nothing on a slide is a placeholder. Anything still to be decided lives in the +speaker notes, where a projector can't show it to the room. + +### Screenshots + +The app screenshots live in `media/` and are captured from the **real app** by +`electron/tests/talk_screenshots.spec.ts` (a capture run, not a regression test): + +```bash +cd electron +npx playwright test tests/talk_screenshots.spec.ts --project=electron \ + --reporter=line --retries=0 +cp talk_shots/*.png ../doc/presentations/media/ +``` + +`build_spyde_overview.py` crops each shot to the region that carries meaning +(`CROPS`) and caps its width (`IMAGE_WIDTH`) before embedding it, which is what +keeps the committed deck under a megabyte. Re-capturing at a different window +size will shift the crop boxes; the crop clamps rather than raising, so check the +slides afterwards. + +### Verifying + +`electron/tests/talk_present.spec.ts` opens the committed deck in the real app, +pages through every slide in Present mode, screenshots each one to +`electron/talk_present_shots/`, and fails on a slide that renders no text or +overflows horizontally. It also asserts the **theme** survives the round trip: +the deck's background and accent, the footer bar with its embedded logo and +contact line, that a title card carries *no* footer, and that slide headings +take the themed colour rather than the stylesheet's hard-coded one. + +Look at the screenshots — that is the actual check. `N_SLIDES` and the theme +colours in the spec are duplicated from the build script on purpose: if you +change one, the other fails loudly. + +```bash +cd electron +npx playwright test tests/talk_present.spec.ts --project=electron \ + --reporter=line --retries=0 +``` + +## The file format + +A `.spyde-report` is a plain zip you can unzip and read: + +``` +report.md # YAML front-matter + markdown body — the whole document +figures/.yaml # a live figure recipe, per figure cell +assets/.png # the baked snapshot / embedded image, per cell +``` + +`report.md` is valid standalone markdown (pandoc-ready once unzipped). +Presentation-only attributes — slide breaks, title slides, background styles, +speaker notes — ride as invisible HTML comments, so an external markdown renderer +shows the prose and ignores the rest. `type: presentation` in the front matter is +what makes the document a deck rather than a scrolling report, and `theme:` +carries its look. The logo is embedded as a `data:` URL rather than a path, so +the deck survives being emailed to someone whose disk has never had this repo on +it. + +This deck uses only markdown, image, and split cells, so it carries no live +signal bindings and opens standalone with no data loaded. A deck built from your +own session can instead hold **live figure cells** that re-bind to the signal when +you reopen it with the data loaded. diff --git a/doc/presentations/SpyDE_an_overview2.html b/doc/presentations/SpyDE_an_overview2.html new file mode 100644 index 00000000..0c8ee6d5 --- /dev/null +++ b/doc/presentations/SpyDE_an_overview2.html @@ -0,0 +1,470 @@ + + + + + +SpyDE — an overview2 + + + +
+

SpyDE — an overview2

+
# SpyDE
+
+## Interactive analysis for electron microscopy
+
+Carter Francis · R&D Scientist · Direct Electron
+
+cfrancis@directelectron.com
+

Motivation

+
    +
  • Code Base Solutions are Good but...

    +
      +
    • Sometimes all you want is a black box
    • +
    • Black boxes are fine, but you HAVE to be able to open it up and look inside
    • +
    +
  • +
  • Data analysis software has to be tested

    +
      +
    • Both end to end validation pipelines
    • +
    • Also needs to be validated though years of testing and refinement
    • +
    +
  • +
+

Open-Source + DE

+
    +
  • The open source community in Electron Microscopy is one of our greatest strengths
  • +
  • At DE we've made a commitment to supporting open data formats, but also to supporting open source software
  • +
+
+
+

How can/should a Company Support Open Source

+
    +
  • Offer stablity to projects, vender new solutions for visualization and ease of use
      +
    • Keep attribution to the scientists who developed the code.
    • +
    • Provide support for training
    • +
    +
  • +
  • Make a commitment to support loading your file format
  • +
+

Act as a bridge between different open source packages

+
+
+ +
+
+
+
+
+

What SpyDE is

+

A desktop application for visualising and analysing electron microscopy data — TEM, STEM, cryo-EM, 4D-STEM, EELS.

+
    +
  • Navigator beside the signal, live, including on data far larger than memory
  • +
  • Opens .hspy .zspy .mrc .tif .de5, and DE's sparse .csb event streams --> Can open anything hyperspy does
  • +
  • HyperSpy is used as the base, with over 15 years of development, 1000's citations, it's a proven ground truth
  • +
+

A two way street. SpyDE was specifically designed to improve HyperSpy as well, bridge the gap between older packages like HyperSpy and newer packages like quantEM

+
+
+
+
+
+

Lazy Data Processing

+

Two processes, one line protocol

+
  Electron main (Node)  ──spawn──▶   python -m spyde
+         │                                 │
+    IPC / preload                  asyncio stdin/stdout
+         │                           PLOTAPP: JSON lines
+         ▼                                 
+  React + TypeScript renderer  ◀───────────┘
+
+
    +
  • The scientific stack stays in Python — HyperSpy, pyxem, Dask, torch
  • +
  • The interface is a modern web stack — React, TypeScript, Electron
  • +
  • Directly transferrable to Jupyter/ other notebook based programs
  • +
+
+
+

anyplotlib — plotting that stays interactive

+

matplotlib's object-oriented API, rendered in the browser instead of in Python. apl.subplots(), ax.imshow(), ax.plot() — switching is often a one-line change.

+
    +
  • Pan, zoom and drag never touch the kernel, so interaction stays at frame rate on large data
  • +
  • Widgets — crosshair, ROI, span — report positions back to Python; SpyDE binds them to navigation axes
  • +
  • Deliberately light: anywidget, numpy, traitlets, colorcet. No matplotlib required.
  • +
  • Supporting matplotlib is one of the biggest challenges that HyperSpy has faced over the last 5-6 years.
  • +
+
+
+
+
+
+

anyplotlib is a bridge

+

Written for SpyDE. Not owned by it — MIT, on PyPI, with no SpyDE anywhere inside it.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
where it runshow
Jupyter Labas an anywidget embedded in the notebook
SpyDEthe renderer mounted directly in Electron
deapilive plots while a detector is running
HyperSpythe goal: an interactive backend for s.plot()
A report, a websitefig.save_html() — one self-contained file, still interactive, no kernel
+

The figure you explored is the figure you ship.

+
## …and this talk is a SpyDE document
+
+The Report Builder turns a session into a document. Drag a figure out of a window into the report and it stays **live and re-bindable** — reopen the report with the data loaded and it re-renders from the signal.
+
+- `.spyde-report` is a plain **zip**: `report.md` + `figures/*.yaml` + `assets/*.png` — valid markdown you can unzip and hand to pandoc
+- One document, several surfaces: scrolling report, **slide deck**, movie editor
+- Themed in the document, so a deck still looks like yours on someone else's machine
+- Exports to static HTML, interactive HTML, PDF, or a markdown folder
+
+**This deck is `doc/presentations/spyde-overview.spyde-report`.**
+
## Active development
+
+- **Spectroscopy** — EELS and EDS model fitting across a whole spectrum image: edges, backgrounds, quantification
+- **EBSD** — kikuchipy indexing, IPF maps and refinement in the same shell
+- **Atomic resolution** — column finding through atomap
+- **In situ** — drift correction, particle segmentation and tracking, and DE's sparse `.csb` event streams, re-cut at any exposure without re-reading the movie
+- **Apple silicon** — the fitting and EBSD paths run on Metal
+

Roadmap

+

quantem — a PyTorch-native toolkit for quantitative EM: ptychographic phase retrieval, HAADF tomography, neural object representations. + - Support both well tested legacy methods and new cutting edge methods + - Visualization work and development is shared

+
    +
  • anyplotlib as an interactive backend for HyperSpy — the same figures in the notebook and in the app
  • +
  • Analyse during acquisition, not after it — the detector API is already open
  • +
+
## Try it
+
+- **Download** — macOS, Windows and Linux builds: `github.com/CSSFrancis/spyde/releases`
+- **From source** — Node 18+ and `uv`, then `uv sync --extra tests` and `npm run dev`
+- **Docs** — `cssfrancis.github.io/spyde`
+- Python **3.10–3.13** · **GPL-3.0-or-later**
+
+Built on the work of the HyperSpy and pyxem communities — and given back to them.
+
# Thank you
+
+## Questions?
+
+Carter Francis · cfrancis@directelectron.com
+
+github.com/CSSFrancis/spyde
+
+ + diff --git a/doc/presentations/build_spyde_overview.py b/doc/presentations/build_spyde_overview.py new file mode 100644 index 00000000..e7f113bb --- /dev/null +++ b/doc/presentations/build_spyde_overview.py @@ -0,0 +1,825 @@ +""" +build_spyde_overview.py — generate ``spyde-overview.spyde-report``, the ~12-minute +conference talk *about* SpyDE, authored *as* a SpyDE presentation. + +The deck is a real ``.spyde-report`` container (a zip of ``report.md`` + +``assets/*.png``), built through :mod:`spyde.actions.report.model` — the SAME +writer ``report_save`` uses — so the artifact the app opens is the artifact this +script writes. Slides are plain markdown cells (and SPLIT cells for the +text-beside-screenshot slides); the screenshots are IMAGE cells, so the deck +carries no live signal bindings and opens standalone with no data loaded. + +Three tables are the whole file, and they are the things you edit: + +* :data:`SPEAKER` — who is giving the talk. It feeds the title slide, the + closing slide AND the theme's footer bar, so a venue change is one edit. +* :data:`THEME` — the deck's look, written into the document's ``theme:`` + front matter. It travels with the file: hand the deck to a colleague and it + still looks like this. +* :data:`SLIDES` — the talk itself. + +Rebuild after editing any of them:: + + python doc/presentations/build_spyde_overview.py + +Open it in the app: the report sidebar's **Open** button (or the backend action +``report_open`` with ``{"path": ...}``), then **Present**. + +Screenshots live in ``doc/presentations/media/`` and are captured by +``electron/tests/talk_screenshots.spec.ts`` (a capture run, not a regression +test). They are downscaled to :data:`IMAGE_WIDTH` here so the committed zip +stays small. +""" +from __future__ import annotations + +import base64 +import io +import os +import sys + +# Import spyde from the repo checkout when run in-place (no install needed). +# THREE levels up: /doc/presentations/. Two levels lands on +# doc/, which still imported fine from a repo-root cwd — and silently made the +# logo lookup below miss. +_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) + +from spyde.actions.report.model import ( # noqa: E402 + _SPLIT_LAYOUTS, Cell, ReportDoc, new_cell_id, normalize_theme, write_report, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +MEDIA = os.path.join(HERE, "media") +OUT = os.path.join(HERE, "spyde-overview.spyde-report") + +# Screenshots are captured at the app's full window size (~2800 px wide). Present +# mode never shows a slide image wider than ~1100 px (a split pane is ~half that), +# so downscale before embedding — this is what keeps the committed zip ~1 MB +# instead of ~4 MB. +IMAGE_WIDTH = 1600 + +# Present mode caps a slide's content column at 60rem, so a split slide gives its +# figure only ~460 CSS px. A raw window capture (2800 px wide, half of it empty +# desktop) is unreadable at that size, so each shot is CROPPED to the region that +# carries meaning — the plot windows — BEFORE it is scaled down. Boxes are +# (left, top, right, bottom) in the captured image's own pixels. +CROPS: dict[str, tuple[int, int, int, int]] = { + "01-navigator-and-dp.png": (20, 95, 1450, 770), + "02-find-vectors-wizard.png": (20, 95, 1450, 1350), + "03-find-vectors-result.png": (20, 95, 2070, 1430), + "04-virtual-imaging.png": (20, 95, 2075, 1165), + "05-eels.png": (20, 95, 1350, 790), +} + + +# ── who is giving the talk ──────────────────────────────────────────────────── +# +# One place, three consumers: the title slide, the closing slide, and the theme +# footer that runs along the bottom of every content slide. VENUE and DATE are +# blank by default — set them for a specific booking and the title slide picks +# them up; leave them blank and the line is simply omitted rather than printing +# an empty separator. + +SPEAKER = { + "name": "Carter Francis", + "role": "R&D Scientist", + "org": "Direct Electron", + "email": "cfrancis@directelectron.com", + "venue": "", # e.g. "M&M 2026" + "date": "", # e.g. "August 2026" +} + +# The links the audience is asked to write down. Both are verified live; the +# directelectron.github.io/spyde and github.com/directelectron/spyde URLs in +# pyproject.toml are NOT yet published, so pointing the room at them would send +# it to a 404. Change these here if the project moves. +REPO_URL = "github.com/CSSFrancis/spyde" +DOCS_URL = "cssfrancis.github.io/spyde" + + +# ── the deck's look ─────────────────────────────────────────────────────────── +# +# Serialized into the document's ``theme:`` front matter, so it travels with the +# file. Colours reach the slide markdown as CSS custom properties on the deck +# root; the footer is drawn on every slide EXCEPT title/section cards, which +# carry their own attribution. +# +# The palette is the app's own: every screenshot in this deck is a dark SpyDE +# window, so a light deck would frame each one in a bright box. The accent is +# SpyDE's blue (#89b4fa) for the same reason — the slide headings and the app +# chrome in the screenshots then agree. + +#: Footer logo. The dark-background variant, because the deck is dark — the +#: light `icon.png` carries black web lines that vanish on a dark bar. +LOGO_SRC = os.path.join(_REPO, "spyde", "SpydeDark.png") +LOGO_PX = 96 # the logo is drawn ~28 px tall; 96 px covers 3× displays + +THEME = { + "bg": "#12121c", + "text": "#e9ecf3", + "muted": "#a6adc8", + "accent": "#89b4fa", + # Interface stacks in preference order; every entry after the first is a + # fallback for a machine that lacks it, so the deck degrades to the host's + # own UI font rather than to Times. + "font": ('Inter, "Segoe UI", -apple-system, BlinkMacSystemFont, ' + 'system-ui, Roboto, "Helvetica Neue", Arial, sans-serif'), + "logo_height": 28, + "footer_show": True, + "footer_name": SPEAKER["name"], + "footer_email": SPEAKER["email"], + "footer_note": SPEAKER["org"], + "slide_numbers": True, +} + + +def _logo_data_url() -> str: + """The footer logo as a ``data:`` URL, downscaled to :data:`LOGO_PX`. + + A data URL rather than a path because the deck has to survive being emailed + to someone whose disk has never had this repo on it.""" + try: + from PIL import Image + except ImportError: # Pillow is a core dep; be forgiving. + return "" + if not os.path.exists(LOGO_SRC): + return "" + img = Image.open(LOGO_SRC).convert("RGBA") + if img.width > LOGO_PX: + img = img.resize((LOGO_PX, round(img.height * LOGO_PX / img.width)), + Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + + +def _png(name: str) -> bytes: + """Read ``media/``, crop it per :data:`CROPS`, and cap its width at + :data:`IMAGE_WIDTH`.""" + path = os.path.join(MEDIA, name) + with open(path, "rb") as fh: + raw = fh.read() + try: + from PIL import Image + except ImportError: # Pillow is a core dep; be forgiving. + return raw + img = Image.open(io.BytesIO(raw)).convert("RGB") + box = CROPS.get(name) + if box: + # Clamp to the real image so a re-capture at a different window size + # degrades to "less cropped" instead of raising. + left, top, right, bottom = box + img = img.crop((min(left, img.width - 1), min(top, img.height - 1), + min(right, img.width), min(bottom, img.height))) + if img.width > IMAGE_WIDTH: + height = round(img.height * IMAGE_WIDTH / img.width) + img = img.resize((IMAGE_WIDTH, height), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() + + +def _title_slide_text() -> str: + """The title card, composed from :data:`SPEAKER` so a venue change is one + edit. Blank fields drop out instead of leaving dangling separators.""" + who = " · ".join(x for x in (SPEAKER["name"], SPEAKER["role"], + SPEAKER["org"]) if x) + where = " · ".join(x for x in (SPEAKER["venue"], SPEAKER["date"]) if x) + lines = [ + "# SpyDE\n", + "## Interactive analysis for electron microscopy\n", + f"{who}\n", + f"{SPEAKER['email']}\n", + ] + if where: + lines.append(f"*{where}*\n") + return "\n".join(lines) + + +def _closing_slide_text() -> str: + return ( + "# Thank you\n\n" + "## Questions?\n\n" + f"{SPEAKER['name']} · {SPEAKER['email']}\n\n" + # Plain, not a code span: on a title card the code chip's pink is the + # only pink on the slide and pulls the eye off the address. + f"{REPO_URL}\n" + ) + + +# ── the deck ────────────────────────────────────────────────────────────────── +# +# Each entry is one SLIDE: +# text — the slide's markdown (a split slide's TEXT side) +# image — a media/ filename; present → the slide carries a screenshot +# layout — "full" (a separate full-width image cell BELOW the text), or one +# of the four SPLIT layouts, which put the text and the picture in +# ONE atomic cell: "text-left" / "text-right" (side by side) and +# "text-top" / "text-bottom" (stacked). An unrecognised value is an +# error, not a silent fallback. +# kind — "title" for a title/section slide, "" for a content slide +# style — "" (default stage) | "plain" | "accent" +# notes — speaker notes (presenter view only, never shown to the audience) +# seconds — the time budget; the total is asserted at the bottom of this file +# +# TOTAL BUDGET: ~12.5 minutes. Nothing on a slide is a placeholder — anything +# still to be decided lives in the speaker notes, where the audience can't read +# it off a projector. + +SLIDES: list[dict] = [ + # ── 1 ────────────────────────────────────────────────────────────────────── + dict( + kind="title", style="accent", seconds=20, + text=_title_slide_text(), + notes=( + "Introduce yourself, then set the plan in one sentence: where this\n" + "came from, what it is built on, how it works, and where it is going.\n\n" + "Timing: the deck is budgeted at ~12.5 min of talking, printed by the\n" + "build script on every rebuild. If you are given 15, the two slides\n" + "with the most give are 'Active development' and 'Roadmap'.\n\n" + "Set SPEAKER['venue'] / SPEAKER['date'] in the build script and the\n" + "line under your name appears; leave them blank and it is omitted." + ), + ), + # ── 2 ────────────────────────────────────────────────────────────────────── + dict( + seconds=45, + text=( + "## Where this comes from\n\n" + "**Graduate school.** A PhD with Paul Voyles at UW–Madison, measuring " + "disorder in glasses with 4D-STEM. The microscope was ready years " + "before the analysis was.\n\n" + "**Open source.** So I wrote some of the analysis, and then helped " + "maintain it — **HyperSpy** and **pyxem**, which is where most of my " + "open-source time still goes.\n\n" + "**Direct Electron.** Now the same problem from the other side: a " + "modern detector produces data faster than any person can look at it.\n\n" + "SpyDE is what those two jobs look like when you do them at the same " + "time.\n" + ), + notes=( + "Keep this to about 45 seconds — it is context, not a CV.\n\n" + "The honest through-line: every job I have had has been the same\n" + "complaint from a different chair. In grad school the data outran the\n" + "tools; on the detector side the tools have to keep up with hardware\n" + "that got much faster.\n\n" + "If the room is mostly pyxem/HyperSpy users, this is also the moment\n" + "to say that SpyDE is not a competitor to either — it is a front end\n" + "for both, and the fixes go upstream." + ), + ), + # ── 3 ────────────────────────────────────────────────────────────────────── + dict( + seconds=35, + text=( + "## Big data, small patience\n\n" + "- A modern 4D-STEM scan is **hundreds of gigabytes**. A script asks " + "you to decide what to look at *before* you have looked at it.\n" + "- The loop that actually matters — *move the probe, see the pattern* " + "— is the one a notebook is worst at.\n" + "- HyperSpy already had the data model and the science. What was " + "missing was a **responsive interface** that never asks you to " + "down-sample first.\n\n" + "> \"No data should be too big to analyze.\"\n" + ), + notes=( + "The pitch: exploration is interactive, and interactivity is an\n" + "engineering problem rather than a science problem.\n\n" + "A concrete anecdote lands well here — the last time you waited on a\n" + "re-run because you had cropped to the wrong region.\n\n" + "The quote is verbatim from doc/intro.rst (FAQ)." + ), + ), + # ── 4 ────────────────────────────────────────────────────────────────────── + dict( + seconds=45, image="01-navigator-and-dp.png", layout="text-left", + text=( + "## What SpyDE is\n\n" + "A **desktop application** for visualising and analysing electron " + "microscopy data — TEM, STEM, cryo-EM, 4D-STEM, EELS.\n\n" + "- Navigator beside the signal, **live**, including on data far larger " + "than memory\n" + "- Opens `.hspy` `.zspy` `.mrc` `.tif` `.de5`, and DE's sparse `.csb` " + "event streams\n" + "- Every operation is a HyperSpy operation, so nothing you do here " + "traps you here\n" + "- Free software — **GPL-3.0-or-later**, developed at Direct Electron\n" + ), + notes=( + "Point at the screenshot: the navigator (N-) and signal (S-) windows,\n" + "the green crosshair, the calibrated k-axis and scale bar on the\n" + "pattern, and the Plot Control dock on the right — histogram and\n" + "contrast, colormap, signal type, workflow chip, axes table.\n\n" + "'Nothing traps you here' is worth saying slowly: the signal tree is\n" + "HyperSpy objects, so you can drop into the built-in Python console\n" + "at any point and keep working in code.\n\n" + "Extensions are SUPPORTED_EXTS in spyde/backend/_session_files.py.\n" + "Scale, if asked: ~65k lines of Python plus ~26k of TypeScript." + ), + ), + # ── 5 ────────────────────────────────────────────────────────────────────── + dict( + seconds=50, + text=( + "## HyperSpy is the data model\n\n" + "Everything in SpyDE **is** a HyperSpy `BaseSignal`.\n\n" + "- **Navigation vs signal axes** — a 4D-STEM scan is 2 nav × 2 signal, " + "which makes \"move the probe, show the pattern\" a *slice* rather than " + "a special case\n" + "- **Calibrated axes and metadata** — scale, offset, units and signal " + "type ride with the data; the signal type decides which actions are " + "even offered\n" + "- **Lazy = Dask** — nothing forces a dataset into RAM\n\n" + "Transformations form a **tree, not a script**. Non-breaking steps " + "update the plot in place; breaking ones branch a new node; a finished " + "result is committed with its provenance. You can walk back and compare " + "states instead of re-running a cell and losing the previous one.\n" + ), + notes=( + "This is the slide that earns the rest of the talk: SpyDE did not\n" + "invent a data model, it adopted one. That is why five other packages\n" + "drop straight in two slides from now.\n\n" + "The tree is the contrast with a notebook: re-running a cell destroys\n" + "the previous state, and here both states stay addressable.\n\n" + "SpyDE currently tracks a HyperSpy fork pinned to a commit — the delta\n" + "is the cached-chunk read the navigator goes through, and it is meant\n" + "to go upstream. Say so if anyone asks; don't volunteer it." + ), + ), + # ── 6 ────────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## Don't reimplement the science — inherit it\n\n" + "| package | what it brings |\n" + "|---|---|\n" + "| **pyxem** | 4D-STEM: template matching, orientation, strain |\n" + "| **exspy** | EELS + EDS: edges, models, quantification |\n" + "| **kikuchipy** | EBSD pattern indexing |\n" + "| **orix** | orientations, symmetry, IPF colouring |\n" + "| **atomap** | atomic column finding |\n\n" + "An enormous amount of excellent work already exists. SpyDE's job is to " + "put one consistent interface on it and hand it back to the " + "community — **free**.\n\n" + "`pip install spyde[eels,ebsd,atoms]`; a missing extra **hides the " + "buttons** rather than raising.\n" + ), + notes=( + "The ecosystem argument: these packages are where the domain expertise\n" + "lives, and they already share HyperSpy's data model, so adopting them\n" + "costs almost nothing.\n\n" + "Accuracy, in case it comes up: pyxem is a core dependency. orix is\n" + "not declared directly — it arrives through pyxem and kikuchipy.\n" + "lumispy is not a dependency at all, which is why it is not listed.\n\n" + "The requires_package gate is a good detail: the UI adapts to what is\n" + "installed instead of erroring at the user." + ), + ), + # ── 7 ────────────────────────────────────────────────────────────────────── + dict( + seconds=30, image="05-eels.png", layout="text-right", + text=( + "## One app, several techniques\n\n" + "The signal type drives the interface, so one shell serves very " + "different data.\n\n" + "- A **4D-STEM** scan gets a diffraction toolbar and a k-calibrated " + "pattern\n" + "- An **EELS** spectrum image gets an energy-loss axis, edges and model " + "fitting\n" + "- Same navigator, same tree, same report\n" + ), + notes=( + "Point at the energy-loss axis in eV, the acceleration voltage and\n" + "convergence angle picked up from metadata, and the signal type set to\n" + "EELS in the dock.\n\n" + "This is bundled synthetic data (spyde.data.eels_si) — nav 16 x 16,\n" + "1024 channels, power-law background with C / N / O K edges — whose\n" + "ground truth is stored on the metadata, so a fit can be scored against\n" + "the numbers the data was built from.\n\n" + "Swap in a screenshot of your own EELS data if you would rather show\n" + "that; re-crop in CROPS afterwards." + ), + ), + # ── 8 ────────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## Open, or it isn't reproducible\n\n" + "There are two ways to ship software. **Apple's** — polished, closed, " + "and you take its word for it. **Linux's** — you can read every line, " + "and it still runs in ten years.\n\n" + "- A number you cannot re-derive is an anecdote. If the analysis lives " + "in a black box, *\"processed in version 4.2\"* is the whole methods " + "section.\n" + "- Science needs the other property: open the same file years later, " + "run the same pipeline, get the same answer — or see exactly what " + "changed.\n" + "- Every layer here is readable — the data model, the algorithms, the " + "file format, the application itself.\n\n" + "SpyDE wants the polish of the first and the guarantees of the second.\n" + ), + notes=( + "This is the argument slide. Deliver the Apple/Linux line as a\n" + "compliment to both — the point is not that closed software is badly\n" + "made, it is that 'well made' and 'checkable' are different properties\n" + "and science needs the second one.\n\n" + "Concrete backing if someone pushes: the report format is markdown in\n" + "a zip, dependency versions are pinned in a lock file, and the deck on\n" + "screen is itself a file in the repository that anyone can rebuild.\n\n" + "A good place to name the failure mode out loud: a student graduates,\n" + "and two years later nobody can reproduce the figure." + ), + ), + # ── 9 ────────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## Direct Electron's bet on open source\n\n" + "DE pays for this work and then gives it away. That is a position, not " + "charity.\n\n" + "- **SpyDE is free and GPL-3.0** — for any detector, not only ours. No " + "paid tier, no licence server.\n" + "- **The work goes upstream** — into HyperSpy, pyxem and RosettaSciIO, " + "where it outlives any one application.\n" + "- **`deapi`**, our detector control API, is MIT and on PyPI. So is " + "**`anyplotlib`**.\n" + "- A camera is only as useful as what you can do with the data. Locking " + "that up helps nobody — least of all the person who bought it.\n" + ), + notes=( + "Say the commitment plainly, because the audience will assume a vendor\n" + "talk otherwise: the licence is GPL-3.0, the repository is public, and\n" + "SpyDE opens other manufacturers' formats.\n\n" + "The business case, if asked: detectors are the product, and every\n" + "hour a customer spends fighting file formats is an hour the detector\n" + "isn't earning its keep. Open tooling is the cheapest way to make the\n" + "hardware worth more.\n\n" + "deapi: github.com/directelectron/deapi (MIT, pip install deapi)." + ), + ), + # ── 10 ───────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## Two processes, one line protocol\n\n" + "```\n" + " Electron main (Node) ──spawn──▶ python -m spyde\n" + " │ │\n" + " IPC / preload asyncio stdin/stdout\n" + " │ PLOTAPP: JSON lines\n" + " ▼ │\n" + " React + TypeScript renderer ◀───────────┘\n" + "```\n\n" + "- The **scientific stack stays in Python** — HyperSpy, pyxem, Dask, " + "torch\n" + "- The **interface is a modern web stack** — React, TypeScript, " + "Electron\n" + "- The boundary is a **line protocol**, so the backend tests headlessly " + "and the frontend tests under Playwright\n" + "- Image pixels bypass JSON entirely, as raw binary frames\n" + ), + notes=( + "Why not PyQt: SpyDE started as a PySide6/pyqtgraph app and was\n" + "migrated. The split buys a modern UI toolkit without dragging the\n" + "scientific stack into it, and a hard, testable seam between the two.\n\n" + "The protocol is PLOTAPP:-prefixed JSON lines on stdout, from\n" + "anyplotlib._electron. Binary frames ride a separate PLOTBIN path — a\n" + "base64 round-trip per frame was measurably slower.\n\n" + "Add a sentence on the Qt migration only if the audience saw the old\n" + "app." + ), + ), + # ── 11 ───────────────────────────────────────────────────────────────────── + dict( + seconds=45, image="04-virtual-imaging.png", layout="text-left", + text=( + "## anyplotlib — plotting that stays interactive\n\n" + "matplotlib's object-oriented API, rendered in the **browser** instead " + "of in Python. `apl.subplots()`, `ax.imshow()`, `ax.plot()` — switching " + "is often a one-line change.\n\n" + "- Pan, zoom and drag **never touch the kernel**, so interaction stays " + "at frame rate on large data\n" + "- Widgets — crosshair, ROI, span — report positions back to Python; " + "SpyDE binds them to navigation axes\n" + "- Deliberately light: `anywidget`, `numpy`, `traitlets`, `colorcet`. " + "**No matplotlib required.**\n" + ), + notes=( + "The screenshot: a virtual detector — the red disk — dropped on the\n" + "diffraction pattern, and the virtual image on the right building live\n" + "as you drag it. That interaction is an anyplotlib widget bound to a\n" + "HyperSpy ROI.\n\n" + "Be fair to matplotlib: it is still the right tool for print-quality\n" + "vector figures, and anyplotlib deliberately does not try to be. The\n" + "trade is the opposite one — raster canvas, browser rendering, and\n" + "interactivity that does not degrade with data size.\n\n" + "ipympl is the honest comparison: it re-renders on the Python side\n" + "every frame, which is exactly the round-trip this avoids." + ), + ), + # ── 12 ───────────────────────────────────────────────────────────────────── + dict( + seconds=35, + text=( + "## anyplotlib is a bridge\n\n" + "Written **for** SpyDE. Not owned by it — MIT, on PyPI, with no SpyDE " + "anywhere inside it.\n\n" + "| where it runs | how |\n" + "|---|---|\n" + "| **Jupyter Lab** | an `anywidget` — the design target |\n" + "| **SpyDE** | the renderer mounted directly in Electron |\n" + "| **`deapi`** | live plots while a detector is running |\n" + "| **HyperSpy** | the goal: an interactive backend for `s.plot()` |\n" + "| **A report, a website** | `fig.save_html()` — one self-contained " + "file, still interactive, no kernel |\n\n" + "The figure you explored is the figure you ship.\n" + ), + notes=( + "This is the slide to linger on if the room is Jupyter-heavy. The\n" + "argument: the same plotting layer serves the notebook, the desktop\n" + "app, the detector's live view and the exported document, so a widget\n" + "written once shows up in all four.\n\n" + "Status, stated accurately: the anywidget, Electron and save_html\n" + "paths all ship today. The HyperSpy backend is intent, not released —\n" + "call it a goal, not a feature.\n\n" + "The Sphinx extension is a nice aside for the docs-minded: figures in\n" + "the gallery are live in the browser via Pyodide, with no server." + ), + ), + # ── 13 ───────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## The hard part: staying live on lazy data\n\n" + "Moving the probe has to feel instant on a dataset that does not fit in " + "RAM.\n\n" + "- **Storage-aligned chunking** — load with chunks that span whole " + "signal frames, so one pattern is one chunk read. Never re-chunk a " + "multi-gigabyte array to fix it afterwards.\n" + "- **One serial dispatcher, latest-position-wins** — no locks and no " + "thread per move; a superseded position is dropped before it ever runs.\n" + "- **Two caches** — decoded frames, and decoded navigation *blocks*, " + "because a compressed chunk is atomic: one frame costs what all of them " + "cost.\n\n" + "Region integration on a 64 × 64 × 256² scan: **2850 ms → ~5 ms** per " + "drag step.\n" + ), + notes=( + "The engineering slide — what separates 'a GUI over HyperSpy' from 'a\n" + "GUI that stays live'.\n\n" + "The honest framing, and the part people remember: almost every obvious\n" + "fix here was tried and made it worse. Per-update threads raced the\n" + "chunk cache. A lock held across the compute wedged the UI. A\n" + "one-entry block cache re-decoded on every chunk crossing. What\n" + "survived is serial, latest-wins, and two LRU caches.\n\n" + "Numbers are from the project's own benchmarks (benchmarks.md). If\n" + "asked what they were measured on, say the dev workstation rather\n" + "than inventing a spec." + ), + ), + # ── 14 ───────────────────────────────────────────────────────────────────── + dict( + seconds=40, image="03-find-vectors-result.png", layout="text-right", + text=( + "## GPU where it pays\n\n" + "- **Peak finding** — classical (difference-of-Gaussians, normalised " + "cross-correlation) *and* a neural detector, both on torch\n" + "- **Orientation mapping** — the whole scan is fit **at once**: every " + "pattern packed into one batched tensor, coarse-seeded by angular " + "cross-correlation, refined with Adam. No Dask, no per-pattern loop.\n" + "- Rewriting that coarse seed from a Python loop over templates into " + "one batched FFT correlation: **289 s → 1.6 s**\n" + "- CUDA *and* Apple Metal, always with a working CPU fallback\n" + ), + notes=( + "The screenshot is a real run on bundled synthetic Si grains — 701\n" + "diffraction vectors found, overlaid in red on the pattern, with the\n" + "vector count map opened as a new window.\n\n" + "The lesson worth saying out loud: when a GPU step is slow, the cause\n" + "is almost always a Python loop launching tiny kernels, not the\n" + "arithmetic. 289 s -> 1.6 s was a restructuring, not a faster card.\n\n" + "Apple Metal has a sharp edge worth a sentence if there are Mac users\n" + "in the room: it is not thread-safe, so every torch call site in the\n" + "app takes one shared device lock." + ), + ), + # ── 15 ───────────────────────────────────────────────────────────────────── + dict( + seconds=30, image="02-find-vectors-wizard.png", layout="text-left", + text=( + "## Interaction is the feature\n\n" + "Heavy compute is staged behind a **live preview**: tune the parameters " + "against the pattern under the crosshair, *then* commit to the full " + "scan.\n\n" + "- The preview follows the navigator, before any full-dataset compute\n" + "- The same shape serves virtual imaging, FFT, line profiles, strain " + "and orientation mapping\n" + "- Results open **early** and fill in progressively\n" + ), + notes=( + "Point at the red circles on the pattern — the peak finder running\n" + "live under the crosshair with the parameters currently in the wizard.\n" + "You never launch a twenty-minute job on a guess.\n\n" + "Note the neural detector in the Method dropdown alongside the\n" + "classical ones — same preview, same commit.\n\n" + "This is the 'Wizard' shape in spyde/actions/README.md: open, tune,\n" + "run, commit, close." + ), + ), + # ── 16 ───────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## …and this talk is a SpyDE document\n\n" + "The Report Builder turns a session into a document. Drag a figure out " + "of a window into the report and it stays **live and re-bindable** — " + "reopen the report with the data loaded and it re-renders from the " + "signal.\n\n" + "- `.spyde-report` is a plain **zip**: `report.md` + `figures/*.yaml` + " + "`assets/*.png` — valid markdown you can unzip and hand to pandoc\n" + "- One document, several surfaces: scrolling report, **slide deck**, " + "movie editor\n" + "- Themed in the document, so a deck still looks like yours on someone " + "else's machine\n" + "- Exports to static HTML, interactive HTML, PDF, or a markdown folder\n\n" + "**This deck is `doc/presentations/spyde-overview.spyde-report`.**\n" + ), + notes=( + "The reveal. Worth a pause: the audience is looking at the feature.\n\n" + "Press S here to show the presenter view live — same screen, since\n" + "SpyDE is a single Electron window: current slide, next slide, these\n" + "notes, and a timer.\n\n" + "The 'no JSON anywhere' choice is deliberate — the document stays\n" + "readable and diffable in git, which is the same reproducibility\n" + "argument from a few slides ago applied to the write-up." + ), + ), + # ── 17 ── section divider ────────────────────────────────────────────────── + dict( + kind="title", style="accent", seconds=10, + text="# What's next\n\n## Active development and roadmap\n", + notes="Breath, and a change of gear from 'what it is' to 'where it goes'.", + ), + # ── 18 ───────────────────────────────────────────────────────────────────── + dict( + seconds=45, + text=( + "## Active development\n\n" + "- **Spectroscopy** — EELS and EDS model fitting across a whole " + "spectrum image: edges, backgrounds, quantification\n" + "- **EBSD** — kikuchipy indexing, IPF maps and refinement in the same " + "shell\n" + "- **Atomic resolution** — column finding through atomap\n" + "- **In situ** — drift correction, particle segmentation and tracking, " + "and DE's sparse `.csb` event streams, re-cut at any exposure without " + "re-reading the movie\n" + "- **Apple silicon** — the fitting and EBSD paths run on Metal\n" + ), + notes=( + "Pick the two items closest to this audience and spend the time there\n" + "rather than reading all five.\n\n" + "The .csb point is the one that surprises people: the file is an event\n" + "stream, not a frame stack, so an image only exists once you choose an\n" + "exposure — and changing that choice is cheap, because the per-frame\n" + "totals come from the block table without reading any payload.\n\n" + "This slide is a snapshot of the tree as of v0.3.0; refresh it before\n" + "you give the talk." + ), + ), + # ── 19 ───────────────────────────────────────────────────────────────────── + dict( + seconds=40, + text=( + "## Roadmap\n\n" + "**quantem** — a PyTorch-native toolkit for quantitative EM: " + "ptychographic phase retrieval, HAADF tomography, neural object " + "representations. It makes the same bet SpyDE's GPU paths already " + "make — put the whole problem on the device and batch it.\n\n" + "- Bring those reconstructions in as SpyDE actions, so a phase map is " + "another **node on the tree** rather than another script\n" + "- **anyplotlib as an interactive backend for HyperSpy** — the same " + "figures in the notebook and in the app\n" + "- **Analyse during acquisition, not after it** — the detector API is " + "already open\n" + ), + notes=( + "quantem is the next-generation piece: github.com/electronmicroscopy/\n" + "quantem, pip install quantem. Ptychography and tomography are exactly\n" + "the workloads SpyDE has no answer for today, and its PyTorch backend\n" + "means it already thinks in batched device tensors — the integration\n" + "is a data-model question, not a rewrite.\n\n" + "Be clear that these are directions, not shipped features, and say\n" + "which one you want help with. This is the slide that turns a talk\n" + "into a collaboration.\n\n" + "Refresh before each delivery — a roadmap slide ages fastest." + ), + ), + # ── 20 ───────────────────────────────────────────────────────────────────── + dict( + seconds=25, + text=( + "## Try it\n\n" + f"- **Download** — macOS, Windows and Linux builds: `{REPO_URL}/releases`\n" + "- **From source** — Node 18+ and `uv`, then `uv sync --extra tests` " + "and `npm run dev`\n" + f"- **Docs** — `{DOCS_URL}`\n" + "- Python **3.10–3.13** · **GPL-3.0-or-later**\n\n" + "Built on the work of the HyperSpy and pyxem communities — and given " + "back to them.\n" + ), + notes=( + "First launch bootstraps its own Python environment with uv, including\n" + "the GPU-correct torch wheel, so 'download and run' really is the path.\n" + "The macOS build is signed and notarised; Windows installers are not\n" + "yet signed, so warn people about the SmartScreen prompt.\n\n" + "Confirm the version you want to point people at before you present —\n" + "REPO_URL and DOCS_URL are constants at the top of the build script." + ), + ), + # ── 21 ───────────────────────────────────────────────────────────────────── + dict( + kind="title", style="accent", seconds=10, + text=_closing_slide_text(), + notes=( + "Leave this up for questions.\n\n" + "Acknowledgements worth naming out loud: Paul Voyles and the Voyles\n" + "group, the HyperSpy and pyxem maintainers, and Direct Electron for\n" + "funding the work and agreeing to give it away." + ), + ), +] + + +def build() -> tuple[ReportDoc, dict[str, bytes]]: + """Assemble :data:`SLIDES` into a presentation ``ReportDoc`` + its assets.""" + theme = normalize_theme({**THEME, "logo": _logo_data_url()}) + doc = ReportDoc(title="SpyDE — an overview", doc_type="presentation", + theme=theme) + assets: dict[str, bytes] = {} + + for i, s in enumerate(SLIDES, start=1): + cid = new_cell_id() + image = s.get("image") + layout = s.get("layout", "full") + if layout != "full" and layout not in _SPLIT_LAYOUTS: + raise ValueError( + f"slide {i}: layout {layout!r} is not one of " + f"{('full',) + _SPLIT_LAYOUTS}") + # A slide with an image and a split layout is ONE atomic split cell (text + # beside the screenshot). Anything else is a markdown cell, optionally + # followed by a full-width image cell. + # All FOUR model layouts, not just the side-by-side pair: text-top and + # text-bottom used to fall through to the full-width path SILENTLY, so a + # slide asking for them got a markdown cell plus a detached image cell + # and no error to say why. + is_split = bool(image) and layout in _SPLIT_LAYOUTS + cell = Cell( + id=cid, + cell_type="split" if is_split else "markdown", + source=s["text"], + # Present-mode per-slide attributes ride on the slide's FIRST cell. + # A leading break on cell 0 is a harmless no-op (see ReportDoc.slides). + slide_break=True, + slide_kind=s.get("kind", ""), + slide_style=s.get("style", ""), + notes=s.get("notes", ""), + ) + if is_split: + cell.split_layout = layout + cell.image_ext = "png" + cell.caption = "" + assets[cid] = _png(image) + doc.cells.append(cell) + + if image and not is_split: + img_id = new_cell_id() + doc.cells.append(Cell(id=img_id, cell_type="image", + image_ext="png", caption="")) + assets[img_id] = _png(image) + + return doc, assets + + +def main() -> None: + doc, assets = build() + write_report(doc, OUT, assets) + + total = sum(int(s["seconds"]) for s in SLIDES) + n_slides = len(doc.slides()) + size_kb = os.path.getsize(OUT) / 1024 + print(f"wrote {OUT}") + print(f" {n_slides} slides · {len(doc.cells)} cells · {size_kb:.0f} KB") + print(f" budget {total} s = {total / 60:.1f} min") + print(f" theme {doc.theme['bg']} / accent {doc.theme['accent']} · " + f"footer {'on' if doc.theme['footer_show'] else 'off'} · " + f"logo {'embedded' if doc.theme['logo'] else 'MISSING'}") + assert n_slides == len(SLIDES), ( + f"slide grouping mismatch: {n_slides} groups vs {len(SLIDES)} entries") + # The talk has a slot. Fail the build rather than discover it on stage. + assert 660 <= total <= 810, ( + f"budget {total} s is outside the ~11-13.5 min slot this deck targets") + + +if __name__ == "__main__": + main() diff --git a/doc/presentations/media/01-navigator-and-dp.png b/doc/presentations/media/01-navigator-and-dp.png new file mode 100644 index 00000000..947f14fa Binary files /dev/null and b/doc/presentations/media/01-navigator-and-dp.png differ diff --git a/doc/presentations/media/02-find-vectors-wizard.png b/doc/presentations/media/02-find-vectors-wizard.png new file mode 100644 index 00000000..6fe0cc47 Binary files /dev/null and b/doc/presentations/media/02-find-vectors-wizard.png differ diff --git a/doc/presentations/media/03-find-vectors-result.png b/doc/presentations/media/03-find-vectors-result.png new file mode 100644 index 00000000..c2c4fbbc Binary files /dev/null and b/doc/presentations/media/03-find-vectors-result.png differ diff --git a/doc/presentations/media/04-virtual-imaging.png b/doc/presentations/media/04-virtual-imaging.png new file mode 100644 index 00000000..a295979e Binary files /dev/null and b/doc/presentations/media/04-virtual-imaging.png differ diff --git a/doc/presentations/media/05-eels.png b/doc/presentations/media/05-eels.png new file mode 100644 index 00000000..92dc9ce7 Binary files /dev/null and b/doc/presentations/media/05-eels.png differ diff --git a/doc/presentations/spyde-overview.spyde-report b/doc/presentations/spyde-overview.spyde-report new file mode 100644 index 00000000..32cb0312 Binary files /dev/null and b/doc/presentations/spyde-overview.spyde-report differ diff --git a/doc/presentations/spyde-overview2.spyde-report b/doc/presentations/spyde-overview2.spyde-report new file mode 100644 index 00000000..a2c6315b Binary files /dev/null and b/doc/presentations/spyde-overview2.spyde-report differ diff --git a/docs/pr/presentation/01-title.png b/docs/pr/presentation/01-title.png new file mode 100644 index 00000000..ee3fef25 Binary files /dev/null and b/docs/pr/presentation/01-title.png differ diff --git a/docs/pr/presentation/02-where-this-comes-from.png b/docs/pr/presentation/02-where-this-comes-from.png new file mode 100644 index 00000000..8e1a3cb9 Binary files /dev/null and b/docs/pr/presentation/02-where-this-comes-from.png differ diff --git a/docs/pr/presentation/04-what-spyde-is.png b/docs/pr/presentation/04-what-spyde-is.png new file mode 100644 index 00000000..b5584f67 Binary files /dev/null and b/docs/pr/presentation/04-what-spyde-is.png differ diff --git a/docs/pr/presentation/08-open-reproducible.png b/docs/pr/presentation/08-open-reproducible.png new file mode 100644 index 00000000..71b537ed Binary files /dev/null and b/docs/pr/presentation/08-open-reproducible.png differ diff --git a/docs/pr/presentation/09-direct-electron-open-source.png b/docs/pr/presentation/09-direct-electron-open-source.png new file mode 100644 index 00000000..1d40e295 Binary files /dev/null and b/docs/pr/presentation/09-direct-electron-open-source.png differ diff --git a/docs/pr/presentation/11-anyplotlib.png b/docs/pr/presentation/11-anyplotlib.png new file mode 100644 index 00000000..45064068 Binary files /dev/null and b/docs/pr/presentation/11-anyplotlib.png differ diff --git a/docs/pr/presentation/12-anyplotlib-bridge.png b/docs/pr/presentation/12-anyplotlib-bridge.png new file mode 100644 index 00000000..b80a4dda Binary files /dev/null and b/docs/pr/presentation/12-anyplotlib-bridge.png differ diff --git a/docs/pr/presentation/18-active-development.png b/docs/pr/presentation/18-active-development.png new file mode 100644 index 00000000..b69ed418 Binary files /dev/null and b/docs/pr/presentation/18-active-development.png differ diff --git a/docs/pr/presentation/19-roadmap.png b/docs/pr/presentation/19-roadmap.png new file mode 100644 index 00000000..787b5346 Binary files /dev/null and b/docs/pr/presentation/19-roadmap.png differ diff --git a/docs/pr/presentation/22-presenter-view.png b/docs/pr/presentation/22-presenter-view.png new file mode 100644 index 00000000..56c59b9d Binary files /dev/null and b/docs/pr/presentation/22-presenter-view.png differ diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index a1f953e3..311ca430 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -3,7 +3,7 @@ */ import { app, BrowserWindow, dialog, ipcMain, Menu, shell, nativeTheme, net, protocol, - clipboard, nativeImage, + clipboard, nativeImage, powerMonitor, } from 'electron' import { join, basename, resolve } from 'path' import { pathToFileURL } from 'url' @@ -248,9 +248,67 @@ function createWindow(): BrowserWindow { win.loadFile(join(__dirname, '../renderer/index.html')) } + attachLifecycleDiagnostics(win) return win } +/** + * DIAGNOSTICS ONLY — no behaviour change. + * + * Symptom being chased: after closing and reopening the laptop, every plot + * window is gone from the workspace, but the app still works and you can just + * re-load the data. + * + * Two facts narrow that a long way. The backend is NEVER respawned (see + * runner.ts: on close it sets `proc = null` and every later sendAction no-ops), + * so if it had died, loading data afterwards would be impossible — it isn't, so + * the backend is alive. And the workspace's window list lives ONLY in the + * renderer's React state (SpyDEContext `windows: Map`), which nothing persists + * or rebuilds from the backend. + * + * So the renderer lost its state while Python kept running. These listeners say + * WHICH of the two ways that happened: + * • the renderer PROCESS was recreated (crash / OOM / GPU-process death on + * resume) — `render-process-gone`, or a navigation the app never asked for; + * • or it survived and something reset the state, in which case none of these + * fire and the answer is in the renderer. + * + * `powerMonitor` is here purely to timestamp the suspend/resume boundary so the + * log reads as a story. Nothing acts on it yet — wiring recovery is the + * proposal, not this. + */ +function attachLifecycleDiagnostics(w: BrowserWindow): void { + const t = () => new Date().toISOString() + const log = (what: string, detail?: unknown) => + console.log(`[spyde lifecycle ${t()}] ${what}`, + detail === undefined ? '' : JSON.stringify(detail)) + + // Each registered separately: the typings give powerMonitor a per-event + // overload, so a loop over a union of event names doesn't narrow. + powerMonitor.on('suspend', () => log('power:suspend')) + powerMonitor.on('resume', () => log('power:resume')) + powerMonitor.on('lock-screen', () => log('power:lock-screen')) + powerMonitor.on('unlock-screen', () => log('power:unlock-screen')) + + w.webContents.on('render-process-gone', (_e, details) => + log('renderer-process-gone', details)) + // macOS specifically: the GPU / utility processes are the usual casualty + // across a lid-close, not the renderer. A dead GPU process takes the WebGPU + // device with it, and Chromium may recreate the renderer to recover — which + // is exactly how in-memory React state would vanish while Python lives on. + app.on('child-process-gone', (_e, details) => + log('child-process-gone', details)) + w.webContents.on('unresponsive', () => log('renderer-unresponsive')) + w.webContents.on('responsive', () => log('renderer-responsive')) + // A reload/navigation the app never requested is the other way React state + // vanishes while the process lives. + w.webContents.on('did-start-navigation', (_e, url, isInPlace, isMainFrame) => { + if (isMainFrame) log('renderer-navigation', { url, isInPlace }) + }) + w.webContents.on('did-finish-load', () => log('renderer-did-finish-load')) + w.on('closed', () => log('window-closed')) +} + // ── App lifecycle ───────────────────────────────────────────────────────────── app.whenReady().then(async () => { diff --git a/electron/src/main/styleConflicts.test.ts b/electron/src/main/styleConflicts.test.ts new file mode 100644 index 00000000..5be5ac9f --- /dev/null +++ b/electron/src/main/styleConflicts.test.ts @@ -0,0 +1,80 @@ +/** + * styleConflicts.test.ts — no React style object may mix a border SHORTHAND with + * a border LONGHAND on the same element. + * + * React warns "Removing a style property during rerender (borderColor) when a + * conflicting property is set (border)" and then drops one of them, so the + * element renders with the wrong border. The pattern that causes it is the + * ordinary base+modifier spread: + * + * seg: { border: '1px solid #313244' } // base — SHORTHAND + * segOpen: { borderColor: '#45475a' } // modifier — LONGHAND + *
+ * + * It fires on every toggle, and there were 14 of these across the app before + * this guard existed. The fix is always the same: give the modifier the full + * shorthand (`border: '1px solid #45475a'`). + * + * This scans source text rather than rendering anything — the conflict is a + * property of the style objects, so it is catchable without a DOM. + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +// This test lives in src/main/ because the renderer tsconfig has no node +// types; it only READS the renderer sources, so the location is immaterial. +const RENDERER = join(HERE, '..', 'renderer', 'src') + +/** Every .tsx under the renderer source tree. */ +function tsxFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name) + if (statSync(p).isDirectory()) tsxFiles(p, out) + else if (name.endsWith('.tsx')) out.push(p) + } + return out +} + +// Longhands that a `border` shorthand resets, and the shorthand each conflicts +// with. `borderTop`/`borderBottom` are themselves per-edge shorthands, so a +// blanket `borderColor` conflicts with those too. +const LONGHAND = /\bborder(Color|Width|Style)\s*:/ + +test('no style object mixes border shorthand and longhand', () => { + const offenders: string[] = [] + for (const file of tsxFiles(RENDERER)) { + const text = readFileSync(file, 'utf8') + // Style objects that set a border LONGHAND. + const longhandNames = new Set() + for (const m of text.matchAll(/(\w+):\s*\{[^{}]*\}/g)) { + if (LONGHAND.test(m[0])) longhandNames.add(m[1]) + } + if (!longhandNames.size) continue + // Style objects that set the `border` (or per-edge) SHORTHAND. + const shorthandNames = new Set() + for (const m of text.matchAll(/(\w+):\s*\{[\s\S]*?\n\s*\},/g)) { + if (/\bborder(Top|Right|Bottom|Left)?\s*:/.test(m[0])) shorthandNames.add(m[1]) + } + // A spread that combines one of each on the SAME element. + for (const name of longhandNames) { + const spread = new RegExp( + String.raw`\{\s*\.\.\.[^}]*?\.\.\.\(?[^}]*?\b${name}\b[^}]*?\}`, 'g') + for (const m of text.matchAll(spread)) { + if ([...shorthandNames].some(b => m[0].includes(`.${b}`))) { + offenders.push(`${file.slice(RENDERER.length + 1)} — '${name}' ` + + `sets a border longhand but is spread over a shorthand base`) + } + } + } + } + assert.deepEqual( + offenders, [], + 'React drops one of the two and the border renders wrong:\n ' + + offenders.join('\n ') + + '\nGive the modifier the full shorthand instead, e.g. ' + + "border: '1px solid #89b4fa'.") +}) diff --git a/electron/src/renderer/src/components/ConsoleBar.tsx b/electron/src/renderer/src/components/ConsoleBar.tsx index 1313d672..c1c6dc68 100644 --- a/electron/src/renderer/src/components/ConsoleBar.tsx +++ b/electron/src/renderer/src/components/ConsoleBar.tsx @@ -801,8 +801,9 @@ const styles: Record = { transition: 'border-color 90ms ease', borderTop: '1px solid transparent', borderBottom: '1px solid transparent', }, - inputRowFlash: { borderColor: '#f38ba8' }, - inputRowDrop: { borderColor: '#89b4fa', background: 'rgba(137,180,250,0.06)' }, + inputRowFlash: { borderTop: '1px solid #f38ba8', borderBottom: '1px solid #f38ba8' }, + inputRowDrop: { borderTop: '1px solid #89b4fa', borderBottom: '1px solid #89b4fa', + background: 'rgba(137,180,250,0.06)' }, prompt: { fontFamily: MONO, fontSize: 12, color: '#89b4fa', fontWeight: 700, flexShrink: 0, userSelect: 'none', diff --git a/electron/src/renderer/src/components/DaskMonitor.tsx b/electron/src/renderer/src/components/DaskMonitor.tsx index 5ff56e76..020516d9 100644 --- a/electron/src/renderer/src/components/DaskMonitor.tsx +++ b/electron/src/renderer/src/components/DaskMonitor.tsx @@ -236,7 +236,7 @@ const S: Record = { color: '#a6adc8', fontSize: 11, cursor: 'pointer', padding: '2px 8px', fontVariantNumeric: 'tabular-nums', }, - segOpen: { background: '#1e1e2e', borderColor: '#45475a' }, + segOpen: { background: '#1e1e2e', border: '1px solid #45475a' }, pop: { position: 'absolute', bottom: 26, right: 0, zIndex: 9300, background: '#1e1e2e', border: '1px solid #313244', diff --git a/electron/src/renderer/src/components/Dropdown.tsx b/electron/src/renderer/src/components/Dropdown.tsx index 55d93077..9c25b3de 100644 --- a/electron/src/renderer/src/components/Dropdown.tsx +++ b/electron/src/renderer/src/components/Dropdown.tsx @@ -125,7 +125,7 @@ const S: Record = { borderRadius: 4, padding: '3px 7px', fontSize: 11, cursor: 'pointer', textAlign: 'left', }, - triggerOpen: { borderColor: '#45475a', background: '#181825' }, + triggerOpen: { border: '1px solid #45475a', background: '#181825' }, triggerBare: { background: 'transparent', border: 'none', padding: '0 5px 0 2px', width: 'auto', diff --git a/electron/src/renderer/src/components/MovieEditor.tsx b/electron/src/renderer/src/components/MovieEditor.tsx index 6b36f6d9..3bbd8a1f 100644 --- a/electron/src/renderer/src/components/MovieEditor.tsx +++ b/electron/src/renderer/src/components/MovieEditor.tsx @@ -645,7 +645,7 @@ const styles: Record = { rail: { display: 'flex', flexDirection: 'column', gap: 5, padding: 12, borderRight: '1px solid #313244', flexShrink: 0, width: 132, overflowY: 'auto' }, railHead: { fontSize: 10, fontWeight: 700, color: '#89b4fa', textTransform: 'uppercase', letterSpacing: 0.4 }, toolBtn: { background: '#1e1e2e', color: '#cdd6f4', border: '1px solid #313244', borderRadius: 5, padding: '5px 8px', fontSize: 11.5, cursor: 'pointer', textAlign: 'left' }, - toolBtnActive: { background: '#89b4fa', color: '#11111b', borderColor: '#89b4fa', fontWeight: 700 }, + toolBtnActive: { background: '#89b4fa', color: '#11111b', border: '1px solid #89b4fa', fontWeight: 700 }, center: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, padding: 14, gap: 10 }, figRow: { flex: 1, display: 'flex', gap: 10, minHeight: 0 }, figWrap: { flex: 1, position: 'relative', background: '#11111b', borderRadius: 8, border: '1px solid #313244', overflow: 'hidden', minWidth: 0 }, diff --git a/electron/src/renderer/src/components/PresentMode.tsx b/electron/src/renderer/src/components/PresentMode.tsx index 2c1d1029..b363bef4 100644 --- a/electron/src/renderer/src/components/PresentMode.tsx +++ b/electron/src/renderer/src/components/PresentMode.tsx @@ -121,6 +121,25 @@ if (typeof document !== 'undefined' && !document.getElementById('spyde-present-m .present-md strong { color: var(--spyde-deck-text, #ffffff); } .present-md .katex-display { display: block; margin: 1rem 0; text-align: center; overflow-x: auto; overflow-y: hidden; } +/* ── text-only slides FILL the stage ────────────────────────────────────────── + A heading and a few bullets at prose size, centred in a 60rem column, is a + small block adrift in a large dark rectangle on a projector. These tiers scale + the type up so a sparse slide uses the room it has; a dense slide gets no + class at all and keeps prose size, which is what stops it overflowing. + + vh-based with a clamp, so it tracks the projector rather than a fixed px + guess, and cannot run away on a very tall or very short display. */ +.present-fill .present-md { font-size: clamp(1.25rem, 2.55vh, 2.05rem); line-height: 1.55; } +.present-fill .present-md h1 { font-size: clamp(2.6rem, 5.6vh, 4.4rem); margin: 0 0 1.4rem; } +.present-fill .present-md h2 { font-size: clamp(2rem, 4.3vh, 3.4rem); margin: 0 0 1rem; } +.present-fill .present-md h3 { font-size: clamp(1.5rem, 3.2vh, 2.4rem); } +.present-fill .present-md li { margin: 0.55em 0; } +.present-fill .present-md ul, .present-fill .present-md ol { margin: 0.8em 0; } +.present-fill .present-md p { margin: 0 0 0.9em; } +/* The sparse tier goes further — this is the "title + four bullets" slide. */ +.present-fill-lg .present-md { font-size: clamp(1.45rem, 3.05vh, 2.5rem); } +.present-fill-lg .present-md h2 { font-size: clamp(2.3rem, 5vh, 3.9rem); } +.present-fill-lg .present-md li { margin: 0.7em 0; } /* ── presentation polish: TITLE / SECTION slides ────────────────────────────── A title slide centers a large title block — first heading huge, the rest a muted subtitle. Scoped to .present-title-md so a content slide is unchanged. */ @@ -171,6 +190,29 @@ interface Props { onLaunchLive: (action: LiveAction) => void } +/** + * Resolve a split cell's layout into the two booleans that actually drive the + * grid — the renderer mirror of `model._SPLIT_LAYOUTS` and of the EDITOR's own + * resolution in ReportSplitCell. + * + * All FOUR layouts, not the left/right pair this used to test with a bare + * `!== 'text-right'`: that read `text-top` and `text-bottom` as text-left, so a + * stacked split round-tripped through the document perfectly and then rendered + * side-by-side on the slide. The editor offered a layout the deck could not + * show. + */ +function splitLayoutOf(raw: string | undefined): { + stacked: boolean; textFirst: boolean; layout: string +} { + const layout = SPLIT_LAYOUTS.includes(raw ?? '') ? (raw as string) : 'text-left' + return { + layout, + stacked: layout === 'text-top' || layout === 'text-bottom', + textFirst: layout === 'text-left' || layout === 'text-top', + } +} +const SPLIT_LAYOUTS: string[] = ['text-left', 'text-right', 'text-top', 'text-bottom'] + /** Group the mirrored report cells into slides by `slide_break` — the renderer * mirror of `ReportDoc.slides()`. A break STARTS a new slide; the first cell * always begins slide 0. */ @@ -767,11 +809,13 @@ function PreviewCell({ cell, titleSlide }: { cell: ReportCell; titleSlide: boole :
figure
}
) - const textLeft = (cell.split_layout ?? 'text-left') !== 'text-right' + const { stacked, textFirst } = splitLayoutOf(cell.split_layout) return ( -
- {textLeft ? [textPane, figPane] : [figPane, textPane]} + {textFirst ? [textPane, figPane] : [figPane, textPane]}
) } @@ -838,6 +882,32 @@ function Slide({ cells, active, reportFigures, iframeRefs, replayState, onLaunch // real stage height whatever the captions and markdown around them cost. const figVh = visualCells <= 1 ? 58 : Math.max(16, Math.round(62 / visualCells)) + // FILL THE STAGE on a text-only slide. + // + // A heading plus a few bullets used to render at prose size, vertically + // centred in a 60rem column, which on a 1900px projector is a small block + // floating in a large dark rectangle with dead space above AND below. A slide + // is not a paragraph — it should use the room it has. + // + // Scaled by CONTENT LENGTH rather than by measuring: a sparse slide gets the + // big type, a dense one keeps prose size so it can't overflow into the + // pager. Deterministic, no layout thrash, no reflow loop — and the tier is + // exported as `data-fill` so a spec can assert it without reading font sizes. + // SPLIT slides qualify too. Their text side is its own column, so scaling it + // cannot crowd the picture — and a split left at prose size was the same + // complaint: a heading and four bullets adrift in half a dark rectangle. + // A slide with a full-width figure/image/movie does NOT qualify: there the + // text shares the vertical budget with the visual and bigger type pushes it + // off the stage. + const splitCells = cells.filter(c => c.cell_type === 'split').length + const splitOnly = splitCells > 0 && visualCells === splitCells + const fillable = !isTitle && (visualCells === 0 || splitOnly) + const textLen = cells.reduce((n, c) => n + (c.source ?? '').length, 0) + // A split's text lives in HALF the width, so the same character count fills + // twice the height — the tiers step down accordingly. + const [lgMax, mdMax] = splitOnly ? [260, 520] : [400, 850] + const fill = !fillable ? '' : textLen < lgMax ? 'lg' : textLen < mdMax ? 'md' : '' + const renderCell = (cell: ReportCell) => ( -
0 ? styles.slideInnerWide : {}), + // A scaled-up text slide needs the column to grow WITH the type: + // max-width is in `rem` (root-relative), so it is a fixed pixel box — + // leaving it at 60rem while the body goes 18px → 27px would SHORTEN the + // measure to ~45 characters and undo the point. + ...(fill ? styles.slideInnerFill : {}), + // TOP-align content slides. Centring is right for a title card, but on a + // content slide it floats the heading at a height that depends on how + // much text follows it — so consecutive slides visibly jump. Anchoring + // the heading is what makes a deck read as one deck. A slide whose + // figures grow to fill has no free space to distribute, so this is a + // no-op there. + ...(isTitle ? {} : styles.slideInnerTop), ...(isTitle ? styles.slideInnerTitle : {}) }}> {cells.map(cell => ( {renderCell(cell)} @@ -1019,12 +1103,19 @@ function SlideSplit({ cell, reportFigures, iframeRefs, replayState }: { )}
) - const textLeft = (cell.split_layout ?? 'text-left') !== 'text-right' + const { stacked, textFirst, layout } = splitLayoutOf(cell.split_layout) return (
- {textLeft ? [textPane, figPane] : [figPane, textPane]} + data-layout={layout} + style={{ ...styles.splitRow, + // Stacked: ONE column, and the text row sizes to its content + // while the figure row takes the rest. `1fr 1fr` here would + // give a two-line caption half the slide. + gridTemplateColumns: stacked ? '1fr' : '1fr 1fr', + ...(stacked + ? { gridTemplateRows: textFirst ? 'auto 1fr' : '1fr auto' } + : {}) }}> + {textFirst ? [textPane, figPane] : [figPane, textPane]}
) } @@ -1044,7 +1135,12 @@ const styles: Record = { // Grow into the stage like a plain figure cell does. flex: '1 1 var(--spyde-fig-vh, 58vh)', minHeight: 0, }, - splitText: { minWidth: 0, alignSelf: 'center', maxHeight: '100%', overflow: 'hidden' }, + // START, not center: the row is stretched to the stage, so centring the text + // inside it floats the heading at a height that depends on how many bullets + // follow — the same drift the content slides had, and it puts the heading out + // of line with the top of the picture beside it. Long text fills the column + // either way, so this only changes the sparse case, which is the broken one. + splitText: { minWidth: 0, alignSelf: 'start', maxHeight: '100%', overflow: 'hidden' }, splitFig: { minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', @@ -1081,6 +1177,11 @@ const styles: Record = { }, // A slide with a figure on it: let the data have the screen. slideInnerWide: { maxWidth: '96rem' }, + // A scaled-up text slide: wider column so the bigger type keeps a sane + // measure, and a little top padding so the heading isn't jammed to the edge. + slideInnerFill: { maxWidth: '78rem', paddingTop: '2vh' }, + // Content slides anchor their heading at the top (see the call site). + slideInnerTop: { justifyContent: 'flex-start' }, // A title slide: content vertically + horizontally centered, tighter column. slideTitle: { justifyContent: 'center', textAlign: 'center' }, slideInnerTitle: { maxWidth: '48rem' }, @@ -1181,7 +1282,7 @@ const styles: Record = { padding: 0, }, iconBtnActive: { - background: '#89b4fa', color: '#11111b', borderColor: '#89b4fa', + background: '#89b4fa', color: '#11111b', border: '1px solid #89b4fa', }, // The whole audience slide stack is hidden (but kept MOUNTED) while the // presenter dashboard is up, so the live figure iframes never tear down. diff --git a/electron/src/renderer/src/components/ReportCell.tsx b/electron/src/renderer/src/components/ReportCell.tsx index dfbff11e..08e7b0f1 100644 --- a/electron/src/renderer/src/components/ReportCell.tsx +++ b/electron/src/renderer/src/components/ReportCell.tsx @@ -19,6 +19,7 @@ import { renderMarkdown } from '../kernel/markdown' import { reportClipboard } from '../kernel/reportClipboard' import type { ReportCell as ReportCellType } from '../kernel/protocol' import { CellChrome } from './CellChrome' +import { useReplaceDrop } from './useReplaceDrop' // One-time scoped markdown stylesheet for the dark theme. Injected under a // `.spyde-md` wrapper so it never leaks into the rest of the app. Sizes are in @@ -229,6 +230,9 @@ const TOOLBAR: Array<[ToolbarCommand, string, string, React.CSSProperties?]> = [ ] export function ReportCell({ cell, onUpdate, onRemove, index, dragProps }: Props) { + // Drop a figure/window or an image file here to turn this TEXT slide into a + // SPLIT slide (the backend converts the cell in place, keeping its prose). + const replace = useReplaceDrop(cell.id) const { sendAction } = useSpyDE() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(cell.source ?? '') @@ -291,8 +295,20 @@ export function ReportCell({ cell, onUpdate, onRemove, index, dragProps }: Props data-testid={`report-cell-${cell.id}`} draggable={!showEditor} onDragStart={dragProps.onDragStart} - onDragOver={dragProps.onDragOver} - onDrop={dragProps.onDrop} + // TWO kinds of drag land on this element and they must not fight. A + // figure/window PILL or an image FILE turns this text slide into a SPLIT + // slide; a cell REORDER drag moves it. `replace` claims only the former + // (it preventDefaults when it does), so anything it declines falls + // through to the reorder wiring exactly as before. + onDragOver={(e) => { + replace.handlers.onDragOver(e) + if (!e.defaultPrevented) dragProps.onDragOver(e) + }} + onDragLeave={replace.handlers.onDragLeave} + onDrop={(e) => { + replace.handlers.onDrop(e) + if (!e.defaultPrevented) dragProps.onDrop(e) + }} onDragEnd={dragProps.onDragEnd} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} @@ -300,8 +316,15 @@ export function ReportCell({ cell, onUpdate, onRemove, index, dragProps }: Props ...styles.cell, ...(dragProps.dragging ? styles.cellDragging : {}), ...(dragProps.dropBefore ? styles.cellDropBefore : {}), + ...(replace.active ? styles.cellSplitDropOn : {}), }} > + {replace.active && ( +
+ Drop to make this a split slide +
+ )} {/* Hover chrome: drag handle (reorder) + copy + duplicate + delete. */} {(hover || showEditor) && ( = { }, cellDragging: { opacity: 0.4 }, cellDropBefore: { borderTop: '2px solid #89b4fa' }, + cellSplitDropOn: { outline: '2px dashed #89b4fa', outlineOffset: 2 }, + splitDropHint: { + position: 'absolute', top: 4, right: 6, zIndex: 5, + background: 'rgba(17,17,27,0.92)', color: '#89b4fa', + border: '1px solid #89b4fa', borderRadius: 5, + padding: '2px 8px', fontSize: 10, fontWeight: 700, + pointerEvents: 'none', + }, chrome: { position: 'absolute', top: 2, right: 4, zIndex: 2, display: 'flex', alignItems: 'center', gap: 4, diff --git a/electron/src/renderer/src/components/ReportFigureCell.tsx b/electron/src/renderer/src/components/ReportFigureCell.tsx index 5964911a..e57133fa 100644 --- a/electron/src/renderer/src/components/ReportFigureCell.tsx +++ b/electron/src/renderer/src/components/ReportFigureCell.tsx @@ -52,6 +52,10 @@ import { ComposeZones, ZONE_TILE, hoverZoneAt, panelLabel, PANEL_LETTERS, type ComposeMode, type HoverZone, } from './composeDrop' +import { + hasImageFiles, imageExtOf, imageFilesFrom, readFileAsDataURL, +} from './imageDrop' +import { useFileDragActive, useReplaceDrop } from './useReplaceDrop' // The compose-zone primitives (types, the tile map, hit-testing and the overlay // component) live in ./composeDrop so the SPLIT block mounts the IDENTICAL @@ -236,6 +240,10 @@ export function ReportFigureCell({ cell, onRemove, index, dragProps, reorderActi const [captionEditing, setCaptionEditing] = useState(false) const [captionDraft, setCaptionDraft] = useState(cell.caption ?? '') const [hover, setHover] = useState(false) + // A dropped image REPLACES this figure (the cell converts to a photo cell). + // fileDragActive is what mounts the shield over the iframe for an OS drag. + const fileDragActive = useFileDragActive() + const replace = useReplaceDrop(cell.id) const [dropHover, setDropHover] = useState(false) // placeholder fill hover const [editOpen, setEditOpen] = useState(false) // The SELECTED spec panel id (null = figure-level), mirrored from the backend's @@ -432,15 +440,40 @@ export function ReportFigureCell({ cell, onRemove, index, dragProps, reorderActi } } - // ── Placeholder fill drop (unchanged Phase-1 behaviour) ─────────────────── + // ── Placeholder fill drop ───────────────────────────────────────────────── + // + // Accepts BOTH transports, like the split cell's figure side: a figure/window + // PILL and an image FILE from the OS. Gating on the pill alone let a dropped + // PNG bubble to the sidebar body, which appended a new image cell BELOW and + // left this placeholder empty. const onPlaceholderDragOver = (e: React.DragEvent) => { - if (!isComposeDrag(e.dataTransfer)) return + if (!isComposeDrag(e.dataTransfer) && !hasImageFiles(e.dataTransfer)) return e.preventDefault() e.stopPropagation() // don't also trigger the sidebar-body insertion logic e.dataTransfer.dropEffect = 'copy' setDropHover(true) } const onPlaceholderDrop = (e: React.DragEvent) => { + // A dropped PHOTO converts this placeholder into an image cell IN PLACE — + // same cell id, so the slide attributes riding on it (break, kind, style, + // speaker notes) survive. Checked first so it can't collide with the pill. + if (hasImageFiles(e.dataTransfer)) { + e.preventDefault() + e.stopPropagation() + setDropHover(false) + const file = imageFilesFrom(e.dataTransfer)[0] + if (!file) return + void (async () => { + try { + const dataUrl = await readFileAsDataURL(file) + if (!dataUrl) return + sendAction('report_set_cell_image', { + cell_id: cell.id, image_b64: dataUrl, image_ext: imageExtOf(file), + }) + } catch { /* unreadable file — leave the placeholder empty */ } + })() + return + } if (!isComposeDrag(e.dataTransfer)) return e.preventDefault() e.stopPropagation() @@ -630,17 +663,27 @@ export function ReportFigureCell({ cell, onRemove, index, dragProps, reorderActi * accepted it, and the release produced `dragend` with no `drop`. Identical * symptom to a hit-testing failure, entirely different cause. */ - const composeShieldNode = dragKind === 'window' ? ( + const composeShieldNode = (dragKind === 'window' || fileDragActive) ? (
dlogOnce('3.shield/mounted', { cell: cell.id })} data-testid={`figcell-compose-shield-${cell.id}`} - style={styles.composeShield} - onDragEnter={onComposeDragOver} - onDragOver={onComposeDragOver} - onDragLeave={onComposeDragLeave} - onDrop={onComposeDrop} + style={{ ...styles.composeShield, + ...(replace.active ? styles.composeShieldFileOn : {}) }} + // A FILE drag gets the shield too. It is gated on `dragKind` for pills, + // which an OS file drag never sets — so nothing covered the iframe, the + // iframe swallowed the dragover, and the drop fell through to the sidebar + // body and appended a new image cell BELOW the figure. + onDragEnter={fileDragActive ? replace.handlers.onDragOver : onComposeDragOver} + onDragOver={fileDragActive ? replace.handlers.onDragOver : onComposeDragOver} + onDragLeave={fileDragActive ? replace.handlers.onDragLeave : onComposeDragLeave} + onDrop={fileDragActive ? replace.handlers.onDrop : onComposeDrop} > - {hoverZone != null && ( + {fileDragActive ? ( +
+ Drop to replace with this image +
+ ) : hoverZone != null && ( )} @@ -703,12 +746,24 @@ export function ReportFigureCell({ cell, onRemove, index, dragProps, reorderActi } trailing={ - + // A DETACHED figure was rebuilt from the report's own saved pixels, + // so there is no live plot to refresh FROM. Everything else about it + // works; offering a button that can only fail is worse than not + // offering one. + cell.data_detached ? ( + ⛓︎ + ) : ( + + ) } /> )} @@ -2145,7 +2200,7 @@ const styles: Record = { transition: 'border-color 90ms, background 90ms', }, placeholderHot: { - borderColor: '#89b4fa', background: 'rgba(137,180,250,0.08)', color: '#89b4fa', + border: '2px dashed #89b4fa', background: 'rgba(137,180,250,0.08)', color: '#89b4fa', }, placeholderIcon: { fontSize: 26, opacity: 0.6 }, placeholderText: { fontSize: 12, textAlign: 'center', padding: '0 12px' }, @@ -2153,6 +2208,16 @@ const styles: Record = { composeShield: { position: 'absolute', inset: 0, zIndex: 3, }, + composeShieldFileOn: { + outline: '2px dashed #89b4fa', outlineOffset: -2, + background: 'rgba(17,17,27,0.55)', + }, + fileDropHint: { + position: 'absolute', inset: 0, display: 'flex', + alignItems: 'center', justifyContent: 'center', + color: '#89b4fa', fontSize: 12, fontWeight: 700, + pointerEvents: 'none', + }, // (The zone overlay's own styles moved to ./composeDrop with the component.) // ── Compose prompt popover ─────────────────────────────────────────────── promptWrap: { diff --git a/electron/src/renderer/src/components/ReportImageCell.tsx b/electron/src/renderer/src/components/ReportImageCell.tsx index 678da338..b490f6df 100644 --- a/electron/src/renderer/src/components/ReportImageCell.tsx +++ b/electron/src/renderer/src/components/ReportImageCell.tsx @@ -19,6 +19,7 @@ import { useSpyDE } from '../kernel/SpyDEContext' import { reportClipboard, type SerializedImageCell } from '../kernel/reportClipboard' import type { ReportCell } from '../kernel/protocol' import { CellChrome } from './CellChrome' +import { useReplaceDrop } from './useReplaceDrop' const WIDTH_KEY = (id: string) => `spyde-report-imgw-${id}` const DEFAULT_WIDTH_PCT = 100 @@ -43,6 +44,9 @@ interface Props { export function ReportImageCell({ cell, onRemove, index, dragProps }: Props) { const { sendAction } = useSpyDE() + // Drop a file OR a live figure window onto the picture to swap it, keeping + // this cell's caption, width and slide attributes. + const replace = useReplaceDrop(cell.id) const [hover, setHover] = useState(false) const [captionEditing, setCaptionEditing] = useState(false) const [captionDraft, setCaptionDraft] = useState(cell.caption ?? '') @@ -147,8 +151,12 @@ export function ReportImageCell({ cell, onRemove, index, dragProps }: Props) {
{cell.image ? ( )} + {replace.active && ( +
+ Replace this image +
+ )} {cell.image && (
= { position: 'relative', maxWidth: '100%', // width set per-instance (widthPct). }, + imgBoxDropOn: { outline: '2px dashed #89b4fa', outlineOffset: 3, borderRadius: 6 }, + dropHint: { + position: 'absolute', inset: 0, display: 'flex', + alignItems: 'center', justifyContent: 'center', + background: 'rgba(17,17,27,0.66)', color: '#89b4fa', + fontSize: 12, fontWeight: 700, borderRadius: 6, pointerEvents: 'none', + }, img: { display: 'block', width: '100%', height: 'auto', borderRadius: 6, border: '1px solid #313244', diff --git a/electron/src/renderer/src/components/ReportMovieCell.tsx b/electron/src/renderer/src/components/ReportMovieCell.tsx index b03ca4ff..4a6b053e 100644 --- a/electron/src/renderer/src/components/ReportMovieCell.tsx +++ b/electron/src/renderer/src/components/ReportMovieCell.tsx @@ -232,8 +232,8 @@ const styles: Record = { color: '#6c7086', fontSize: 11.5, textAlign: 'center', padding: '14px 10px', cursor: 'default', width: '100%', boxSizing: 'border-box', }, - dropZoneActive: { borderColor: '#89b4fa', color: '#89b4fa' }, - dropZoneReady: { cursor: 'pointer', borderStyle: 'solid', color: '#a6adc8' }, + dropZoneActive: { border: '1px dashed #89b4fa', color: '#89b4fa' }, + dropZoneReady: { cursor: 'pointer', border: '1px solid #45475a', color: '#a6adc8' }, filmIcon: { fontSize: 22, lineHeight: 1 }, summary: { display: 'flex', alignItems: 'center', gap: 8, diff --git a/electron/src/renderer/src/components/ReportSidebar.tsx b/electron/src/renderer/src/components/ReportSidebar.tsx index 5ba77b91..eccc796b 100644 --- a/electron/src/renderer/src/components/ReportSidebar.tsx +++ b/electron/src/renderer/src/components/ReportSidebar.tsx @@ -32,6 +32,9 @@ import { GUIDES } from '@guides/index' import type { ReportCell as ReportCellType } from '../kernel/protocol' import { dlog, dlogOnce } from '../kernel/dragDiag' import { ThemePanel, resolveTheme } from './ThemePanel' +import { + IMAGE_EXTS, hasImageFiles, imageExtOf, imageFilesFrom, readFileAsDataURL, +} from './imageDrop' const MIN_W = 300 const MAX_W = 800 @@ -63,47 +66,9 @@ function figurePayloadFromDrop(dt: DataTransfer): DropFigurePayload | null { return null } -// The image file extensions a PHOTO cell may carry (must mirror the backend's -// IMAGE_EXTS). Anything else the browser hands us is normalised to png (the -// backend defaults unknown exts to png too). -const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp'] as const - -/** Map an image file's MIME / name to one of IMAGE_EXTS. */ -function imageExtOf(file: File): string { - const fromType = (file.type.split('/')[1] || '').toLowerCase() - if ((IMAGE_EXTS as readonly string[]).includes(fromType)) return fromType - const fromName = (file.name.split('.').pop() || '').toLowerCase() - if ((IMAGE_EXTS as readonly string[]).includes(fromName)) return fromName - return 'png' -} - -/** Read a File/Blob as a data URL. Rejects on read error. */ -function readFileAsDataURL(file: Blob): Promise { - return new Promise((resolve, reject) => { - const fr = new FileReader() - fr.onload = () => resolve(String(fr.result || '')) - fr.onerror = () => reject(fr.error) - fr.readAsDataURL(file) - }) -} - -/** True when a DataTransfer carries at least one image FILE (drop-a-photo path, - * distinct from a figure/window pill drop). */ -function hasImageFiles(dt: DataTransfer): boolean { - if (dt.files && dt.files.length) { - for (const f of Array.from(dt.files)) { - if (f.type.startsWith('image/')) return true - } - } - // During dragover the file list isn't readable yet — fall back to the items - // kind/type (Files with an image type). - if (dt.items && dt.items.length) { - for (const it of Array.from(dt.items)) { - if (it.kind === 'file' && it.type.startsWith('image/')) return true - } - } - return false -} +// The image-file drop helpers now live in ./imageDrop so the split cell's +// figure side and the empty figure placeholder can accept a dropped PNG too — +// while they were private here, only the sidebar BODY recognised one. /** One slide's worth of cells, carrying each cell's ORIGINAL flat index (so the * slide-native list still drives the index-keyed drop/reorder machinery @@ -692,9 +657,9 @@ export function ReportSidebar() { e.preventDefault() const idx = computeDropIndex(e.clientY) setDropIndex(null) - const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')) // Insert each dropped image in order at the drop point. - files.forEach((f, k) => { void addImageFile(f, imageExtOf(f), idx + k) }) + imageFilesFrom(e.dataTransfer) + .forEach((f, k) => { void addImageFile(f, imageExtOf(f), idx + k) }) return } if (!DROP_MIMES.some(m => e.dataTransfer.types.includes(m))) { @@ -1584,7 +1549,7 @@ const styles: Record = { transition: 'border-color 120ms ease, background 120ms ease, transform 120ms ease', }, docCardHover: { - background: '#1e1e2e', borderColor: '#89b4fa', transform: 'translateY(-1px)', + background: '#1e1e2e', border: '1px solid #89b4fa', transform: 'translateY(-1px)', }, docCardIcon: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -1637,7 +1602,7 @@ const styles: Record = { marginBottom: 12, }, slideGroupDragging: { opacity: 0.4 }, - slideGroupDropOn: { borderColor: '#89b4fa', boxShadow: '0 0 0 1px #89b4fa inset' }, + slideGroupDropOn: { border: '1px solid #89b4fa', boxShadow: '0 0 0 1px #89b4fa inset' }, slideHeader: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 2px 6px', borderBottom: '1px solid #313244', diff --git a/electron/src/renderer/src/components/ReportSplitCell.tsx b/electron/src/renderer/src/components/ReportSplitCell.tsx index 27d65886..7973582b 100644 --- a/electron/src/renderer/src/components/ReportSplitCell.tsx +++ b/electron/src/renderer/src/components/ReportSplitCell.tsx @@ -41,6 +41,10 @@ import { AddFigureMenu } from './AddFigureMenu' import { SeamlessFigureFrame, FigureEditOverlay } from './ReportFigureCell' import { ComposeZones, ZONE_TILE, hoverZoneAt, type HoverZone } from './composeDrop' import { dlog, dlogOnce } from '../kernel/dragDiag' +import { + hasImageFiles, imageExtOf, imageFilesFrom, readFileAsDataURL, +} from './imageDrop' +import { useReplaceDrop } from './useReplaceDrop' const DROP_MIMES = [FIGURE_DRAG_MIME, WINDOW_DRAG_MIME] const isComposeDrag = (dt: DataTransfer) => DROP_MIMES.some(m => dt.types.includes(m)) @@ -104,6 +108,8 @@ export function ReportSplitCell({ cell, onRemove, index, dragProps, reorderActiv // window on its EDGE must tile into a subplot grid exactly as it does on a // plain figure cell — before this it only ever replaced the figure. const [hoverZone, setHoverZone] = useState(null) + // Swap a FILLED photo side for another file / a live figure, in place. + const replace = useReplaceDrop(cell.id) // The + chrome button's window picker (the click path to a subplot grid). const [addMenu, setAddMenu] = useState(false) const taRef = useRef(null) @@ -129,6 +135,11 @@ export function ReportSplitCell({ cell, onRemove, index, dragProps, reorderActiv const fig = state.reportFigures.get(cell.id) const hasImage = !empty && !cell.figure && !!cell.image const isLive = !empty && !!cell.figure && !!fig + // The saved still, shown when the figure side has a spec but no live window + // and no photo — i.e. the data is offline and no pixels were saved with the + // report. `isLive` is checked first, so a DETACHED figure (rebuilt from the + // report's own data) renders as the real interactive figure, not this. + const hasBakedPng = !empty && !isLive && !hasImage && !!cell.png // Keep the text draft in sync when the backing source changes and we're not // actively editing (a live report_state update from elsewhere). @@ -206,14 +217,39 @@ export function ReportSplitCell({ cell, onRemove, index, dragProps, reorderActiv ] // ── Figure-side drop (fill the empty figure side in place) ───────────────── + // + // TWO transports land here, and the handler has to test for both: a figure / + // window PILL (`application/x-spyde-*` data) and an image FILE dragged in from + // the OS (`Files`). Gating on the pill alone made a dropped PNG fail the test, + // bubble to the sidebar body, and get APPENDED as a new image cell below — + // the slot the user aimed at stayed empty. const onFigDragOver = (e: React.DragEvent) => { - if (!isComposeDrag(e.dataTransfer)) return + if (!isComposeDrag(e.dataTransfer) && !hasImageFiles(e.dataTransfer)) return e.preventDefault() e.stopPropagation() // don't also trigger the sidebar-body insertion logic e.dataTransfer.dropEffect = 'copy' setDropHover(true) } const onFigDrop = (e: React.DragEvent) => { + // A dropped PHOTO fills this cell's slot in place. Checked FIRST so it can + // never collide with the pill path below. + if (hasImageFiles(e.dataTransfer)) { + e.preventDefault() + e.stopPropagation() + setDropHover(false) + const file = imageFilesFrom(e.dataTransfer)[0] + if (!file) return + void (async () => { + try { + const dataUrl = await readFileAsDataURL(file) + if (!dataUrl) return + sendAction('report_set_cell_image', { + cell_id: cell.id, image_b64: dataUrl, image_ext: imageExtOf(file), + }) + } catch { /* unreadable file — leave the slot empty */ } + })() + return + } if (!isComposeDrag(e.dataTransfer)) return e.preventDefault() e.stopPropagation() @@ -396,11 +432,22 @@ export function ReportSplitCell({ cell, onRemove, index, dragProps, reorderActiv
) : hasImage ? (
setFigHover(true)} onMouseLeave={() => setFigHover(false)} + // A FILLED photo side had no drop target at all — the dropzone below + // only ever rendered while the side was empty — so swapping the + // picture meant removing the figure side and re-adding it. + {...replace.handlers} > {cell.caption + {replace.active && ( +
+ Replace this image +
+ )} {figHover && ( )}
+ ) : hasBakedPng ? ( + // OFFLINE: the source signal is unavailable AND the report carries no + // saved pixels for this cell, so all that is left is the baked still. + // Without this branch the pane fell through to "rendering…" and sat + // there forever — Present mode has always shown the PNG here, the + // editor just never looked at it. +
+ {cell.caption +
+ data offline +
+
) : (
rendering…
@@ -638,6 +699,19 @@ const styles: Record = { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', color: '#6c7086', fontSize: 11, }, + figBoxDropOn: { outline: '2px dashed #89b4fa', outlineOffset: 2 }, + dropHint: { + position: 'absolute', inset: 0, display: 'flex', + alignItems: 'center', justifyContent: 'center', + background: 'rgba(17,17,27,0.66)', color: '#89b4fa', + fontSize: 11, fontWeight: 700, pointerEvents: 'none', + }, + offlineBadge: { + position: 'absolute', left: 6, bottom: 6, + background: 'rgba(24,24,37,0.9)', border: '1px solid #45475a', + borderRadius: 5, padding: '2px 7px', fontSize: 10, color: '#a6adc8', + pointerEvents: 'none', + }, shield: { position: 'absolute', inset: 0, zIndex: 3, background: 'transparent', }, diff --git a/electron/src/renderer/src/components/SlideOverview.tsx b/electron/src/renderer/src/components/SlideOverview.tsx index ff6ef76c..ae06e250 100644 --- a/electron/src/renderer/src/components/SlideOverview.tsx +++ b/electron/src/renderer/src/components/SlideOverview.tsx @@ -220,9 +220,9 @@ const styles: Record = { background: '#14141f', cursor: 'pointer', transition: 'border-color 0.12s, transform 0.12s', }, - thumbCurrent: { borderColor: '#89b4fa', boxShadow: '0 0 0 2px rgba(137,180,250,0.35)' }, + thumbCurrent: { border: '2px solid #89b4fa', boxShadow: '0 0 0 2px rgba(137,180,250,0.35)' }, thumbDragging: { opacity: 0.4 }, - thumbDropTarget: { borderColor: '#a6e3a1' }, + thumbDropTarget: { border: '2px solid #a6e3a1' }, dropBar: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 4, background: '#a6e3a1', zIndex: 2, borderRadius: '2px 0 0 2px', diff --git a/electron/src/renderer/src/components/WindowContent.tsx b/electron/src/renderer/src/components/WindowContent.tsx index 444fb43a..154ce9f0 100644 --- a/electron/src/renderer/src/components/WindowContent.tsx +++ b/electron/src/renderer/src/components/WindowContent.tsx @@ -516,7 +516,7 @@ const styles: Record = { transition: 'background 70ms, border-color 70ms', }, overlayZoneHot: { - borderColor: '#89b4fa', background: 'rgba(137,180,250,0.24)', + border: '2px dashed #89b4fa', background: 'rgba(137,180,250,0.24)', }, overlayZoneLabel: { fontSize: 12, fontWeight: 600, color: '#cdd6f4', diff --git a/electron/src/renderer/src/components/composeDrop.tsx b/electron/src/renderer/src/components/composeDrop.tsx index 791ad2e7..1f39d149 100644 --- a/electron/src/renderer/src/components/composeDrop.tsx +++ b/electron/src/renderer/src/components/composeDrop.tsx @@ -179,7 +179,7 @@ const styles: Record = { transition: 'background 70ms, border-color 70ms', }, zoneHot: { - borderColor: '#89b4fa', borderWidth: 2, + border: '2px solid #89b4fa', background: 'rgba(137,180,250,0.42)', boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.25)', }, diff --git a/electron/src/renderer/src/components/imageDrop.ts b/electron/src/renderer/src/components/imageDrop.ts new file mode 100644 index 00000000..28dc94a3 --- /dev/null +++ b/electron/src/renderer/src/components/imageDrop.ts @@ -0,0 +1,66 @@ +/** + * imageDrop.ts — the shared "an image FILE is being dragged in from the OS" + * helpers, used by every report drop target. + * + * These lived privately in ReportSidebar, which is why only the SIDEBAR BODY + * ever recognised a dropped PNG. The split cell's figure side and the empty + * figure placeholder both gate their drop handlers on the figure/window PILL + * mimes alone, so a dragged file failed their test, bubbled to the body, and was + * appended as a NEW image cell BELOW the split instead of filling the slot the + * user aimed at. Sharing them is what lets a drop zone accept both kinds. + * + * A pill drag and a file drag are genuinely different transports — a pill + * carries `application/x-spyde-*` data, a file carries `Files` — so a drop + * target that wants both has to test for both. + */ + +/** The image file extensions a PHOTO cell may carry (mirrors the backend's + * IMAGE_EXTS). Anything else is normalised to png, as the backend does. */ +export const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp'] as const + +/** Map an image file's MIME / name to one of {@link IMAGE_EXTS}. */ +export function imageExtOf(file: File): string { + const fromType = (file.type.split('/')[1] || '').toLowerCase() + if ((IMAGE_EXTS as readonly string[]).includes(fromType)) return fromType + const fromName = (file.name.split('.').pop() || '').toLowerCase() + if ((IMAGE_EXTS as readonly string[]).includes(fromName)) return fromName + return 'png' +} + +/** Read a File/Blob as a data URL. Rejects on read error. */ +export function readFileAsDataURL(file: Blob): Promise { + return new Promise((resolve, reject) => { + const fr = new FileReader() + fr.onload = () => resolve(String(fr.result || '')) + fr.onerror = () => reject(fr.error) + fr.readAsDataURL(file) + }) +} + +/** + * True when a DataTransfer carries at least one image FILE (the drop-a-photo + * path, distinct from a figure/window pill drop). + * + * The `items` fallback is load-bearing: during `dragover` the browser does not + * expose `files` yet (only on `drop`), so a target that tested `files` alone + * would never call preventDefault and would therefore never RECEIVE the drop. + */ +export function hasImageFiles(dt: DataTransfer): boolean { + if (dt.files && dt.files.length) { + for (const f of Array.from(dt.files)) { + if (f.type.startsWith('image/')) return true + } + } + if (dt.items && dt.items.length) { + for (const it of Array.from(dt.items)) { + if (it.kind === 'file' && it.type.startsWith('image/')) return true + } + } + return false +} + +/** The image files on a drop, in order (empty when there are none). */ +export function imageFilesFrom(dt: DataTransfer): File[] { + if (!dt.files || !dt.files.length) return [] + return Array.from(dt.files).filter(f => f.type.startsWith('image/')) +} diff --git a/electron/src/renderer/src/components/useReplaceDrop.ts b/electron/src/renderer/src/components/useReplaceDrop.ts new file mode 100644 index 00000000..b4267fa3 --- /dev/null +++ b/electron/src/renderer/src/components/useReplaceDrop.ts @@ -0,0 +1,166 @@ +/** + * useReplaceDrop.ts — "drop something here to REPLACE what's already there". + * + * A picture in a report can come from two places, and once one is in the cell + * there was no way to swap it: an image cell had only its reorder wiring, and a + * split cell's drop zone existed solely while the figure side was EMPTY. So + * changing a picture meant deleting the cell and re-adding it — which loses the + * caption, the display width, and (on a slide's first cell) the slide break and + * speaker notes. + * + * Both sources land here: + * • an image FILE from the OS → report_set_cell_image (bytes swapped in place) + * • a figure/window PILL → report_add_figure {at_cell} (a LIVE figure) + * + * Both verbs target the EXISTING cell id, so the cell keeps its identity and + * everything hanging off it. The two transports are genuinely different + * (`Files` vs `application/x-spyde-*`), so a target that wants both has to test + * for both — see imageDrop.ts. + */ +import React from 'react' +import { useSpyDE } from '../kernel/SpyDEContext' +import { FIGURE_DRAG_MIME, WINDOW_DRAG_MIME, peekWindowDrag } from '../kernel/dnd' +import { hasImageFiles, imageExtOf, imageFilesFrom, readFileAsDataURL } from './imageDrop' + +const PILL_MIMES = [FIGURE_DRAG_MIME, WINDOW_DRAG_MIME] +const isPillDrag = (dt: DataTransfer) => PILL_MIMES.some(m => dt.types.includes(m)) + +/** The source window (+ optional view / figure id) behind a pill drop. */ +function pillPayload(dt: DataTransfer): { + windowId: number; view?: string; figId?: string +} | null { + const raw = dt.getData(FIGURE_DRAG_MIME) + if (raw) { + try { + const parsed = JSON.parse(raw) + if (typeof parsed?.windowId === 'number') return parsed + } catch { /* fall through to the window mime / stash */ } + } + const win = dt.getData(WINDOW_DRAG_MIME) + if (win) { + const n = parseInt(win, 10) + if (Number.isFinite(n)) return { windowId: n } + } + // The drag stash — set at dragstart, read when the DataTransfer is empty + // (some platforms withhold getData outside the drop handler). + return peekWindowDrag() +} + +/** + * True while an image FILE is being dragged over the window from the OS. + * + * Needed because a report figure is an OUT-OF-PROCESS IFRAME, which swallows + * drag events over itself — the cell only ever sees them through a transparent + * shield mounted on top. That shield was gated on `dragKind`, which is set at + * dragstart of an IN-APP pill; an OS file drag never sets it, so no shield + * mounted, the iframe ate the dragover, and the drop reached the sidebar body + * instead (appending a new image cell below the figure). + * + * Detected by a window-level `dragover` refreshing a short timer rather than by + * `dragend`: for a drag originating OUTSIDE the page, dragend fires on the + * source, which isn't in this document, so it never arrives here. The timer is + * the only signal that reliably says "the drag has gone". + */ +export function useFileDragActive(): boolean { + const [active, setActive] = React.useState(false) + React.useEffect(() => { + let timer: ReturnType | null = null + const clear = () => { setActive(false); if (timer) { clearTimeout(timer); timer = null } } + const onOver = (e: DragEvent) => { + if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return + setActive(true) + if (timer) clearTimeout(timer) + // Comfortably longer than the ~50-100 ms dragover cadence, short enough + // that the shield doesn't linger after the pointer leaves. + timer = setTimeout(() => setActive(false), 220) + } + const onLeave = (e: DragEvent) => { + // Leaving the window entirely (no related target) — not crossing a child. + if (!e.relatedTarget) clear() + } + window.addEventListener('dragover', onOver) + window.addEventListener('drop', clear) + window.addEventListener('dragleave', onLeave) + return () => { + window.removeEventListener('dragover', onOver) + window.removeEventListener('drop', clear) + window.removeEventListener('dragleave', onLeave) + if (timer) clearTimeout(timer) + } + }, []) + return active +} + +export interface ReplaceDrop { + active: boolean + handlers: { + onDragOver: (e: React.DragEvent) => void + onDragLeave: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void + } +} + +/** + * Drop handlers that REPLACE the content of `cellId` in place. + * + * `active` is true while a droppable drag is over the target, for the caller's + * highlight. Handlers stopPropagation so the drop never also reaches the + * sidebar body, which would append a NEW cell underneath — the exact bug this + * exists to avoid. + */ +export function useReplaceDrop(cellId: string): ReplaceDrop { + const { sendAction } = useSpyDE() + const [active, setActive] = React.useState(false) + + const onDragOver = (e: React.DragEvent) => { + if (!isPillDrag(e.dataTransfer) && !hasImageFiles(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setActive(true) + } + + const onDragLeave = (e: React.DragEvent) => { + // Only clear when the pointer actually leaves the box, not when it crosses + // a child (the caption, the resize grip, the overlay itself). + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) { + setActive(false) + } + } + + const onDrop = (e: React.DragEvent) => { + // FILE first, so it can never be mistaken for a pill. + if (hasImageFiles(e.dataTransfer)) { + e.preventDefault() + e.stopPropagation() + setActive(false) + const file = imageFilesFrom(e.dataTransfer)[0] + if (!file) return + void (async () => { + try { + const dataUrl = await readFileAsDataURL(file) + if (!dataUrl) return + sendAction('report_set_cell_image', { + cell_id: cellId, image_b64: dataUrl, image_ext: imageExtOf(file), + }) + } catch { /* unreadable file — leave the picture as it was */ } + })() + return + } + if (!isPillDrag(e.dataTransfer)) return + e.preventDefault() + e.stopPropagation() + setActive(false) + const src = pillPayload(e.dataTransfer) + if (src == null) return + // at_cell targets THIS cell, so the backend converts it in place rather + // than appending a figure below the picture it was meant to replace. + sendAction('report_add_figure', { + source_window_id: src.windowId, at_cell: cellId, + ...(src.view !== undefined ? { view: src.view } : {}), + ...(src.figId !== undefined ? { fig_id: src.figId } : {}), + }) + } + + return { active, handlers: { onDragOver, onDragLeave, onDrop } } +} diff --git a/electron/src/renderer/src/kernel/SpyDEContext.tsx b/electron/src/renderer/src/kernel/SpyDEContext.tsx index 0d97b1c5..cfb42bc9 100644 --- a/electron/src/renderer/src/kernel/SpyDEContext.tsx +++ b/electron/src/renderer/src/kernel/SpyDEContext.tsx @@ -342,7 +342,18 @@ function spydeReducer(state: State, action: Action): State { return { ...state, ready: true, - dashboardUrl: action.dashboardUrl ?? null, + // NEVER erase a dashboard URL we already have. + // + // Two different messages dispatch READY: `ready` (the backend's stdin + // loop is up — carries NO dashboard) and `dask_ready` (the cluster is + // up — carries one). Their order is NOT fixed: `ready` is emitted after + // _prewarm_io() + prewarm_torch_cuda(), which costs seconds, while the + // cluster comes up on a background thread — so `dask_ready` frequently + // lands FIRST. With `?? null`, the later `ready` then wiped the URL, + // which disabled the "Open full Dask dashboard" menu item and hid the + // button in DaskMonitor. A READY without a URL carries no information + // about the dashboard, so it must not overwrite one. + dashboardUrl: action.dashboardUrl ?? state.dashboardUrl ?? null, status: 'Ready', } diff --git a/electron/src/renderer/src/kernel/protocol.ts b/electron/src/renderer/src/kernel/protocol.ts index a07972db..e0342bbb 100644 --- a/electron/src/renderer/src/kernel/protocol.ts +++ b/electron/src/renderer/src/kernel/protocol.ts @@ -509,6 +509,11 @@ export interface ReportCell { fig_id?: string | null /** figure cells: the SignalRef couldn't be rebound → show the baked PNG. */ data_offline?: boolean + /** figure cells: rebuilt from the report's OWN saved pixels (data/.npz) + * because the source signal wasn't available. A REAL, interactive figure — + * pan, zoom and widgets all work — with nothing behind it to refresh FROM, + * so only the refresh-from-data affordance is withheld. Not data_offline. */ + data_detached?: boolean /** figure cells: a data-URL PNG fallback (present only for offline cells). */ png?: string /** figure cells: the pixel-free FigureSpec recipe (panels/layers/annotations) diff --git a/electron/tests/dask_dashboard_link.spec.ts b/electron/tests/dask_dashboard_link.spec.ts new file mode 100644 index 00000000..11819110 --- /dev/null +++ b/electron/tests/dask_dashboard_link.spec.ts @@ -0,0 +1,41 @@ +/** + * dask_dashboard_link.spec.ts — the "Open full Dask dashboard" affordance must + * actually be there once a cluster is up. + * + * TWO different backend messages dispatch the renderer's READY action: `ready` + * (the stdin loop is up — carries NO dashboard field) and `dask_ready` (the + * cluster is up — carries the URL). The reducer used `action.dashboardUrl ?? + * null`, so ANY `ready` arriving after `dask_ready` erased the URL. That + * disabled the File-menu item (`disabled: !state.dashboardUrl`) and hid the + * button in DaskMonitor entirely — the dashboard simply could not be opened. + * + * Needs a REAL cluster (dask: true), because the whole point is the second + * message. + * + * Run: + * npx playwright test tests/dask_dashboard_link.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test, expect } from '@playwright/test' +const { launchApp } = require('./_harness.cjs') + +test.setTimeout(300_000) + +test('the Dask dashboard link survives every ready message', async () => { + const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + try { + // Let BOTH messages land in whatever order they arrive — the bug was that + // one order silently destroyed the URL the other had just delivered. + await ctx.page.waitForTimeout(4000) + + await ctx.page.getByTestId('dask-monitor').click() + await expect( + ctx.page.getByRole('button', { name: /Open full Dask dashboard/ }), + 'no dashboard button — the URL was lost between ready and dask_ready', + ).toBeVisible({ timeout: 20_000 }) + + ctx.assertNoJsErrors() + } finally { + await ctx.app?.close() + } +}) diff --git a/electron/tests/fixtures/split-photo.png b/electron/tests/fixtures/split-photo.png new file mode 100644 index 00000000..19fdb4c1 Binary files /dev/null and b/electron/tests/fixtures/split-photo.png differ diff --git a/electron/tests/present_layout.spec.ts b/electron/tests/present_layout.spec.ts new file mode 100644 index 00000000..65955a63 --- /dev/null +++ b/electron/tests/present_layout.spec.ts @@ -0,0 +1,174 @@ +/** + * present_layout.spec.ts — Present-mode slide LAYOUT, against the real app. + * + * Three things that a green typecheck and a passing selector cannot see, so each + * one is screenshotted and the pixels are looked at: + * + * 1. STACKED SPLITS. `split_layout` has four values, but Present mode used to + * test it with a bare `!== 'text-right'` — so `text-top` / `text-bottom` + * round-tripped through the document perfectly and then rendered SIDE BY + * SIDE. The editor offered a layout the deck could not show. + * 2. TEXT SLIDES FILL THE STAGE. A heading plus a few bullets used to render at + * prose size, vertically centred in a 60rem column: a small block adrift in + * a large dark rectangle. Sparse slides now scale up; dense ones must NOT + * (that is what keeps them from overflowing into the pager). + * 3. A DROPPED PNG FILLS THE SLOT. `report_set_cell_image` puts a photo into an + * EXISTING split cell rather than appending a new cell below it. + * + * The deck is built through the real backend verbs rather than a fixture file, + * so the wiring under test is the wiring a user drives. + * + * Run: + * npx playwright test tests/present_layout.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test, expect } from '@playwright/test' +import { join } from 'path' +import { mkdirSync, readFileSync } from 'fs' +const { launchApp, backendAction, backendErrorLines } = require('./_harness.cjs') + +const SHOTS = join(__dirname, '..', 'present_layout_shots') + +// A 480×300 PNG — a blue disc in a bordered navy panel. Deliberately NOT a 1×1 +// pixel: the screenshot is the actual verification here, and a 1-pixel image +// satisfies every assertion while showing nothing, so it would prove the slot +// was filled without proving anything was DRAWN in it. +const PHOTO = join(__dirname, 'fixtures', 'split-photo.png') +const PHOTO_URL = + 'data:image/png;base64,' + readFileSync(PHOTO).toString('base64') + +// Slide 1: sparse — a heading and four short bullets. THE case from the report. +const SPARSE = '## Motivation\n\n- Code base solutions are good, but…\n' + + '- Sometimes all you want is a black box\n' + + '- Black boxes are fine — if you can open them\n' + + '- Data analysis software has to be tested\n' +// Slide 2: dense — long enough that scaling it up would overflow the stage. +const DENSE = '## A dense slide\n\n' + Array.from({ length: 7 }, (_, i) => + `- Bullet ${i + 1}: a deliberately long line of body copy that exists purely ` + + 'to push this slide past the density threshold so it keeps prose sizing.\n').join('') + +let ctx: Awaited> + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }) + ctx = await launchApp({ env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + await ctx.page.waitForTimeout(1500) +}) + +test.afterAll(async () => { + try { ctx?.assertNoJsErrors() } finally { await ctx?.app?.close() } +}) + +test('stacked splits render, sparse text fills, a dropped PNG fills the slot', async () => { + const { page } = ctx + + await page.getByTestId('toggle-report').click() + await expect(page.getByTestId('report-sidebar')).toBeVisible() + await backendAction(page, 'report_new', { type: 'presentation' }) + + await backendAction(page, 'report_add_cell', { source: SPARSE }) + await backendAction(page, 'report_add_cell', { source: DENSE, slide_break: true }) + await backendAction(page, 'report_add_split_cell', { + source: '## Stacked\n\nText above, picture below.\n', + layout: 'text-top', slide_break: true, + }) + + await expect + .poll(async () => page.getByTestId(/^report-slide-\d+$/).count(), + { timeout: 60_000, message: 'the seeded deck produced no slides' }) + .toBe(3) + + // ── 3. the dropped-PNG path: fill the split's slot, don't append ─────────── + // The split cell carries its id in its testid; the empty figure side shows a + // dropzone until something fills it. + const splitTestId = await page.locator('[data-testid^="report-splitcell-"]') + .first().getAttribute('data-testid') + const splitId = (splitTestId || '').replace('report-splitcell-', '') + expect(splitId, 'could not resolve the split cell id').not.toBe('') + await expect(page.getByTestId(`report-split-dropzone-${splitId}`)).toBeVisible() + + const cellsBefore = await page.locator('[data-report-cell="1"]').count() + await backendAction(page, 'report_set_cell_image', { + cell_id: splitId, image_b64: PHOTO_URL, image_ext: 'png', + }) + // The dropzone gives way to the picture, IN the same cell. + await expect(page.getByTestId(`report-split-dropzone-${splitId}`)) + .toBeHidden({ timeout: 15_000 }) + expect(await page.locator('[data-report-cell="1"]').count(), + 'filling a slot must not append a new cell').toBe(cellsBefore) + + // ── Present ──────────────────────────────────────────────────────────────── + await page.getByTestId('report-present').click() + const stage = page.locator('[data-testid="present-slide"][data-active="1"]') + await expect(stage).toBeVisible({ timeout: 30_000 }) + await page.waitForTimeout(1200) + + // ── 2. sparse fills, dense does not ─────────────────────────────────────── + await page.screenshot({ path: join(SHOTS, '01-sparse-fill.png') }) + expect(await stage.getAttribute('data-fill'), + 'a sparse text slide should take the large fill tier').toBe('lg') + const sparsePx = await stage.locator('.present-md').first() + .evaluate(el => parseFloat(getComputedStyle(el).fontSize)) + + await page.keyboard.press('ArrowRight') + await page.waitForTimeout(700) + await page.screenshot({ path: join(SHOTS, '02-dense-base.png') }) + expect(await stage.getAttribute('data-fill'), + 'a dense text slide must keep prose sizing').toBe('base') + const densePx = await stage.locator('.present-md').first() + .evaluate(el => parseFloat(getComputedStyle(el).fontSize)) + + // The whole point: the sparse slide is VISIBLY bigger, not marginally. + expect(sparsePx, `sparse ${sparsePx}px vs dense ${densePx}px`) + .toBeGreaterThan(densePx * 1.25) + + // A scaled-up slide that overflows is a regression, not a feature. + const denseOverflow = await stage.evaluate((el: HTMLElement) => + el.scrollHeight - el.clientHeight) + expect(denseOverflow, 'the dense slide overflows its stage').toBeLessThanOrEqual(2) + + // ── 1. the stacked split ────────────────────────────────────────────────── + await page.keyboard.press('ArrowRight') + await page.waitForTimeout(700) + await page.screenshot({ path: join(SHOTS, '03-split-text-top.png') }) + + const split = stage.locator('[data-testid^="present-split-"]') + await expect(split).toBeVisible() + expect(await split.getAttribute('data-layout'), + 'the split lost its layout on the way to the slide').toBe('text-top') + + // The photo really renders on the slide, at a size worth looking at — not a + // collapsed 0-height box that every geometry assertion below would still pass. + const img = split.locator('img') + await expect(img).toBeVisible() + const box = await img.boundingBox() + expect(box!.height, 'the dropped photo rendered too small to see') + .toBeGreaterThan(120) + + // ONE grid column is what "stacked" means. Two columns here is exactly the + // old bug: the document said text-top and the slide drew text-left. + const cols = await split.evaluate( + (el: HTMLElement) => getComputedStyle(el).gridTemplateColumns) + expect(cols.trim().split(/\s+/).length, + `stacked split rendered ${cols} — expected a single column`).toBe(1) + + // The text really is ABOVE the picture, not merely in a one-column grid. + const order = await split.evaluate((el: HTMLElement) => { + const kids = Array.from(el.children) as HTMLElement[] + const text = kids.find(k => k.querySelector('h2')) + const fig = kids.find(k => k.querySelector('img') || k !== text) + return { textTop: text?.getBoundingClientRect().top ?? 0, + figTop: fig?.getBoundingClientRect().top ?? 0 } + }) + expect(order.figTop, 'text-top must put the text above the figure') + .toBeGreaterThan(order.textTop) + + await page.keyboard.press('Escape') + await expect(stage).toBeHidden({ timeout: 15_000 }) + + const errs = backendErrorLines(ctx.backend) + expect(errs, `backend errors:\n${errs.join('\n')}`).toEqual([]) +}) diff --git a/electron/tests/report_replace_image.spec.ts b/electron/tests/report_replace_image.spec.ts new file mode 100644 index 00000000..9156d775 --- /dev/null +++ b/electron/tests/report_replace_image.spec.ts @@ -0,0 +1,193 @@ +/** + * report_replace_image.spec.ts — swap a picture that is ALREADY in the report. + * + * The backend verbs are unit-tested; what only the app can show is the RENDERER + * wiring, because that is what was missing. An image cell carried only its + * reorder drag handlers and a split cell's drop zone existed solely while the + * figure side was EMPTY, so a file dragged onto an existing picture fell through + * to the sidebar body and was APPENDED as a new cell below it. + * + * The drop is synthesized with a real DataTransfer carrying a real File, and + * dispatched at the element — the same events the browser fires — so the + * handlers under test are the ones a user hits. + * + * Run: + * npx playwright test tests/report_replace_image.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test, expect } from '@playwright/test' +import { join } from 'path' +import { mkdirSync, readFileSync } from 'fs' +const { launchApp, backendAction, backendErrorLines } = require('./_harness.cjs') + +const SHOTS = join(__dirname, '..', 'replace_image_shots') +const PHOTO = join(__dirname, 'fixtures', 'split-photo.png') +const PHOTO_URL = + 'data:image/png;base64,' + readFileSync(PHOTO).toString('base64') + +let ctx: Awaited> + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }) + ctx = await launchApp({ env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + await ctx.page.waitForTimeout(1500) +}) + +test.afterAll(async () => { + try { ctx?.assertNoJsErrors() } finally { await ctx?.app?.close() } +}) + +/** Dispatch a real dragover+drop carrying one PNG file at `selector`. */ +async function dropPngOn(page: any, selector: string, tint: number) { + await page.evaluate(async ({ sel, tint }: { sel: string; tint: number }) => { + // A tiny but VALID png whose bytes differ per `tint`, so the resulting data + // URL is provably a different image rather than the same one re-sent. + const cv = document.createElement('canvas') + cv.width = 24; cv.height = 24 + const g = cv.getContext('2d')! + g.fillStyle = `rgb(${tint},20,40)` + g.fillRect(0, 0, 24, 24) + const blob: Blob = await new Promise(res => cv.toBlob(b => res(b!), 'image/png')) + const file = new File([blob], 'replacement.png', { type: 'image/png' }) + + const dt = new DataTransfer() + dt.items.add(file) + const el = document.querySelector(sel) + if (!el) throw new Error(`no element for ${sel}`) + for (const type of ['dragenter', 'dragover', 'drop']) { + el.dispatchEvent(new DragEvent(type, { + bubbles: true, cancelable: true, dataTransfer: dt, + })) + } + }, { sel: selector, tint }) +} + +test('a dropped PNG replaces an existing picture instead of stacking below it', + async () => { + const { page } = ctx + + await page.getByTestId('toggle-report').click() + await expect(page.getByTestId('report-sidebar')).toBeVisible() + await backendAction(page, 'report_new', {}) + await backendAction(page, 'report_add_image_cell', { + image_b64: PHOTO_URL, image_ext: 'png', caption: 'original', + }) + + const box = page.locator('[data-testid^="report-imgcell-box-"]').first() + await expect(box).toBeVisible({ timeout: 30_000 }) + const cellId = (await box.getAttribute('data-testid'))! + .replace('report-imgcell-box-', '') + const img = page.getByTestId(`report-imgcell-img-${cellId}`) + const before = await img.getAttribute('src') + const cellsBefore = await page.locator('[data-report-cell="1"]').count() + await page.screenshot({ path: join(SHOTS, '01-before.png') }) + + await dropPngOn(page, `[data-testid="report-imgcell-box-${cellId}"]`, 200) + + // The SAME cell now shows a DIFFERENT image. + await expect + .poll(async () => img.getAttribute('src'), + { timeout: 20_000, message: 'the picture never changed' }) + .not.toBe(before) + expect(await page.locator('[data-report-cell="1"]').count(), + 'replacing must not append a second cell').toBe(cellsBefore) + // Identity survives the swap — this is why it replaces rather than re-creates. + await expect(page.getByTestId(`report-imgcell-caption-${cellId}`)) + .toContainText('original') + await page.screenshot({ path: join(SHOTS, '02-after.png') }) + + const errs = backendErrorLines(ctx.backend) + expect(errs, `backend errors:\n${errs.join('\n')}`).toEqual([]) +}) + +test('dropping a picture on a TEXT slide turns it into a split slide', async () => { + const { page } = ctx + + if (!(await page.getByTestId('report-sidebar').isVisible())) { + await page.getByTestId('toggle-report').click() + } + await backendAction(page, 'report_new', { type: 'presentation' }) + await page.waitForTimeout(600) + await backendAction(page, 'report_add_cell', { + source: '## Motivation\n\n- a point worth making\n', + }) + const textCell = page.locator('[data-testid^="report-cell-"]').first() + await expect(textCell).toBeVisible({ timeout: 30_000 }) + const cellId = (await textCell.getAttribute('data-testid'))! + .replace('report-cell-', '') + const cellsBefore = await page.locator('[data-report-cell="1"]').count() + await page.screenshot({ path: join(SHOTS, '04-text-before.png') }) + + await dropPngOn(page, `[data-testid="report-cell-${cellId}"]`, 140) + + // The markdown cell became a SPLIT cell — same id, so the slide is still one + // slide and keeps whatever was riding on it. + await expect(page.getByTestId(`report-splitcell-${cellId}`), + 'the text cell never became a split').toBeVisible({ timeout: 20_000 }) + await expect(page.getByTestId(`report-split-figure-${cellId}`).locator('img')) + .toBeVisible({ timeout: 20_000 }) + // The prose came with it. + await expect(page.getByTestId(`report-split-rendered-${cellId}`)) + .toContainText('a point worth making') + expect(await page.locator('[data-report-cell="1"]').count(), + 'a layout change must not add a cell').toBe(cellsBefore) + await page.screenshot({ path: join(SHOTS, '05-text-became-split.png') }) + + const errs = backendErrorLines(ctx.backend) + expect(errs, `backend errors:\n${errs.join('\n')}`).toEqual([]) +}) + +// NOT COVERED HERE: dropping a file on a LIVE figure cell. +// +// The backend half is pinned by test_report_handlers.py +// (TestPhotoReplacesALiveFigure — the figure cell converts to a photo cell and +// its window is torn down). The RENDERER half — a shield mounting over the +// out-of-process iframe for an OS file drag, so the iframe cannot swallow the +// drop — is NOT verified end-to-end. An attempt using load_test_data_si_grains +// resolved a window id of 0 from the first breadcrumb (the NAVIGATOR window, +// not the signal one) and report_add_figure then produced no figure cell with +// no backend error, so the spec was measuring nothing. Getting a real painted +// signal window and a real figure cell into this fixture is the work; it is +// worth doing, and it is not done. + +test("a split cell's FILLED photo side accepts a replacement too", async () => { + const { page } = ctx + + // Self-sufficient: the sidebar may not be open when this test runs alone. + if (!(await page.getByTestId('report-sidebar').isVisible())) { + await page.getByTestId('toggle-report').click() + } + await backendAction(page, 'report_new', {}) + await page.waitForTimeout(600) + await backendAction(page, 'report_add_split_cell', { + source: '## Split\n\ntext side\n', layout: 'text-left', + }) + const splitLoc = page.locator('[data-testid^="report-splitcell-"]').first() + await expect(splitLoc).toBeVisible({ timeout: 30_000 }) + const splitTestId = await splitLoc.getAttribute('data-testid') + const splitId = (splitTestId || '').replace('report-splitcell-', '') + // Fill it first — the bug was specifically that a FILLED side had no target. + await backendAction(page, 'report_set_cell_image', { + cell_id: splitId, image_b64: PHOTO_URL, image_ext: 'png', + }) + const figPane = page.getByTestId(`report-split-figure-${splitId}`) + await expect(figPane.locator('img')).toBeVisible({ timeout: 20_000 }) + const before = await figPane.locator('img').getAttribute('src') + const cellsBefore = await page.locator('[data-report-cell="1"]').count() + + await dropPngOn(page, `[data-testid="report-split-figure-${splitId}"] img`, 90) + + await expect + .poll(async () => figPane.locator('img').getAttribute('src'), + { timeout: 20_000, message: 'the split photo never changed' }) + .not.toBe(before) + expect(await page.locator('[data-report-cell="1"]').count(), + 'replacing must not append a second cell').toBe(cellsBefore) + await page.screenshot({ path: join(SHOTS, '03-split-replaced.png') }) + + const errs = backendErrorLines(ctx.backend) + expect(errs, `backend errors:\n${errs.join('\n')}`).toEqual([]) +}) diff --git a/electron/tests/resume_probe.spec.ts b/electron/tests/resume_probe.spec.ts new file mode 100644 index 00000000..cc9d77f2 --- /dev/null +++ b/electron/tests/resume_probe.spec.ts @@ -0,0 +1,146 @@ +/** + * resume_probe.spec.ts — PHASE 1 PROBE for SESSION_RESYNC_PLAN.md. + * + * Not a regression test. This MEASURES what survives a renderer loss and what + * does not, so the plan stops guessing. It prints a findings block; the + * assertions only pin things confident enough to regress on. + * + * A laptop lid-close cannot be driven from CI, so this uses the faithful proxy: + * reload the renderer with data loaded. That recreates the renderer's JS context + * and destroys its in-memory React state exactly as a renderer-process death + * would, while leaving the Python backend and its Dask cluster untouched — which + * is precisely the situation §1 of the plan reasons about. + * + * What it answers: + * 1. Do the plot windows survive a renderer loss? (expected: NO) + * 2. Does the Python backend survive it? (expected: YES) + * 3. Does the Dask dashboard link survive? (expected: NO — same React state) + * 4. Do the figure HTML files still exist afterwards? (plan §3.3 Q1 — the cost driver) + * 5. Can the app still load data afterwards? (the "it still works" claim) + * + * Run: + * npx playwright test tests/resume_probe.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test, expect } from '@playwright/test' +import { join } from 'path' +import { existsSync, mkdirSync } from 'fs' +import { tmpdir } from 'os' +const { launchApp, backendAction, backendErrorLines } = require('./_harness.cjs') + +const SHOTS = join(__dirname, '..', 'resume_probe_shots') + +test.setTimeout(420_000) + +test('what survives a renderer loss', async () => { + mkdirSync(SHOTS, { recursive: true }) + const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + const { page } = ctx + const findings: string[] = [] + + try { + await page.waitForTimeout(2000) + + // ── Establish a populated workspace ──────────────────────────────────── + await backendAction(page, 'load_test_data_si_grains', {}) + await expect(page.getByTestId('subwindow').first()) + .toBeVisible({ timeout: 90_000 }) + await page.waitForTimeout(4000) + + const windowsBefore = await page.getByTestId('subwindow').count() + const framesBefore = await page.locator('iframe').count() + // Every figure iframe's src — the HTML files on disk (plan §3.3 Q1). + const figureSrcs: string[] = await page.evaluate(() => + Array.from(document.querySelectorAll('iframe')) + .map(f => (f as HTMLIFrameElement).src) + // Figures are served over the custom spyde-fig:// protocol, which + // resolves to a real HTML file in the OS tmpdir (main/index.ts + // resolveFigPath). Whether those files outlive a renderer loss is the + // cost driver for the plan's Phase 2. + .filter(s => s.startsWith('spyde-fig://'))) + await page.getByTestId('dask-monitor').click() + const dashBefore = await page.getByRole( + 'button', { name: /Open full Dask dashboard/ }).isVisible().catch(() => false) + await page.keyboard.press('Escape') + await page.screenshot({ path: join(SHOTS, '01-populated.png') }) + + findings.push(`BEFORE windows=${windowsBefore} iframes=${framesBefore} ` + + `dashboardLink=${dashBefore} figureFiles=${figureSrcs.length}`) + + // The backend's identity, so we can prove it is the SAME process after. + const backendLinesBefore = (ctx.backend.logBuffer || []).length + + // ── The renderer loss ────────────────────────────────────────────────── + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForTimeout(6000) + await page.screenshot({ path: join(SHOTS, '02-after-renderer-loss.png') }) + + const windowsAfter = await page.getByTestId('subwindow').count() + const framesAfter = await page.locator('iframe').count() + + // Did the backend die? It is never respawned, so if it had, its log would + // stop and every action below would no-op. + const backendExited = (ctx.backend.logBuffer || []) + .some((l: string) => /SpyDE exited with code/.test(l)) + + // Do the figure HTML files still exist on disk? THE cost driver for the + // plan's Phase 2 — if they survive, a resync re-sends the same paths. + const filesOnDisk = figureSrcs.map(src => { + try { + const name = decodeURIComponent(new URL(src).pathname).replace(/^\//, '') + return existsSync(join(tmpdir(), name)) + } catch { return false } + }) + const survivingFiles = filesOnDisk.filter(Boolean).length + + let dashAfter = false + try { + await page.getByTestId('dask-monitor').click({ timeout: 5000 }) + dashAfter = await page.getByRole( + 'button', { name: /Open full Dask dashboard/ }) + .isVisible().catch(() => false) + await page.keyboard.press('Escape') + } catch { /* the monitor itself may be gone */ } + + findings.push(`AFTER windows=${windowsAfter} iframes=${framesAfter} ` + + `dashboardLink=${dashAfter} ` + + `figureFilesStillOnDisk=${survivingFiles}/${figureSrcs.length}`) + findings.push(`BACKEND exited=${backendExited} ` + + `logGrew=${(ctx.backend.logBuffer || []).length > backendLinesBefore}`) + + // ── Can it still be used? ("it still works, you just re-load the data") ── + await backendAction(page, 'load_test_data_si_grains', {}) + let reloadWorks = false + try { + await expect(page.getByTestId('subwindow').first()) + .toBeVisible({ timeout: 90_000 }) + reloadWorks = true + } catch { /* recorded below */ } + await page.waitForTimeout(3000) + const windowsAfterReload = await page.getByTestId('subwindow').count() + await page.screenshot({ path: join(SHOTS, '03-after-reloading-data.png') }) + + findings.push(`RELOAD worked=${reloadWorks} windows=${windowsAfterReload}`) + + console.log('\n──────── PROBE FINDINGS ────────\n' + + findings.join('\n') + + '\n────────────────────────────────\n') + + // ── The only assertions: the two claims the plan is built on ─────────── + expect(backendExited, + 'the backend must NOT have died — the plan assumes it survives') + .toBe(false) + expect(reloadWorks, + 'the app must still be usable after a renderer loss').toBe(true) + // Recorded, not asserted: window survival. If windows DO come back, the + // plan's premise is wrong and that is the most valuable thing this can say. + if (windowsAfter > 0) { + console.log('!! WINDOWS SURVIVED — SESSION_RESYNC_PLAN §1 premise is WRONG') + } + + const errs = backendErrorLines(ctx.backend) + console.log('BACKEND ERRORS:', JSON.stringify(errs.slice(0, 5))) + } finally { + await ctx.app?.close() + } +}) diff --git a/electron/tests/talk_present.spec.ts b/electron/tests/talk_present.spec.ts new file mode 100644 index 00000000..e9868d11 --- /dev/null +++ b/electron/tests/talk_present.spec.ts @@ -0,0 +1,174 @@ +/** + * talk_present.spec.ts — open the committed SpyDE overview deck in the REAL app + * and screenshot EVERY slide in Present mode. + * + * The deck (`doc/presentations/spyde-overview.spyde-report`) is built by + * `doc/presentations/build_spyde_overview.py`. It carries only markdown / image / + * split cells — no live figure bindings — so it opens standalone with no data + * loaded and needs no Dask. + * + * This is the verification the presentation is judged by: every shot in + * `talk_present_shots/` gets looked at. Text that overflows its slide, an empty + * figure box, or a blank slide is a FAILURE even when the selectors pass. + * + * Run: + * npx playwright test tests/talk_present.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test, expect } from '@playwright/test' +import { join } from 'path' +import { mkdirSync } from 'fs' +const { launchApp, backendAction, backendErrorLines } = require('./_harness.cjs') + +const SHOTS = join(__dirname, '..', 'talk_present_shots') +const DECK = join(__dirname, '..', '..', 'doc', 'presentations', + 'spyde-overview.spyde-report') +/** + * The slide count is DISCOVERED, not hard-coded. + * + * This deck is a live document: it is generated by build_spyde_overview.py, but + * it is also opened and edited in the app — that is the whole point of shipping + * a talk as a SpyDE file rather than a PDF. A constant here made the spec assert + * against whichever version happened to be on disk, and it failed with a + * different number every run while someone was editing it. What this spec is + * actually for is "the committed deck presents cleanly", which is true of any + * slide count. + */ +async function settledSlideCount(page: any): Promise { + let last = -1, stable = 0 + for (let i = 0; i < 60 && stable < 3; i++) { + const n = await page.getByTestId(/^report-slide-\d+$/).count() + stable = n === last ? stable + 1 : 0 + last = n + await page.waitForTimeout(400) + } + return last +} + +let ctx: Awaited> + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }) + ctx = await launchApp({ env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + await ctx.page.waitForTimeout(1500) +}) + +test.afterAll(async () => { + try { ctx?.assertNoJsErrors() } finally { await ctx?.app?.close() } +}) + +test('the committed deck opens and presents cleanly', async () => { + const { page } = ctx + + // Open the report sidebar, then load the committed deck through the SAME + // backend handler the sidebar's Open button calls. + await page.getByTestId('toggle-report').click() + await expect(page.getByTestId('report-sidebar')).toBeVisible() + await backendAction(page, 'report_open', { path: DECK }) + + // The sidebar mirrors report_state: wait for the deck's slides to arrive and + // SETTLE, then take that as the count to page through. + const N_SLIDES = await settledSlideCount(page) + expect(N_SLIDES, 'report_open produced no slides').toBeGreaterThan(1) + await page.waitForTimeout(1200) + await page.screenshot({ path: join(SHOTS, '00-sidebar.png') }) + + // Enter Present mode. + await page.getByTestId('report-present').click() + const stage = page.locator('[data-testid="present-slide"][data-active="1"]') + await expect(stage).toBeVisible({ timeout: 30_000 }) + await page.waitForTimeout(1500) + + // The deck carries its own theme. Asserted RELATIVELY — the deck's painted + // background must equal the theme variable it publishes — rather than against + // a hard-coded hex: the failure worth catching is a theme that round-trips + // through the front matter perfectly and then loses to a hard-coded stylesheet + // rule, and that is true whatever colours the author picked. + const deck = page.getByTestId('present-mode') + const themed = await deck.evaluate((el) => { + const cs = getComputedStyle(el) + const hex = (h: string) => { + const m = h.trim().replace('#', '') + if (m.length !== 6) return h.trim() + return `rgb(${parseInt(m.slice(0, 2), 16)}, ${parseInt(m.slice(2, 4), 16)}, ` + + `${parseInt(m.slice(4, 6), 16)})` + } + return { bg: cs.backgroundColor, + bgVar: hex(cs.getPropertyValue('--spyde-deck-bg')), + textVar: hex(cs.getPropertyValue('--spyde-deck-text')), + accent: cs.getPropertyValue('--spyde-deck-accent').trim() } + }) + expect(themed.bg, 'the deck is not painted with its own theme background') + .toBe(themed.bgVar) + expect(themed.accent, 'the deck publishes no accent').not.toBe('') + + // Present mode keeps EVERY slide mounted, so a bare locator matches them all — + // scope to the active stage. A title card carries its own attribution, so the + // footer belongs on every OTHER slide; the count is derived rather than fixed. + await expect(stage.getByTestId('present-footer'), + 'the title slide must not carry the footer bar').toHaveCount(0) + const titleCards = await page.locator( + '[data-testid="present-slide"][data-kind="title"]').count() + await expect(page.getByTestId('present-footer'), + 'footer count != non-title slides') + .toHaveCount(N_SLIDES - titleCards) + + // Page through EVERY slide, screenshotting each. Assert the active slide + // actually carries rendered text (a blank stage is the failure mode that + // selectors alone miss), and that nothing overflows the viewport. + for (let i = 1; i <= N_SLIDES; i++) { + await page.waitForTimeout(650) // let the slide settle/paint + const n = String(i).padStart(2, '0') + await page.screenshot({ path: join(SHOTS, `${n}-slide.png`) }) + + const text = ((await stage.innerText()) || '').trim() + expect(text.length, `slide ${i} rendered no text`).toBeGreaterThan(10) + + // The first CONTENT slide: the footer bar, its embedded logo, and the themed + // heading colour. Checked once rather than per slide — the same component + // draws the footer every time. + if (i === 2 && (await stage.getAttribute('data-kind')) === 'content') { + await expect(stage.getByTestId('present-footer')).toBeVisible() + await expect(stage.getByTestId('present-footer-logo')).toBeVisible() + await expect(stage.getByTestId('present-footer-text')).not.toBeEmpty() + // The base .present-md sheet hard-codes a heading colour that beats the + // inherited one; this is the assertion that catches a themed deck whose + // headings stay stock lavender. Compared against the deck's OWN text + // variable so it holds for any palette the author chose. + const h2 = stage.locator('h2').first() + if (await h2.count()) { + expect(await h2.evaluate((el) => getComputedStyle(el).color), + 'slide headings ignore the deck theme').toBe(themed.textVar) + } + } + + // Overflow guard: the slide's content must fit its own scroll box. + const overflow = await stage.evaluate((el: HTMLElement) => ({ + v: el.scrollHeight - el.clientHeight, + h: el.scrollWidth - el.clientWidth, + })) + expect(overflow.h, `slide ${i} overflows HORIZONTALLY`).toBeLessThanOrEqual(2) + if (overflow.v > 2) console.log(` ! slide ${i} overflows vertically by ${overflow.v}px`) + + if (i < N_SLIDES) await page.keyboard.press('ArrowRight') + } + + // The presenter view (S) — speaker notes must be visible to the presenter. + await page.keyboard.press('Home') + await page.waitForTimeout(500) + await page.keyboard.press('s') + await page.waitForTimeout(1200) + await page.screenshot({ path: join(SHOTS, '22-presenter-view.png') }) + + // ESC exits. + await page.keyboard.press('s') + await page.waitForTimeout(300) + await page.keyboard.press('Escape') + await expect(stage).toBeHidden({ timeout: 15_000 }) + + const errs = backendErrorLines(ctx.backend) + expect(errs, `backend errors:\n${errs.join('\n')}`).toEqual([]) +}) diff --git a/electron/tests/talk_screenshots.spec.ts b/electron/tests/talk_screenshots.spec.ts new file mode 100644 index 00000000..0542e264 --- /dev/null +++ b/electron/tests/talk_screenshots.spec.ts @@ -0,0 +1,110 @@ +/** + * talk_screenshots.spec.ts — capture REAL SpyDE screenshots for the + * "SpyDE — an overview" presentation (doc/presentations/). + * + * Not a regression test: a capture run. Each block loads bundled synthetic data + * the way a user would, drives one differentiating feature, and screenshots the + * whole window into `talk_shots/`. The presentation build script embeds those + * PNGs as report IMAGE cells. + * + * Run: + * npx playwright test tests/talk_screenshots.spec.ts --project=electron \ + * --reporter=line --retries=0 + */ +import { test } from '@playwright/test' +import { join } from 'path' +import { mkdirSync } from 'fs' +const { + launchApp, backendAction, waitForSubwindowCount, countColorPixels, sigWindow, +} = require('./_harness.cjs') + +const SHOTS = join(__dirname, '..', 'talk_shots') + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(300_000) + +test.beforeAll(() => { mkdirSync(SHOTS, { recursive: true }) }) + +/** Screenshot the whole app window under `talk_shots/.png`. */ +async function shot(page: any, name: string) { + await page.screenshot({ path: join(SHOTS, `${name}.png`) }) +} + +test('4D-STEM overview + find vectors + virtual imaging', async () => { + const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + const { page } = ctx + try { + await page.waitForTimeout(1500) + await backendAction(page, 'load_test_data_si_grains') + await waitForSubwindowCount(page, 2, 120_000) + await page.waitForTimeout(4000) // let the DP paint + + // 1) The core two-window live view: navigator + diffraction pattern. + await shot(page, '01-navigator-and-dp') + + // 2) Find Diffraction Vectors — wizard open, live red peak preview on the DP. + const sig = sigWindow(page) + await sig.getByTestId('subwindow-title').click() + await sig.getByTestId('subwindow-titlebar').hover() + await sig.getByTestId('action-btn-Find Diffraction Vectors').click() + await page.getByTestId('find-vectors-wizard').waitFor({ timeout: 30_000 }) + await expectRed(page, 30_000) + await page.waitForTimeout(1200) + await shot(page, '02-find-vectors-wizard') + + // 3) Compute across the scan → the vectors result window opens. + const before = await page.getByTestId('subwindow').count() + await page.getByTestId('fv-compute').click() + await waitForSubwindowCount(page, before + 1, 180_000) + await page.waitForTimeout(6000) + await shot(page, '03-find-vectors-result') + } finally { + try { await ctx?.app?.close() } catch { /* best effort */ } + } +}) + +test('virtual imaging live ROI', async () => { + const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + const { page } = ctx + try { + await page.waitForTimeout(1500) + await backendAction(page, 'load_test_data_si_grains') + await waitForSubwindowCount(page, 2, 120_000) + await page.waitForTimeout(4000) + + const sig = sigWindow(page) + await sig.getByTestId('subwindow-title').click() + await sig.getByTestId('subwindow-titlebar').hover() + await sig.getByTestId('action-btn-Virtual Imaging').click() + await page.waitForTimeout(600) + await page.getByTestId('subaction-add_virtual_image').click() + await waitForSubwindowCount(page, 3, 120_000) + await page.waitForTimeout(6000) + await shot(page, '04-virtual-imaging') + } finally { + try { await ctx?.app?.close() } catch { /* best effort */ } + } +}) + +test('EELS spectrum image', async () => { + const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'WARNING' } }) + const { page } = ctx + try { + await page.waitForTimeout(1500) + await backendAction(page, 'load_test_data_eels') + await waitForSubwindowCount(page, 2, 120_000) + await page.waitForTimeout(5000) + await shot(page, '05-eels') + } finally { + try { await ctx?.app?.close() } catch { /* best effort */ } + } +}) + +/** Poll until the DP shows saturated-red overlay pixels (the live peak preview). */ +async function expectRed(page: any, timeout: number) { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + if ((await countColorPixels(page, 'red')) > 0) return + await page.waitForTimeout(500) + } +} diff --git a/spyde/actions/registry.py b/spyde/actions/registry.py index 64b7c807..b529ebe4 100644 --- a/spyde/actions/registry.py +++ b/spyde/actions/registry.py @@ -121,6 +121,7 @@ "report_close": "spyde.actions.report.handlers.report_close", "report_add_cell": "spyde.actions.report.handlers.report_add_cell", "report_add_image_cell": "spyde.actions.report.handlers.report_add_image_cell", + "report_set_cell_image": "spyde.actions.report.handlers.report_set_cell_image", # Report/Presentation redesign Wave A — the split-block primitive (text side # BESIDE a figure/photo side, one atomic cell). "report_add_split_cell": "spyde.actions.report.handlers.report_add_split_cell", diff --git a/spyde/actions/report/export_html.py b/spyde/actions/report/export_html.py index bf2ede70..35c7378b 100644 --- a/spyde/actions/report/export_html.py +++ b/spyde/actions/report/export_html.py @@ -293,9 +293,11 @@ def _render_figure_side_html(mgr, cell: Cell, assets: dict, *, interactive: bool def _split_cell_html(mgr, cell: Cell, assets: dict, *, interactive: bool, session=None) -> str: - """A SPLIT cell (Wave A) → a 2-column ``.split-block`` grid: the TEXT side - (its markdown) BESIDE the FIGURE/PHOTO side, ordered by ``split_layout`` - (``text-left`` → text then figure; ``text-right`` → figure then text). The + """A SPLIT cell (Wave A) → a ``.split-block`` grid: the TEXT side (its + markdown) beside — or above — the FIGURE/PHOTO side, ordered and oriented by + ``split_layout``. All FOUR layouts: ``text-left``/``text-right`` are two + columns, ``text-top``/``text-bottom`` add ``--stacked`` for two rows; the + ``text-left``/``text-top`` pair puts the text first. The figure side reuses :func:`_render_figure_side_html` (interactive iframe / baked PNG / photo data URL — all self-contained). An empty figure side just renders the text beside an empty column. Reused by the article/static export AND (via diff --git a/spyde/actions/report/handlers.py b/spyde/actions/report/handlers.py index 175ddef2..ff2620e8 100644 --- a/spyde/actions/report/handlers.py +++ b/spyde/actions/report/handlers.py @@ -154,6 +154,16 @@ def __init__(self, session): self._selected: dict[str, "str | None"] = {} # cell_id -> True while a figure's rebuild is pending self._offline: set[str] = set() + # DETACHED cells: rebuilt from the report's OWN saved pixels + # (data/.npz) because the source signal wasn't available. The figure + # is fully live and interactive — pan, zoom, widgets — it just has + # nothing to refresh FROM, so the UI hides "refresh from data". + # Deliberately NOT _offline: offline means "show the flat PNG". + self._detached: set[str] = set() + # cell_id -> {(panel_id, layer_id): ndarray} loaded from data/.npz on + # open. Kept separately from _snapshots so a re-save can round-trip the + # pixels of a cell that was never rebuilt this session. + self._loaded_snapshots: dict[str, dict] = {} # pending save handshake: token -> {cells, path, remaining} self._pending_save: dict[str, dict] = {} # UNDO stack of {"label": str, "restore": callable}. Bounded, because an @@ -249,6 +259,8 @@ def new(self, template: bool = False, doc_type: str = "report") -> None: self._baked.clear() self._images.clear() self._offline.clear() + self._detached.clear() + self._loaded_snapshots.clear() self._editing.clear() self._edit_wiring.clear() self._ann_widgets.clear() @@ -306,6 +318,8 @@ def close(self) -> None: self._baked.clear() self._images.clear() self._offline.clear() + self._detached.clear() + self._loaded_snapshots.clear() self._pending_save.clear() self._editing.clear() self._edit_wiring.clear() @@ -369,6 +383,12 @@ def state(self) -> dict: "fig_id": (c.id if c.cell_type in ("figure", "split") else None), "data_offline": bool( c.cell_type in ("figure", "split") and c.id in self._offline), + # Rebuilt from the report's OWN saved pixels: a real, interactive + # figure with no signal behind it. Distinct from data_offline + # (which means "there are no pixels, show the PNG") — the UI + # keeps every interaction and hides only refresh-from-data. + "data_detached": bool( + c.cell_type in ("figure", "split") and c.id in self._detached), # Present-mode fields (Phase 6): slide grouping + go-live handle # + per-slide kind/style (title/section slide + background preset — # carried on the slide's first cell). @@ -884,6 +904,44 @@ def push_ann_widget(self, cell_id: str, panel_id: str, ann_index: int, cell_id, panel_id, ann_index, e) return False + #: Per-cell cap on saved figure pixels. A report is a file people email; a + #: multi-panel figure of 4096² float64 layers would otherwise quietly add + #: hundreds of MB. Over the cap the cell simply saves without its data and + #: reloads as the baked PNG — the pre-existing behaviour, not a failure. + SNAPSHOT_MAX_BYTES = 32 * 1024 * 1024 + + def assemble_snapshots(self) -> dict: + """``{cell_id -> npz bytes}`` of the per-layer pixels behind every figure + cell, for ``data/.npz`` in the zip. + + This is what makes a reopened figure INTERACTIVE rather than a flat PNG: + ``figure_builder.build_figure`` wants a spec AND a snapshot map, the spec + already round-trips through ``figures/.yaml``, and this is the other + half. Prefers the LIVE snapshots; falls back to the ones loaded from the + opened report so re-saving a report whose sources were never available + doesn't silently drop the pixels it came with.""" + out: dict[str, bytes] = {} + for c in self.doc.cells: + if c.cell_type not in ("figure", "split") or c.spec is None: + continue + snap = self._snapshots.get(c.id) or self._loaded_snapshots.get(c.id) + if not snap: + continue + try: + blob = model.snapshots_to_npz(snap) + except Exception as e: # pragma: no cover + log.debug("snapshot pack failed for cell %s: %s", c.id, e) + continue + if blob and len(blob) <= self.SNAPSHOT_MAX_BYTES: + out[c.id] = blob + elif blob: + log.info( + "figure %s: %.1f MB of pixels exceeds the %.0f MB save cap — " + "it will reopen as a static image", + c.id, len(blob) / (1024 * 1024), + self.SNAPSHOT_MAX_BYTES / (1024 * 1024)) + return out + def assemble_assets(self, harvested: dict) -> dict: """Build ``{cell_id -> bytes}`` for every non-placeholder figure cell AND every image (photo) cell. For a figure it prefers (in order): the @@ -2115,6 +2173,10 @@ def report_open(session, plot, payload) -> None: or (c.cell_type == "split" and c.spec is None and c.image_ext)) } mgr._offline.clear() + mgr._detached.clear() + # The figure PIXELS this report was saved with — empty for a report written + # before they were persisted, which then behaves exactly as it always did. + mgr._loaded_snapshots = model.read_report_snapshots(path) # Rebind each figure cell: resolve EVERY layer of EVERY panel against open trees # / files. The cell rebinds live only when ALL its layers resolve; if any layer's # source is offline the whole cell is offline (renderer shows the baked PNG). @@ -2149,10 +2211,26 @@ def report_open(session, plot, payload) -> None: mgr.set_snapshot(c.id, panel.id, layer.id, arr) if all_resolved: mgr.build_figure_window(c) - else: - # Unresolved → offline: renderer shows the baked PNG (data URL in state). + continue + # The source is unavailable — but the report may carry its OWN pixels. + # Rebuild from those: spec + saved snapshots is exactly what + # build_figure_window consumes, so the figure comes back fully + # interactive (pan, zoom, widgets) and is merely DETACHED — there is no + # signal behind it to refresh from. That is strictly better than the flat + # PNG this used to fall back to. + saved = mgr._loaded_snapshots.get(c.id) + if saved: + mgr._snapshots[c.id] = dict(saved) + mgr.build_figure_window(c) + if mgr._window_by_cell.get(c.id) is not None: + mgr._detached.add(c.id) + continue + # The rebuild produced no window (an unusable spec / snapshot pair): + # fall through to the static path rather than leaving a dead box. mgr._snapshots.pop(c.id, None) - mgr._offline.add(c.id) + # No saved pixels → offline: renderer shows the baked PNG (data URL in state). + mgr._snapshots.pop(c.id, None) + mgr._offline.add(c.id) # A figure whose spec yaml was corrupt (read_report recorded spec_error) is shown # as its baked PNG but can no longer be edited/refreshed — tell the user loudly # rather than leaving them to discover the dead Edit button. @@ -2266,9 +2344,12 @@ def _finish_save(session, mgr: ReportManager, path: str, """Assemble the asset PNGs (harvested → held-baked → offline-baked) and write the zip atomically, then emit ``report_saved`` + refresh state.""" assets = mgr.assemble_assets(harvested) + # The figure PIXELS as well as the baked stills, so reopening this report + # without its source data still gives a figure you can pan, zoom and drag. + snapshots = mgr.assemble_snapshots() try: mgr.doc.touch() - write_report(mgr.doc, path, assets=assets) + write_report(mgr.doc, path, assets=assets, snapshots=snapshots) except Exception as e: ipc.emit_error(f"Saving report failed: {e}") return @@ -2382,28 +2463,127 @@ def report_add_image_cell(session, plot, payload) -> None: are refused over :data:`_IMAGE_CELL_MAX_BYTES` so a giant photo can't bloat the report.""" mgr = _ensure_open(session) - raw = payload.get("image_b64") + decoded = _decode_image_payload(payload.get("image_b64"), + payload.get("image_ext"), + "report_add_image_cell") + if decoded is None: + return + data, ext = decoded + cell = Cell(id=new_cell_id(), cell_type="image", + caption=str(payload.get("caption", "") or ""), image_ext=ext) + if payload.get("slide_break") is not None: + cell.slide_break = bool(payload.get("slide_break")) + mgr._images[cell.id] = data + _insert_cell(mgr.doc, cell, payload.get("index")) + mgr.dirty = True + mgr.emit_state() + + +def _decode_image_payload(raw, ext_raw, verb: str): + """Shared decode + size-cap + extension normalisation for the two image + verbs. Returns ``(data, ext)`` or ``None`` after emitting the error.""" data = _decode_data_url(raw) if raw else None if not data: - ipc.emit_error("report_add_image_cell: no / undecodable image data.") - return + ipc.emit_error(f"{verb}: no / undecodable image data.") + return None if len(data) > _IMAGE_CELL_MAX_BYTES: mb = _IMAGE_CELL_MAX_BYTES / (1024 * 1024) ipc.emit_error( f"Image is too large ({len(data) / (1024 * 1024):.1f} MB) — the limit " f"is {mb:.0f} MB. Resize it and try again.") - return - ext = str(payload.get("image_ext", "") or "").lower().lstrip(".") + return None + ext = str(ext_raw or "").lower().lstrip(".") if ext == "jpeg": ext = "jpg" if ext not in IMAGE_EXTS: ext = "png" - cell = Cell(id=new_cell_id(), cell_type="image", - caption=str(payload.get("caption", "") or ""), image_ext=ext) - if payload.get("slide_break") is not None: - cell.slide_break = bool(payload.get("slide_break")) + return data, ext + + +def report_set_cell_image(session, plot, payload) -> None: + """Fill an EXISTING cell's figure slot with a dropped/pasted PHOTO — the + image counterpart of :func:`report_set_split_figure`. + + ``{cell_id, image_b64, image_ext, caption?}``. + + Why this exists: dropping a PNG onto a split cell's empty figure side, or + onto an empty figure placeholder, used to fall through to + :func:`report_add_image_cell` and APPEND a new image cell BELOW — the slot + the user aimed at stayed empty and the picture landed somewhere else. There + was no verb that could fill a slot that already existed. + + Four accepted targets: + + * a ``markdown`` cell — the text slide BECOMES a split slide: its prose moves + to the text side and the photo fills the other. ``layout`` picks the side + (default ``text-left``). + * a ``split`` cell — the photo becomes its figure side; the TEXT side + (``source``) and ``split_layout`` are untouched. Works whether the side + was empty or already held a photo/figure, so this is also the REPLACE + path. + * an ``image`` cell — swap the bytes in place. Same cell, so its caption, + size and slide attributes all survive; only the picture changes. + * a ``figure`` cell, placeholder OR filled — there is no "figure cell + holding a photo" in the model, so it converts IN PLACE to an ``image`` + cell, tearing down the live figure window first. Converting rather + than replacing is what preserves the cell's identity: its id, and with it + the slide attributes that ride on a slide's first cell (``slide_break``, + kind, style, speaker notes). Re-creating the cell would silently drop the + slide's notes and could merge it into the previous slide. + + Any other cell type is a no-op with an error. Filling with a photo clears + any figure previously in the slot (spec, snapshot, baked PNG, figure + window), mirroring how :func:`report_set_split_figure` clears a prior photo. + """ + mgr = _ensure_open(session) + cell = mgr.doc.cell_by_id(payload.get("cell_id")) + if cell is None: + ipc.emit_error("report_set_cell_image: unknown cell.") + return + if cell.cell_type not in ("split", "figure", "image", "markdown"): + ipc.emit_error( + f"report_set_cell_image: cell is a {cell.cell_type!r}, which has no " + f"figure slot to fill.") + return + decoded = _decode_image_payload(payload.get("image_b64"), + payload.get("image_ext"), + "report_set_cell_image") + if decoded is None: + return + data, ext = decoded + # Tear down whatever figure was in the slot, exactly as + # report_split_remove_figure does, so nothing leaks behind the photo. + wid = mgr._window_by_cell.get(cell.id) + if wid is not None: + mgr._forget(wid) + mgr._snapshots.pop(cell.id, None) + mgr._baked.pop(cell.id, None) + mgr._offline.discard(cell.id) + mgr._editing.discard(cell.id) + mgr._edit_wiring.pop(cell.id, None) + mgr._ann_widgets.pop(cell.id, None) + mgr._selected.pop(cell.id, None) + _clear_vectors_explorer_cache(cell.id) + cell.spec = None + if cell.cell_type == "figure": + # A placeholder becomes a real photo cell — same id, same slide flags. + cell.cell_type = "image" + cell.placeholder = False + elif cell.cell_type == "markdown": + # A TEXT slide becomes a SPLIT slide: the prose it already had moves to + # the text side and the picture fills the other. Converting in place + # (rather than adding an image cell after it) is what makes this a + # layout change instead of a new slide — the cell keeps its id, so the + # slide break, kind, style and speaker notes riding on it survive, and + # a slide that WAS one text block stays one slide. + cell.cell_type = "split" + cell.split_layout = model._normalize_split_layout( + payload.get("layout") or cell.split_layout) + cell.image_ext = ext mgr._images[cell.id] = data - _insert_cell(mgr.doc, cell, payload.get("index")) + caption = str(payload.get("caption", "") or "") + if caption: + cell.caption = caption mgr.dirty = True mgr.emit_state() @@ -2415,8 +2595,8 @@ def report_add_split_cell(session, plot, payload) -> None: ``payload``: ``{index?, slide_break?, source?, caption?, layout?}``. The figure side starts EMPTY (a drop zone) — Wave B wires a figure/photo drop onto it via :func:`report_set_split_figure` (or ``report_add_figure`` targeting the split - cell's slot). ``layout`` is ``"text-left"`` / ``"text-right"`` (normalised; - default ``text-left``). Mirrors :func:`report_add_cell` for the seed-time + cell's slot). ``layout`` is any of the four in ``_SPLIT_LAYOUTS`` + (normalised; default ``text-left``). Mirrors :func:`report_add_cell` for the seed-time Present-mode fields so a seeded deck can create it already-marked.""" from spyde.actions.report.model import _normalize_split_layout mgr = _ensure_open(session) @@ -2451,9 +2631,10 @@ def report_add_figure_placeholder(session, plot, payload) -> None: def report_set_split_layout(session, plot, payload) -> None: - """Set a SPLIT cell's ``split_layout`` — ``"text-left"`` (text on the left, - figure on the right — the default) or ``"text-right"`` (mirror). Any other - value normalises to ``"text-left"``. + """Set a SPLIT cell's ``split_layout`` — one of ``"text-left"`` (the + default), ``"text-right"``, ``"text-top"`` or ``"text-bottom"``: the first + pair is side by side, the second stacks. Any other value normalises to + ``"text-left"``. ``{cell_id, layout}``. A non-split / unknown cell is a no-op (no crash).""" from spyde.actions.report.model import _normalize_split_layout @@ -3057,6 +3238,35 @@ def report_add_figure(session, plot, payload) -> None: mgr._images.pop(cell.id, None) if caption: cell.caption = caption + elif cell is not None and cell.cell_type == "markdown": + # A TEXT slide becomes a SPLIT slide with a LIVE figure beside its prose. + # Same in-place conversion as the photo path in report_set_cell_image — + # a layout change, not a new slide, so the cell id (and the slide break / + # kind / style / speaker notes on it) survives. + cell.cell_type = "split" + cell.split_layout = model._normalize_split_layout( + payload.get("layout") or cell.split_layout) + cell.placeholder = False + cell.spec = spec + cell.image_ext = "" + mgr._images.pop(cell.id, None) + if caption: + cell.caption = caption + elif cell is not None and cell.cell_type == "image": + # Dropping a live window onto a PHOTO replaces the picture with the + # figure. There is no "image cell holding a figure", so it converts IN + # PLACE to a figure cell — same id, so the caption, the display size and + # the slide attributes riding on a slide's first cell (break / kind / + # style / speaker notes) all survive. Appending a new cell instead (what + # the else-branch below used to do for an image target) left the photo + # sitting above an unrelated new figure. + cell.cell_type = "figure" + cell.placeholder = False + cell.spec = spec + cell.image_ext = "" + mgr._images.pop(cell.id, None) + if caption: + cell.caption = caption else: cell = Cell(id=new_cell_id(), cell_type="figure", caption=caption, placeholder=False, spec=spec) diff --git a/spyde/actions/report/model.py b/spyde/actions/report/model.py index f2dfd33a..bd6a04fe 100644 --- a/spyde/actions/report/model.py +++ b/spyde/actions/report/model.py @@ -1608,8 +1608,97 @@ def bake_fallback_png(array2d: np.ndarray, cmap: str = "viridis", REPORT_SUFFIX = ".spyde-report" +# ── figure PIXEL data (detached-interactive round trip) ─────────────────────── +# +# A figure cell persists as a RECIPE (``figures/.yaml``) plus a baked still +# (``assets/.png``). The recipe references the SOURCE signal, so reopening a +# report without that data loaded left the figure as a flat PNG — you could look +# at it but not pan, zoom, or touch a widget. +# +# ``data/.npz`` closes that gap: the per-layer arrays the figure was built +# from, which is exactly the other half of what +# ``figure_builder.build_figure(spec, snapshots)`` consumes. Reopening rebuilds a +# REAL anyplotlib figure from recipe + saved pixels — fully interactive, just +# detached from the signal, so "refresh from data" is the one thing it can't do. +# +# Purely additive: a reader that doesn't know about ``data/`` ignores it, and a +# report saved without it loads exactly as before. + +#: Separator packing a ``(panel_id, layer_id)`` key into one npz member name. +#: A record separator, so it cannot collide with an id. +_SNAP_KEY_SEP = "\x1f" + + +def snapshots_to_npz(snap_map: dict) -> bytes: + """A cell's ``{(panel_id, layer_id): ndarray}`` map → compressed npz bytes. + + Non-array values and object-dtype arrays are skipped rather than pickled: + ``allow_pickle`` on the way back in would make opening a report a code- + execution path, and a report is a file people email each other.""" + import io as _io + out = {} + for key, arr in (snap_map or {}).items(): + try: + panel_id, layer_id = key + except (TypeError, ValueError): + continue + a = np.asarray(arr) + if a.dtype == object or a.size == 0: + continue + out[f"{panel_id}{_SNAP_KEY_SEP}{layer_id}"] = a + if not out: + return b"" + buf = _io.BytesIO() + np.savez_compressed(buf, **out) + return buf.getvalue() + + +def npz_from_snapshots(raw: bytes) -> dict: + """npz bytes → ``{(panel_id, layer_id): ndarray}``. Unreadable / unexpected + content yields ``{}`` — a corrupt data blob must degrade to "the figure is a + static PNG", never to a failed open.""" + import io as _io + if not raw: + return {} + out: dict = {} + try: + with np.load(_io.BytesIO(raw), allow_pickle=False) as z: + for name in z.files: + if _SNAP_KEY_SEP not in name: + continue + panel_id, layer_id = name.split(_SNAP_KEY_SEP, 1) + out[(panel_id, layer_id)] = z[name] + except Exception: + return {} + return out + + +def read_report_snapshots(path: str) -> "dict[str, dict]": + """Read the saved figure pixels from a ``.spyde-report`` → ``{cell_id -> + {(panel_id, layer_id): ndarray}}``. Empty for a report written before this + existed, or one saved with the data omitted. + + A SIBLING of :func:`read_report` rather than another element in its tuple: + that tuple is unpacked at ~20 call sites, and widening it would churn every + one of them to carry something most do not want.""" + out: dict[str, dict] = {} + try: + with zipfile.ZipFile(path, "r") as zf: + for name in zf.namelist(): + if not name.startswith("data/") or not name.endswith(".npz"): + continue + cell_id = name[len("data/"):-len(".npz")] + snap = npz_from_snapshots(zf.read(name)) + if snap: + out[cell_id] = snap + except Exception: + return {} + return out + + def write_report(doc: ReportDoc, path: str, - assets: "dict[str, bytes] | None" = None) -> None: + assets: "dict[str, bytes] | None" = None, + snapshots: "dict[str, dict] | None" = None) -> None: """Write *doc* to a ``.spyde-report`` zip at *path*, ATOMICALLY (tmp file in the same dir + ``os.replace``) so a crash never leaves a torn container. @@ -1618,12 +1707,19 @@ def write_report(doc: ReportDoc, path: str, are written without an asset (the caller is expected to always provide one via harvest or bake for figures, and the held image bytes for image cells).""" assets = assets or {} + snapshots = snapshots or {} directory = os.path.dirname(os.path.abspath(path)) or "." os.makedirs(directory, exist_ok=True) tmp = os.path.join(directory, f".{os.path.basename(path)}.{uuid.uuid4().hex}.tmp") try: with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr("report.md", serialize_report_md(doc)) + # Figure PIXELS, so a reopened figure is interactive rather than a + # flat PNG. Already-compressed npz — stored, not deflated again. + for cell_id, blob in snapshots.items(): + if blob: + zf.writestr(f"data/{cell_id}.npz", blob, + compress_type=zipfile.ZIP_STORED) for c in doc.cells: if c.cell_type == "image": # A photo: the raw image bytes at assets/., NO yaml diff --git a/spyde/tests/migrated/test_dask_ready_latch.py b/spyde/tests/migrated/test_dask_ready_latch.py new file mode 100644 index 00000000..cac06a76 --- /dev/null +++ b/spyde/tests/migrated/test_dask_ready_latch.py @@ -0,0 +1,98 @@ +""" +test_dask_ready_latch.py — PHASE 1 PROBE for SESSION_RESYNC_PLAN.md §8/§9. + +Pins the claim the plan's correction rests on: **a dead cluster is +indistinguishable from a live one at the load gate**. + +``Session._await_dask()`` blocks every file/example load until the Dask cluster +is ready, using ``self._dask_ready`` — a ``threading.Event``. An Event is a +LATCH: once ``set()`` it stays set until something explicitly clears it. Exactly +one place in the codebase clears this one (``compute_config.py``, on a +user-driven cluster restart). Nothing clears it when a cluster dies on its own. + +So after a cluster death the gate still opens instantly and the load proceeds +against a dead client. "Re-loading data works" is therefore evidence about the +latch, NOT about the cluster — which is why the earlier claim that the cluster +survives a laptop sleep was withdrawn. + +These are characterisation tests: they describe what the code does today so the +behaviour cannot change silently while §9 is being designed. They are not +asserting that the current behaviour is CORRECT — §9 proposes changing it. +""" +from __future__ import annotations + +import pathlib +import re + +from spyde.backend import session as session_mod + + +class TestTheGateIsALatch: + def test_await_dask_returns_immediately_once_set(self, window): + """The gate does not re-check anything — it reads the Event.""" + session = window["window"] + assert session._dask_ready.is_set(), "fixture should start cluster-ready" + # No cluster is consulted here; this is a pure Event read. + assert session._await_dask(timeout=0.01) is True + + def test_a_dead_client_still_passes_the_gate(self, window): + """THE finding. Tear the client out from under the session — the state a + cluster death leaves behind — and the gate still opens instantly.""" + session = window["window"] + mgr = session.dask_manager + if mgr is not None: + # Simulate the cluster being gone without touching the latch, which + # is precisely what a death (as opposed to a restart) does. + try: + mgr.client = None + except Exception: + pass + + assert session._await_dask(timeout=0.01) is True, ( + "a load proceeds against a dead client — the gate cannot tell") + + def test_nothing_clears_the_latch_on_death(self): + """Structural: the ONLY clear() is the user-driven restart path. + + If a liveness detector is added (§9) it will add a second clear() and + this test will fail — deliberately. Update it then; the point is that + the count cannot change by accident.""" + root = pathlib.Path(session_mod.__file__).resolve().parents[2] + hits = [] + for path in (root / "spyde").rglob("*.py"): + if "tests" in path.parts: + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for line in text.splitlines(): + if re.search(r"_dask_ready\s*\.\s*clear\s*\(", line): + hits.append(f"{path.relative_to(root)}: {line.strip()}") + + assert len(hits) == 1, ( + "expected exactly ONE place to clear the dask gate (the user-driven " + f"cluster restart in compute_config.py); found:\n " + "\n ".join(hits)) + assert "compute_config" in hits[0], hits[0] + + def test_no_liveness_check_consumes_the_cluster(self): + """Structural: nothing polls the cluster for health. + + Searches for the shapes a detector would take. If this starts failing, + someone added one — good, but §9's traps (worker churn is not cluster + death; never probe from the asyncio main thread) need reading first.""" + root = pathlib.Path(session_mod.__file__).resolve().parents[2] + suspects = [] + for path in (root / "spyde").rglob("*.py"): + if "tests" in path.parts: + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if re.search(r"(cluster_alive|is_cluster_alive|_check_cluster|" + r"cluster_died|on_cluster_lost)", text): + suspects.append(str(path.relative_to(root))) + assert suspects == [], ( + "a cluster-liveness path now exists — see SESSION_RESYNC_PLAN §9 " + f"before changing it: {suspects}") diff --git a/spyde/tests/migrated/test_report_detached_figures.py b/spyde/tests/migrated/test_report_detached_figures.py new file mode 100644 index 00000000..3963c67d --- /dev/null +++ b/spyde/tests/migrated/test_report_detached_figures.py @@ -0,0 +1,243 @@ +""" +test_report_detached_figures.py — a saved figure comes back INTERACTIVE. + +A figure cell persists as a RECIPE (``figures/.yaml``) plus a baked still +(``assets/.png``). The recipe points at the SOURCE signal, so reopening a +report without that data loaded gave you a flat PNG: you could look at the +figure but not pan, zoom, or touch a widget. Since +``figure_builder.build_figure`` wants a spec AND a per-layer snapshot map, and +the spec already round-tripped, only the pixels were missing. + +``data/.npz`` supplies them. On open, a cell whose sources don't resolve but +whose pixels WERE saved is rebuilt into a real anyplotlib figure and marked +DETACHED — every interaction works; the one thing it cannot do is refresh from a +signal that isn't there. + +Covered: +* the npz pack/unpack round-trip, including what it refuses to pickle, +* ``read_report_snapshots`` as a SIBLING of read_report (whose 2-tuple is + unpacked at ~20 call sites and must not change), +* a report written WITHOUT data still opens (back-compat) and lands offline, +* a report written WITH data opens DETACHED, not offline, +* re-saving a detached report round-trips the pixels it was opened with, +* the per-cell size cap. +""" +from __future__ import annotations + +import os +import tempfile + +import numpy as np + +from spyde.actions.report import handlers as h +from spyde.actions.report import model as m +from spyde.actions.report.model import ( + Cell, FigureSpec, LayerSpec, PanelSpec, ReportDoc, read_report, + read_report_snapshots, write_report, +) + + +def _tmp(name: str) -> str: + return os.path.join(tempfile.mkdtemp(), name) + + +def _states(messages): + return [msg for msg in messages if msg.get("type") == "report_state"] + + +def _last_state(messages): + st = _states(messages) + assert st, "no report_state emitted" + return st[-1]["report"] + + +class TestSnapshotNpzRoundTrip: + def test_arrays_survive_exactly(self): + snap = { + ("p1", "l1"): np.arange(64, dtype=np.uint16).reshape(8, 8), + ("p1", "l2"): np.linspace(0, 1, 32, dtype=np.float32).reshape(4, 8), + } + back = m.npz_from_snapshots(m.snapshots_to_npz(snap)) + assert set(back) == set(snap) + for key in snap: + assert np.array_equal(back[key], snap[key]) + assert back[key].dtype == snap[key].dtype + + def test_object_arrays_are_dropped_not_pickled(self): + """A report is a file people email each other, so loading one must never + be a code-execution path (np.load runs with allow_pickle=False).""" + snap = {("p", "obj"): np.array([{"a": 1}, None], dtype=object), + ("p", "ok"): np.ones((2, 2), dtype=np.uint8)} + back = m.npz_from_snapshots(m.snapshots_to_npz(snap)) + assert set(back) == {("p", "ok")} + + def test_empty_map_packs_to_nothing(self): + assert m.snapshots_to_npz({}) == b"" + assert m.npz_from_snapshots(b"") == {} + + def test_corrupt_blob_degrades_to_empty(self): + """A damaged data blob must cost the figure its interactivity, never the + whole open.""" + assert m.npz_from_snapshots(b"not an npz at all") == {} + + def test_ids_containing_odd_characters_round_trip(self): + snap = {("panel with space", "layer-1.2"): np.zeros((2, 2), np.uint8)} + back = m.npz_from_snapshots(m.snapshots_to_npz(snap)) + assert set(back) == set(snap) + + +class TestZipCarriesTheData: + def _doc(self): + spec = FigureSpec(panels=[PanelSpec(id="p1", layers=[LayerSpec(id="l1")])]) + doc = ReportDoc(title="t") + doc.cells = [Cell(id="c1", cell_type="figure", spec=spec)] + return doc + + def test_data_written_and_read_back(self): + arr = np.arange(100, dtype=np.uint16).reshape(10, 10) + path = _tmp("with_data.spyde-report") + write_report(self._doc(), path, assets={"c1": b"PNG"}, + snapshots={"c1": m.snapshots_to_npz({("p1", "l1"): arr})}) + + got = read_report_snapshots(path) + assert set(got) == {"c1"} + assert np.array_equal(got["c1"][("p1", "l1")], arr) + + def test_read_report_arity_is_unchanged(self): + """~20 call sites unpack this 2-tuple; the pixels ride in a sibling + reader precisely so none of them had to change.""" + path = _tmp("arity.spyde-report") + write_report(self._doc(), path, assets={"c1": b"PNG"}, + snapshots={"c1": m.snapshots_to_npz( + {("p1", "l1"): np.zeros((4, 4), np.uint8)})}) + doc, assets = read_report(path) + assert doc.cells[0].id == "c1" + assert assets["c1"] == b"PNG" + + def test_report_written_without_data_reads_empty(self): + """Back-compat: every report saved before this existed.""" + path = _tmp("no_data.spyde-report") + write_report(self._doc(), path, assets={"c1": b"PNG"}) + assert read_report_snapshots(path) == {} + + def test_a_non_report_zip_does_not_raise(self): + import zipfile + path = _tmp("junk.spyde-report") + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("data/bad.npz", b"garbage") + assert read_report_snapshots(path) == {} + + +class TestAssembleSnapshots: + def test_packs_live_snapshots_for_figure_cells(self, window): + session = window["window"] + h.report_new(session, None, {}) + mgr = session._report + spec = FigureSpec(panels=[PanelSpec(id="p1", layers=[LayerSpec(id="l1")])]) + mgr.doc.cells.append(Cell(id="c1", cell_type="figure", spec=spec)) + mgr.set_snapshot("c1", "p1", "l1", np.ones((4, 4), np.uint8)) + + packed = mgr.assemble_snapshots() + assert set(packed) == {"c1"} + back = m.npz_from_snapshots(packed["c1"]) + assert np.array_equal(back[("p1", "l1")], np.ones((4, 4), np.uint8)) + + def test_falls_back_to_the_snapshots_the_report_was_opened_with(self, window): + """Re-saving a report whose sources were never available must not + silently drop the pixels it came with.""" + session = window["window"] + h.report_new(session, None, {}) + mgr = session._report + spec = FigureSpec(panels=[PanelSpec(id="p1", layers=[LayerSpec(id="l1")])]) + mgr.doc.cells.append(Cell(id="c1", cell_type="figure", spec=spec)) + arr = np.full((3, 3), 7, np.uint8) + mgr._loaded_snapshots["c1"] = {("p1", "l1"): arr} + + packed = mgr.assemble_snapshots() + assert np.array_equal( + m.npz_from_snapshots(packed["c1"])[("p1", "l1")], arr) + + def test_a_cell_over_the_cap_saves_without_data(self, window): + session = window["window"] + h.report_new(session, None, {}) + mgr = session._report + spec = FigureSpec(panels=[PanelSpec(id="p1", layers=[LayerSpec(id="l1")])]) + mgr.doc.cells.append(Cell(id="c1", cell_type="figure", spec=spec)) + # Random noise so compression can't shrink it under the cap. + rng = np.random.default_rng(0) + mgr.set_snapshot("c1", "p1", "l1", + rng.integers(0, 255, (2048, 2048), dtype=np.uint8)) + mgr.SNAPSHOT_MAX_BYTES = 1024 + + assert mgr.assemble_snapshots() == {} + + def test_cells_without_a_spec_are_skipped(self, window): + session = window["window"] + h.report_new(session, None, {}) + mgr = session._report + mgr.doc.cells.append(Cell(id="cmd", cell_type="markdown", source="x")) + mgr._snapshots["cmd"] = {("p", "l"): np.zeros((2, 2), np.uint8)} + assert mgr.assemble_snapshots() == {} + + +class TestOpenRestoresDetached: + """The behaviour the whole change exists for.""" + + def _write(self, path, *, with_data: bool): + spec = FigureSpec(panels=[PanelSpec(id="p1", layers=[LayerSpec(id="l1")])]) + doc = ReportDoc(title="detached") + doc.cells = [Cell(id="c1", cell_type="figure", spec=spec)] + arr = np.arange(256, dtype=np.uint8).reshape(16, 16) + snaps = ({"c1": m.snapshots_to_npz({("p1", "l1"): arr})} + if with_data else None) + write_report(doc, path, assets={"c1": b"PNG"}, snapshots=snaps) + return arr + + def test_without_saved_data_the_cell_is_offline(self, window): + """The old behaviour, unchanged: no pixels → the flat PNG.""" + session, messages = window["window"], window["messages"] + path = _tmp("offline.spyde-report") + self._write(path, with_data=False) + messages.clear() + h.report_open(session, None, {"path": path}) + + mgr = session._report + assert "c1" in mgr._offline + assert "c1" not in mgr._detached + entry = _last_state(messages)["cells"][0] + assert entry["data_offline"] is True + assert entry["data_detached"] is False + + def test_with_saved_data_the_cell_is_detached_and_live(self, window): + session, messages = window["window"], window["messages"] + path = _tmp("detached.spyde-report") + arr = self._write(path, with_data=True) + messages.clear() + h.report_open(session, None, {"path": path}) + + mgr = session._report + # THE assertion: interactive, not a static fallback. + assert "c1" in mgr._detached, "saved pixels should rebuild a live figure" + assert "c1" not in mgr._offline + assert mgr._window_by_cell.get("c1") is not None, "no live figure window" + # The pixels really are the ones that were saved. + assert np.array_equal(mgr.snapshot_map("c1")[("p1", "l1")], arr) + + entry = _last_state(messages)["cells"][0] + assert entry["data_detached"] is True + assert entry["data_offline"] is False + + def test_a_detached_report_re_saves_its_pixels(self, window): + """Open detached → save → the next open is detached too, not offline.""" + session = window["window"] + first = _tmp("d1.spyde-report") + arr = self._write(first, with_data=True) + h.report_open(session, None, {"path": first}) + + second = _tmp("d2.spyde-report") + packed = session._report.assemble_snapshots() + write_report(session._report.doc, second, + assets={"c1": b"PNG"}, snapshots=packed) + + again = read_report_snapshots(second) + assert np.array_equal(again["c1"][("p1", "l1")], arr) diff --git a/spyde/tests/migrated/test_report_handlers.py b/spyde/tests/migrated/test_report_handlers.py index d05be5d3..1a9c4d2f 100644 --- a/spyde/tests/migrated/test_report_handlers.py +++ b/spyde/tests/migrated/test_report_handlers.py @@ -697,6 +697,151 @@ def test_finish_save_warns_on_dropped_figure(self, tem_2d_dataset, tmp_path): # ── open + rebind round-trip ─────────────────────────────────────────────────── +class TestReplaceAPhotoWithALiveFigure: + """Dropping a plot window onto a PICTURE replaces it with a live figure. + + ``report_add_figure {at_cell}`` knew how to fill a figure cell and a split + cell's figure side, but an ``image`` target fell through to the append + branch — so the photo stayed put and an unrelated figure landed underneath + it.""" + + def test_image_cell_becomes_a_live_figure_in_place(self, tem_2d_dataset): + import base64 + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAE" + "AAH2FzhVAAAAAElFTkSuQmCC") + session = tem_2d_dataset["window"] + messages = tem_2d_dataset["messages"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_image_cell(session, None, { + "image_b64": "data:image/png;base64," + + base64.b64encode(png).decode("ascii"), + "image_ext": "png", "caption": "a photo"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_add_figure(session, None, + {"source_window_id": wid, "at_cell": cell_id}) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1, "the figure must REPLACE the photo, not follow it" + cell = session._report.doc.cells[0] + assert cell.id == cell_id # same cell, converted + assert cell.cell_type == "figure" + assert cell.spec is not None + assert cell.image_ext == "" + assert cell_id not in session._report._images + # And it really is live. + assert session._report._window_by_cell.get(cell_id) is not None + + def test_the_photo_cell_keeps_its_slide_role(self, tem_2d_dataset): + import base64 + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAE" + "AAH2FzhVAAAAAElFTkSuQmCC") + session = tem_2d_dataset["window"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "slide one"}) + h.report_add_image_cell(session, None, { + "image_b64": "data:image/png;base64," + + base64.b64encode(png).decode("ascii"), + "image_ext": "png", "slide_break": True}) + cell = session._report.doc.cells[1] + cell.notes = "point at the peak" + + h.report_add_figure(session, None, + {"source_window_id": wid, "at_cell": cell.id}) + + after = session._report.doc.cells[1] + assert after.slide_break is True + assert after.notes == "point at the peak" + assert len(session._report.doc.slides()) == 2 + + +class TestPhotoReplacesALiveFigure: + """A dropped image onto a FILLED figure cell replaces the figure. + + The renderer gap this pairs with: a report figure is an out-of-process + iframe that swallows drag events, and the shield that normally covers it was + mounted only for in-app pill drags — so an OS file drag never reached the + cell at all.""" + + def test_filled_figure_converts_to_a_photo_cell(self, tem_2d_dataset): + import base64 + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAE" + "AAH2FzhVAAAAAElFTkSuQmCC") + session = tem_2d_dataset["window"] + messages = tem_2d_dataset["messages"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_figure(session, None, + {"source_window_id": wid, "caption": "DP"}) + cell_id = session._report.doc.cells[0].id + assert session._report._window_by_cell.get(cell_id) is not None + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, + "image_b64": "data:image/png;base64," + + base64.b64encode(png).decode("ascii"), + "image_ext": "png"}) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1, "replacing must not append a second cell" + cell = session._report.doc.cells[0] + assert cell.id == cell_id + assert cell.cell_type == "image" + assert cell.spec is None + assert session._report._images[cell_id] == png + # The live figure is torn down, not orphaned. + assert session._report._window_by_cell.get(cell_id) is None + assert cell_id not in session._report._snapshots + + +class TestTextSlideBecomesASplitWithALiveFigure: + def test_markdown_converts_in_place_keeping_its_prose(self, tem_2d_dataset): + session = tem_2d_dataset["window"] + messages = tem_2d_dataset["messages"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "## Live\n\n- a point\n"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_add_figure(session, None, + {"source_window_id": wid, "at_cell": cell_id}) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1, "a layout change must not add a second cell" + cell = session._report.doc.cells[0] + assert cell.id == cell_id + assert cell.cell_type == "split" + assert cell.source == "## Live\n\n- a point\n" + assert cell.spec is not None + # The figure side really is live. + assert session._report._window_by_cell.get(cell_id) is not None + + def test_conversion_respects_an_explicit_layout(self, tem_2d_dataset): + session = tem_2d_dataset["window"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "text"}) + cell_id = session._report.doc.cells[0].id + + h.report_add_figure(session, None, { + "source_window_id": wid, "at_cell": cell_id, "layout": "text-right"}) + + assert session._report.doc.cells[0].split_layout == "text-right" + + class TestReportOpenRebind: def test_save_then_open_rebinds_live(self, tem_2d_dataset, tmp_path): session = tem_2d_dataset["window"] @@ -725,9 +870,15 @@ def test_save_then_open_rebinds_live(self, tem_2d_dataset, tmp_path): # A live report figure was re-emitted. assert len(_report_figures(messages)) == 1 - def test_open_offline_when_source_gone_includes_png(self, tem_2d_dataset, tmp_path): - """With NO matching open tree, a figure cell is offline and its baked PNG - rides along in the state as a data URL (renderer has no zip access).""" + def test_open_detached_when_source_gone_but_pixels_were_saved( + self, tem_2d_dataset, tmp_path): + """With NO matching open tree, a figure saved WITH its pixels + (``data/.npz``) comes back DETACHED — a real, interactive figure + rebuilt from the report's own data — rather than the flat PNG this used + to fall back to. See test_report_detached_figures.py. + + The offline-PNG path is still exercised by the sibling test below, which + strips the data from the zip first.""" session = tem_2d_dataset["window"] messages = tem_2d_dataset["messages"] _prime_plot_data(session) @@ -743,9 +894,45 @@ def test_open_offline_when_source_gone_includes_png(self, tem_2d_dataset, tmp_pa messages.clear() h.report_open(session, None, {"path": path}) + st = _last_state(messages) + fig_cell = [c for c in st["cells"] if c["cell_type"] == "figure"][0] + assert fig_cell["data_detached"] is True + assert fig_cell["data_offline"] is False + # A detached cell IS live — that is the whole point. + assert _report_figures(messages) != [] + + def test_open_offline_when_no_saved_pixels_includes_png( + self, tem_2d_dataset, tmp_path): + """With no matching open tree AND no saved pixels, the cell is offline + and its baked PNG rides in the state as a data URL (the renderer has no + zip access). This is the pre-existing fallback, still reachable for any + report written before figure data was persisted.""" + import zipfile + session = tem_2d_dataset["window"] + messages = tem_2d_dataset["messages"] + _prime_plot_data(session) + wid = _signal_window_id(session) + h.report_new(session, None, {}) + h.report_add_figure(session, None, {"source_window_id": wid, "caption": "DP"}) + path = str(tmp_path / "off.spyde-report") + h.report_save(session, None, {"path": path}) + + # Rewrite the zip WITHOUT its data/ members — a pre-data report. + stripped = str(tmp_path / "off_nodata.spyde-report") + with zipfile.ZipFile(path) as src, zipfile.ZipFile(stripped, "w") as dst: + for item in src.namelist(): + if not item.startswith("data/"): + dst.writestr(item, src.read(item)) + + session._plots = [] + h.report_close(session, None, {}) + messages.clear() + h.report_open(session, None, {"path": stripped}) + st = _last_state(messages) fig_cell = [c for c in st["cells"] if c["cell_type"] == "figure"][0] assert fig_cell["data_offline"] is True + assert fig_cell["data_detached"] is False assert isinstance(fig_cell.get("png"), str) assert fig_cell["png"].startswith("data:image/png;base64,") # No live figure emitted for an offline cell. diff --git a/spyde/tests/migrated/test_report_scene3d.py b/spyde/tests/migrated/test_report_scene3d.py index 68bacc53..450860ff 100644 --- a/spyde/tests/migrated/test_report_scene3d.py +++ b/spyde/tests/migrated/test_report_scene3d.py @@ -265,7 +265,7 @@ def test_save_reopen_rebinds_live(self, tem_2d_dataset, tmp_path): assert ("p1", "xyz") in mgr._snapshots[cell["id"]] assert _report_figures(messages) - def test_reopen_without_result_goes_offline_gracefully( + def test_reopen_without_result_rebuilds_from_saved_points( self, tem_2d_dataset, tmp_path): session = tem_2d_dataset["window"] messages = tem_2d_dataset["messages"] @@ -275,7 +275,11 @@ def test_reopen_without_result_goes_offline_gracefully( h.report_close(session, None, {}) # The orientation result is gone (e.g. a fresh session where OM was - # never recomputed) → the cell must open OFFLINE, never crash. + # never recomputed). The scene's POINT CLOUD was saved with the report + # (data/.npz holds the xyz/rgb pseudo-layers), so the cell reopens + # DETACHED — a real, spinnable 3-D scene with no result behind it — + # rather than the flat PNG it used to degrade to. Either way it must + # never crash, which is what this test has always been about. tree.orientation_map = None if hasattr(tree, "_ipf_result"): tree._ipf_result = None @@ -283,7 +287,8 @@ def test_reopen_without_result_goes_offline_gracefully( h.report_open(session, None, {"path": path}) st = _last_state(messages) cell = [c for c in st["cells"] if c["cell_type"] == "figure"][0] - assert cell["data_offline"] is True + assert cell["data_detached"] is True + assert cell["data_offline"] is False json.dumps(st) # state still serializable (badge path, maybe no png) diff --git a/spyde/tests/migrated/test_report_set_cell_image.py b/spyde/tests/migrated/test_report_set_cell_image.py new file mode 100644 index 00000000..f98e33cc --- /dev/null +++ b/spyde/tests/migrated/test_report_set_cell_image.py @@ -0,0 +1,350 @@ +""" +test_report_set_cell_image.py — filling an EXISTING cell's figure slot with a +dropped/pasted PHOTO. + +The bug this pins: dropping a PNG onto a split cell's empty figure side (or onto +an empty figure placeholder) had no verb that could fill the slot. The renderer's +drop zones only recognised a figure/window PILL, so a file drag failed their +test, bubbled up to the sidebar body, and was APPENDED as a brand-new image cell +BELOW — the slot the user aimed at stayed empty and the picture landed somewhere +else on the page. + +``report_set_cell_image`` is the image counterpart of ``report_set_split_figure``: + +* a SPLIT cell takes the photo as its figure side, TEXT and layout untouched, +* a FIGURE PLACEHOLDER converts IN PLACE to an ``image`` cell — same cell id, so + the slide attributes riding on a slide's first cell (break / kind / style / + speaker notes) survive; re-creating the cell would silently lose them, +* an already-filled slot is REPLACED (the prior figure's snapshot is dropped), +* a cell with no figure slot at all (markdown) is a no-op with an error, +* the shared decode path still caps size and normalises the extension. +""" +from __future__ import annotations + +import base64 + +from spyde.actions.report import handlers as h + + +# A 1×1 red PNG (67 bytes) — the smallest real PNG, so the bytes round-trip +# through a real data URL without any faking. +_PNG_1x1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhV" + "AAAAAElFTkSuQmCC") +_GIF_1x1 = base64.b64decode( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + +_PNG_URL = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode("ascii") +_GIF_URL = "data:image/gif;base64," + base64.b64encode(_GIF_1x1).decode("ascii") + + +def _states(messages): + return [msg for msg in messages if msg.get("type") == "report_state"] + + +def _last_state(messages): + st = _states(messages) + assert st, "no report_state emitted" + return st[-1]["report"] + + +def _errors(messages): + return [msg for msg in messages if msg.get("type") == "error"] + + +class TestSetCellImageOnSplit: + def test_fills_the_split_figure_side_in_place(self, window): + """The photo lands in the EXISTING cell — no new cell is appended.""" + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, { + "source": "## Motivation\n\n- a bullet\n", "layout": "text-left"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + assert not _errors(messages) + + cells = _last_state(messages)["cells"] + # THE assertion: still ONE cell. The old path produced two. + assert len(cells) == 1 + assert cells[0]["id"] == cell_id + assert cells[0]["cell_type"] == "split" + assert cells[0]["image"].startswith("data:image/png;base64,") + assert session._report._images[cell_id] == _PNG_1x1 + + def test_text_side_and_layout_are_untouched(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, { + "source": "## Keep me\n", "layout": "text-bottom"}) + cell_id = session._report.doc.cells[0].id + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + + cell = session._report.doc.cells[0] + assert cell.source == "## Keep me\n" + assert cell.split_layout == "text-bottom" + + def test_caption_is_optional_and_applied_when_given(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x", "caption": "before"}) + cell_id = session._report.doc.cells[0].id + + # No caption in the payload → the existing one survives. + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + assert session._report.doc.cells[0].caption == "before" + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png", + "caption": "after"}) + assert session._report.doc.cells[0].caption == "after" + + def test_replacing_a_photo_swaps_the_bytes(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell_id = session._report.doc.cells[0].id + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _GIF_URL, "image_ext": "gif"}) + + assert session._report.doc.cells[0].image_ext == "gif" + assert session._report._images[cell_id] == _GIF_1x1 + assert len(session._report.doc.cells) == 1 + + def test_photo_clears_a_figure_previously_in_the_slot(self, window): + """A slot holds a figure OR a photo, never both — the mirror of + report_set_split_figure clearing a prior photo.""" + session = window["window"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell = session._report.doc.cells[0] + # Stand in for a filled figure side without needing a real plot window. + cell.spec = object() + session._report._snapshots[cell.id] = {"panel": b""} + + h.report_set_cell_image(session, None, { + "cell_id": cell.id, "image_b64": _PNG_URL, "image_ext": "png"}) + + assert cell.spec is None + assert cell.id not in session._report._snapshots + assert session._report._images[cell.id] == _PNG_1x1 + + +class TestSetCellImageOnPlaceholder: + def test_placeholder_becomes_an_image_cell_with_the_same_id(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_figure_placeholder(session, None, {}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + assert not _errors(messages) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1 + assert cells[0]["id"] == cell_id # SAME cell, converted in place + assert cells[0]["cell_type"] == "image" + assert not session._report.doc.cells[0].placeholder + + def test_slide_attributes_survive_the_conversion(self, window): + """The regression a re-created cell would cause: a slide's first cell + carries the break and the speaker notes, so losing the cell merges the + slide into the previous one and drops the notes.""" + session = window["window"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "slide one"}) + h.report_add_figure_placeholder(session, None, {"slide_break": True}) + cell = session._report.doc.cells[1] + cell.slide_kind, cell.slide_style = "", "accent" + cell.notes = "remember to breathe" + + h.report_set_cell_image(session, None, { + "cell_id": cell.id, "image_b64": _PNG_URL, "image_ext": "png"}) + + after = session._report.doc.cells[1] + assert after.slide_break is True + assert after.slide_style == "accent" + assert after.notes == "remember to breathe" + assert len(session._report.doc.slides()) == 2 + + +class TestReplaceAnExistingPhoto: + """Drop another image onto a picture that is already there.""" + + def test_image_cell_bytes_are_swapped_in_place(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_image_cell(session, None, { + "image_b64": _PNG_URL, "image_ext": "png", "caption": "keep me"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _GIF_URL, "image_ext": "gif"}) + assert not _errors(messages) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1, "replacing must not append a second picture" + assert cells[0]["id"] == cell_id + assert cells[0]["cell_type"] == "image" + assert cells[0]["image_ext"] == "gif" + assert session._report._images[cell_id] == _GIF_1x1 + # The identity that hangs off the cell survives the swap. + assert cells[0]["caption"] == "keep me" + + def test_slide_attributes_survive_an_image_swap(self, window): + session = window["window"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "slide one"}) + h.report_add_image_cell(session, None, { + "image_b64": _PNG_URL, "image_ext": "png", "slide_break": True}) + cell = session._report.doc.cells[1] + cell.notes = "say the thing" + + h.report_set_cell_image(session, None, { + "cell_id": cell.id, "image_b64": _GIF_URL, "image_ext": "gif"}) + + after = session._report.doc.cells[1] + assert after.slide_break is True + assert after.notes == "say the thing" + assert len(session._report.doc.slides()) == 2 + + +class TestTextSlideBecomesASplit: + """Drop a picture on a TEXT slide and it becomes a SPLIT slide.""" + + def test_markdown_converts_in_place_and_keeps_its_prose(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "## Motivation\n\n- a point\n"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png"}) + assert not _errors(messages) + + cells = _last_state(messages)["cells"] + assert len(cells) == 1, "a layout change must not add a second cell" + cell = session._report.doc.cells[0] + assert cell.id == cell_id + assert cell.cell_type == "split" + assert cell.source == "## Motivation\n\n- a point\n" # prose preserved + assert cell.image_ext == "png" + assert session._report._images[cell_id] == _PNG_1x1 + assert cell.split_layout == "text-left" # sane default + + def test_the_layout_can_be_chosen_at_conversion(self, window): + session = window["window"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "text"}) + cell_id = session._report.doc.cells[0].id + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "png", + "layout": "text-top"}) + assert session._report.doc.cells[0].split_layout == "text-top" + + def test_the_slide_stays_one_slide(self, window): + """The conversion is a LAYOUT change, so a slide that was one text block + must not become two slides — and its notes must survive.""" + session = window["window"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "slide one"}) + h.report_add_cell(session, None, {"source": "## Two\n", "slide_break": True}) + cell = session._report.doc.cells[1] + cell.notes = "remember the aside" + cell.slide_style = "accent" + + h.report_set_cell_image(session, None, { + "cell_id": cell.id, "image_b64": _PNG_URL, "image_ext": "png"}) + + after = session._report.doc.cells[1] + assert after.slide_break is True + assert after.notes == "remember the aside" + assert after.slide_style == "accent" + assert len(session._report.doc.slides()) == 2 + + +class TestSetCellImageRejects: + def test_a_movie_cell_has_no_slot(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_cell(session, None, {"source": "x"}) + cell = session._report.doc.cells[0] + cell.cell_type = "movie" + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell.id, "image_b64": _PNG_URL, "image_ext": "png"}) + + assert _errors(messages), "a slot-less cell must report why, not no-op" + assert session._report.doc.cells[0].cell_type == "movie" + + def test_unknown_cell_id_errors(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + messages.clear() + h.report_set_cell_image(session, None, { + "cell_id": "nope", "image_b64": _PNG_URL, "image_ext": "png"}) + assert _errors(messages) + + def test_undecodable_payload_errors_and_leaves_the_slot_empty(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell_id = session._report.doc.cells[0].id + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": "", "image_ext": "png"}) + + assert _errors(messages) + assert cell_id not in session._report._images + + def test_oversized_image_is_refused(self, window): + session, messages = window["window"], window["messages"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell_id = session._report.doc.cells[0].id + big = base64.b64encode(b"\x00" * (h._IMAGE_CELL_MAX_BYTES + 1)).decode("ascii") + messages.clear() + + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": big, "image_ext": "png"}) + + assert _errors(messages) + assert cell_id not in session._report._images + + def test_unknown_extension_normalises_to_png(self, window): + session = window["window"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell_id = session._report.doc.cells[0].id + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "tiff"}) + assert session._report.doc.cells[0].image_ext == "png" + + def test_jpeg_normalises_to_jpg(self, window): + session = window["window"] + h.report_new(session, None, {}) + h.report_add_split_cell(session, None, {"source": "x"}) + cell_id = session._report.doc.cells[0].id + h.report_set_cell_image(session, None, { + "cell_id": cell_id, "image_b64": _PNG_URL, "image_ext": "jpeg"}) + assert session._report.doc.cells[0].image_ext == "jpg" + + +class TestSetCellImageIsRegistered: + def test_the_verb_dispatches(self, window): + """The handler is reachable through the action registry — the renderer + calls it by name, so an unregistered verb is a silent dead drop.""" + from spyde.actions import registry + assert "report_set_cell_image" in registry.STAGED_HANDLERS