Skip to main content

fresh/view/
popup.rs

1use ratatui::{
2    layout::Rect,
3    style::{Modifier, Style},
4    text::{Line, Span},
5    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
6    Frame,
7};
8
9use super::markdown::{parse_markdown, wrap_styled_lines, wrap_text_lines, StyledLine};
10
11pub mod input;
12use super::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
13use crate::primitives::grammar::GrammarRegistry;
14
15/// Clamp a rectangle to fit within bounds, preventing out-of-bounds rendering panics.
16/// Returns a rectangle that is guaranteed to be fully contained within `bounds`.
17fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
18    // Clamp x to be within bounds
19    let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
20    // Clamp y to be within bounds
21    let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
22
23    // Calculate maximum possible width/height from the clamped position
24    let max_width = (bounds.x + bounds.width).saturating_sub(x);
25    let max_height = (bounds.y + bounds.height).saturating_sub(y);
26
27    Rect {
28        x,
29        y,
30        width: rect.width.min(max_width),
31        height: rect.height.min(max_height),
32    }
33}
34
35/// Position of a popup relative to a point in the buffer
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PopupPosition {
38    /// At cursor position
39    AtCursor,
40    /// Below cursor position
41    BelowCursor,
42    /// Above cursor position
43    AboveCursor,
44    /// Fixed screen coordinates (x, y)
45    Fixed { x: u16, y: u16 },
46    /// Centered on screen
47    Centered,
48    /// Bottom right corner (above status bar)
49    BottomRight,
50    /// Anchored above the status bar at a specific column (left-aligned at x).
51    /// Used by the LSP-status popup so it appears directly above the LSP
52    /// segment that opened it.
53    AboveStatusBarAt { x: u16 },
54}
55
56/// Kind of popup - determines input handling behavior
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum PopupKind {
59    /// LSP completion popup - supports type-to-filter, Tab/Enter accept
60    Completion,
61    /// Hover/documentation popup - read-only, scroll, dismiss on keypress
62    Hover,
63    /// Action popup with selectable actions - navigate and execute
64    Action,
65    /// Generic list popup
66    List,
67    /// Generic text popup
68    Text,
69}
70
71/// Content of a popup window
72#[derive(Debug, Clone, PartialEq)]
73pub enum PopupContent {
74    /// Simple text content
75    Text(Vec<String>),
76    /// Markdown content with styling
77    Markdown(Vec<StyledLine>),
78    /// List of selectable items
79    List {
80        items: Vec<PopupListItem>,
81        selected: usize,
82    },
83    /// Custom rendered content (just store strings for now)
84    Custom(Vec<String>),
85}
86
87/// Text selection within a popup (line, column positions)
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct PopupTextSelection {
90    /// Start position (line index, column index)
91    pub start: (usize, usize),
92    /// End position (line index, column index)
93    pub end: (usize, usize),
94}
95
96impl PopupTextSelection {
97    /// Get normalized selection (start <= end)
98    pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
99        if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
100            (self.start, self.end)
101        } else {
102            (self.end, self.start)
103        }
104    }
105
106    /// Check if a position is within the selection
107    pub fn contains(&self, line: usize, col: usize) -> bool {
108        let ((start_line, start_col), (end_line, end_col)) = self.normalized();
109        if line < start_line || line > end_line {
110            return false;
111        }
112        if line == start_line && line == end_line {
113            col >= start_col && col < end_col
114        } else if line == start_line {
115            col >= start_col
116        } else if line == end_line {
117            col < end_col
118        } else {
119            true
120        }
121    }
122}
123
124/// A single item in a popup list
125#[derive(Debug, Clone, PartialEq)]
126pub struct PopupListItem {
127    /// Main text to display
128    pub text: String,
129    /// Optional secondary text (description, type info, etc.)
130    pub detail: Option<String>,
131    /// Optional icon or prefix
132    pub icon: Option<String>,
133    /// User data associated with this item (for completion, etc.)
134    pub data: Option<String>,
135    /// If true, item is rendered grayed-out and not selectable.
136    pub disabled: bool,
137}
138
139impl PopupListItem {
140    pub fn new(text: String) -> Self {
141        Self {
142            text,
143            detail: None,
144            icon: None,
145            data: None,
146            disabled: false,
147        }
148    }
149
150    pub fn with_detail(mut self, detail: String) -> Self {
151        self.detail = Some(detail);
152        self
153    }
154
155    pub fn with_icon(mut self, icon: String) -> Self {
156        self.icon = Some(icon);
157        self
158    }
159
160    pub fn with_data(mut self, data: String) -> Self {
161        self.data = Some(data);
162        self
163    }
164
165    pub fn disabled(mut self) -> Self {
166        self.disabled = true;
167        self
168    }
169}
170
171/// A popup/floating window
172/// This is a general-purpose UI primitive that can be used for:
173/// - Completion menus
174/// - Hover documentation
175/// - Command palette
176/// - File picker
177/// - Diagnostic messages
178/// - Quick fixes / code actions
179#[derive(Debug, Clone, PartialEq)]
180pub struct Popup {
181    /// Kind of popup - determines input handling behavior
182    pub kind: PopupKind,
183
184    /// Title of the popup (optional)
185    pub title: Option<String>,
186
187    /// Description text shown below title, above content (optional)
188    pub description: Option<String>,
189
190    /// Whether this popup is transient (dismissed on focus loss, e.g. hover, signature help)
191    pub transient: bool,
192
193    /// Content to display
194    pub content: PopupContent,
195
196    /// Position strategy
197    pub position: PopupPosition,
198
199    /// Width of popup (in columns)
200    pub width: u16,
201
202    /// Maximum height (will be clamped to available space)
203    pub max_height: u16,
204
205    /// Whether to show borders
206    pub bordered: bool,
207
208    /// Border style
209    pub border_style: Style,
210
211    /// Background style
212    pub background_style: Style,
213
214    /// Scroll offset for content (for scrolling through long lists)
215    pub scroll_offset: usize,
216
217    /// Text selection for copy/paste (None if no selection)
218    pub text_selection: Option<PopupTextSelection>,
219
220    /// Key hint shown right-aligned on the selected item (e.g. "(Tab)")
221    pub accept_key_hint: Option<String>,
222}
223
224impl Popup {
225    /// Create a new popup with text content using theme colors
226    pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
227        Self {
228            kind: PopupKind::Text,
229            title: None,
230            description: None,
231            transient: false,
232            content: PopupContent::Text(content),
233            position: PopupPosition::AtCursor,
234            width: 50,
235            max_height: 15,
236            bordered: true,
237            border_style: Style::default().fg(theme.popup_border_fg),
238            background_style: Style::default().bg(theme.popup_bg),
239            scroll_offset: 0,
240            text_selection: None,
241            accept_key_hint: None,
242        }
243    }
244
245    /// Create a new popup with markdown content using theme colors
246    ///
247    /// If `registry` is provided, code blocks will have syntax highlighting
248    /// for ~150+ languages via syntect.
249    pub fn markdown(
250        markdown_text: &str,
251        theme: &crate::view::theme::Theme,
252        registry: Option<&GrammarRegistry>,
253    ) -> Self {
254        let styled_lines = parse_markdown(markdown_text, theme, registry);
255        Self {
256            kind: PopupKind::Text,
257            title: None,
258            description: None,
259            transient: false,
260            content: PopupContent::Markdown(styled_lines),
261            position: PopupPosition::AtCursor,
262            width: 60,      // Wider for markdown content
263            max_height: 20, // Taller for documentation
264            bordered: true,
265            border_style: Style::default().fg(theme.popup_border_fg),
266            background_style: Style::default().bg(theme.popup_bg),
267            scroll_offset: 0,
268            text_selection: None,
269            accept_key_hint: None,
270        }
271    }
272
273    /// Create a new popup with a list of items using theme colors
274    pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
275        Self {
276            kind: PopupKind::List,
277            title: None,
278            description: None,
279            transient: false,
280            content: PopupContent::List { items, selected: 0 },
281            position: PopupPosition::AtCursor,
282            width: 50,
283            max_height: 15,
284            bordered: true,
285            border_style: Style::default().fg(theme.popup_border_fg),
286            background_style: Style::default().bg(theme.popup_bg),
287            scroll_offset: 0,
288            text_selection: None,
289            accept_key_hint: None,
290        }
291    }
292
293    /// Set the title
294    pub fn with_title(mut self, title: String) -> Self {
295        self.title = Some(title);
296        self
297    }
298
299    /// Set the popup kind (determines input handling behavior)
300    pub fn with_kind(mut self, kind: PopupKind) -> Self {
301        self.kind = kind;
302        self
303    }
304
305    /// Mark this popup as transient (will be dismissed on focus loss)
306    pub fn with_transient(mut self, transient: bool) -> Self {
307        self.transient = transient;
308        self
309    }
310
311    /// Set the position
312    pub fn with_position(mut self, position: PopupPosition) -> Self {
313        self.position = position;
314        self
315    }
316
317    /// Set the width
318    pub fn with_width(mut self, width: u16) -> Self {
319        self.width = width;
320        self
321    }
322
323    /// Set the max height
324    pub fn with_max_height(mut self, max_height: u16) -> Self {
325        self.max_height = max_height;
326        self
327    }
328
329    /// Set border style
330    pub fn with_border_style(mut self, style: Style) -> Self {
331        self.border_style = style;
332        self
333    }
334
335    /// Get the currently selected item (if this is a list popup)
336    pub fn selected_item(&self) -> Option<&PopupListItem> {
337        match &self.content {
338            PopupContent::List { items, selected } => items.get(*selected),
339            _ => None,
340        }
341    }
342
343    /// Get the actual visible content height (accounting for borders)
344    fn visible_height(&self) -> usize {
345        let border_offset = if self.bordered { 2 } else { 0 };
346        (self.max_height as usize).saturating_sub(border_offset)
347    }
348
349    /// Move selection down (for list popups)
350    pub fn select_next(&mut self) {
351        let visible = self.visible_height();
352        if let PopupContent::List { items, selected } = &mut self.content {
353            if *selected < items.len().saturating_sub(1) {
354                *selected += 1;
355                // Adjust scroll if needed (use visible_height to account for borders)
356                if *selected >= self.scroll_offset + visible {
357                    self.scroll_offset = (*selected + 1).saturating_sub(visible);
358                }
359            }
360        }
361    }
362
363    /// Move selection up (for list popups)
364    pub fn select_prev(&mut self) {
365        if let PopupContent::List { items: _, selected } = &mut self.content {
366            if *selected > 0 {
367                *selected -= 1;
368                // Adjust scroll if needed
369                if *selected < self.scroll_offset {
370                    self.scroll_offset = *selected;
371                }
372            }
373        }
374    }
375
376    /// Select a specific item by index. Returns true if the index was valid.
377    pub fn select_index(&mut self, index: usize) -> bool {
378        let visible = self.visible_height();
379        if let PopupContent::List { items, selected } = &mut self.content {
380            if index < items.len() {
381                *selected = index;
382                // Adjust scroll to keep selection visible
383                if *selected >= self.scroll_offset + visible {
384                    self.scroll_offset = (*selected + 1).saturating_sub(visible);
385                } else if *selected < self.scroll_offset {
386                    self.scroll_offset = *selected;
387                }
388                return true;
389            }
390        }
391        false
392    }
393
394    /// Scroll down by one page
395    pub fn page_down(&mut self) {
396        let visible = self.visible_height();
397        if let PopupContent::List { items, selected } = &mut self.content {
398            *selected = (*selected + visible).min(items.len().saturating_sub(1));
399            self.scroll_offset = (*selected + 1).saturating_sub(visible);
400        } else {
401            self.scroll_offset += visible;
402        }
403    }
404
405    /// Scroll up by one page
406    pub fn page_up(&mut self) {
407        let visible = self.visible_height();
408        if let PopupContent::List { items: _, selected } = &mut self.content {
409            *selected = selected.saturating_sub(visible);
410            self.scroll_offset = *selected;
411        } else {
412            self.scroll_offset = self.scroll_offset.saturating_sub(visible);
413        }
414    }
415
416    /// Select the first item (for list popups)
417    pub fn select_first(&mut self) {
418        if let PopupContent::List { items: _, selected } = &mut self.content {
419            *selected = 0;
420            self.scroll_offset = 0;
421        } else {
422            self.scroll_offset = 0;
423        }
424    }
425
426    /// Select the last item (for list popups)
427    pub fn select_last(&mut self) {
428        let visible = self.visible_height();
429        if let PopupContent::List { items, selected } = &mut self.content {
430            *selected = items.len().saturating_sub(1);
431            // Ensure the last item is visible
432            if *selected >= visible {
433                self.scroll_offset = (*selected + 1).saturating_sub(visible);
434            }
435        } else {
436            // For non-list content, scroll to the end
437            let content_height = self.item_count();
438            if content_height > visible {
439                self.scroll_offset = content_height.saturating_sub(visible);
440            }
441        }
442    }
443
444    /// Scroll by a delta amount (positive = down, negative = up)
445    /// Used for mouse wheel scrolling
446    pub fn scroll_by(&mut self, delta: i32) {
447        let content_len = self.wrapped_item_count();
448        let visible = self.visible_height();
449        let max_scroll = content_len.saturating_sub(visible);
450
451        if delta < 0 {
452            // Scroll up
453            self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
454        } else {
455            // Scroll down
456            self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
457        }
458
459        // For list popups, adjust selection to stay visible
460        if let PopupContent::List { items, selected } = &mut self.content {
461            let visible_start = self.scroll_offset;
462            let visible_end = (self.scroll_offset + visible).min(items.len());
463
464            if *selected < visible_start {
465                *selected = visible_start;
466            } else if *selected >= visible_end {
467                *selected = visible_end.saturating_sub(1);
468            }
469        }
470    }
471
472    /// Get the total number of items/lines in the popup
473    pub fn item_count(&self) -> usize {
474        match &self.content {
475            PopupContent::Text(lines) => lines.len(),
476            PopupContent::Markdown(lines) => lines.len(),
477            PopupContent::List { items, .. } => items.len(),
478            PopupContent::Custom(lines) => lines.len(),
479        }
480    }
481
482    /// Get the total number of wrapped lines in the popup
483    ///
484    /// This accounts for line wrapping based on the popup width,
485    /// which is necessary for correct scroll calculations.
486    fn wrapped_item_count(&self) -> usize {
487        // Calculate wrap width same as render: width - borders (2) - scrollbar (2)
488        let border_width = if self.bordered { 2 } else { 0 };
489        let scrollbar_width = 2; // 1 for scrollbar + 1 for spacing
490        let wrap_width = (self.width as usize)
491            .saturating_sub(border_width)
492            .saturating_sub(scrollbar_width);
493
494        if wrap_width == 0 {
495            return self.item_count();
496        }
497
498        match &self.content {
499            PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
500            PopupContent::Markdown(styled_lines) => {
501                wrap_styled_lines(styled_lines, wrap_width).len()
502            }
503            // Lists and custom content don't wrap
504            PopupContent::List { items, .. } => items.len(),
505            PopupContent::Custom(lines) => lines.len(),
506        }
507    }
508
509    /// Start text selection at position (used for mouse click)
510    pub fn start_selection(&mut self, line: usize, col: usize) {
511        self.text_selection = Some(PopupTextSelection {
512            start: (line, col),
513            end: (line, col),
514        });
515    }
516
517    /// Extend text selection to position (used for mouse drag)
518    pub fn extend_selection(&mut self, line: usize, col: usize) {
519        if let Some(ref mut sel) = self.text_selection {
520            sel.end = (line, col);
521        }
522    }
523
524    /// Clear text selection
525    pub fn clear_selection(&mut self) {
526        self.text_selection = None;
527    }
528
529    /// Check if popup has active text selection
530    pub fn has_selection(&self) -> bool {
531        if let Some(sel) = &self.text_selection {
532            sel.start != sel.end
533        } else {
534            false
535        }
536    }
537
538    /// Compute the effective content wrap width, replicating the logic
539    /// from `render_with_hover` so line indices match visual positions.
540    fn content_wrap_width(&self) -> usize {
541        let border_width: u16 = if self.bordered { 2 } else { 0 };
542        let inner_width = self.width.saturating_sub(border_width);
543        let scrollbar_reserved: u16 = 2;
544        let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
545
546        if conservative_width == 0 {
547            return 0;
548        }
549
550        let visible_height = self.max_height.saturating_sub(border_width) as usize;
551        let line_count = match &self.content {
552            PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
553            PopupContent::Markdown(styled_lines) => {
554                wrap_styled_lines(styled_lines, conservative_width).len()
555            }
556            _ => self.item_count(),
557        };
558
559        let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
560
561        if needs_scrollbar {
562            conservative_width
563        } else {
564            inner_width as usize
565        }
566    }
567
568    /// Get plain text lines from popup content, wrapped to match rendering.
569    ///
570    /// Selection coordinates are in wrapped-line space (visual positions),
571    /// so this must wrap lines identically to how `render_with_hover` does.
572    fn get_text_lines(&self) -> Vec<String> {
573        let wrap_width = self.content_wrap_width();
574
575        match &self.content {
576            PopupContent::Text(lines) => {
577                if wrap_width > 0 {
578                    wrap_text_lines(lines, wrap_width)
579                } else {
580                    lines.clone()
581                }
582            }
583            PopupContent::Markdown(styled_lines) => {
584                if wrap_width > 0 {
585                    wrap_styled_lines(styled_lines, wrap_width)
586                        .iter()
587                        .map(|sl| sl.plain_text())
588                        .collect()
589                } else {
590                    styled_lines.iter().map(|sl| sl.plain_text()).collect()
591                }
592            }
593            PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
594            PopupContent::Custom(lines) => lines.clone(),
595        }
596    }
597
598    /// Get selected text from popup content
599    pub fn get_selected_text(&self) -> Option<String> {
600        let sel = self.text_selection.as_ref()?;
601        if sel.start == sel.end {
602            return None;
603        }
604
605        let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
606        let lines = self.get_text_lines();
607
608        if start_line >= lines.len() {
609            return None;
610        }
611
612        if start_line == end_line {
613            let line = &lines[start_line];
614            let end_col = end_col.min(line.len());
615            let start_col = start_col.min(end_col);
616            Some(line[start_col..end_col].to_string())
617        } else {
618            let mut result = String::new();
619            // First line from start_col to end
620            let first_line = &lines[start_line];
621            result.push_str(&first_line[start_col.min(first_line.len())..]);
622            result.push('\n');
623            // Middle lines (full)
624            for line in lines.iter().take(end_line).skip(start_line + 1) {
625                result.push_str(line);
626                result.push('\n');
627            }
628            // Last line from start to end_col
629            if end_line < lines.len() {
630                let last_line = &lines[end_line];
631                result.push_str(&last_line[..end_col.min(last_line.len())]);
632            }
633            Some(result)
634        }
635    }
636
637    /// Check if the popup needs a scrollbar (content exceeds visible area)
638    pub fn needs_scrollbar(&self) -> bool {
639        self.item_count() > self.visible_height()
640    }
641
642    /// Get scroll state for scrollbar rendering
643    pub fn scroll_state(&self) -> (usize, usize, usize) {
644        let total = self.item_count();
645        let visible = self.visible_height();
646        (total, visible, self.scroll_offset)
647    }
648
649    /// Find the link URL at a given relative position within the popup content area.
650    /// `relative_col` and `relative_row` are relative to the inner content area (after borders).
651    /// Returns None if:
652    /// - The popup doesn't contain markdown content
653    /// - The position doesn't have a link
654    pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
655        let PopupContent::Markdown(styled_lines) = &self.content else {
656            return None;
657        };
658
659        // Calculate the content width for wrapping
660        let border_width = if self.bordered { 2 } else { 0 };
661        let scrollbar_reserved = 2;
662        let content_width = self
663            .width
664            .saturating_sub(border_width)
665            .saturating_sub(scrollbar_reserved) as usize;
666
667        // Wrap the styled lines
668        let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
669
670        // Account for scroll offset
671        let line_index = self.scroll_offset + relative_row;
672
673        // Get the line at this position
674        let line = wrapped_lines.get(line_index)?;
675
676        // Find the link at the column position
677        line.link_at_column(relative_col).map(|s| s.to_string())
678    }
679
680    /// Get the height of the description area (including blank line separator)
681    /// Returns 0 if there is no description.
682    pub fn description_height(&self) -> u16 {
683        if let Some(desc) = &self.description {
684            let border_width = if self.bordered { 2 } else { 0 };
685            let scrollbar_reserved = 2;
686            let content_width = self
687                .width
688                .saturating_sub(border_width)
689                .saturating_sub(scrollbar_reserved) as usize;
690            let desc_vec = vec![desc.clone()];
691            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
692            wrapped.len() as u16 + 1 // +1 for blank line after description
693        } else {
694            0
695        }
696    }
697
698    /// Calculate the actual content height based on the popup content
699    fn content_height(&self) -> u16 {
700        // Use the popup's configured width for wrapping calculation
701        self.content_height_for_width(self.width)
702    }
703
704    /// Calculate content height for a specific width, accounting for word wrapping
705    fn content_height_for_width(&self, popup_width: u16) -> u16 {
706        // Calculate the effective content width (accounting for borders and scrollbar)
707        let border_width = if self.bordered { 2 } else { 0 };
708        let scrollbar_reserved = 2; // Reserve space for potential scrollbar
709        let content_width = popup_width
710            .saturating_sub(border_width)
711            .saturating_sub(scrollbar_reserved) as usize;
712
713        // Calculate description height if present
714        let description_lines = if let Some(desc) = &self.description {
715            let desc_vec = vec![desc.clone()];
716            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
717            wrapped.len() as u16 + 1 // +1 for blank line after description
718        } else {
719            0
720        };
721
722        let content_lines = match &self.content {
723            PopupContent::Text(lines) => {
724                // Count wrapped lines
725                wrap_text_lines(lines, content_width).len() as u16
726            }
727            PopupContent::Markdown(styled_lines) => {
728                // Count wrapped styled lines
729                wrap_styled_lines(styled_lines, content_width).len() as u16
730            }
731            PopupContent::List { items, .. } => items.len() as u16,
732            PopupContent::Custom(lines) => lines.len() as u16,
733        };
734
735        // Add border lines if bordered
736        let border_height = if self.bordered { 2 } else { 0 };
737
738        description_lines + content_lines + border_height
739    }
740
741    /// Calculate the area where this popup should be rendered
742    pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
743        match self.position {
744            PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
745                let (cursor_x, cursor_y) =
746                    cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
747
748                let width = self.width.min(terminal_area.width);
749                // Use the minimum of max_height, actual content height, and terminal height
750                let height = self
751                    .content_height()
752                    .min(self.max_height)
753                    .min(terminal_area.height);
754
755                let x = if cursor_x + width > terminal_area.width {
756                    terminal_area.width.saturating_sub(width)
757                } else {
758                    cursor_x
759                };
760
761                let y = match self.position {
762                    PopupPosition::AtCursor => cursor_y,
763                    PopupPosition::BelowCursor => {
764                        if cursor_y + 1 + height > terminal_area.height {
765                            // Not enough space below, put above cursor
766                            cursor_y.saturating_sub(height)
767                        } else {
768                            // Directly below cursor
769                            cursor_y + 1
770                        }
771                    }
772                    PopupPosition::AboveCursor => {
773                        // Position so bottom of popup is one row above cursor
774                        (cursor_y + 1).saturating_sub(height)
775                    }
776                    _ => cursor_y,
777                };
778
779                Rect {
780                    x,
781                    y,
782                    width,
783                    height,
784                }
785            }
786            PopupPosition::Fixed { x, y } => {
787                let width = self.width.min(terminal_area.width);
788                let height = self
789                    .content_height()
790                    .min(self.max_height)
791                    .min(terminal_area.height);
792                // Clamp x and y to ensure popup stays within terminal bounds
793                let x = if x + width > terminal_area.width {
794                    terminal_area.width.saturating_sub(width)
795                } else {
796                    x
797                };
798                let y = if y + height > terminal_area.height {
799                    terminal_area.height.saturating_sub(height)
800                } else {
801                    y
802                };
803                Rect {
804                    x,
805                    y,
806                    width,
807                    height,
808                }
809            }
810            PopupPosition::Centered => {
811                let width = self.width.min(terminal_area.width);
812                let height = self
813                    .content_height()
814                    .min(self.max_height)
815                    .min(terminal_area.height);
816                let x = (terminal_area.width.saturating_sub(width)) / 2;
817                let y = (terminal_area.height.saturating_sub(height)) / 2;
818                Rect {
819                    x,
820                    y,
821                    width,
822                    height,
823                }
824            }
825            PopupPosition::BottomRight => {
826                let width = self.width.min(terminal_area.width);
827                let height = self
828                    .content_height()
829                    .min(self.max_height)
830                    .min(terminal_area.height);
831                // Position in bottom right, leaving 2 rows for status bar
832                let x = terminal_area.width.saturating_sub(width);
833                let y = terminal_area
834                    .height
835                    .saturating_sub(height)
836                    .saturating_sub(2);
837                Rect {
838                    x,
839                    y,
840                    width,
841                    height,
842                }
843            }
844            PopupPosition::AboveStatusBarAt { x } => {
845                let width = self.width.min(terminal_area.width);
846                let height = self
847                    .content_height()
848                    .min(self.max_height)
849                    .min(terminal_area.height);
850                // Align left edge with the given x, but clamp so the popup
851                // stays within the terminal bounds.
852                let x = if x + width > terminal_area.width {
853                    terminal_area.width.saturating_sub(width)
854                } else {
855                    x
856                };
857                // Leave one empty row between the popup's bottom border
858                // and the status bar.  Without this gap, the popup's
859                // bottom border visually touches the LSP indicator it was
860                // opened from, making the indicator harder to read and
861                // obscuring the spinner while progress is in flight.
862                //   - terminal_height - 1 = status bar row
863                //   - terminal_height - 2 = gap row
864                //   - popup bottom ends at terminal_height - 3
865                let y = terminal_area
866                    .height
867                    .saturating_sub(height)
868                    .saturating_sub(2);
869                Rect {
870                    x,
871                    y,
872                    width,
873                    height,
874                }
875            }
876        }
877    }
878
879    /// Render the popup to the frame
880    pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
881        self.render_with_hover(frame, area, theme, None);
882    }
883
884    /// Render the popup to the frame with hover highlighting
885    pub fn render_with_hover(
886        &self,
887        frame: &mut Frame,
888        area: Rect,
889        theme: &crate::view::theme::Theme,
890        hover_target: Option<&crate::app::HoverTarget>,
891    ) {
892        // Defensive bounds checking: clamp area to frame bounds to prevent panic
893        let frame_area = frame.area();
894        let area = clamp_rect_to_bounds(area, frame_area);
895
896        // Skip rendering if area is empty after clamping
897        if area.width == 0 || area.height == 0 {
898            return;
899        }
900
901        // Clear the area behind the popup first to hide underlying text
902        frame.render_widget(Clear, area);
903
904        let block = if self.bordered {
905            let mut block = Block::default()
906                .borders(Borders::ALL)
907                .border_style(self.border_style)
908                .style(self.background_style);
909
910            if let Some(title) = &self.title {
911                block = block.title(title.as_str());
912            }
913
914            block
915        } else {
916            Block::default().style(self.background_style)
917        };
918
919        let inner_area = block.inner(area);
920        frame.render_widget(block, area);
921
922        // Close-button overlay on the top border ("[×]", bracketed so the
923        // click target is 3 cells wide and obviously a UI affordance rather
924        // than stray content).  Rendered only for bordered popups that are
925        // big enough to accommodate it without colliding with the title.
926        if self.bordered && area.width >= 5 {
927            let close_x = area.x + area.width - 4;
928            let close_area = Rect {
929                x: close_x,
930                y: area.y,
931                width: 3,
932                height: 1,
933            };
934            frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
935        }
936
937        // Render description if present, and adjust content area
938        let content_start_y;
939        if let Some(desc) = &self.description {
940            // Word-wrap description to fit inner width
941            let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; // Leave some padding
942            let desc_vec = vec![desc.clone()];
943            let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
944            let desc_lines: usize = wrapped_desc.len();
945
946            // Render each description line
947            for (i, line) in wrapped_desc.iter().enumerate() {
948                if i >= inner_area.height as usize {
949                    break;
950                }
951                let line_area = Rect {
952                    x: inner_area.x,
953                    y: inner_area.y + i as u16,
954                    width: inner_area.width,
955                    height: 1,
956                };
957                let desc_style = Style::default().fg(theme.help_separator_fg);
958                frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
959            }
960
961            // Add blank line after description
962            content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
963        } else {
964            content_start_y = inner_area.y;
965        }
966
967        // Adjust inner_area to start after description
968        let inner_area = Rect {
969            x: inner_area.x,
970            y: content_start_y,
971            width: inner_area.width,
972            height: inner_area
973                .height
974                .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
975        };
976
977        // For text and markdown content, we need to wrap first to determine if scrollbar is needed.
978        // We wrap to the width that would be available if scrollbar is shown (conservative approach).
979        let scrollbar_reserved_width = 2; // 1 for scrollbar + 1 for spacing
980        let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
981        let visible_lines_count = inner_area.height as usize;
982
983        // Calculate wrapped line count and determine if scrollbar is needed
984        let (wrapped_total_lines, needs_scrollbar) = match &self.content {
985            PopupContent::Text(lines) => {
986                let wrapped = wrap_text_lines(lines, wrap_width);
987                let count = wrapped.len();
988                (
989                    count,
990                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
991                )
992            }
993            PopupContent::Markdown(styled_lines) => {
994                let wrapped = wrap_styled_lines(styled_lines, wrap_width);
995                let count = wrapped.len();
996                (
997                    count,
998                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
999                )
1000            }
1001            PopupContent::List { items, .. } => {
1002                let count = items.len();
1003                (
1004                    count,
1005                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1006                )
1007            }
1008            PopupContent::Custom(lines) => {
1009                let count = lines.len();
1010                (
1011                    count,
1012                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1013                )
1014            }
1015        };
1016
1017        // Adjust content area to leave room for scrollbar if needed
1018        let content_area = if needs_scrollbar {
1019            Rect {
1020                x: inner_area.x,
1021                y: inner_area.y,
1022                width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1023                height: inner_area.height,
1024            }
1025        } else {
1026            inner_area
1027        };
1028
1029        match &self.content {
1030            PopupContent::Text(lines) => {
1031                // Word-wrap lines to fit content area width
1032                let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1033                let selection_style = Style::default().bg(theme.selection_bg);
1034
1035                let visible_lines: Vec<Line> = wrapped_lines
1036                    .iter()
1037                    .enumerate()
1038                    .skip(self.scroll_offset)
1039                    .take(content_area.height as usize)
1040                    .map(|(line_idx, line)| {
1041                        if let Some(ref sel) = self.text_selection {
1042                            // Apply selection highlighting
1043                            let chars: Vec<char> = line.chars().collect();
1044                            let spans: Vec<Span> = chars
1045                                .iter()
1046                                .enumerate()
1047                                .map(|(col, ch)| {
1048                                    if sel.contains(line_idx, col) {
1049                                        Span::styled(ch.to_string(), selection_style)
1050                                    } else {
1051                                        Span::raw(ch.to_string())
1052                                    }
1053                                })
1054                                .collect();
1055                            Line::from(spans)
1056                        } else {
1057                            Line::from(line.as_str())
1058                        }
1059                    })
1060                    .collect();
1061
1062                let paragraph = Paragraph::new(visible_lines);
1063                frame.render_widget(paragraph, content_area);
1064            }
1065            PopupContent::Markdown(styled_lines) => {
1066                // Word-wrap styled lines to fit content area width
1067                let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1068                let selection_style = Style::default().bg(theme.selection_bg);
1069
1070                // Collect link overlay info for OSC 8 rendering after the main draw
1071                // Each entry: (visible_line_idx, start_column, link_text, url)
1072                let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1073
1074                let visible_lines: Vec<Line> = wrapped_lines
1075                    .iter()
1076                    .enumerate()
1077                    .skip(self.scroll_offset)
1078                    .take(content_area.height as usize)
1079                    .map(|(line_idx, styled_line)| {
1080                        let mut col = 0usize;
1081                        let spans: Vec<Span> = styled_line
1082                            .spans
1083                            .iter()
1084                            .flat_map(|s| {
1085                                let span_start_col = col;
1086                                let span_width =
1087                                    unicode_width::UnicodeWidthStr::width(s.text.as_str());
1088                                if let Some(url) = &s.link_url {
1089                                    link_overlays.push((
1090                                        line_idx - self.scroll_offset,
1091                                        col,
1092                                        s.text.clone(),
1093                                        url.clone(),
1094                                    ));
1095                                }
1096                                col += span_width;
1097
1098                                // Check if any part of this span is selected
1099                                if let Some(ref sel) = self.text_selection {
1100                                    // Split span into selected/unselected parts
1101                                    let chars: Vec<char> = s.text.chars().collect();
1102                                    chars
1103                                        .iter()
1104                                        .enumerate()
1105                                        .map(|(i, ch)| {
1106                                            let char_col = span_start_col + i;
1107                                            if sel.contains(line_idx, char_col) {
1108                                                Span::styled(ch.to_string(), selection_style)
1109                                            } else {
1110                                                Span::styled(ch.to_string(), s.style)
1111                                            }
1112                                        })
1113                                        .collect::<Vec<_>>()
1114                                } else {
1115                                    vec![Span::styled(s.text.clone(), s.style)]
1116                                }
1117                            })
1118                            .collect();
1119                        Line::from(spans)
1120                    })
1121                    .collect();
1122
1123                let paragraph = Paragraph::new(visible_lines);
1124                frame.render_widget(paragraph, content_area);
1125
1126                // Apply OSC 8 hyperlinks following Ratatui's official workaround
1127                let buffer = frame.buffer_mut();
1128                let max_x = content_area.x + content_area.width;
1129                for (line_idx, col_start, text, url) in link_overlays {
1130                    let y = content_area.y + line_idx as u16;
1131                    if y >= content_area.y + content_area.height {
1132                        continue;
1133                    }
1134                    let start_x = content_area.x + col_start as u16;
1135                    apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1136                }
1137            }
1138            PopupContent::List { items, selected } => {
1139                let list_items: Vec<ListItem> = items
1140                    .iter()
1141                    .enumerate()
1142                    .skip(self.scroll_offset)
1143                    .take(content_area.height as usize)
1144                    .map(|(idx, item)| {
1145                        // Check if this item is hovered or selected
1146                        let is_hovered = matches!(
1147                            hover_target,
1148                            Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1149                        );
1150                        let is_selected = idx == *selected;
1151
1152                        let mut spans = Vec::new();
1153
1154                        // Add icon if present
1155                        if let Some(icon) = &item.icon {
1156                            spans.push(Span::raw(format!("{} ", icon)));
1157                        }
1158
1159                        // Add main text.  Items are "clickable" when they
1160                        // carry a `data` payload and are not disabled — those
1161                        // get an underline (like a link) so the user can see
1162                        // at a glance which rows act on click.  Header-only
1163                        // rows (no data) stay plain; disabled rows are dimmed.
1164                        // Leading whitespace is kept separate so the underline
1165                        // only sits under the visible text.
1166                        let text = &item.text;
1167                        let trimmed = text.trim_start();
1168                        let indent_len = text.len() - trimmed.len();
1169                        if indent_len > 0 {
1170                            spans.push(Span::raw(&text[..indent_len]));
1171                        }
1172                        let is_clickable = item.data.is_some() && !item.disabled;
1173                        let mut text_style = Style::default();
1174                        if is_selected {
1175                            text_style = text_style.add_modifier(Modifier::BOLD);
1176                        }
1177                        if is_clickable {
1178                            text_style = text_style.add_modifier(Modifier::UNDERLINED);
1179                        }
1180                        if item.disabled {
1181                            text_style = text_style
1182                                .fg(theme.help_separator_fg)
1183                                .add_modifier(Modifier::DIM);
1184                        }
1185                        spans.push(Span::styled(trimmed, text_style));
1186
1187                        // Add detail if present
1188                        if let Some(detail) = &item.detail {
1189                            spans.push(Span::styled(
1190                                format!(" {}", detail),
1191                                Style::default().fg(theme.help_separator_fg),
1192                            ));
1193                        }
1194
1195                        // Add an empty span without underline so ratatui doesn't
1196                        // extend the underline across the remaining row padding.
1197                        spans.push(Span::raw(""));
1198
1199                        // Add right-aligned accept key hint on the selected item
1200                        if is_selected {
1201                            if let Some(ref hint) = self.accept_key_hint {
1202                                let hint_text = format!("({})", hint);
1203                                // Calculate used width
1204                                let used_width: usize = spans
1205                                    .iter()
1206                                    .map(|s| {
1207                                        unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1208                                    })
1209                                    .sum();
1210                                let available = content_area.width as usize;
1211                                let hint_len = hint_text.len();
1212                                if used_width + hint_len + 1 < available {
1213                                    let padding = available - used_width - hint_len;
1214                                    spans.push(Span::raw(" ".repeat(padding)));
1215                                    spans.push(Span::styled(
1216                                        hint_text,
1217                                        Style::default().fg(theme.help_separator_fg),
1218                                    ));
1219                                }
1220                            }
1221                        }
1222
1223                        // Row style (background only, no underline)
1224                        let row_style = if is_selected {
1225                            Style::default().bg(theme.popup_selection_bg)
1226                        } else if is_hovered {
1227                            Style::default()
1228                                .bg(theme.menu_hover_bg)
1229                                .fg(theme.menu_hover_fg)
1230                        } else {
1231                            Style::default()
1232                        };
1233
1234                        ListItem::new(Line::from(spans)).style(row_style)
1235                    })
1236                    .collect();
1237
1238                let list = List::new(list_items);
1239                frame.render_widget(list, content_area);
1240            }
1241            PopupContent::Custom(lines) => {
1242                let visible_lines: Vec<Line> = lines
1243                    .iter()
1244                    .skip(self.scroll_offset)
1245                    .take(content_area.height as usize)
1246                    .map(|line| Line::from(line.as_str()))
1247                    .collect();
1248
1249                let paragraph = Paragraph::new(visible_lines);
1250                frame.render_widget(paragraph, content_area);
1251            }
1252        }
1253
1254        // Render scrollbar if needed
1255        if needs_scrollbar {
1256            let scrollbar_area = Rect {
1257                x: inner_area.x + inner_area.width - 1,
1258                y: inner_area.y,
1259                width: 1,
1260                height: inner_area.height,
1261            };
1262
1263            let scrollbar_state =
1264                ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1265            let scrollbar_colors = ScrollbarColors::from_theme(theme);
1266            render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1267        }
1268    }
1269}
1270
1271/// Manager for popups - can show multiple popups with z-ordering
1272#[derive(Debug, Clone)]
1273pub struct PopupManager {
1274    /// Stack of active popups (top of stack = topmost popup)
1275    popups: Vec<Popup>,
1276}
1277
1278impl PopupManager {
1279    pub fn new() -> Self {
1280        Self { popups: Vec::new() }
1281    }
1282
1283    /// Show a popup (adds to top of stack)
1284    pub fn show(&mut self, popup: Popup) {
1285        self.popups.push(popup);
1286    }
1287
1288    /// Show a popup, replacing any existing popup of the same kind.
1289    /// If a popup with the same `PopupKind` already exists in the stack,
1290    /// it is replaced in-place. Otherwise the new popup is pushed on top.
1291    pub fn show_or_replace(&mut self, popup: Popup) {
1292        if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1293            self.popups[pos] = popup;
1294        } else {
1295            self.popups.push(popup);
1296        }
1297    }
1298
1299    /// Hide the topmost popup
1300    pub fn hide(&mut self) -> Option<Popup> {
1301        self.popups.pop()
1302    }
1303
1304    /// Clear all popups
1305    pub fn clear(&mut self) {
1306        self.popups.clear();
1307    }
1308
1309    /// Get the topmost popup
1310    pub fn top(&self) -> Option<&Popup> {
1311        self.popups.last()
1312    }
1313
1314    /// Get mutable reference to topmost popup
1315    pub fn top_mut(&mut self) -> Option<&mut Popup> {
1316        self.popups.last_mut()
1317    }
1318
1319    /// Get reference to popup by index
1320    pub fn get(&self, index: usize) -> Option<&Popup> {
1321        self.popups.get(index)
1322    }
1323
1324    /// Get mutable reference to popup by index
1325    pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1326        self.popups.get_mut(index)
1327    }
1328
1329    /// Check if any popups are visible
1330    pub fn is_visible(&self) -> bool {
1331        !self.popups.is_empty()
1332    }
1333
1334    /// Check if the topmost popup is a completion popup (supports type-to-filter)
1335    pub fn is_completion_popup(&self) -> bool {
1336        self.top()
1337            .map(|p| p.kind == PopupKind::Completion)
1338            .unwrap_or(false)
1339    }
1340
1341    /// Check if the topmost popup is a hover popup
1342    pub fn is_hover_popup(&self) -> bool {
1343        self.top()
1344            .map(|p| p.kind == PopupKind::Hover)
1345            .unwrap_or(false)
1346    }
1347
1348    /// Check if the topmost popup is an action popup
1349    pub fn is_action_popup(&self) -> bool {
1350        self.top()
1351            .map(|p| p.kind == PopupKind::Action)
1352            .unwrap_or(false)
1353    }
1354
1355    /// Get all popups (for rendering)
1356    pub fn all(&self) -> &[Popup] {
1357        &self.popups
1358    }
1359
1360    /// Dismiss transient popups if present at the top.
1361    /// These popups should be dismissed when the buffer loses focus.
1362    /// Returns true if a popup was dismissed.
1363    pub fn dismiss_transient(&mut self) -> bool {
1364        let is_transient = self.popups.last().is_some_and(|p| p.transient);
1365
1366        if is_transient {
1367            self.popups.pop();
1368            true
1369        } else {
1370            false
1371        }
1372    }
1373}
1374
1375impl Default for PopupManager {
1376    fn default() -> Self {
1377        Self::new()
1378    }
1379}
1380
1381/// Overlay OSC 8 hyperlinks in 2-character chunks to keep text layout aligned.
1382///
1383/// This mirrors the approach used in Ratatui's official hyperlink example to
1384/// work around Crossterm width accounting bugs for OSC sequences.
1385fn apply_hyperlink_overlay(
1386    buffer: &mut ratatui::buffer::Buffer,
1387    start_x: u16,
1388    y: u16,
1389    max_x: u16,
1390    text: &str,
1391    url: &str,
1392) {
1393    let mut chunk_index = 0u16;
1394    let mut chars = text.chars();
1395
1396    loop {
1397        let mut chunk = String::new();
1398        for _ in 0..2 {
1399            if let Some(ch) = chars.next() {
1400                chunk.push(ch);
1401            } else {
1402                break;
1403            }
1404        }
1405
1406        if chunk.is_empty() {
1407            break;
1408        }
1409
1410        let x = start_x + chunk_index * 2;
1411        if x >= max_x {
1412            break;
1413        }
1414
1415        let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1416        buffer[(x, y)].set_symbol(&hyperlink);
1417
1418        chunk_index += 1;
1419    }
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::*;
1425    use crate::view::theme;
1426
1427    #[test]
1428    fn test_popup_list_item() {
1429        let item = PopupListItem::new("test".to_string())
1430            .with_detail("detail".to_string())
1431            .with_icon("📄".to_string());
1432
1433        assert_eq!(item.text, "test");
1434        assert_eq!(item.detail, Some("detail".to_string()));
1435        assert_eq!(item.icon, Some("📄".to_string()));
1436    }
1437
1438    #[test]
1439    fn test_popup_selection() {
1440        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1441        let items = vec![
1442            PopupListItem::new("item1".to_string()),
1443            PopupListItem::new("item2".to_string()),
1444            PopupListItem::new("item3".to_string()),
1445        ];
1446
1447        let mut popup = Popup::list(items, &theme);
1448
1449        assert_eq!(popup.selected_item().unwrap().text, "item1");
1450
1451        popup.select_next();
1452        assert_eq!(popup.selected_item().unwrap().text, "item2");
1453
1454        popup.select_next();
1455        assert_eq!(popup.selected_item().unwrap().text, "item3");
1456
1457        popup.select_next(); // Should stay at last item
1458        assert_eq!(popup.selected_item().unwrap().text, "item3");
1459
1460        popup.select_prev();
1461        assert_eq!(popup.selected_item().unwrap().text, "item2");
1462
1463        popup.select_prev();
1464        assert_eq!(popup.selected_item().unwrap().text, "item1");
1465
1466        popup.select_prev(); // Should stay at first item
1467        assert_eq!(popup.selected_item().unwrap().text, "item1");
1468    }
1469
1470    #[test]
1471    fn test_popup_manager() {
1472        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1473        let mut manager = PopupManager::new();
1474
1475        assert!(!manager.is_visible());
1476        assert_eq!(manager.top(), None);
1477
1478        let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1479        manager.show(popup1);
1480
1481        assert!(manager.is_visible());
1482        assert_eq!(manager.all().len(), 1);
1483
1484        let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1485        manager.show(popup2);
1486
1487        assert_eq!(manager.all().len(), 2);
1488
1489        manager.hide();
1490        assert_eq!(manager.all().len(), 1);
1491
1492        manager.clear();
1493        assert!(!manager.is_visible());
1494        assert_eq!(manager.all().len(), 0);
1495    }
1496
1497    #[test]
1498    fn test_popup_area_calculation() {
1499        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1500        let terminal_area = Rect {
1501            x: 0,
1502            y: 0,
1503            width: 100,
1504            height: 50,
1505        };
1506
1507        let popup = Popup::text(vec!["test".to_string()], &theme)
1508            .with_width(30)
1509            .with_max_height(10);
1510
1511        // Centered
1512        let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1513        let area = popup_centered.calculate_area(terminal_area, None);
1514        assert_eq!(area.width, 30);
1515        // Height is now based on content: 1 text line + 2 border lines = 3
1516        assert_eq!(area.height, 3);
1517        assert_eq!(area.x, (100 - 30) / 2);
1518        assert_eq!(area.y, (50 - 3) / 2);
1519
1520        // Below cursor
1521        let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1522        let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1523        assert_eq!(area.x, 20);
1524        assert_eq!(area.y, 11); // One row below cursor
1525    }
1526
1527    #[test]
1528    fn test_popup_fixed_position_clamping() {
1529        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1530        let terminal_area = Rect {
1531            x: 0,
1532            y: 0,
1533            width: 100,
1534            height: 50,
1535        };
1536
1537        let popup = Popup::text(vec!["test".to_string()], &theme)
1538            .with_width(30)
1539            .with_max_height(10);
1540
1541        // Fixed position within bounds - should stay as specified
1542        let popup_fixed = popup
1543            .clone()
1544            .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1545        let area = popup_fixed.calculate_area(terminal_area, None);
1546        assert_eq!(area.x, 10);
1547        assert_eq!(area.y, 20);
1548
1549        // Fixed position at right edge - x should be clamped
1550        let popup_right_edge = popup
1551            .clone()
1552            .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1553        let area = popup_right_edge.calculate_area(terminal_area, None);
1554        // x=99 + width=30 > 100, so x should be clamped to 100-30=70
1555        assert_eq!(area.x, 70);
1556        assert_eq!(area.y, 20);
1557
1558        // Fixed position beyond right edge - x should be clamped
1559        let popup_beyond = popup
1560            .clone()
1561            .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1562        let area = popup_beyond.calculate_area(terminal_area, None);
1563        // x=199 + width=30 > 100, so x should be clamped to 100-30=70
1564        assert_eq!(area.x, 70);
1565        assert_eq!(area.y, 20);
1566
1567        // Fixed position at bottom edge - y should be clamped
1568        let popup_bottom = popup
1569            .clone()
1570            .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1571        let area = popup_bottom.calculate_area(terminal_area, None);
1572        assert_eq!(area.x, 10);
1573        // y=49 + height=3 > 50, so y should be clamped to 50-3=47
1574        assert_eq!(area.y, 47);
1575    }
1576
1577    #[test]
1578    fn test_clamp_rect_to_bounds() {
1579        let bounds = Rect {
1580            x: 0,
1581            y: 0,
1582            width: 100,
1583            height: 50,
1584        };
1585
1586        // Rect within bounds - unchanged
1587        let rect = Rect {
1588            x: 10,
1589            y: 20,
1590            width: 30,
1591            height: 10,
1592        };
1593        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1594        assert_eq!(clamped, rect);
1595
1596        // Rect at exact right edge of bounds
1597        let rect = Rect {
1598            x: 99,
1599            y: 20,
1600            width: 30,
1601            height: 10,
1602        };
1603        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1604        assert_eq!(clamped.x, 99); // x is within bounds
1605        assert_eq!(clamped.width, 1); // width clamped to fit
1606
1607        // Rect beyond bounds
1608        let rect = Rect {
1609            x: 199,
1610            y: 60,
1611            width: 30,
1612            height: 10,
1613        };
1614        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1615        assert_eq!(clamped.x, 99); // x clamped to last valid position
1616        assert_eq!(clamped.y, 49); // y clamped to last valid position
1617        assert_eq!(clamped.width, 1); // width clamped to fit
1618        assert_eq!(clamped.height, 1); // height clamped to fit
1619    }
1620
1621    #[test]
1622    fn hyperlink_overlay_chunks_pairs() {
1623        use ratatui::{buffer::Buffer, layout::Rect};
1624
1625        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1626        buffer[(0, 0)].set_symbol("P");
1627        buffer[(1, 0)].set_symbol("l");
1628        buffer[(2, 0)].set_symbol("a");
1629        buffer[(3, 0)].set_symbol("y");
1630
1631        apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1632
1633        let first = buffer[(0, 0)].symbol().to_string();
1634        let second = buffer[(2, 0)].symbol().to_string();
1635
1636        assert!(
1637            first.contains("Pl"),
1638            "first chunk should contain 'Pl', got {first:?}"
1639        );
1640        assert!(
1641            second.contains("ay"),
1642            "second chunk should contain 'ay', got {second:?}"
1643        );
1644    }
1645
1646    #[test]
1647    fn test_popup_text_selection() {
1648        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1649        let mut popup = Popup::text(
1650            vec![
1651                "Line 0: Hello".to_string(),
1652                "Line 1: World".to_string(),
1653                "Line 2: Test".to_string(),
1654            ],
1655            &theme,
1656        );
1657
1658        // Initially no selection
1659        assert!(!popup.has_selection());
1660        assert_eq!(popup.get_selected_text(), None);
1661
1662        // Start selection at line 0, col 8 ("Hello" starts at col 8)
1663        popup.start_selection(0, 8);
1664        assert!(!popup.has_selection()); // Selection start == end
1665
1666        // Extend selection to line 1, col 8 ("World" starts at col 8)
1667        popup.extend_selection(1, 8);
1668        assert!(popup.has_selection());
1669
1670        // Get selected text: "Hello\nLine 1: "
1671        let selected = popup.get_selected_text().unwrap();
1672        assert_eq!(selected, "Hello\nLine 1: ");
1673
1674        // Clear selection
1675        popup.clear_selection();
1676        assert!(!popup.has_selection());
1677        assert_eq!(popup.get_selected_text(), None);
1678
1679        // Test single-line selection
1680        popup.start_selection(1, 8);
1681        popup.extend_selection(1, 13); // "World"
1682        let selected = popup.get_selected_text().unwrap();
1683        assert_eq!(selected, "World");
1684    }
1685
1686    #[test]
1687    fn test_popup_text_selection_contains() {
1688        let sel = PopupTextSelection {
1689            start: (1, 5),
1690            end: (2, 10),
1691        };
1692
1693        // Line 0 - before selection
1694        assert!(!sel.contains(0, 5));
1695
1696        // Line 1 - start of selection
1697        assert!(!sel.contains(1, 4)); // Before start col
1698        assert!(sel.contains(1, 5)); // At start
1699        assert!(sel.contains(1, 10)); // After start on same line
1700
1701        // Line 2 - end of selection
1702        assert!(sel.contains(2, 0)); // Beginning of last line
1703        assert!(sel.contains(2, 9)); // Before end col
1704        assert!(!sel.contains(2, 10)); // At end (exclusive)
1705        assert!(!sel.contains(2, 11)); // After end
1706
1707        // Line 3 - after selection
1708        assert!(!sel.contains(3, 0));
1709    }
1710
1711    #[test]
1712    fn test_popup_text_selection_normalized() {
1713        // Forward selection
1714        let sel = PopupTextSelection {
1715            start: (1, 5),
1716            end: (2, 10),
1717        };
1718        let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1719        assert_eq!((s_line, s_col), (1, 5));
1720        assert_eq!((e_line, e_col), (2, 10));
1721
1722        // Backward selection (user dragged up)
1723        let sel_backward = PopupTextSelection {
1724            start: (2, 10),
1725            end: (1, 5),
1726        };
1727        let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1728        assert_eq!((s_line, s_col), (1, 5));
1729        assert_eq!((e_line, e_col), (2, 10));
1730    }
1731}