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