diff --git a/crates/nemo/src/commands/screenshot.rs b/crates/nemo/src/commands/screenshot.rs index dce8d65..f1e312b 100644 --- a/crates/nemo/src/commands/screenshot.rs +++ b/crates/nemo/src/commands/screenshot.rs @@ -19,6 +19,7 @@ use anyhow::{bail, Context as _, Result}; use crate::args::ScreenshotArgs; use crate::config::NemoConfig; +use crate::project::ActiveProject; use crate::workspace::WorkspaceArgs; use crate::{build_app_window, theme, BootstrapParams}; @@ -75,10 +76,53 @@ pub fn run(args: ScreenshotArgs) -> Result<()> { let _ = window.update(cx, |_, w, _| w.refresh()); } + // `render_to_image` reads the window's *last drawn* frame, and + // data-source values arrive asynchronously and reach the UI only once + // their bindings are propagated into the layout. In the interactive run + // loop `App`'s data task applies those on every `data_notify` and marks + // the view dirty; the one-shot capture path must drive the same loop + // itself, or bound values stay at their placeholders in the PNG + // (issue #82). Grab the active project's runtime to pump it. + let runtime = cx + .has_global::() + .then(|| cx.global::().runtime.clone()); + let out = out.clone(); let capture_err_task = capture_err_run.clone(); cx.spawn(async move |cx| { - cx.background_executor().timer(settle).await; + // Pump data → binding propagation across the settle window in small + // slices rather than sleeping it all at once. Each `timer` await + // yields to the executor, giving the async data sources time to + // deliver and letting any refresh-requested redraw actually land. + let tick = Duration::from_millis(50); + let mut remaining = settle; + loop { + let slice = remaining.min(tick); + cx.background_executor().timer(slice).await; + remaining = remaining.saturating_sub(slice); + + if let Some(rt) = runtime.as_ref() { + let navigated = rt.apply_pending_navigations(); + let updated = rt.apply_pending_data_updates(); + if navigated || updated { + let _ = cx.update(|cx| { + let _ = window.update(cx, |_, w, _| w.refresh()); + }); + } + } + + if remaining.is_zero() { + break; + } + } + + // Ensure the last-applied state is committed to the drawn frame + // before we read it back: refresh, then yield once more so gpui + // draws the dirty window into `rendered_frame`. + let _ = cx.update(|cx| { + let _ = window.update(cx, |_, w, _| w.refresh()); + }); + cx.background_executor().timer(tick).await; let result: Result<()> = cx.update(|cx| { let img = window diff --git a/crates/nemo/src/runtime.rs b/crates/nemo/src/runtime.rs index 05e224d..ad59e02 100644 --- a/crates/nemo/src/runtime.rs +++ b/crates/nemo/src/runtime.rs @@ -763,7 +763,17 @@ impl NemoRuntime { } } - // Start all registered sources + // Subscribe the update loops *before* starting sources. A source's + // `start()` broadcasts its initial `full` value immediately, and a + // tokio broadcast channel drops messages sent while no receiver is + // attached — so subscribing after `start_all()` loses the first + // value, leaving the repository unseeded until some later event + // (e.g. a file-watcher change) re-delivers it. Interactive runs + // usually get such a follow-up; a one-shot `nemo screenshot` does + // not, so bound values render as placeholders (issue #82). + self.start_data_update_loop().await; + + // Start all registered sources (now that receivers are attached). let results = self.data_engine.start_all().await; for (id, result) in &results { match result { @@ -772,9 +782,6 @@ impl NemoRuntime { } } - // Start the data update loop for each source - self.start_data_update_loop().await; - Ok::<(), anyhow::Error>(()) })?; diff --git a/docs/knowledgebase/concepts/data-flow.md b/docs/knowledgebase/concepts/data-flow.md index afe099e..91ce098 100644 --- a/docs/knowledgebase/concepts/data-flow.md +++ b/docs/knowledgebase/concepts/data-flow.md @@ -34,6 +34,16 @@ created via `nemo_data::create_source()`, registered and started by the `DataFlowEngine`, then consumed by tokio tasks the runtime spawns (`crates/nemo/src/runtime.rs`, source setup ~520, update loop ~594). +**Startup ordering is load-bearing.** A source's `start()` broadcasts its +initial `full` value *immediately*, and a tokio `broadcast` channel drops +messages sent while no receiver is attached. So the runtime subscribes the +update loops (`start_data_update_loop()`) **before** `data_engine.start_all()`; +subscribing afterward loses each source's first value, leaving the repository +unseeded until some later event (e.g. a file-watcher change) re-delivers it. +Interactive runs usually get such a follow-up and appear to recover, but a +one-shot `nemo screenshot` does not — that was the root cause of issue #82 +(bound values rendered as placeholders in captures). + # Transforms The `Transform` trait (`crates/nemo-data/src/transform.rs:31`) and a `Pipeline` diff --git a/docs/knowledgebase/index.md b/docs/knowledgebase/index.md index afabdc3..b9092f0 100644 --- a/docs/knowledgebase/index.md +++ b/docs/knowledgebase/index.md @@ -80,6 +80,7 @@ to touch the KB on every edit. * [Roadmap](plans/roadmap.md) - current capabilities, phase-2 status, remaining roadmap items, and pointers to full planning docs. * [Declarative children over JSON-string properties](plans/declarative-children-migration.md) - migrate collection components from JSON-string attributes to nested child elements, piloted on accordion. * [Headless renderer and screenshots](plans/headless-screenshots.md) - `nemo screenshot` implemented on macOS via gpui's offscreen `Window::render_to_image`; Linux capture remains open. +* [Screenshot as a plugin](plans/screenshot-as-plugin.md) - move `nemo screenshot` off the feature-gated host build onto a stock release binary via an OS-native capture plugin; needs `Capability::Command` (plugin-contributed CLI subcommands) + a host `capture_app_window` bootstrap primitive. **Planned.** * [Devtools inspector](plans/devtools-inspector.md) - what a nemo-devtools crate would take; the introspection surfaces already exist, in-process panel recommended over an external client. * [Design tokens and active redesign](plans/design-tokens.md) - centralized spacing/radius/typography/semantic-color tokens (gpui-free `nemo-tokens` crate); full chrome migration with screenshot verification. * [Design-system export](plans/design-system-export.md) - `cargo xtask design-export` emits tokens + themes + component structure as a pencil.dev-friendly JSON intermediate. diff --git a/docs/knowledgebase/log.md b/docs/knowledgebase/log.md index c1ce31e..1d711ae 100644 --- a/docs/knowledgebase/log.md +++ b/docs/knowledgebase/log.md @@ -1,6 +1,7 @@ # Knowledge Base Update Log ## 2026-07-31 +* **Plan**: Added [Screenshot as a plugin](/docs/knowledgebase/plans/screenshot-as-plugin.md) — a design to make `nemo screenshot` deployable on a **stock release binary** by moving it out of the feature-gated host build into an **OS-native capture plugin**. Grounded the blocker in code: capture today calls gpui's `Window::render_to_image()` (`commands/screenshot.rs:85`), gated `#[cfg(feature="test-support")]` inside gpui and kept out of release by decision; a native plugin only ever receives `PluginContext` (`nemo-plugin-api/src/lib.rs:285`) — no `&mut Window`, and it can't flip a host compile-time feature — so a plugin **must capture at the OS level** (ScreenCaptureKit / `CGWindowListCreateImage`), not through gpui. Two new host primitives designed: (1) **`Capability::Command`** + `CommandSpec`/`register_command`/`PluginCommandFn` so plugins contribute CLI subcommands — requires a two-phase parse ahead of the static clap match (`main.rs:66`, `args.rs`) with **built-ins taking precedence**; (2) a host **`capture_app_window`** `PluginContext` method (default `Unsupported`, matching the `navigate` pattern at `:327`) that owns the gpui run loop + `build_app_window`/`BootstrapParams` (`main.rs:246/265`) and returns a `CapturedFrame` of raw RGBA — recommended split is host-captures/plugin-encodes, which lets the OS-capture backend ship in the **default binary** (no `test-support`) while the plugin supplies CLI surface + PNG/permission policy. Open questions logged: TCC permission UX for CI, discovery cost, native ABI versioning (no check today, `:538`), WASM parity. Registered in both plan indexes. Planning only — nothing implemented. * **Fix**: Wired `` inside `` ([issue #83](https://github.com/geoffjay/nemo/issues/83)). The build path in `app.rs` collected only each menu item's `label` into a `Vec`, so `DropdownButton` rendered click-less `PopupMenuItem`s and every entry was a silent no-op. Now `app.rs` collects a `Vec` (new struct in `components/dropdown_button.rs`, re-exported from `components/mod.rs`), carrying each item's `handlers.get("click")`, and passes `runtime` + `entity_id` into `DropdownButton` (same as `