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