Skip to main content

saorsa_core/
overlay.rs

1//! Overlay management for modal dialogs, toasts, and tooltips.
2//!
3//! Provides [`ScreenStack`] to manage a stack of overlay layers with
4//! automatic z-indexing, position resolution, and optional background dimming.
5
6use crate::compositor::Layer;
7use crate::geometry::{Position, Rect, Size};
8use crate::segment::Segment;
9use crate::style::Style;
10
11/// Unique overlay identifier.
12pub type OverlayId = u64;
13
14/// Placement of an overlay relative to an anchor element.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Placement {
17    /// Above the anchor.
18    Above,
19    /// Below the anchor.
20    Below,
21    /// Left of the anchor.
22    Left,
23    /// Right of the anchor.
24    Right,
25}
26
27/// Position strategy for an overlay.
28#[derive(Debug, Clone, PartialEq)]
29pub enum OverlayPosition {
30    /// Centered on the screen.
31    Center,
32    /// At a fixed position.
33    At(Position),
34    /// Anchored relative to a rectangle.
35    Anchored {
36        /// The anchor rectangle.
37        anchor: Rect,
38        /// Placement relative to the anchor.
39        placement: Placement,
40    },
41}
42
43/// Configuration for an overlay.
44#[derive(Debug, Clone)]
45pub struct OverlayConfig {
46    /// How the overlay should be positioned.
47    pub position: OverlayPosition,
48    /// Size of the overlay content.
49    pub size: Size,
50    /// Z-index offset from the stack's base z-index.
51    pub z_offset: i32,
52    /// Whether to insert a dim layer behind this overlay.
53    pub dim_background: bool,
54}
55
56struct OverlayEntry {
57    id: OverlayId,
58    config: OverlayConfig,
59    lines: Vec<Vec<Segment>>,
60}
61
62/// Manages a stack of overlay layers with auto z-indexing.
63///
64/// Overlays are rendered in insertion order. Each overlay receives a unique
65/// z-index spaced 10 apart from the base. Dim layers are inserted one
66/// z-level below overlays that request background dimming.
67pub struct ScreenStack {
68    overlays: Vec<OverlayEntry>,
69    next_id: OverlayId,
70    base_z: i32,
71}
72
73impl ScreenStack {
74    /// Creates an empty screen stack with base z-index 1000.
75    pub fn new() -> Self {
76        Self {
77            overlays: Vec::new(),
78            next_id: 1,
79            base_z: 1000,
80        }
81    }
82
83    /// Pushes an overlay onto the stack.
84    ///
85    /// Returns a unique [`OverlayId`] for later removal.
86    pub fn push(&mut self, config: OverlayConfig, lines: Vec<Vec<Segment>>) -> OverlayId {
87        let id = self.next_id;
88        self.next_id += 1;
89        self.overlays.push(OverlayEntry { id, config, lines });
90        id
91    }
92
93    /// Removes the topmost overlay from the stack.
94    pub fn pop(&mut self) -> Option<OverlayId> {
95        self.overlays.pop().map(|e| e.id)
96    }
97
98    /// Removes a specific overlay by ID.
99    ///
100    /// Returns `true` if the overlay was found and removed.
101    pub fn remove(&mut self, id: OverlayId) -> bool {
102        let before = self.overlays.len();
103        self.overlays.retain(|e| e.id != id);
104        self.overlays.len() < before
105    }
106
107    /// Removes all overlays.
108    pub fn clear(&mut self) {
109        self.overlays.clear();
110    }
111
112    /// Returns the number of overlays in the stack.
113    pub fn len(&self) -> usize {
114        self.overlays.len()
115    }
116
117    /// Returns `true` if the stack has no overlays.
118    pub fn is_empty(&self) -> bool {
119        self.overlays.is_empty()
120    }
121
122    /// Resolves an overlay position to absolute screen coordinates.
123    pub fn resolve_position(position: &OverlayPosition, size: Size, screen: Size) -> Position {
124        match position {
125            OverlayPosition::Center => {
126                let x = screen.width.saturating_sub(size.width) / 2;
127                let y = screen.height.saturating_sub(size.height) / 2;
128                Position::new(x, y)
129            }
130            OverlayPosition::At(pos) => *pos,
131            OverlayPosition::Anchored { anchor, placement } => match placement {
132                Placement::Above => {
133                    let x = anchor
134                        .position
135                        .x
136                        .saturating_add(anchor.size.width / 2)
137                        .saturating_sub(size.width / 2);
138                    let y = anchor.position.y.saturating_sub(size.height);
139                    Position::new(x, y)
140                }
141                Placement::Below => {
142                    let x = anchor
143                        .position
144                        .x
145                        .saturating_add(anchor.size.width / 2)
146                        .saturating_sub(size.width / 2);
147                    let y = anchor.position.y.saturating_add(anchor.size.height);
148                    Position::new(x, y)
149                }
150                Placement::Left => {
151                    let x = anchor.position.x.saturating_sub(size.width);
152                    let y = anchor
153                        .position
154                        .y
155                        .saturating_add(anchor.size.height / 2)
156                        .saturating_sub(size.height / 2);
157                    Position::new(x, y)
158                }
159                Placement::Right => {
160                    let x = anchor.position.x.saturating_add(anchor.size.width);
161                    let y = anchor
162                        .position
163                        .y
164                        .saturating_add(anchor.size.height / 2)
165                        .saturating_sub(size.height / 2);
166                    Position::new(x, y)
167                }
168            },
169        }
170    }
171
172    /// Applies all overlays as layers to a compositor.
173    ///
174    /// Each overlay is added with its resolved position and z-index.
175    /// Overlays with `dim_background` get a full-screen dim layer inserted
176    /// one z-level below them.
177    pub fn apply_to_compositor(
178        &self,
179        compositor: &mut crate::compositor::Compositor,
180        screen: Size,
181    ) {
182        for (i, entry) in self.overlays.iter().enumerate() {
183            let z = self.base_z + (i as i32) * 10 + entry.config.z_offset;
184
185            if entry.config.dim_background {
186                compositor.add_layer(create_dim_layer(screen, z - 1));
187            }
188
189            let pos = Self::resolve_position(&entry.config.position, entry.config.size, screen);
190            let region = Rect::new(
191                pos.x,
192                pos.y,
193                entry.config.size.width,
194                entry.config.size.height,
195            );
196            compositor.add_layer(Layer::new(entry.id, region, z, entry.lines.clone()));
197        }
198    }
199}
200
201impl Default for ScreenStack {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// Creates a full-screen dim layer for background dimming.
208pub fn create_dim_layer(screen: Size, z_index: i32) -> Layer {
209    let dim_style = Style::new().dim(true);
210    let mut lines = Vec::new();
211    for _ in 0..screen.height {
212        lines.push(vec![Segment::styled(
213            " ".repeat(screen.width as usize),
214            dim_style.clone(),
215        )]);
216    }
217    Layer::new(
218        0,
219        Rect::new(0, 0, screen.width, screen.height),
220        z_index,
221        lines,
222    )
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn empty_stack() {
231        let stack = ScreenStack::new();
232        assert!(stack.is_empty());
233        assert!(stack.is_empty());
234    }
235
236    #[test]
237    fn push_increments_len() {
238        let mut stack = ScreenStack::new();
239        let config = OverlayConfig {
240            position: OverlayPosition::Center,
241            size: Size::new(10, 5),
242            z_offset: 0,
243            dim_background: false,
244        };
245        let id = stack.push(config, vec![vec![Segment::new("hi")]]);
246        assert!(id == 1);
247        assert!(stack.len() == 1);
248    }
249
250    #[test]
251    fn pop_returns_topmost() {
252        let mut stack = ScreenStack::new();
253        let config = OverlayConfig {
254            position: OverlayPosition::Center,
255            size: Size::new(10, 5),
256            z_offset: 0,
257            dim_background: false,
258        };
259        let _id1 = stack.push(config.clone(), vec![]);
260        let id2 = stack.push(config, vec![]);
261        assert!(stack.pop() == Some(id2));
262        assert!(stack.len() == 1);
263    }
264
265    #[test]
266    fn pop_empty_returns_none() {
267        let mut stack = ScreenStack::new();
268        assert!(stack.pop().is_none());
269    }
270
271    #[test]
272    fn remove_by_id() {
273        let mut stack = ScreenStack::new();
274        let config = OverlayConfig {
275            position: OverlayPosition::Center,
276            size: Size::new(10, 5),
277            z_offset: 0,
278            dim_background: false,
279        };
280        let id1 = stack.push(config.clone(), vec![]);
281        let _id2 = stack.push(config, vec![]);
282        assert!(stack.remove(id1));
283        assert!(stack.len() == 1);
284    }
285
286    #[test]
287    fn remove_nonexistent_returns_false() {
288        let mut stack = ScreenStack::new();
289        assert!(!stack.remove(999));
290    }
291
292    #[test]
293    fn clear_removes_all() {
294        let mut stack = ScreenStack::new();
295        let config = OverlayConfig {
296            position: OverlayPosition::Center,
297            size: Size::new(10, 5),
298            z_offset: 0,
299            dim_background: false,
300        };
301        stack.push(config.clone(), vec![]);
302        stack.push(config, vec![]);
303        stack.clear();
304        assert!(stack.is_empty());
305    }
306
307    #[test]
308    fn resolve_center() {
309        let pos = ScreenStack::resolve_position(
310            &OverlayPosition::Center,
311            Size::new(20, 10),
312            Size::new(80, 24),
313        );
314        assert!(pos.x == 30);
315        assert!(pos.y == 7);
316    }
317
318    #[test]
319    fn resolve_at() {
320        let pos = ScreenStack::resolve_position(
321            &OverlayPosition::At(Position::new(5, 3)),
322            Size::new(20, 10),
323            Size::new(80, 24),
324        );
325        assert!(pos.x == 5);
326        assert!(pos.y == 3);
327    }
328
329    #[test]
330    fn resolve_anchored_below() {
331        let anchor = Rect::new(30, 5, 10, 2);
332        let pos = ScreenStack::resolve_position(
333            &OverlayPosition::Anchored {
334                anchor,
335                placement: Placement::Below,
336            },
337            Size::new(20, 3),
338            Size::new(80, 24),
339        );
340        // x centered: 30 + 5 - 10 = 25
341        assert!(pos.x == 25);
342        // y below: 5 + 2 = 7
343        assert!(pos.y == 7);
344    }
345
346    #[test]
347    fn resolve_anchored_above() {
348        let anchor = Rect::new(30, 10, 10, 2);
349        let pos = ScreenStack::resolve_position(
350            &OverlayPosition::Anchored {
351                anchor,
352                placement: Placement::Above,
353            },
354            Size::new(20, 3),
355            Size::new(80, 24),
356        );
357        assert!(pos.x == 25);
358        assert!(pos.y == 7); // 10 - 3 = 7
359    }
360
361    #[test]
362    fn resolve_anchored_right() {
363        let anchor = Rect::new(10, 10, 5, 4);
364        let pos = ScreenStack::resolve_position(
365            &OverlayPosition::Anchored {
366                anchor,
367                placement: Placement::Right,
368            },
369            Size::new(8, 3),
370            Size::new(80, 24),
371        );
372        assert!(pos.x == 15); // 10 + 5
373        assert!(pos.y == 11); // 10 + 2 - 1
374    }
375
376    #[test]
377    fn dim_layer_covers_screen() {
378        let layer = create_dim_layer(Size::new(80, 24), 999);
379        assert!(layer.z_index == 999);
380        assert!(layer.region.size.width == 80);
381        assert!(layer.region.size.height == 24);
382        assert!(layer.lines.len() == 24);
383    }
384
385    #[test]
386    fn dim_layer_style_is_dim() {
387        let layer = create_dim_layer(Size::new(10, 2), 500);
388        assert!(layer.lines.len() == 2);
389        assert!(layer.lines[0][0].style.dim);
390    }
391
392    #[test]
393    fn apply_to_compositor_adds_layers() {
394        let mut stack = ScreenStack::new();
395        let config = OverlayConfig {
396            position: OverlayPosition::Center,
397            size: Size::new(10, 3),
398            z_offset: 0,
399            dim_background: false,
400        };
401        stack.push(config, vec![vec![Segment::new("test")]]);
402
403        let mut compositor = crate::compositor::Compositor::new(80, 24);
404        stack.apply_to_compositor(&mut compositor, Size::new(80, 24));
405
406        let mut buf = crate::buffer::ScreenBuffer::new(Size::new(80, 24));
407        compositor.compose(&mut buf);
408
409        // Check content appears near center (x=35, y=10)
410        match buf.get(35, 10) {
411            Some(cell) => assert!(cell.grapheme == "t"),
412            None => unreachable!(),
413        }
414    }
415
416    #[test]
417    fn apply_with_dim_background() {
418        let mut stack = ScreenStack::new();
419        let config = OverlayConfig {
420            position: OverlayPosition::At(Position::new(5, 5)),
421            size: Size::new(10, 3),
422            z_offset: 0,
423            dim_background: true,
424        };
425        stack.push(config, vec![vec![Segment::new("modal")]]);
426
427        let mut compositor = crate::compositor::Compositor::new(80, 24);
428        stack.apply_to_compositor(&mut compositor, Size::new(80, 24));
429
430        let mut buf = crate::buffer::ScreenBuffer::new(Size::new(80, 24));
431        compositor.compose(&mut buf);
432
433        // Corner should have dim style from dim layer
434        match buf.get(0, 0) {
435            Some(cell) => assert!(cell.style.dim),
436            None => unreachable!(),
437        }
438        // Overlay content should be visible
439        match buf.get(5, 5) {
440            Some(cell) => assert!(cell.grapheme == "m"),
441            None => unreachable!(),
442        }
443    }
444
445    // --- Integration tests: full overlay pipeline ---
446
447    #[test]
448    fn modal_centered_on_screen() {
449        use crate::widget::modal::Modal;
450
451        let modal = Modal::new("Test", 20, 5);
452        let lines = modal.render_to_lines();
453        let config = modal.to_overlay_config();
454
455        let mut stack = ScreenStack::new();
456        stack.push(config, lines);
457
458        let screen = Size::new(80, 24);
459        let mut compositor = crate::compositor::Compositor::new(80, 24);
460        stack.apply_to_compositor(&mut compositor, screen);
461
462        let mut buf = crate::buffer::ScreenBuffer::new(screen);
463        compositor.compose(&mut buf);
464
465        // Center x = (80-20)/2 = 30, y = (24-5)/2 = 9
466        // Top-left corner should be the border character
467        match buf.get(30, 9) {
468            Some(cell) => assert!(cell.grapheme == "┌"),
469            None => unreachable!(),
470        }
471    }
472
473    #[test]
474    fn modal_with_dim_background_pipeline() {
475        use crate::widget::modal::Modal;
476
477        let modal = Modal::new("Dim", 20, 5);
478        let lines = modal.render_to_lines();
479        let config = modal.to_overlay_config();
480        // Confirm dim is set
481        assert!(config.dim_background);
482
483        let mut stack = ScreenStack::new();
484        stack.push(config, lines);
485
486        let screen = Size::new(80, 24);
487        let mut compositor = crate::compositor::Compositor::new(80, 24);
488        stack.apply_to_compositor(&mut compositor, screen);
489
490        let mut buf = crate::buffer::ScreenBuffer::new(screen);
491        compositor.compose(&mut buf);
492
493        // Corner (outside modal) should have dim style
494        match buf.get(0, 0) {
495            Some(cell) => assert!(cell.style.dim),
496            None => unreachable!(),
497        }
498    }
499
500    #[test]
501    fn toast_at_top_right_pipeline() {
502        use crate::widget::toast::Toast;
503
504        let toast = Toast::new("Saved!").with_width(10);
505        let lines = toast.render_to_lines();
506        let screen = Size::new(80, 24);
507        let config = toast.to_overlay_config(screen);
508
509        let mut stack = ScreenStack::new();
510        stack.push(config, lines);
511
512        let mut compositor = crate::compositor::Compositor::new(80, 24);
513        stack.apply_to_compositor(&mut compositor, screen);
514
515        let mut buf = crate::buffer::ScreenBuffer::new(screen);
516        compositor.compose(&mut buf);
517
518        // Toast at top-right: x = 80-10 = 70, y = 0
519        match buf.get(70, 0) {
520            Some(cell) => assert!(cell.grapheme == "S"),
521            None => unreachable!(),
522        }
523    }
524
525    #[test]
526    fn tooltip_below_anchor_pipeline() {
527        use crate::overlay::Placement;
528        use crate::widget::tooltip::Tooltip;
529
530        let anchor = Rect::new(30, 5, 10, 2);
531        let tooltip = Tooltip::new("hint", anchor).with_placement(Placement::Below);
532        let lines = tooltip.render_to_lines();
533        let screen = Size::new(80, 24);
534        let config = tooltip.to_overlay_config(screen);
535
536        let mut stack = ScreenStack::new();
537        stack.push(config, lines);
538
539        let mut compositor = crate::compositor::Compositor::new(80, 24);
540        stack.apply_to_compositor(&mut compositor, screen);
541
542        let mut buf = crate::buffer::ScreenBuffer::new(screen);
543        compositor.compose(&mut buf);
544
545        // Below anchor: y = 5 + 2 = 7, x centered: 30 + 5 - 2 = 33
546        match buf.get(33, 7) {
547            Some(cell) => assert!(cell.grapheme == "h"),
548            None => unreachable!(),
549        }
550    }
551
552    #[test]
553    fn two_modals_stacked() {
554        let mut stack = ScreenStack::new();
555
556        // First modal at (10, 5)
557        let config1 = OverlayConfig {
558            position: OverlayPosition::At(Position::new(10, 5)),
559            size: Size::new(10, 3),
560            z_offset: 0,
561            dim_background: false,
562        };
563        stack.push(config1, vec![vec![Segment::new("first")]]);
564
565        // Second modal at same position (on top)
566        let config2 = OverlayConfig {
567            position: OverlayPosition::At(Position::new(10, 5)),
568            size: Size::new(10, 3),
569            z_offset: 0,
570            dim_background: false,
571        };
572        stack.push(config2, vec![vec![Segment::new("second")]]);
573
574        let screen = Size::new(80, 24);
575        let mut compositor = crate::compositor::Compositor::new(80, 24);
576        stack.apply_to_compositor(&mut compositor, screen);
577
578        let mut buf = crate::buffer::ScreenBuffer::new(screen);
579        compositor.compose(&mut buf);
580
581        // Topmost (second) should be visible
582        match buf.get(10, 5) {
583            Some(cell) => assert!(cell.grapheme == "s"),
584            None => unreachable!(),
585        }
586    }
587
588    #[test]
589    fn modal_plus_toast_z_order() {
590        use crate::widget::modal::Modal;
591        use crate::widget::toast::Toast;
592
593        let modal = Modal::new("M", 20, 5);
594        let modal_lines = modal.render_to_lines();
595        let modal_config = modal.to_overlay_config();
596
597        let toast = Toast::new("Toast!").with_width(10);
598        let screen = Size::new(80, 24);
599        let toast_lines = toast.render_to_lines();
600        let toast_config = toast.to_overlay_config(screen);
601
602        let mut stack = ScreenStack::new();
603        stack.push(modal_config, modal_lines);
604        stack.push(toast_config, toast_lines);
605
606        let mut compositor = crate::compositor::Compositor::new(80, 24);
607        stack.apply_to_compositor(&mut compositor, screen);
608
609        let mut buf = crate::buffer::ScreenBuffer::new(screen);
610        compositor.compose(&mut buf);
611
612        // Toast at top-right should be visible (higher z since added second)
613        match buf.get(70, 0) {
614            Some(cell) => assert!(cell.grapheme == "T"),
615            None => unreachable!(),
616        }
617    }
618
619    #[test]
620    fn remove_modal_clears_dim() {
621        let mut stack = ScreenStack::new();
622        let config = OverlayConfig {
623            position: OverlayPosition::Center,
624            size: Size::new(10, 3),
625            z_offset: 0,
626            dim_background: true,
627        };
628        let id = stack.push(config, vec![vec![Segment::new("x")]]);
629
630        // Remove it
631        assert!(stack.remove(id));
632        assert!(stack.is_empty());
633
634        let screen = Size::new(80, 24);
635        let mut compositor = crate::compositor::Compositor::new(80, 24);
636        stack.apply_to_compositor(&mut compositor, screen);
637
638        let mut buf = crate::buffer::ScreenBuffer::new(screen);
639        compositor.compose(&mut buf);
640
641        // No dim layer should be present — corner should be blank
642        match buf.get(0, 0) {
643            Some(cell) => assert!(!cell.style.dim),
644            None => unreachable!(),
645        }
646    }
647
648    #[test]
649    fn clear_removes_all_overlays() {
650        let mut stack = ScreenStack::new();
651        let config = OverlayConfig {
652            position: OverlayPosition::At(Position::new(0, 0)),
653            size: Size::new(5, 1),
654            z_offset: 0,
655            dim_background: false,
656        };
657        stack.push(config.clone(), vec![vec![Segment::new("A")]]);
658        stack.push(config, vec![vec![Segment::new("B")]]);
659        stack.clear();
660
661        let screen = Size::new(80, 24);
662        let mut compositor = crate::compositor::Compositor::new(80, 24);
663        stack.apply_to_compositor(&mut compositor, screen);
664
665        let mut buf = crate::buffer::ScreenBuffer::new(screen);
666        compositor.compose(&mut buf);
667
668        // Should be blank (no overlays)
669        match buf.get(0, 0) {
670            Some(cell) => assert!(cell.grapheme == " "),
671            None => unreachable!(),
672        }
673    }
674}