Skip to main content

uzor_interactive/
spotlight.rs

1//! Spotlight card effect - radial gradient following cursor
2//!
3//! Creates a spotlight effect that follows the mouse cursor within
4//! card bounds. The spotlight is rendered as a radial gradient.
5
6/// Spotlight card state
7///
8/// Tracks cursor position and computes spotlight parameters for rendering.
9#[derive(Debug, Clone, Copy)]
10pub struct SpotlightCard {
11    /// Card width in pixels
12    pub width: f32,
13
14    /// Card height in pixels
15    pub height: f32,
16
17    /// Spotlight radius in pixels
18    pub radius: f32,
19
20    /// Current spotlight X position (relative to card, 0 = left edge)
21    spotlight_x: f32,
22
23    /// Current spotlight Y position (relative to card, 0 = top edge)
24    spotlight_y: f32,
25
26    /// Whether cursor is currently over the card
27    is_active: bool,
28}
29
30impl Default for SpotlightCard {
31    fn default() -> Self {
32        Self::new(400.0, 300.0)
33    }
34}
35
36impl SpotlightCard {
37    /// Create a new spotlight card
38    pub fn new(width: f32, height: f32) -> Self {
39        Self {
40            width,
41            height,
42            radius: 200.0,
43            spotlight_x: width / 2.0,
44            spotlight_y: height / 2.0,
45            is_active: false,
46        }
47    }
48
49    /// Set spotlight radius
50    pub fn with_radius(mut self, radius: f32) -> Self {
51        self.radius = radius;
52        self
53    }
54
55    /// Update card dimensions
56    pub fn set_dimensions(&mut self, width: f32, height: f32) {
57        self.width = width;
58        self.height = height;
59    }
60
61    /// Update cursor position
62    ///
63    /// # Arguments
64    /// * `cursor_x` - Cursor X position in screen/window coordinates
65    /// * `cursor_y` - Cursor Y position in screen/window coordinates
66    /// * `card_x` - Card X position in screen/window coordinates
67    /// * `card_y` - Card Y position in screen/window coordinates
68    pub fn update_cursor(
69        &mut self,
70        cursor_x: f32,
71        cursor_y: f32,
72        card_x: f32,
73        card_y: f32,
74    ) {
75        // Convert to card-relative coordinates
76        self.spotlight_x = cursor_x - card_x;
77        self.spotlight_y = cursor_y - card_y;
78
79        // Check if cursor is within card bounds
80        self.is_active = self.spotlight_x >= 0.0
81            && self.spotlight_x <= self.width
82            && self.spotlight_y >= 0.0
83            && self.spotlight_y <= self.height;
84    }
85
86    /// Set cursor position directly in card-relative coordinates
87    pub fn set_spotlight_position(&mut self, x: f32, y: f32) {
88        self.spotlight_x = x;
89        self.spotlight_y = y;
90
91        self.is_active = x >= 0.0
92            && x <= self.width
93            && y >= 0.0
94            && y <= self.height;
95    }
96
97    /// Get spotlight X position (card-relative)
98    pub fn spotlight_x(&self) -> f32 {
99        self.spotlight_x
100    }
101
102    /// Get spotlight Y position (card-relative)
103    pub fn spotlight_y(&self) -> f32 {
104        self.spotlight_y
105    }
106
107    /// Get spotlight center as (x, y) tuple
108    pub fn spotlight_center(&self) -> (f32, f32) {
109        (self.spotlight_x, self.spotlight_y)
110    }
111
112    /// Check if spotlight is active (cursor over card)
113    pub fn is_active(&self) -> bool {
114        self.is_active
115    }
116
117    /// Deactivate spotlight (e.g., when cursor leaves card)
118    pub fn deactivate(&mut self) {
119        self.is_active = false;
120    }
121
122    /// Get normalized spotlight position (0.0 to 1.0)
123    pub fn normalized_position(&self) -> (f32, f32) {
124        let x = if self.width > 0.0 {
125            (self.spotlight_x / self.width).clamp(0.0, 1.0)
126        } else {
127            0.5
128        };
129
130        let y = if self.height > 0.0 {
131            (self.spotlight_y / self.height).clamp(0.0, 1.0)
132        } else {
133            0.5
134        };
135
136        (x, y)
137    }
138
139    /// Get spotlight parameters for CSS radial-gradient
140    ///
141    /// Returns (x, y, radius, opacity) where:
142    /// - x, y: position in pixels
143    /// - radius: spotlight radius in pixels
144    /// - opacity: 0.0 if inactive, 1.0 if active
145    pub fn gradient_params(&self) -> (f32, f32, f32, f32) {
146        let opacity = if self.is_active { 1.0 } else { 0.0 };
147        (self.spotlight_x, self.spotlight_y, self.radius, opacity)
148    }
149}
150
151/// Spotlight color configuration
152#[derive(Debug, Clone, Copy)]
153pub struct SpotlightColor {
154    pub r: u8,
155    pub g: u8,
156    pub b: u8,
157    pub a: f32,
158}
159
160impl Default for SpotlightColor {
161    fn default() -> Self {
162        Self::white(0.25)
163    }
164}
165
166impl SpotlightColor {
167    /// White spotlight with given opacity
168    pub fn white(alpha: f32) -> Self {
169        Self {
170            r: 255,
171            g: 255,
172            b: 255,
173            a: alpha,
174        }
175    }
176
177    /// Create from RGBA values
178    pub fn rgba(r: u8, g: u8, b: u8, a: f32) -> Self {
179        Self { r, g, b, a }
180    }
181
182    /// Convert to CSS rgba string
183    pub fn to_css(&self) -> String {
184        format!("rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a)
185    }
186
187    /// Get color components as (r, g, b, a)
188    pub fn components(&self) -> (u8, u8, u8, f32) {
189        (self.r, self.g, self.b, self.a)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_spotlight_creation() {
199        let spotlight = SpotlightCard::new(400.0, 300.0);
200        assert_eq!(spotlight.width, 400.0);
201        assert_eq!(spotlight.height, 300.0);
202        assert!(!spotlight.is_active());
203    }
204
205    #[test]
206    fn test_cursor_update() {
207        let mut spotlight = SpotlightCard::new(400.0, 300.0);
208
209        // Cursor at (150, 100) screen, card at (50, 50)
210        // Spotlight should be at (100, 50) relative to card
211        spotlight.update_cursor(150.0, 100.0, 50.0, 50.0);
212
213        assert_eq!(spotlight.spotlight_x(), 100.0);
214        assert_eq!(spotlight.spotlight_y(), 50.0);
215        assert!(spotlight.is_active());
216    }
217
218    #[test]
219    fn test_cursor_outside_bounds() {
220        let mut spotlight = SpotlightCard::new(400.0, 300.0);
221
222        // Cursor outside card bounds (negative relative position)
223        spotlight.update_cursor(10.0, 10.0, 50.0, 50.0);
224
225        assert!(!spotlight.is_active());
226    }
227
228    #[test]
229    fn test_direct_position_set() {
230        let mut spotlight = SpotlightCard::new(400.0, 300.0);
231
232        spotlight.set_spotlight_position(200.0, 150.0);
233        assert_eq!(spotlight.spotlight_center(), (200.0, 150.0));
234        assert!(spotlight.is_active());
235
236        // Set position outside bounds
237        spotlight.set_spotlight_position(-10.0, 150.0);
238        assert!(!spotlight.is_active());
239    }
240
241    #[test]
242    fn test_normalized_position() {
243        let mut spotlight = SpotlightCard::new(400.0, 300.0);
244
245        // Center of card
246        spotlight.set_spotlight_position(200.0, 150.0);
247        let (nx, ny) = spotlight.normalized_position();
248        assert!((nx - 0.5).abs() < 0.01);
249        assert!((ny - 0.5).abs() < 0.01);
250
251        // Top-left corner
252        spotlight.set_spotlight_position(0.0, 0.0);
253        let (nx, ny) = spotlight.normalized_position();
254        assert!(nx < 0.01);
255        assert!(ny < 0.01);
256
257        // Bottom-right corner
258        spotlight.set_spotlight_position(400.0, 300.0);
259        let (nx, ny) = spotlight.normalized_position();
260        assert!((nx - 1.0).abs() < 0.01);
261        assert!((ny - 1.0).abs() < 0.01);
262    }
263
264    #[test]
265    fn test_gradient_params() {
266        let mut spotlight = SpotlightCard::new(400.0, 300.0)
267            .with_radius(150.0);
268
269        spotlight.set_spotlight_position(200.0, 150.0);
270        let (x, y, r, opacity) = spotlight.gradient_params();
271
272        assert_eq!(x, 200.0);
273        assert_eq!(y, 150.0);
274        assert_eq!(r, 150.0);
275        assert_eq!(opacity, 1.0);
276
277        // Deactivate
278        spotlight.deactivate();
279        let (_, _, _, opacity) = spotlight.gradient_params();
280        assert_eq!(opacity, 0.0);
281    }
282
283    #[test]
284    fn test_spotlight_color() {
285        let white = SpotlightColor::white(0.5);
286        assert_eq!(white.r, 255);
287        assert_eq!(white.g, 255);
288        assert_eq!(white.b, 255);
289        assert!((white.a - 0.5).abs() < 0.01);
290
291        let custom = SpotlightColor::rgba(128, 64, 32, 0.75);
292        assert_eq!(custom.r, 128);
293        assert_eq!(custom.g, 64);
294        assert_eq!(custom.b, 32);
295        assert!((custom.a - 0.75).abs() < 0.01);
296
297        let css = white.to_css();
298        assert_eq!(css, "rgba(255, 255, 255, 0.5)");
299    }
300
301    #[test]
302    fn test_dimensions_update() {
303        let mut spotlight = SpotlightCard::new(400.0, 300.0);
304
305        spotlight.set_dimensions(800.0, 600.0);
306        assert_eq!(spotlight.width, 800.0);
307        assert_eq!(spotlight.height, 600.0);
308    }
309}