Skip to main content

ratatui_widgets/block/
shadow.rs

1use alloc::sync::Arc;
2use core::hash::{Hash, Hasher};
3use core::{fmt, ptr};
4
5use ratatui_core::buffer::Buffer;
6use ratatui_core::layout::{Offset, Position, Rect};
7use ratatui_core::style::{Color, Modifier, Style, Styled};
8use ratatui_core::symbols::shade;
9use ratatui_core::widgets::Widget;
10
11/// A configurable shadow that can be rendered behind a [`Block`](crate::block::Block).
12///
13/// A [`Shadow`] is rendered in an offset area relative to the block. Its [`Style`] is applied
14/// first, then an optional cell effect can modify the affected cells, for example by filling them
15/// with a shading symbol or dimming the existing background.
16///
17/// Built-in presets:
18///
19/// - [`Shadow::overlay`] applies only style
20/// - [`Shadow::block`] fills with full block symbols
21/// - [`Shadow::light_shade`], [`Shadow::medium_shade`], and [`Shadow::dark_shade`] fill with shade
22///   symbols
23///
24/// ```plain
25/// ┌Popup─────┐
26/// │content   │▒
27/// └──────────┘▒
28///   ▒▒▒▒▒▒▒▒▒▒▒
29/// ```
30///
31/// # Custom effects
32///
33/// ```
34/// use ratatui::buffer::Buffer;
35/// use ratatui::layout::{Position, Rect};
36/// use ratatui::widgets::{Block, CellEffect, Shadow};
37///
38/// #[derive(Debug)]
39/// struct Checker;
40///
41/// impl CellEffect for Checker {
42///     fn apply(&self, shadow_area: Rect, base_area: Rect, buf: &mut Buffer) {
43///         for y in shadow_area.top()..shadow_area.bottom() {
44///             for x in shadow_area.left()..shadow_area.right() {
45///                 if base_area.contains(Position { x, y }) {
46///                     continue;
47///                 }
48///                 if (x + y) % 2 == 0 {
49///                     buf[(x, y)].set_symbol("░");
50///                 }
51///             }
52///         }
53///     }
54/// }
55///
56/// let shadow = Shadow::custom(Checker);
57/// let block = Block::bordered().shadow(shadow);
58/// ```
59#[derive(Debug, Clone, Eq)]
60pub struct Shadow {
61    effect: Effect,
62    style: Style,
63    offset: Offset,
64}
65
66/// The built-in shadow effects.
67#[derive(Debug, Clone)]
68enum Effect {
69    /// Applies no symbol changes and only keeps the shadow style.
70    Overlay,
71    /// Fills the shadow area with a single symbol.
72    Symbol(&'static str),
73    /// Applies a user-defined shadow effect.
74    Custom(Arc<dyn CellEffect>),
75}
76
77/// A cell effect that modifies the cells covered by a [`Shadow`].
78///
79/// See [`Shadow::custom`] for how to create a shadow from a custom effect.
80pub trait CellEffect: fmt::Debug {
81    /// Applies the effect to the cells in `shadow_area`.
82    fn apply(&self, shadow_area: Rect, base_area: Rect, buf: &mut Buffer);
83}
84
85impl Effect {
86    /// Applies the effect to the shadow area in the buffer.
87    fn apply(&self, shadow_area: Rect, base_area: Rect, buf: &mut Buffer) {
88        match self {
89            Self::Overlay => {}
90            Self::Symbol(symbol) => {
91                for_each_shadow_cell(shadow_area, base_area, buf, |x, y, buf| {
92                    buf[(x, y)].set_symbol(symbol);
93                });
94            }
95            Self::Custom(filter) => filter.apply(shadow_area, base_area, buf),
96        }
97    }
98}
99
100impl PartialEq for Effect {
101    fn eq(&self, other: &Self) -> bool {
102        match (self, other) {
103            (Self::Overlay, Self::Overlay) => true,
104            (Self::Symbol(lhs), Self::Symbol(rhs)) => lhs == rhs,
105            (Self::Custom(lhs), Self::Custom(rhs)) => Arc::ptr_eq(lhs, rhs),
106            _ => false,
107        }
108    }
109}
110
111impl Eq for Effect {}
112
113impl Hash for Effect {
114    fn hash<H: Hasher>(&self, state: &mut H) {
115        match self {
116            Self::Overlay => "overlay".hash(state),
117            Self::Symbol(symbol) => {
118                "symbol".hash(state);
119                symbol.hash(state);
120            }
121            Self::Custom(filter) => {
122                "custom".hash(state);
123                ptr::hash(Arc::as_ptr(filter), state);
124            }
125        }
126    }
127}
128
129impl PartialEq for Shadow {
130    fn eq(&self, other: &Self) -> bool {
131        self.effect == other.effect && self.style == other.style && self.offset == other.offset
132    }
133}
134
135impl Hash for Shadow {
136    fn hash<H: Hasher>(&self, state: &mut H) {
137        self.effect.hash(state);
138        self.style.hash(state);
139        self.offset.hash(state);
140    }
141}
142
143impl Shadow {
144    /// Creates a shadow that only applies style to the offset area.
145    ///
146    /// This leaves the existing cell symbols unchanged.
147    ///
148    /// # Example
149    ///
150    /// ```
151    /// use ratatui::style::Stylize;
152    /// use ratatui::widgets::{Block, Shadow};
153    ///
154    /// let shadow = Shadow::overlay().black().on_white();
155    /// let block = Block::bordered().shadow(shadow);
156    /// ```
157    pub fn overlay() -> Self {
158        Self {
159            effect: Effect::Overlay,
160            style: Style::default(),
161            offset: Offset::new(1, 1),
162        }
163    }
164
165    /// Creates a shadow filled with full block symbols.
166    ///
167    /// # Example
168    ///
169    /// ```
170    /// use ratatui::widgets::{Block, Shadow};
171    ///
172    /// let block = Block::bordered().shadow(Shadow::block());
173    /// ```
174    pub fn block() -> Self {
175        Self::symbol(shade::FULL)
176    }
177
178    /// Creates a shadow filled with light shade symbols.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// use ratatui::widgets::{Block, Shadow};
184    ///
185    /// let block = Block::bordered().shadow(Shadow::light_shade());
186    /// ```
187    pub fn light_shade() -> Self {
188        Self::symbol(shade::LIGHT)
189    }
190
191    /// Creates a shadow filled with medium shade symbols.
192    ///
193    /// # Example
194    ///
195    /// ```
196    /// use ratatui::widgets::{Block, Shadow};
197    ///
198    /// let block = Block::bordered().shadow(Shadow::medium_shade());
199    /// ```
200    pub fn medium_shade() -> Self {
201        Self::symbol(shade::MEDIUM)
202    }
203
204    /// Creates a shadow filled with dark shade symbols.
205    ///
206    /// # Example
207    ///
208    /// ```
209    /// use ratatui::layout::Offset;
210    /// use ratatui::style::Stylize;
211    /// use ratatui::widgets::{Block, Shadow};
212    ///
213    /// let block = Block::bordered().shadow(
214    ///     Shadow::dark_shade()
215    ///         .black()
216    ///         .on_white()
217    ///         .offset(Offset::new(2, 1)),
218    /// );
219    /// ```
220    pub fn dark_shade() -> Self {
221        Self::symbol(shade::DARK)
222    }
223
224    /// Creates a shadow filled with the given symbol.
225    ///
226    /// # Example
227    ///
228    /// ```
229    /// use ratatui::widgets::{Block, Shadow};
230    ///
231    /// let shadow = Shadow::symbol("░");
232    /// let block = Block::bordered().shadow(shadow);
233    /// ```
234    pub fn symbol(symbol: &'static str) -> Self {
235        Self {
236            effect: Effect::Symbol(symbol),
237            style: Style::default(),
238            offset: Offset::new(1, 1),
239        }
240    }
241
242    /// Creates a new shadow from a custom cell effect.
243    ///
244    /// The effect receives the shadow area, the original block area, and the target buffer. It is
245    /// called after the shadow style has been applied.
246    pub fn custom<F: CellEffect + 'static>(effect: F) -> Self {
247        Self {
248            effect: Effect::Custom(Arc::new(effect)),
249            style: Style::default(),
250            offset: Offset::new(1, 1),
251        }
252    }
253
254    /// Creates a new shadow from a custom cell effect.
255    ///
256    /// Alias for [`Shadow::custom`].
257    pub fn new<F: CellEffect + 'static>(effect: F) -> Self {
258        Self::custom(effect)
259    }
260
261    /// Sets the style applied to the shadow area.
262    #[must_use]
263    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
264        self.style = style.into();
265        self
266    }
267
268    /// Sets the shadow offset relative to the original area.
269    ///
270    /// Positive horizontal values move the shadow to the right and positive vertical values move it
271    /// downward.
272    #[must_use]
273    pub const fn offset(mut self, offset: Offset) -> Self {
274        self.offset = offset;
275        self
276    }
277}
278
279impl Default for Shadow {
280    fn default() -> Self {
281        Self::overlay()
282    }
283}
284
285impl Styled for Shadow {
286    type Item = Self;
287
288    fn style(&self) -> Style {
289        self.style
290    }
291
292    fn set_style<S: Into<Style>>(self, style: S) -> Self::Item {
293        self.style(style)
294    }
295}
296
297impl Widget for &Shadow {
298    fn render(self, area: Rect, buf: &mut Buffer) {
299        let shadow_area = area.offset(self.offset).intersection(buf.area);
300
301        // Apply style
302        for y in shadow_area.top()..shadow_area.bottom() {
303            for x in shadow_area.left()..shadow_area.right() {
304                if area.contains(Position { x, y }) {
305                    continue;
306                }
307                buf[(x, y)].set_style(self.style);
308            }
309        }
310
311        // Apply effect
312        self.effect.apply(shadow_area, area, buf);
313    }
314}
315
316/// A [`CellEffect`] that dims the shadow cells by setting the [`DIM`](Modifier::DIM) modifier.
317///
318/// If the cell background is RGB, each channel is halved. Otherwise the background is replaced
319/// with [`Color::Black`].
320#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash)]
321pub struct Dimmed;
322
323impl CellEffect for Dimmed {
324    fn apply(&self, shadow_area: Rect, base_area: Rect, buf: &mut Buffer) {
325        for_each_shadow_cell(shadow_area, base_area, buf, |x, y, buf| {
326            buf[(x, y)].modifier.insert(Modifier::DIM);
327            if let Color::Rgb(r, g, b) = buf[(x, y)].bg {
328                buf[(x, y)].bg = Color::Rgb(r / 2, g / 2, b / 2);
329            } else {
330                buf[(x, y)].bg = Color::Black;
331            }
332        });
333    }
334}
335
336/// Creates a [`Dimmed`] shadow effect.
337pub const fn dimmed() -> Dimmed {
338    Dimmed
339}
340
341/// Helper for iterating over the shadow area while skipping cells that overlap the base area.
342fn for_each_shadow_cell(
343    shadow_area: Rect,
344    base_area: Rect,
345    buf: &mut Buffer,
346    mut f: impl FnMut(u16, u16, &mut Buffer),
347) {
348    for y in shadow_area.top()..shadow_area.bottom() {
349        for x in shadow_area.left()..shadow_area.right() {
350            if base_area.contains(Position { x, y }) {
351                continue;
352            }
353            f(x, y, buf);
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use ratatui_core::buffer::Buffer;
361    use ratatui_core::layout::Rect;
362    use ratatui_core::style::{Color, Style};
363    use ratatui_core::widgets::Widget;
364    use rstest::rstest;
365
366    use super::*;
367
368    fn render_shadow(shadow: &Shadow) -> Buffer {
369        let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 4));
370        shadow.render(Rect::new(0, 0, 2, 2), &mut buffer);
371        buffer
372    }
373
374    #[test]
375    fn overlay_renders_style_without_changing_symbols() {
376        let mut buffer = Buffer::with_lines(["abcd", "efgh", "ijkl", "mnop"]);
377        let shadow = Shadow::overlay().style(Style::new().red().on_blue());
378
379        (&shadow).render(Rect::new(0, 0, 2, 2), &mut buffer);
380
381        assert_eq!(buffer[(2, 1)].symbol(), "g");
382        assert_eq!(buffer[(1, 2)].symbol(), "j");
383        assert_eq!(buffer[(2, 2)].symbol(), "k");
384        assert_eq!(buffer[(2, 1)].fg, Color::Red);
385        assert_eq!(buffer[(2, 1)].bg, Color::Blue);
386        assert_eq!(buffer[(1, 1)].fg, Color::Reset);
387        assert_eq!(buffer[(1, 1)].bg, Color::Reset);
388    }
389
390    #[rstest]
391    #[case(Shadow::symbol("$"), "$")]
392    #[case(Shadow::block(), shade::FULL)]
393    fn symbol_filters_fill_only_visible_shadow_cells(
394        #[case] shadow: Shadow,
395        #[case] symbol: &'static str,
396    ) {
397        let buffer = render_shadow(&shadow);
398
399        assert_eq!(buffer[(2, 1)].symbol(), symbol);
400        assert_eq!(buffer[(1, 2)].symbol(), symbol);
401        assert_eq!(buffer[(2, 2)].symbol(), symbol);
402        assert_eq!(buffer[(1, 1)].symbol(), " ");
403    }
404
405    #[test]
406    fn render_is_clipped_to_buffer() {
407        let mut buffer = Buffer::empty(Rect::new(0, 0, 3, 2));
408        let shadow = Shadow::symbol("#");
409
410        (&shadow).render(Rect::new(0, 0, 2, 1), &mut buffer);
411
412        assert_eq!(buffer[(2, 1)].symbol(), "#");
413    }
414
415    #[test]
416    fn custom_filter_is_applied() {
417        #[derive(Debug)]
418        struct PlusFilter;
419
420        impl CellEffect for PlusFilter {
421            fn apply(&self, shadow_area: Rect, base_area: Rect, buf: &mut Buffer) {
422                for_each_shadow_cell(shadow_area, base_area, buf, |x, y, buf| {
423                    buf[(x, y)].set_symbol("+");
424                });
425            }
426        }
427
428        let buffer = render_shadow(&Shadow::new(PlusFilter));
429
430        assert_eq!(buffer[(2, 1)].symbol(), "+");
431        assert_eq!(buffer[(1, 2)].symbol(), "+");
432        assert_eq!(buffer[(2, 2)].symbol(), "+");
433    }
434
435    #[test]
436    fn dimmed_filter_dims_background() {
437        let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 4));
438        buffer.set_style(buffer.area, Style::new().bg(Color::Rgb(100, 120, 140)));
439        let shadow = Shadow::new(dimmed());
440
441        (&shadow).render(Rect::new(0, 0, 2, 2), &mut buffer);
442
443        assert!(buffer[(2, 1)].modifier.contains(Modifier::DIM));
444        assert_eq!(buffer[(2, 1)].bg, Color::Rgb(50, 60, 70));
445        assert_eq!(buffer[(1, 1)].bg, Color::Rgb(100, 120, 140));
446        assert!(!buffer[(1, 1)].modifier.contains(Modifier::DIM));
447    }
448}