viewpoint_core/page/emulation/
mod.rs

1//! Page emulation features for media and vision deficiency emulation.
2
3use std::sync::Arc;
4use tracing::{debug, info, instrument};
5
6use viewpoint_cdp::protocol::emulation::{
7    MediaFeature, SetDeviceMetricsOverrideParams, SetEmulatedMediaParams,
8    SetEmulatedVisionDeficiencyParams, ViewportSize, VisionDeficiency as CdpVisionDeficiency,
9};
10use viewpoint_cdp::CdpConnection;
11
12use super::Page;
13use crate::context::{ColorScheme, ForcedColors, ReducedMotion};
14use crate::error::PageError;
15
16/// Media type for CSS media emulation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum MediaType {
19    /// Screen media type.
20    Screen,
21    /// Print media type.
22    Print,
23}
24
25impl MediaType {
26    /// Convert to CSS media type string.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            Self::Screen => "screen",
30            Self::Print => "print",
31        }
32    }
33}
34
35/// Vision deficiency types for accessibility testing.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum VisionDeficiency {
38    /// No vision deficiency emulation (normal vision).
39    #[default]
40    None,
41    /// Achromatopsia - complete color blindness.
42    Achromatopsia,
43    /// Blurred vision.
44    BlurredVision,
45    /// Deuteranopia - green-blind (red-green color blindness).
46    Deuteranopia,
47    /// Protanopia - red-blind (red-green color blindness).
48    Protanopia,
49    /// Tritanopia - blue-blind (blue-yellow color blindness).
50    Tritanopia,
51}
52
53impl From<VisionDeficiency> for CdpVisionDeficiency {
54    fn from(deficiency: VisionDeficiency) -> Self {
55        match deficiency {
56            VisionDeficiency::None => CdpVisionDeficiency::None,
57            VisionDeficiency::Achromatopsia => CdpVisionDeficiency::Achromatopsia,
58            VisionDeficiency::BlurredVision => CdpVisionDeficiency::BlurredVision,
59            VisionDeficiency::Deuteranopia => CdpVisionDeficiency::Deuteranopia,
60            VisionDeficiency::Protanopia => CdpVisionDeficiency::Protanopia,
61            VisionDeficiency::Tritanopia => CdpVisionDeficiency::Tritanopia,
62        }
63    }
64}
65
66/// Builder for emulating CSS media features on a page.
67///
68/// Use this to test how your application responds to different media preferences
69/// like dark mode, print media, reduced motion, and forced colors.
70///
71/// # Example
72///
73/// ```ignore
74/// // Emulate dark mode
75/// page.emulate_media()
76///     .color_scheme(ColorScheme::Dark)
77///     .apply()
78///     .await?;
79///
80/// // Emulate print media
81/// page.emulate_media()
82///     .media(MediaType::Print)
83///     .apply()
84///     .await?;
85///
86/// // Combine multiple settings
87/// page.emulate_media()
88///     .color_scheme(ColorScheme::Dark)
89///     .reduced_motion(ReducedMotion::Reduce)
90///     .apply()
91///     .await?;
92///
93/// // Clear all media emulation
94/// page.emulate_media()
95///     .clear()
96///     .await?;
97/// ```
98#[derive(Debug)]
99pub struct EmulateMediaBuilder<'a> {
100    connection: &'a Arc<CdpConnection>,
101    session_id: &'a str,
102    media: Option<MediaType>,
103    color_scheme: Option<ColorScheme>,
104    reduced_motion: Option<ReducedMotion>,
105    forced_colors: Option<ForcedColors>,
106}
107
108impl<'a> EmulateMediaBuilder<'a> {
109    /// Create a new emulate media builder.
110    pub(crate) fn new(connection: &'a Arc<CdpConnection>, session_id: &'a str) -> Self {
111        Self {
112            connection,
113            session_id,
114            media: None,
115            color_scheme: None,
116            reduced_motion: None,
117            forced_colors: None,
118        }
119    }
120
121    /// Set the CSS media type (screen or print).
122    #[must_use]
123    pub fn media(mut self, media: MediaType) -> Self {
124        self.media = Some(media);
125        self
126    }
127
128    /// Set the color scheme preference.
129    ///
130    /// This affects CSS `prefers-color-scheme` media queries.
131    #[must_use]
132    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
133        self.color_scheme = Some(color_scheme);
134        self
135    }
136
137    /// Set the reduced motion preference.
138    ///
139    /// This affects CSS `prefers-reduced-motion` media queries.
140    #[must_use]
141    pub fn reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
142        self.reduced_motion = Some(reduced_motion);
143        self
144    }
145
146    /// Set the forced colors preference.
147    ///
148    /// This affects CSS `forced-colors` media queries.
149    #[must_use]
150    pub fn forced_colors(mut self, forced_colors: ForcedColors) -> Self {
151        self.forced_colors = Some(forced_colors);
152        self
153    }
154
155    /// Apply the media emulation settings.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the CDP command fails.
160    #[instrument(level = "debug", skip(self))]
161    pub async fn apply(self) -> Result<(), PageError> {
162        let mut features = Vec::new();
163
164        if let Some(color_scheme) = self.color_scheme {
165            features.push(MediaFeature {
166                name: "prefers-color-scheme".to_string(),
167                value: match color_scheme {
168                    ColorScheme::Light => "light".to_string(),
169                    ColorScheme::Dark => "dark".to_string(),
170                    ColorScheme::NoPreference => "no-preference".to_string(),
171                },
172            });
173        }
174
175        if let Some(reduced_motion) = self.reduced_motion {
176            features.push(MediaFeature {
177                name: "prefers-reduced-motion".to_string(),
178                value: match reduced_motion {
179                    ReducedMotion::Reduce => "reduce".to_string(),
180                    ReducedMotion::NoPreference => "no-preference".to_string(),
181                },
182            });
183        }
184
185        if let Some(forced_colors) = self.forced_colors {
186            features.push(MediaFeature {
187                name: "forced-colors".to_string(),
188                value: match forced_colors {
189                    ForcedColors::Active => "active".to_string(),
190                    ForcedColors::None => "none".to_string(),
191                },
192            });
193        }
194
195        let media = self.media.map(|m| m.as_str().to_string());
196        let features_opt = if features.is_empty() { None } else { Some(features) };
197
198        debug!(
199            media = ?media,
200            features_count = features_opt.as_ref().map_or(0, std::vec::Vec::len),
201            "Applying media emulation"
202        );
203
204        self.connection
205            .send_command::<_, serde_json::Value>(
206                "Emulation.setEmulatedMedia",
207                Some(SetEmulatedMediaParams {
208                    media,
209                    features: features_opt,
210                }),
211                Some(self.session_id),
212            )
213            .await?;
214
215        Ok(())
216    }
217
218    /// Clear all media emulation.
219    ///
220    /// This resets media type and all media features to their defaults.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the CDP command fails.
225    #[instrument(level = "debug", skip(self))]
226    pub async fn clear(self) -> Result<(), PageError> {
227        debug!("Clearing media emulation");
228
229        self.connection
230            .send_command::<_, serde_json::Value>(
231                "Emulation.setEmulatedMedia",
232                Some(SetEmulatedMediaParams {
233                    media: Some(String::new()),  // Empty string clears media type
234                    features: Some(Vec::new()),  // Empty array clears features
235                }),
236                Some(self.session_id),
237            )
238            .await?;
239
240        Ok(())
241    }
242}
243
244/// Emulate a vision deficiency on the page (implementation).
245///
246/// This is useful for accessibility testing to ensure your application
247/// is usable by people with various types of color blindness.
248///
249/// # Errors
250///
251/// Returns an error if the CDP command fails.
252#[instrument(level = "debug", skip(connection))]
253async fn emulate_vision_deficiency_impl(
254    connection: &Arc<CdpConnection>,
255    session_id: &str,
256    deficiency: VisionDeficiency,
257) -> Result<(), PageError> {
258    debug!(?deficiency, "Emulating vision deficiency");
259
260    connection
261        .send_command::<_, serde_json::Value>(
262            "Emulation.setEmulatedVisionDeficiency",
263            Some(SetEmulatedVisionDeficiencyParams::new(deficiency.into())),
264            Some(session_id),
265        )
266        .await?;
267
268    Ok(())
269}
270
271// =============================================================================
272// Page impl for viewport and emulation methods
273// =============================================================================
274
275impl Page {
276    // =========================================================================
277    // Viewport Methods
278    // =========================================================================
279
280    /// Get the current viewport size.
281    ///
282    /// Returns the width and height in pixels.
283    pub fn viewport_size(&self) -> Option<ViewportSize> {
284        // This would need to be tracked during page creation
285        // For now, return None to indicate it's not set
286        None
287    }
288
289    /// Set the viewport size.
290    ///
291    /// # Example
292    ///
293    /// ```no_run
294    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
295    /// page.set_viewport_size(1280, 720).await?;
296    /// # Ok(())
297    /// # }
298    /// ```
299    #[instrument(level = "info", skip(self), fields(width = width, height = height))]
300    pub async fn set_viewport_size(&self, width: i32, height: i32) -> Result<(), PageError> {
301        if self.closed {
302            return Err(PageError::Closed);
303        }
304
305        self.connection
306            .send_command::<_, serde_json::Value>(
307                "Emulation.setDeviceMetricsOverride",
308                Some(SetDeviceMetricsOverrideParams {
309                    width,
310                    height,
311                    device_scale_factor: 1.0,
312                    mobile: false,
313                    scale: None,
314                    screen_width: None,
315                    screen_height: None,
316                    position_x: None,
317                    position_y: None,
318                    dont_set_visible_size: None,
319                    screen_orientation: None,
320                    viewport: None,
321                    display_feature: None,
322                    device_posture: None,
323                }),
324                Some(&self.session_id),
325            )
326            .await?;
327
328        info!("Viewport size set to {}x{}", width, height);
329        Ok(())
330    }
331
332    /// Bring this page to the front (activate the tab).
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the page is closed or the CDP command fails.
337    #[instrument(level = "info", skip(self))]
338    pub async fn bring_to_front(&self) -> Result<(), PageError> {
339        if self.closed {
340            return Err(PageError::Closed);
341        }
342
343        self.connection
344            .send_command::<_, serde_json::Value>(
345                "Page.bringToFront",
346                None::<()>,
347                Some(&self.session_id),
348            )
349            .await?;
350
351        info!("Page brought to front");
352        Ok(())
353    }
354
355    // =========================================================================
356    // Emulation Methods
357    // =========================================================================
358
359    /// Create a builder for emulating CSS media features.
360    ///
361    /// Use this to test how your application responds to different media preferences
362    /// like dark mode, print media, reduced motion, and forced colors.
363    ///
364    /// # Example
365    ///
366    /// ```no_run
367    /// use viewpoint_core::{Page, page::MediaType};
368    /// use viewpoint_core::{ColorScheme, ReducedMotion};
369    ///
370    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
371    /// // Emulate dark mode
372    /// page.emulate_media()
373    ///     .color_scheme(ColorScheme::Dark)
374    ///     .apply()
375    ///     .await?;
376    ///
377    /// // Emulate print media
378    /// page.emulate_media()
379    ///     .media(MediaType::Print)
380    ///     .apply()
381    ///     .await?;
382    ///
383    /// // Combine multiple settings
384    /// page.emulate_media()
385    ///     .color_scheme(ColorScheme::Dark)
386    ///     .reduced_motion(ReducedMotion::Reduce)
387    ///     .apply()
388    ///     .await?;
389    ///
390    /// // Clear all media emulation
391    /// page.emulate_media()
392    ///     .clear()
393    ///     .await?;
394    /// # Ok(())
395    /// # }
396    /// ```
397    pub fn emulate_media(&self) -> EmulateMediaBuilder<'_> {
398        EmulateMediaBuilder::new(&self.connection, &self.session_id)
399    }
400
401    /// Emulate a vision deficiency on the page.
402    ///
403    /// This is useful for accessibility testing to ensure your application
404    /// is usable by people with various types of color blindness.
405    ///
406    /// # Example
407    ///
408    /// ```no_run
409    /// use viewpoint_core::{Page, page::VisionDeficiency};
410    ///
411    /// # async fn example(page: Page) -> Result<(), viewpoint_core::CoreError> {
412    /// // Emulate deuteranopia (green-blind)
413    /// page.emulate_vision_deficiency(VisionDeficiency::Deuteranopia).await?;
414    ///
415    /// // Clear vision deficiency emulation
416    /// page.emulate_vision_deficiency(VisionDeficiency::None).await?;
417    /// # Ok(())
418    /// # }
419    /// ```
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the page is closed or the CDP command fails.
424    pub async fn emulate_vision_deficiency(
425        &self,
426        deficiency: VisionDeficiency,
427    ) -> Result<(), PageError> {
428        if self.closed {
429            return Err(PageError::Closed);
430        }
431        emulate_vision_deficiency_impl(&self.connection, &self.session_id, deficiency).await
432    }
433}
434
435#[cfg(test)]
436mod tests;