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