Skip to main content

eguidev/
devmcp.rs

1//! DevMCP instrumentation helpers and public API.
2#![allow(missing_docs)]
3
4use std::{
5    any::Any,
6    fmt,
7    sync::{Arc, atomic::Ordering},
8};
9
10use egui::Context;
11
12use crate::{
13    actions::InputAction,
14    fixtures::FixtureHandler,
15    instrument::{ACTIVE, swallow_panic},
16    registry::Inner,
17    types::FixtureSpec,
18};
19
20#[derive(Clone, Debug, Default)]
21enum DevMcpState {
22    #[default]
23    Inactive,
24    Active(Arc<Inner>),
25}
26
27pub trait RuntimeHooks: Send + Sync {
28    fn as_any(&self) -> &(dyn Any + Send + Sync);
29
30    fn on_raw_input(&self, _inner: &Inner, _events: &[egui::Event]) {}
31
32    fn on_frame_end(&self, _inner: &Inner, _ctx: &Context) {}
33}
34
35/// DevMCP handle stored in app state.
36#[derive(Clone, Default)]
37pub struct DevMcp {
38    state: DevMcpState,
39    fixtures: Vec<FixtureSpec>,
40    verbose_logging: bool,
41    fixture_handler: Option<FixtureHandler>,
42}
43
44impl fmt::Debug for DevMcp {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_struct("DevMcp")
47            .field("state", &self.state)
48            .field("fixtures", &self.fixtures)
49            .field("verbose_logging", &self.verbose_logging)
50            .finish()
51    }
52}
53
54impl DevMcp {
55    /// Create a new inert DevMCP handle.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Enable or disable verbose internal logging for DevMCP operations.
61    pub fn verbose_logging(mut self, verbose_logging: bool) -> Self {
62        self.verbose_logging = verbose_logging;
63        if let Some(inner) = self.inner() {
64            inner.set_verbose_logging(verbose_logging);
65        }
66        self
67    }
68
69    /// Register fixture metadata for discovery and validation.
70    pub fn fixtures(mut self, fixtures: impl IntoIterator<Item = FixtureSpec>) -> Self {
71        self.fixtures = fixtures.into_iter().collect();
72        if let Some(inner) = self.inner() {
73            inner.fixtures.set_fixtures(self.fixtures.clone());
74        }
75        self
76    }
77
78    /// Register a callback that applies named fixtures to app state.
79    ///
80    /// The handler is called directly from the tokio runtime when a fixture
81    /// tool call arrives, removing the need for frame-driven polling.
82    pub fn on_fixture<F>(mut self, handler: F) -> Self
83    where
84        F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
85    {
86        let handler: FixtureHandler = Arc::new(handler);
87        if let Some(inner) = self.inner() {
88            inner.fixtures.set_fixture_handler(handler.clone());
89        }
90        self.fixture_handler = Some(handler);
91        self
92    }
93
94    /// Returns true if DevMCP automation is attached.
95    pub fn is_enabled(&self) -> bool {
96        matches!(self.state, DevMcpState::Active(_))
97    }
98
99    #[doc(hidden)]
100    pub fn inner_arc(&self) -> Option<Arc<Inner>> {
101        self.inner().map(Arc::clone)
102    }
103
104    #[doc(hidden)]
105    pub fn runtime_hooks(&self) -> Option<Arc<dyn RuntimeHooks>> {
106        self.inner().and_then(|inner| inner.runtime_hooks())
107    }
108
109    fn verbose_logging_enabled(&self) -> bool {
110        self.inner()
111            .map_or(self.verbose_logging, |inner| inner.verbose_logging())
112    }
113
114    #[cfg(test)]
115    fn context_for(&self, viewport_id: egui::ViewportId) -> Option<Context> {
116        self.inner()
117            .and_then(|inner| inner.context_for(viewport_id))
118    }
119
120    #[doc(hidden)]
121    pub fn activate_runtime(mut self, inner: Arc<Inner>, hooks: Arc<dyn RuntimeHooks>) -> Self {
122        inner.set_runtime_hooks(hooks);
123        inner.set_verbose_logging(self.verbose_logging);
124        if !self.fixtures.is_empty() {
125            inner.fixtures.set_fixtures(self.fixtures.clone());
126        }
127        if let Some(handler) = &self.fixture_handler {
128            inner.fixtures.set_fixture_handler(handler.clone());
129        }
130        self.state = DevMcpState::Active(inner);
131        self
132    }
133
134    pub(crate) fn inner(&self) -> Option<&Arc<Inner>> {
135        match &self.state {
136            DevMcpState::Inactive => None,
137            DevMcpState::Active(inner) => Some(inner),
138        }
139    }
140
141    /// Begin a frame, enabling widget tracking for this thread.
142    ///
143    /// Prefer [`FrameGuard`] over calling this directly.
144    pub(crate) fn begin_frame(&self, ctx: &Context) {
145        let Some(inner) = self.inner() else {
146            return;
147        };
148        swallow_panic("begin_frame", || {
149            let viewport_id = ctx.viewport_id();
150            inner.capture_context(viewport_id, ctx);
151            inner.widgets.clear_registry(viewport_id);
152            ACTIVE.with(|active| {
153                if let Ok(mut active) = active.try_borrow_mut() {
154                    *active = Some(Arc::clone(inner));
155                } else {
156                    eprintln!("eguidev: begin_frame skipped; active already borrowed");
157                }
158            });
159        });
160    }
161
162    /// End a frame, finalizing widget registry and handling automation state.
163    ///
164    /// Prefer [`FrameGuard`] over calling this directly.
165    pub(crate) fn end_frame(&self, ctx: &Context) {
166        let Some(inner) = self.inner() else {
167            return;
168        };
169        swallow_panic("end_frame", || {
170            self.finish_frame(inner, ctx);
171            ACTIVE.with(|active| {
172                if let Ok(mut active) = active.try_borrow_mut() {
173                    *active = None;
174                } else {
175                    eprintln!("eguidev: end_frame skipped; active already borrowed");
176                }
177            });
178        });
179    }
180
181    fn finish_frame(&self, inner: &Arc<Inner>, ctx: &Context) {
182        inner.widgets.finalize_registry(ctx.viewport_id());
183        let next_frame = inner.frame_count() + 1;
184        inner
185            .viewports
186            .capture_input_snapshot(ctx, inner.fixture_epoch(), next_frame);
187        inner.advance_frame();
188        if let Some(hooks) = inner.runtime_hooks() {
189            hooks.on_frame_end(inner, ctx);
190        }
191    }
192
193    /// Inject queued raw input during the raw input hook.
194    pub(crate) fn raw_input_hook(&self, ctx: &Context, raw_input: &mut egui::RawInput) {
195        let Some(inner) = self.inner() else {
196            return;
197        };
198        swallow_panic("raw_input_hook", || {
199            let viewport_id = raw_input.viewport_id;
200            inner.capture_context(viewport_id, ctx);
201            if let Some(hooks) = inner.runtime_hooks() {
202                hooks.on_raw_input(inner, &raw_input.events);
203            }
204            let actions = inner.actions.drain_actions(viewport_id);
205            if !actions.is_empty() {
206                inner
207                    .last_action_frame
208                    .store(inner.frame_count(), Ordering::Relaxed);
209                if self.verbose_logging_enabled() {
210                    eprintln!(
211                        "eguidev: raw_input_hook viewport={:?} actions={}",
212                        viewport_id,
213                        actions.len()
214                    );
215                }
216            }
217            let base_modifiers = raw_input.modifiers;
218            let mut current_modifiers = base_modifiers;
219            let mut force_focus = false;
220            for action in &actions {
221                if let InputAction::Key {
222                    pressed, modifiers, ..
223                } = action
224                {
225                    current_modifiers = if *pressed {
226                        base_modifiers.plus((*modifiers).into())
227                    } else {
228                        base_modifiers
229                    };
230                }
231                if matches!(
232                    action,
233                    InputAction::Key { .. } | InputAction::Text { .. } | InputAction::Paste { .. }
234                ) {
235                    force_focus = true;
236                }
237            }
238            raw_input.modifiers = current_modifiers;
239            if force_focus {
240                raw_input.focused = true;
241            }
242            for action in actions {
243                action.apply(raw_input);
244            }
245        });
246    }
247}
248
249/// RAII guard that calls `begin_frame` and `end_frame` automatically.
250#[must_use = "FrameGuard must be held for the duration of the frame"]
251pub struct FrameGuard<'a> {
252    /// DevMcp handle for the active frame.
253    devmcp: &'a DevMcp,
254    /// Egui context for the current frame.
255    ctx: &'a egui::Context,
256}
257
258impl<'a> FrameGuard<'a> {
259    /// Create a new frame guard for the provided DevMcp.
260    pub fn new(devmcp: &'a DevMcp, ctx: &'a Context) -> Self {
261        devmcp.begin_frame(ctx);
262        Self { devmcp, ctx }
263    }
264}
265
266impl Drop for FrameGuard<'_> {
267    fn drop(&mut self) {
268        self.devmcp.end_frame(self.ctx);
269    }
270}
271
272/// Forward the raw input hook into the DevMcp handler.
273pub fn raw_input_hook(devmcp: &DevMcp, ctx: &Context, raw_input: &mut egui::RawInput) {
274    devmcp.raw_input_hook(ctx, raw_input);
275}
276
277#[cfg(test)]
278#[allow(deprecated)]
279#[allow(clippy::tests_outside_test_module)]
280mod inactive_tests {
281    use egui::Context;
282
283    use super::*;
284    use crate::{instrument, ui_ext::DevUiExt};
285
286    #[test]
287    fn inactive_raw_input_hook_is_a_noop() {
288        let devmcp = DevMcp::new();
289        let ctx = Context::default();
290        let mut raw_input = egui::RawInput {
291            viewport_id: egui::ViewportId::ROOT,
292            focused: false,
293            ..Default::default()
294        };
295
296        devmcp.raw_input_hook(&ctx, &mut raw_input);
297
298        assert!(!raw_input.focused);
299        assert!(raw_input.events.is_empty());
300    }
301
302    #[test]
303    fn inactive_frame_guard_does_not_capture_context() {
304        let devmcp = DevMcp::new();
305        let ctx = Context::default();
306        instrument::reset_test_counters();
307
308        let _output = ctx.run_ui(egui::RawInput::default(), |ui| {
309            let ctx = ui.ctx().clone();
310            let _guard = FrameGuard::new(&devmcp, &ctx);
311            ui.dev_button("inactive.button", "Inactive");
312        });
313
314        assert!(devmcp.context_for(egui::ViewportId::ROOT).is_none());
315        assert_eq!(instrument::test_layout_capture_count(), 0);
316    }
317}