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