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