Skip to main content

tui_vfx_shadow/renderers/
cls_gradient.rs

1// <FILE>crates/tui-vfx-shadow/src/renderers/cls_gradient.rs</FILE> - <DESC>Multi-layer gradient shadow renderer</DESC>
2// <VERS>VERSION: 0.4.1</VERS>
3// <WCTX>Add +1 inset to right-edge shadow start_y for grade-underlying visual weight</WCTX>
4// <CLOG>+1 inset on both right-edge start_y and bottom-edge start_x for grade-underlying visual weight</CLOG>
5
6//! Multi-layer gradient shadow renderer.
7//!
8//! Creates softer shadows by rendering multiple layers with different colors
9//! from a theme's surface ladder, creating a visible gradient falloff effect.
10//!
11//! Since terminals don't support alpha blending, gradients must use distinct
12//! RGB colors (e.g., surface_container → surface_container_low → surface_container_lowest).
13
14use tui_vfx_types::{Cell, Color, Grid, Rect};
15
16use crate::types::ShadowConfig;
17
18/// Multi-layer gradient shadow renderer.
19///
20/// Renders shadows as multiple concentric layers with different colors,
21/// creating a visible falloff effect. Each layer is rendered slightly further
22/// from the element with a different color from the provided gradient.
23pub struct GradientRenderer;
24
25impl GradientRenderer {
26    /// Render a gradient shadow using an array of colors.
27    ///
28    /// # Arguments
29    /// * `grid` - The grid to render into
30    /// * `element_rect` - The rect of the element casting the shadow
31    /// * `config` - Shadow configuration (offset and edges used, color ignored)
32    /// * `colors` - Gradient colors from lightest (outer) to darkest (inner)
33    /// * `progress` - Animation progress 0.0-1.0
34    ///
35    /// # Example
36    /// ```ignore
37    /// // Use theme surface ladder for visible gradient
38    /// let colors = [
39    ///     theme.surface.surface_container,      // outer (lightest)
40    ///     theme.surface.surface_container_low,  // middle
41    ///     theme.surface.surface_container_lowest, // inner (darkest)
42    /// ];
43    /// GradientRenderer::render_with_colors(&mut grid, rect, &config, &colors, 1.0);
44    /// ```
45    pub fn render_with_colors<G: Grid>(
46        grid: &mut G,
47        element_rect: Rect,
48        config: &ShadowConfig,
49        colors: &[Color],
50        progress: f64,
51    ) {
52        if colors.is_empty() || progress <= 0.0 {
53            return;
54        }
55
56        let layers = colors.len();
57
58        // Convert rect fields to i32 for arithmetic with signed offsets
59        let rect_x = element_rect.x as i32;
60        let rect_y = element_rect.y as i32;
61        let rect_w = element_rect.width as i32;
62        let rect_h = element_rect.height as i32;
63
64        let ox = config.offset_x as i32;
65        let oy = config.offset_y as i32;
66        let edges = config.edges;
67
68        // Render layers from outermost to innermost (so inner layers overwrite outer)
69        // colors[0] = outermost/lightest, colors[n-1] = innermost/darkest
70        for (layer_idx, color) in colors.iter().enumerate().rev() {
71            // Layer 0 is outermost (furthest), layer n-1 is innermost (closest)
72            let layer_mult = (layers - layer_idx) as i32;
73            let layer_ox = ox * layer_mult;
74            let layer_oy = oy * layer_mult;
75
76            // Apply progress to alpha if needed
77            let layer_color = if progress < 1.0 {
78                let alpha = (color.a as f64 * progress).round() as u8;
79                color.with_alpha(alpha)
80            } else {
81                *color
82            };
83
84            // Render this layer's shadow regions
85            Self::render_layer(
86                grid,
87                rect_x,
88                rect_y,
89                rect_w,
90                rect_h,
91                layer_ox,
92                layer_oy,
93                edges,
94                layer_color,
95            );
96        }
97    }
98
99    /// Render a gradient shadow for the given element rect (legacy alpha-based).
100    ///
101    /// Note: This uses alpha variation which may not be visible in terminals.
102    /// Prefer `render_with_colors` with theme colors for visible gradients.
103    ///
104    /// # Arguments
105    /// * `grid` - The grid to render into
106    /// * `element_rect` - The rect of the element casting the shadow
107    /// * `config` - Shadow configuration
108    /// * `layers` - Number of gradient layers (1-4)
109    /// * `progress` - Animation progress 0.0-1.0
110    pub fn render<G: Grid>(
111        grid: &mut G,
112        element_rect: Rect,
113        config: &ShadowConfig,
114        layers: u8,
115        progress: f64,
116    ) {
117        let base_color = config.color_at_progress(progress);
118        if base_color.a == 0 {
119            return;
120        }
121
122        let layers = layers.clamp(1, 4) as usize;
123
124        // Convert rect fields to i32 for arithmetic with signed offsets
125        let rect_x = element_rect.x as i32;
126        let rect_y = element_rect.y as i32;
127        let rect_w = element_rect.width as i32;
128        let rect_h = element_rect.height as i32;
129
130        let ox = config.offset_x as i32;
131        let oy = config.offset_y as i32;
132        let edges = config.edges;
133
134        // Render layers from outermost to innermost (so inner layers overwrite outer)
135        for layer in (0..layers).rev() {
136            // Calculate layer offset multiplier (outer layers are further)
137            let layer_mult = (layer + 1) as i32;
138            let layer_ox = ox * layer_mult;
139            let layer_oy = oy * layer_mult;
140
141            // Calculate layer color (outer layers are lighter/more transparent)
142            let intensity = 1.0 - (layer as f32 / layers as f32);
143            let layer_alpha = (base_color.a as f32 * intensity).round() as u8;
144            let layer_color = base_color.with_alpha(layer_alpha);
145
146            // Render this layer's shadow regions
147            Self::render_layer(
148                grid,
149                rect_x,
150                rect_y,
151                rect_w,
152                rect_h,
153                layer_ox,
154                layer_oy,
155                edges,
156                layer_color,
157            );
158        }
159    }
160
161    /// Render a single shadow layer.
162    #[allow(clippy::too_many_arguments)]
163    fn render_layer<G: Grid>(
164        grid: &mut G,
165        rect_x: i32,
166        rect_y: i32,
167        rect_w: i32,
168        rect_h: i32,
169        ox: i32,
170        oy: i32,
171        edges: crate::types::ShadowEdges,
172        color: Color,
173    ) {
174        if color.a == 0 {
175            return;
176        }
177
178        let cell = Cell::new(' ').with_bg(color).with_mod_alpha(Some(255));
179
180        // Right edge shadow
181        if edges.has_right() && ox > 0 {
182            let start_x = (rect_x + rect_w).max(0) as usize;
183            let end_x = (rect_x + rect_w + ox).max(0) as usize;
184            // +1 inset: start shadow 1 row below element top for grade-underlying visual weight
185            // TODO: plumb inset_x/inset_y through ShadowConfig when tunability is needed
186            let start_y = (rect_y + oy.max(0) + 1).max(0) as usize;
187            let end_y = (rect_y + rect_h + oy.min(0)).max(0) as usize;
188
189            Self::fill_region(
190                grid,
191                start_x,
192                start_y,
193                end_x.saturating_sub(start_x),
194                end_y.saturating_sub(start_y),
195                cell,
196            );
197        }
198
199        // Bottom edge shadow
200        if edges.has_bottom() && oy > 0 {
201            // +1 inset: start shadow 1 col right of element left for grade-underlying visual weight
202            // TODO: plumb inset_x/inset_y through ShadowConfig when tunability is needed
203            let start_x = (rect_x + ox.max(0) + 1).max(0) as usize;
204            let end_x = (rect_x + rect_w + ox.min(0)).max(0) as usize;
205            let start_y = (rect_y + rect_h).max(0) as usize;
206            let end_y = (rect_y + rect_h + oy).max(0) as usize;
207
208            Self::fill_region(
209                grid,
210                start_x,
211                start_y,
212                end_x.saturating_sub(start_x),
213                end_y.saturating_sub(start_y),
214                cell,
215            );
216        }
217
218        // Left edge shadow
219        if edges.has_left() && ox < 0 {
220            let start_x = (rect_x + ox).max(0) as usize;
221            let end_x = rect_x.max(0) as usize;
222            let start_y = (rect_y + oy.max(0)).max(0) as usize;
223            let end_y = (rect_y + rect_h + oy.min(0)).max(0) as usize;
224
225            Self::fill_region(
226                grid,
227                start_x,
228                start_y,
229                end_x.saturating_sub(start_x),
230                end_y.saturating_sub(start_y),
231                cell,
232            );
233        }
234
235        // Top edge shadow
236        if edges.has_top() && oy < 0 {
237            let start_x = (rect_x + ox.max(0)).max(0) as usize;
238            let end_x = (rect_x + rect_w + ox.min(0)).max(0) as usize;
239            let start_y = (rect_y + oy).max(0) as usize;
240            let end_y = rect_y.max(0) as usize;
241
242            Self::fill_region(
243                grid,
244                start_x,
245                start_y,
246                end_x.saturating_sub(start_x),
247                end_y.saturating_sub(start_y),
248                cell,
249            );
250        }
251
252        // Corner region (bottom-right for positive offset)
253        if edges.has_right() && edges.has_bottom() && ox > 0 && oy > 0 {
254            let start_x = (rect_x + rect_w).max(0) as usize;
255            let start_y = (rect_y + rect_h).max(0) as usize;
256
257            Self::fill_region(grid, start_x, start_y, ox as usize, oy as usize, cell);
258        }
259
260        // Corner region (top-left for negative offset)
261        if edges.has_left() && edges.has_top() && ox < 0 && oy < 0 {
262            let start_x = (rect_x + ox).max(0) as usize;
263            let start_y = (rect_y + oy).max(0) as usize;
264
265            Self::fill_region(grid, start_x, start_y, (-ox) as usize, (-oy) as usize, cell);
266        }
267    }
268
269    /// Fill a rectangular region with a cell.
270    fn fill_region<G: Grid>(grid: &mut G, x: usize, y: usize, w: usize, h: usize, cell: Cell) {
271        for dy in 0..h {
272            for dx in 0..w {
273                let px = x + dx;
274                let py = y + dy;
275                if grid.in_bounds(px, py) {
276                    grid.set(px, py, cell);
277                }
278            }
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::types::ShadowEdges;
287    use tui_vfx_types::OwnedGrid;
288
289    #[test]
290    fn test_render_single_layer() {
291        let mut grid = OwnedGrid::new(30, 15);
292        let rect = Rect::new(5, 2, 10, 6);
293        let config = ShadowConfig::new(Color::BLACK.with_alpha(200))
294            .with_offset(1, 1)
295            .with_edges(ShadowEdges::BOTTOM_RIGHT);
296
297        GradientRenderer::render(&mut grid, rect, &config, 1, 1.0);
298
299        // Check that shadow exists (y=4 due to +1 inset on right edge)
300        let cell = grid.get(15, 4).unwrap();
301        assert_ne!(cell.bg, Color::TRANSPARENT);
302    }
303
304    #[test]
305    fn test_render_multiple_layers() {
306        let mut grid = OwnedGrid::new(30, 15);
307        let rect = Rect::new(5, 2, 10, 6);
308        let config = ShadowConfig::new(Color::BLACK.with_alpha(200))
309            .with_offset(1, 1)
310            .with_edges(ShadowEdges::BOTTOM_RIGHT);
311
312        GradientRenderer::render(&mut grid, rect, &config, 3, 1.0);
313
314        // Outer layer should be lighter (lower alpha)
315        let outer_cell = grid.get(17, 9).unwrap(); // Further out
316        let inner_cell = grid.get(15, 8).unwrap(); // Closer in
317
318        // Both should have some shadow
319        assert_ne!(outer_cell.bg, Color::TRANSPARENT);
320        assert_ne!(inner_cell.bg, Color::TRANSPARENT);
321    }
322
323    #[test]
324    fn test_zero_progress_renders_nothing() {
325        let mut grid = OwnedGrid::new(30, 15);
326        let rect = Rect::new(5, 2, 10, 6);
327        let config = ShadowConfig::new(Color::BLACK);
328
329        GradientRenderer::render(&mut grid, rect, &config, 3, 0.0);
330
331        // All cells should be default
332        for y in 0..15 {
333            for x in 0..30 {
334                let cell = grid.get(x, y).unwrap();
335                assert_eq!(cell.bg, Color::TRANSPARENT);
336            }
337        }
338    }
339}
340
341// <FILE>crates/tui-vfx-shadow/src/renderers/cls_gradient.rs</FILE> - <DESC>Multi-layer gradient shadow renderer</DESC>
342// <VERS>END OF VERSION: 0.4.1</VERS>