Skip to main content

rpi_tui/
overlay.rs

1//! Overlay system for modal dialogs and selectors.
2//!
3//! Provides overlay components that can be shown on top of the main content.
4
5use std::any::Any;
6use std::sync::{Arc, Mutex};
7
8use super::component::Component;
9use super::tui::{OverlayAnchor, OverlayOptions, SizeValue};
10use crate::ansi::{bold, dim, visible_width};
11
12pub(crate) struct RenderedOverlay {
13    pub lines: Vec<String>,
14    pub width: usize,
15    pub x: usize,
16    pub y: usize,
17}
18
19/// Resolve geometry before rendering so wrapping uses the actual overlay
20/// width. Clamp offsets and height to keep the entire overlay on screen.
21pub(crate) fn render_overlay(
22    component: &dyn Component,
23    options: &OverlayOptions,
24    width: usize,
25    height: usize,
26) -> Option<RenderedOverlay> {
27    if width == 0
28        || height == 0
29        || options
30            .visible
31            .is_some_and(|visible| !visible(width, height))
32    {
33        return None;
34    }
35    let margin = options.margin.unwrap_or_default();
36    let available_width = width.saturating_sub(margin.left.saturating_add(margin.right));
37    let available_height = height.saturating_sub(margin.top.saturating_add(margin.bottom));
38    if available_width == 0 || available_height == 0 {
39        return None;
40    }
41    let size = |value: SizeValue, total: usize| match value {
42        SizeValue::Absolute(value) => value,
43        SizeValue::Percent(value) => (total as f64 * value).round() as usize,
44    };
45    let requested_width = options
46        .width
47        .map(|value| size(value, width))
48        .unwrap_or(available_width)
49        .max(options.min_width.unwrap_or(0))
50        .clamp(1, available_width);
51    let mut lines = component.render(requested_width);
52    let overlay_width = if options.width.is_some() {
53        requested_width
54    } else {
55        lines
56            .iter()
57            .map(|row| visible_width(row))
58            .max()
59            .unwrap_or(1)
60            .max(options.min_width.unwrap_or(0))
61            .clamp(1, available_width)
62    };
63    let max_height = options
64        .max_height
65        .map(|value| size(value, height))
66        .unwrap_or(available_height);
67    lines.truncate(max_height.min(available_height));
68    if lines.is_empty() {
69        return None;
70    }
71    let free_x = available_width - overlay_width;
72    let free_y = available_height - lines.len();
73    let x = margin.left
74        + match options.anchor {
75            OverlayAnchor::TopLeft | OverlayAnchor::LeftCenter | OverlayAnchor::BottomLeft => 0,
76            OverlayAnchor::TopRight | OverlayAnchor::RightCenter | OverlayAnchor::BottomRight => {
77                free_x
78            }
79            _ => free_x / 2,
80        };
81    let y = margin.top
82        + match options.anchor {
83            OverlayAnchor::TopLeft | OverlayAnchor::TopCenter | OverlayAnchor::TopRight => 0,
84            OverlayAnchor::BottomLeft
85            | OverlayAnchor::BottomCenter
86            | OverlayAnchor::BottomRight => free_y,
87            _ => free_y / 2,
88        };
89    Some(RenderedOverlay {
90        lines,
91        width: overlay_width,
92        x: x.saturating_add_signed(options.offset_x as isize)
93            .clamp(margin.left, margin.left + free_x),
94        y: y.saturating_add_signed(options.offset_y as isize)
95            .clamp(margin.top, margin.top + free_y),
96    })
97}
98
99/// Overlay manager for managing multiple overlays.
100pub struct OverlayManager {
101    overlays: Arc<Mutex<Vec<OverlayEntry>>>,
102    next_id: Arc<Mutex<usize>>,
103}
104
105struct OverlayEntry {
106    component: Arc<dyn Component>,
107    options: OverlayOptions,
108    visible: bool,
109    z_index: usize,
110}
111
112// Clone implementation for OverlayEntry (partial - we don't clone the component)
113impl Clone for OverlayEntry {
114    fn clone(&self) -> Self {
115        Self {
116            component: self.component.clone(),
117            options: self.options.clone(),
118            visible: self.visible,
119            z_index: self.z_index,
120        }
121    }
122}
123
124impl OverlayManager {
125    /// Create a new overlay manager.
126    pub fn new() -> Self {
127        Self {
128            overlays: Arc::new(Mutex::new(Vec::new())),
129            next_id: Arc::new(Mutex::new(0)),
130        }
131    }
132
133    /// Add an overlay.
134    pub fn add(&self, component: Arc<dyn Component>, options: OverlayOptions) -> OverlayHandle {
135        let id = self
136            .next_id
137            .lock()
138            .map(|mut next| {
139                let id = *next;
140                *next += 1;
141                id
142            })
143            .unwrap_or(0);
144        let entry = OverlayEntry {
145            component,
146            options,
147            visible: true,
148            z_index: id,
149        };
150
151        let handle = OverlayHandle {
152            id: entry.z_index,
153            manager: Arc::new(self.clone_manager()),
154        };
155
156        if let Ok(mut overlays) = self.overlays.lock() {
157            overlays.push(entry);
158        }
159
160        handle
161    }
162
163    /// Remove an overlay by ID.
164    pub fn remove(&self, id: usize) {
165        if let Ok(mut overlays) = self.overlays.lock() {
166            overlays.retain(|e| e.z_index != id);
167        }
168    }
169
170    /// Set overlay visibility.
171    pub fn set_visible(&self, id: usize, visible: bool) {
172        if let Ok(mut overlays) = self.overlays.lock() {
173            if let Some(entry) = overlays.iter_mut().find(|e| e.z_index == id) {
174                entry.visible = visible;
175            }
176        }
177    }
178
179    /// Get visible overlays sorted by z-index.
180    pub fn get_visible(&self) -> Vec<(Arc<dyn Component>, OverlayOptions)> {
181        self.overlays
182            .lock()
183            .map(|overlays| {
184                overlays
185                    .iter()
186                    .filter(|e| e.visible)
187                    .map(|e| (e.component.clone(), e.options.clone()))
188                    .collect()
189            })
190            .unwrap_or_default()
191    }
192
193    /// Passive panels share screen space without covering one another. Keep
194    /// modal overlays in their requested position and preserve insertion order.
195    pub(crate) fn render_visible(&self, width: usize, height: usize) -> Vec<RenderedOverlay> {
196        let mut passive = Vec::<(usize, usize, usize, usize)>::new();
197        let mut rendered = Vec::new();
198        for (component, options) in self.get_visible() {
199            let Some(mut overlay) = render_overlay(component.as_ref(), &options, width, height)
200            else {
201                continue;
202            };
203            if options.non_capturing {
204                let margin = options.margin.unwrap_or_default();
205                let first = margin.top;
206                let last = height - margin.bottom - overlay.lines.len();
207                let y = (first..=last)
208                    .filter(|&y| {
209                        passive.iter().all(|&(x, top, w, h)| {
210                            overlay.x >= x + w
211                                || x >= overlay.x + overlay.width
212                                || y >= top + h + 1
213                                || top >= y + overlay.lines.len() + 1
214                        })
215                    })
216                    .min_by_key(|&y| y.abs_diff(overlay.y));
217                let Some(y) = y else {
218                    continue;
219                };
220                overlay.y = y;
221                passive.push((overlay.x, overlay.y, overlay.width, overlay.lines.len()));
222            }
223            rendered.push(overlay);
224        }
225        rendered
226    }
227
228    /// Remove the highest z-index overlay and return whether one existed.
229    pub fn remove_topmost(&self) -> bool {
230        let Ok(mut overlays) = self.overlays.lock() else {
231            return false;
232        };
233        let Some(id) = overlays.iter().map(|entry| entry.z_index).max() else {
234            return false;
235        };
236        overlays.retain(|entry| entry.z_index != id);
237        true
238    }
239
240    fn clone_manager(&self) -> Self {
241        Self {
242            overlays: Arc::clone(&self.overlays),
243            next_id: Arc::clone(&self.next_id),
244        }
245    }
246}
247
248impl Clone for OverlayManager {
249    fn clone(&self) -> Self {
250        self.clone_manager()
251    }
252}
253
254impl Default for OverlayManager {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260/// Handle to control an overlay.
261pub struct OverlayHandle {
262    id: usize,
263    manager: Arc<OverlayManager>,
264}
265
266impl OverlayHandle {
267    /// Hide the overlay.
268    pub fn hide(&self) {
269        self.manager.remove(self.id);
270    }
271
272    /// Set visibility.
273    pub fn set_visible(&self, visible: bool) {
274        self.manager.set_visible(self.id, visible);
275    }
276
277    /// Check if visible.
278    pub fn is_visible(&self) -> bool {
279        self.manager
280            .overlays
281            .lock()
282            .map(|o| o.iter().any(|e| e.z_index == self.id && e.visible))
283            .unwrap_or(false)
284    }
285}
286
287/// Selector item.
288#[derive(Debug, Clone)]
289pub struct SelectorItem {
290    pub label: String,
291    pub value: String,
292    pub description: Option<String>,
293}
294
295/// Selector - A component for selecting from a list of items.
296pub struct Selector {
297    items: Mutex<Vec<SelectorItem>>,
298    selected: Mutex<usize>,
299    query: Mutex<String>,
300    padding_x: usize,
301    max_visible: usize,
302    on_select: Mutex<Option<Arc<dyn Fn(&SelectorItem) + Send + Sync>>>,
303}
304
305impl Selector {
306    /// Create a new selector.
307    pub fn new(items: Vec<SelectorItem>, max_visible: usize) -> Self {
308        Self {
309            items: Mutex::new(items),
310            selected: Mutex::new(0),
311            query: Mutex::new(String::new()),
312            padding_x: 1,
313            max_visible,
314            on_select: Mutex::new(None),
315        }
316    }
317
318    /// Set items.
319    pub fn set_items(&self, items: Vec<SelectorItem>) {
320        if let Ok(mut i) = self.items.lock() {
321            *i = items;
322        }
323    }
324
325    /// Get selected index.
326    pub fn selected(&self) -> usize {
327        self.selected.lock().map(|s| *s).unwrap_or(0)
328    }
329
330    /// Get selected item.
331    pub fn selected_item(&self) -> Option<SelectorItem> {
332        let items = self.items.lock().ok()?;
333        let selected = self.selected.lock().ok()?;
334        items.get(*selected).cloned()
335    }
336
337    /// Move selection up.
338    pub fn select_up(&self) {
339        if let Ok(mut selected) = self.selected.lock() {
340            if *selected > 0 {
341                *selected -= 1;
342            }
343        }
344    }
345
346    /// Move selection down.
347    pub fn select_down(&self) {
348        if let Ok(mut selected) = self.selected.lock() {
349            let items = self.items.lock();
350            if let Ok(items) = items {
351                if *selected < items.len().saturating_sub(1) {
352                    *selected += 1;
353                }
354            }
355        }
356    }
357
358    /// Set filter query.
359    pub fn set_query(&self, query: &str) {
360        if let Ok(mut q) = self.query.lock() {
361            *q = query.to_string();
362        }
363        // Reset selection
364        if let Ok(mut selected) = self.selected.lock() {
365            *selected = 0;
366        }
367    }
368
369    /// Set callback for when an item is selected.
370    pub fn on_select(&self, callback: Arc<dyn Fn(&SelectorItem) + Send + Sync>) {
371        if let Ok(mut cb) = self.on_select.lock() {
372            *cb = Some(callback);
373        }
374    }
375
376    /// Confirm selection.
377    pub fn confirm(&self) {
378        if let Some(item) = self.selected_item() {
379            if let Ok(cb) = self.on_select.lock() {
380                if let Some(callback) = cb.as_ref() {
381                    callback(&item);
382                }
383            }
384        }
385    }
386
387    /// Get filtered items based on query.
388    fn filtered_items(&self) -> Vec<(usize, SelectorItem)> {
389        let items = self.items.lock().map(|i| i.clone()).unwrap_or_default();
390        let query = self
391            .query
392            .lock()
393            .map(|q| q.to_lowercase())
394            .unwrap_or_default();
395
396        if query.is_empty() {
397            return items.into_iter().enumerate().collect();
398        }
399
400        items
401            .into_iter()
402            .enumerate()
403            .filter(|(_, item)| {
404                item.label.to_lowercase().contains(&query)
405                    || item.value.to_lowercase().contains(&query)
406                    || item
407                        .description
408                        .as_ref()
409                        .map(|d| d.to_lowercase().contains(&query))
410                        .unwrap_or(false)
411            })
412            .collect()
413    }
414}
415
416impl Component for Selector {
417    fn render(&self, width: usize) -> Vec<String> {
418        let filtered = self.filtered_items();
419        let mut lines = Vec::new();
420
421        let selected = self.selected.lock().map(|s| *s).unwrap_or(0);
422        let query = self.query.lock().map(|q| q.clone()).unwrap_or_default();
423
424        // Search box
425        let mut search_line = " ".repeat(self.padding_x);
426        search_line.push_str(&bold("Search: "));
427        search_line.push_str(&query);
428        lines.push(search_line);
429        lines.push(String::new());
430
431        // Items
432        let visible_count = self.max_visible.min(filtered.len());
433        let scroll_offset = selected.saturating_sub(self.max_visible / 2);
434
435        for (i, (_original_idx, item)) in filtered
436            .iter()
437            .skip(scroll_offset)
438            .take(visible_count)
439            .enumerate()
440        {
441            let is_selected = scroll_offset + i == selected;
442            let mut line = " ".repeat(self.padding_x);
443
444            if is_selected {
445                line.push_str(&format!("\x1b[7m> {}\x1b[0m", item.label));
446                if let Some(desc) = &item.description {
447                    line.push_str(&format!(" - {}", desc));
448                }
449            } else {
450                line.push_str(&format!("  {}", item.label));
451                if let Some(desc) = &item.description {
452                    line.push_str(&dim(&format!(" - {}", desc)));
453                }
454            }
455
456            // Truncate to width
457            if line.len() > width {
458                line = line[..width].to_string();
459            }
460
461            lines.push(line);
462        }
463
464        if filtered.is_empty() {
465            let mut line = " ".repeat(self.padding_x);
466            line.push_str(&dim("No results found"));
467            lines.push(line);
468        }
469
470        // Footer
471        lines.push(String::new());
472        let mut footer = " ".repeat(self.padding_x);
473        footer.push_str(&dim(&format!("{} items", filtered.len())));
474        footer.push_str(&dim(" | Enter: Select | Esc: Cancel"));
475        lines.push(footer);
476
477        lines
478    }
479
480    fn invalidate(&self) {
481        // No cache
482    }
483
484    fn as_any(&self) -> &dyn Any {
485        self
486    }
487}
488
489/// Dialog - A simple message dialog.
490pub struct Dialog {
491    title: Mutex<String>,
492    message: Mutex<String>,
493    buttons: Mutex<Vec<String>>,
494    selected: Mutex<usize>,
495}
496
497impl Dialog {
498    /// Create a new dialog.
499    pub fn new(title: impl Into<String>, message: impl Into<String>, buttons: Vec<String>) -> Self {
500        Self {
501            title: Mutex::new(title.into()),
502            message: Mutex::new(message.into()),
503            buttons: Mutex::new(buttons),
504            selected: Mutex::new(0),
505        }
506    }
507
508    /// Create an info dialog.
509    pub fn info(message: impl Into<String>) -> Self {
510        Self::new("Info", message, vec!["OK".to_string()])
511    }
512
513    /// Create a confirm dialog.
514    pub fn confirm(message: impl Into<String>) -> Self {
515        Self::new(
516            "Confirm",
517            message,
518            vec!["Yes".to_string(), "No".to_string()],
519        )
520    }
521
522    /// Get selected button index.
523    pub fn selected(&self) -> usize {
524        self.selected.lock().map(|s| *s).unwrap_or(0)
525    }
526
527    /// Move selection left.
528    pub fn select_left(&self) {
529        if let Ok(mut selected) = self.selected.lock() {
530            if *selected > 0 {
531                *selected -= 1;
532            }
533        }
534    }
535
536    /// Move selection right.
537    pub fn select_right(&self) {
538        if let Ok(mut selected) = self.selected.lock() {
539            let buttons = self.buttons.lock();
540            if let Ok(buttons) = buttons {
541                if *selected < buttons.len().saturating_sub(1) {
542                    *selected += 1;
543                }
544            }
545        }
546    }
547}
548
549impl Component for Dialog {
550    fn render(&self, width: usize) -> Vec<String> {
551        let title = self.title.lock().map(|t| t.clone()).unwrap_or_default();
552        let message = self.message.lock().map(|m| m.clone()).unwrap_or_default();
553        let buttons = self.buttons.lock().map(|b| b.clone()).unwrap_or_default();
554        let selected = self.selected.lock().map(|s| *s).unwrap_or(0);
555
556        let mut lines = Vec::new();
557
558        // Border top
559        let border_width = width.min(60);
560        lines.push(format!("┌{}┐", "─".repeat(border_width.saturating_sub(2))));
561
562        // Title
563        lines.push(format!(
564            "│ {} {}│",
565            bold(&title),
566            " ".repeat(border_width.saturating_sub(4) - title.len())
567        ));
568
569        // Separator
570        lines.push(format!("├{}┤", "─".repeat(border_width.saturating_sub(2))));
571
572        // Message
573        for line in message.lines() {
574            let padded = format!(
575                "│ {}{}",
576                line,
577                " ".repeat(border_width.saturating_sub(3) - visible_width(line))
578            );
579            lines.push(padded);
580        }
581
582        // Separator
583        lines.push(format!("├{}┤", "─".repeat(border_width.saturating_sub(2))));
584
585        // Buttons
586        let mut button_line = "│ ".to_string();
587        for (i, button) in buttons.iter().enumerate() {
588            if i == selected {
589                button_line.push_str(&format!("[{}] ", bold(button)));
590            } else {
591                button_line.push_str(&format!("[{}] ", button));
592            }
593        }
594        button_line
595            .push_str(&" ".repeat(border_width.saturating_sub(3) - visible_width(&button_line)));
596        button_line.push('│');
597        lines.push(button_line);
598
599        // Border bottom
600        lines.push(format!("└{}┘", "─".repeat(border_width.saturating_sub(2))));
601
602        lines
603    }
604
605    fn invalidate(&self) {
606        // No cache
607    }
608
609    fn as_any(&self) -> &dyn Any {
610        self
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn overlay_wraps_at_allocated_width_and_stays_inside_margins_after_resize() {
620        let content = crate::Markdown::new("one two three four five six", 0, 0);
621        let options = OverlayOptions {
622            width: Some(SizeValue::Percent(0.5)),
623            anchor: OverlayAnchor::BottomRight,
624            offset_x: 1000,
625            offset_y: 1000,
626            margin: Some(super::super::tui::OverlayMargin {
627                bottom: 3,
628                ..Default::default()
629            }),
630            ..Default::default()
631        };
632        for width in [12, 20, 60] {
633            let overlay = render_overlay(&content, &options, width, 10).unwrap();
634            assert_eq!(overlay.width, width / 2);
635            assert!(overlay
636                .lines
637                .iter()
638                .all(|row| visible_width(row) <= overlay.width));
639            assert!(overlay.x + overlay.width <= width);
640            assert!(overlay.y + overlay.lines.len() <= 7);
641            if width == 20 {
642                assert!(
643                    overlay.lines.len() > 1,
644                    "wrap instead of clipping a full-width line"
645                );
646            }
647        }
648        let options = OverlayOptions {
649            offset_x: -1000,
650            offset_y: -1000,
651            ..options
652        };
653        let overlay = render_overlay(&content, &options, 20, 10).unwrap();
654        assert_eq!((overlay.x, overlay.y), (0, 0));
655        assert!(render_overlay(&content, &options, 20, 3).is_none());
656    }
657
658    #[test]
659    fn passive_panels_avoid_each_other_and_reappear_when_space_returns() {
660        let manager = OverlayManager::new();
661        let options = OverlayOptions {
662            anchor: OverlayAnchor::BottomRight,
663            width: Some(SizeValue::Absolute(12)),
664            non_capturing: true,
665            ..Default::default()
666        };
667        let _a = manager.add(
668            Arc::new(crate::Text::new("first\nsecond", 0, 0)),
669            options.clone(),
670        );
671        let _b = manager.add(
672            Arc::new(crate::Text::new("other\nlast", 0, 0)),
673            options.clone(),
674        );
675        let rendered = manager.render_visible(40, 8);
676        assert_eq!(rendered.len(), 2);
677        assert_eq!(rendered[0].y, 6);
678        assert_eq!(rendered[1].y, 3);
679        assert_eq!(manager.render_visible(40, 4).len(), 1);
680        assert_eq!(manager.render_visible(40, 8).len(), 2);
681        // Capturing modal overlays intentionally paint above the passive panels.
682        let _modal = manager.add(
683            Arc::new(crate::Text::new("dialog", 0, 0)),
684            OverlayOptions {
685                non_capturing: false,
686                ..options
687            },
688        );
689        assert_eq!(manager.render_visible(40, 8).last().unwrap().y, 7);
690    }
691
692    #[test]
693    fn test_selector() {
694        let items = vec![
695            SelectorItem {
696                label: "Item 1".into(),
697                value: "1".into(),
698                description: None,
699            },
700            SelectorItem {
701                label: "Item 2".into(),
702                value: "2".into(),
703                description: None,
704            },
705        ];
706        let selector = Selector::new(items, 5);
707        assert_eq!(selector.selected(), 0);
708        selector.select_down();
709        assert_eq!(selector.selected(), 1);
710    }
711
712    #[test]
713    fn test_dialog() {
714        let dialog = Dialog::info("Hello, World!");
715        let lines = dialog.render(40);
716        assert!(!lines.is_empty());
717    }
718}