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