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) = cursor_pos.unwrap_or((
876                    terminal_area.x + terminal_area.width / 2,
877                    terminal_area.y + terminal_area.height / 2,
878                ));
879
880                let width = self.width.min(terminal_area.width);
881                // Use the minimum of max_height, actual content height, and terminal height
882                let height = self
883                    .content_height()
884                    .min(self.max_height)
885                    .min(terminal_area.height);
886
887                // `cursor_*` are absolute screen coordinates, so clamp
888                // against the area's absolute edges. The area's `x`/`y`
889                // are non-zero when rendering into the chrome area beside
890                // a left dock; using the bare width/height here would clamp
891                // relative to column 0 and pull the popup under the dock.
892                let right = terminal_area.x + terminal_area.width;
893                let bottom = terminal_area.y + terminal_area.height;
894
895                let x = if cursor_x + width > right {
896                    right.saturating_sub(width)
897                } else {
898                    cursor_x
899                }
900                .max(terminal_area.x);
901
902                let y = match self.position {
903                    PopupPosition::AtCursor => cursor_y,
904                    PopupPosition::BelowCursor => {
905                        if cursor_y + 1 + height > bottom {
906                            // Not enough space below, put above cursor
907                            cursor_y.saturating_sub(height)
908                        } else {
909                            // Directly below cursor
910                            cursor_y + 1
911                        }
912                    }
913                    PopupPosition::AboveCursor => {
914                        // Position so bottom of popup is one row above cursor
915                        (cursor_y + 1).saturating_sub(height)
916                    }
917                    _ => cursor_y,
918                };
919
920                Rect {
921                    x,
922                    y,
923                    width,
924                    height,
925                }
926            }
927            PopupPosition::Fixed { x, y } => {
928                let width = self.width.min(terminal_area.width);
929                let height = self
930                    .content_height()
931                    .min(self.max_height)
932                    .min(terminal_area.height);
933                // Clamp x and y to ensure popup stays within the area's
934                // absolute bounds (its `x`/`y` may be offset by a left dock).
935                let right = terminal_area.x + terminal_area.width;
936                let bottom = terminal_area.y + terminal_area.height;
937                let x = if x + width > right {
938                    right.saturating_sub(width)
939                } else {
940                    x
941                };
942                let y = if y + height > bottom {
943                    bottom.saturating_sub(height)
944                } else {
945                    y
946                };
947                Rect {
948                    x,
949                    y,
950                    width,
951                    height,
952                }
953            }
954            PopupPosition::Centered => {
955                let width = self.width.min(terminal_area.width);
956                let height = self
957                    .content_height()
958                    .min(self.max_height)
959                    .min(terminal_area.height);
960                let x = terminal_area.x + (terminal_area.width.saturating_sub(width)) / 2;
961                let y = terminal_area.y + (terminal_area.height.saturating_sub(height)) / 2;
962                Rect {
963                    x,
964                    y,
965                    width,
966                    height,
967                }
968            }
969            PopupPosition::CenteredOverlay {
970                width_pct,
971                height_pct,
972            } => {
973                let w_pct = width_pct.clamp(1, 100) as u32;
974                let h_pct = height_pct.clamp(1, 100) as u32;
975                let width = ((terminal_area.width as u32 * w_pct) / 100) as u16;
976                let height = ((terminal_area.height as u32 * h_pct) / 100) as u16;
977                let width = width.max(1).min(terminal_area.width);
978                let height = height.max(1).min(terminal_area.height);
979                let x = terminal_area.x + (terminal_area.width.saturating_sub(width)) / 2;
980                let y = terminal_area.y + (terminal_area.height.saturating_sub(height)) / 2;
981                Rect {
982                    x,
983                    y,
984                    width,
985                    height,
986                }
987            }
988            PopupPosition::BottomRight => {
989                let width = self.width.min(terminal_area.width);
990                let height = self
991                    .content_height()
992                    .min(self.max_height)
993                    .min(terminal_area.height);
994                // Position in bottom right of the area, leaving 2 rows for
995                // the status bar. Offset by the area's `x`/`y` so the popup
996                // hugs the chrome's right edge (beside a left dock) rather
997                // than floating mid-screen.
998                let x = terminal_area.x + terminal_area.width.saturating_sub(width);
999                let y = terminal_area.y
1000                    + terminal_area
1001                        .height
1002                        .saturating_sub(height)
1003                        .saturating_sub(2);
1004                Rect {
1005                    x,
1006                    y,
1007                    width,
1008                    height,
1009                }
1010            }
1011            PopupPosition::AboveStatusBarAt { x, status_row } => {
1012                let width = self.width.min(terminal_area.width);
1013                let height = self
1014                    .content_height()
1015                    .min(self.max_height)
1016                    .min(terminal_area.height);
1017                // Reserve the rightmost column for the editor scrollbar.
1018                // Without the reservation, a popup that overflows the
1019                // right edge gets clamped flush to the area's right edge
1020                // and its right border paints over the scrollbar of the
1021                // split underneath. The right edge is absolute so the
1022                // clamp respects a left-dock offset.
1023                let right = terminal_area.x + terminal_area.width;
1024                let max_x = right.saturating_sub(width).saturating_sub(1);
1025                let x = x.min(max_x).max(terminal_area.x);
1026                // Sit the popup's bottom border on the row immediately
1027                // above the status bar. Anchoring to `status_row` (rather
1028                // than `terminal_area.height - 2`) keeps the popup hugging
1029                // the status bar in both prompt-visible and prompt-auto-
1030                // hide modes — the prompt-line constraint shifts the
1031                // status bar's row by one when it disappears.
1032                let y = status_row.saturating_sub(height);
1033                Rect {
1034                    x,
1035                    y,
1036                    width,
1037                    height,
1038                }
1039            }
1040        }
1041    }
1042
1043    /// Render the popup to the frame
1044    pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
1045        self.render_with_hover(frame, area, theme, None);
1046    }
1047
1048    /// Render the popup to the frame with hover highlighting
1049    pub fn render_with_hover(
1050        &self,
1051        frame: &mut Frame,
1052        area: Rect,
1053        theme: &crate::view::theme::Theme,
1054        hover_target: Option<&crate::app::HoverTarget>,
1055    ) {
1056        // Defensive bounds checking: clamp area to frame bounds to prevent panic
1057        let frame_area = frame.area();
1058        let area = clamp_rect_to_bounds(area, frame_area);
1059
1060        // Skip rendering if area is empty after clamping
1061        if area.width == 0 || area.height == 0 {
1062            return;
1063        }
1064
1065        // Clear the area behind the popup first to hide underlying text
1066        frame.render_widget(Clear, area);
1067
1068        let rendered_title = self.render_title();
1069        let block = if self.bordered {
1070            let mut block = Block::default()
1071                .borders(Borders::ALL)
1072                .border_style(self.border_style)
1073                .style(self.background_style);
1074
1075            if let Some(title) = rendered_title.as_deref() {
1076                block = block.title(title);
1077            }
1078
1079            block
1080        } else {
1081            Block::default().style(self.background_style)
1082        };
1083
1084        let inner_area = block.inner(area);
1085        frame.render_widget(block, area);
1086
1087        // Close-button overlay on the top border ("[×]", bracketed so the
1088        // click target is 3 cells wide and obviously a UI affordance rather
1089        // than stray content).  Rendered only for bordered popups that are
1090        // big enough to accommodate it without colliding with the title.
1091        // The workspace-trust prompt is a forced choice with no dismiss, so it
1092        // gets no close button.
1093        let dismissible = !matches!(self.resolver, PopupResolver::WorkspaceTrust);
1094        if self.bordered && area.width >= 5 && dismissible {
1095            let close_x = area.x + area.width - 4;
1096            let close_area = Rect {
1097                x: close_x,
1098                y: area.y,
1099                width: 3,
1100                height: 1,
1101            };
1102            frame.render_widget(Paragraph::new("[×]").style(self.border_style), close_area);
1103        }
1104
1105        // Render description if present, and adjust content area
1106        let content_start_y;
1107        if let Some(desc) = &self.description {
1108            // Word-wrap description to fit inner width
1109            let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; // Leave some padding
1110            let desc_vec = vec![desc.clone()];
1111            let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
1112            let desc_lines: usize = wrapped_desc.len();
1113
1114            // Render each description line
1115            for (i, line) in wrapped_desc.iter().enumerate() {
1116                if i >= inner_area.height as usize {
1117                    break;
1118                }
1119                let line_area = Rect {
1120                    x: inner_area.x,
1121                    y: inner_area.y + i as u16,
1122                    width: inner_area.width,
1123                    height: 1,
1124                };
1125                let desc_style = Style::default().fg(theme.help_separator_fg);
1126                frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
1127            }
1128
1129            // Add blank line after description
1130            content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
1131        } else {
1132            content_start_y = inner_area.y;
1133        }
1134
1135        // Adjust inner_area to start after description
1136        let inner_area = Rect {
1137            x: inner_area.x,
1138            y: content_start_y,
1139            width: inner_area.width,
1140            height: inner_area
1141                .height
1142                .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
1143        };
1144
1145        // For text and markdown content, we need to wrap first to determine if scrollbar is needed.
1146        // We wrap to the width that would be available if scrollbar is shown (conservative approach).
1147        let scrollbar_reserved_width = 2; // 1 for scrollbar + 1 for spacing
1148        let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
1149        let visible_lines_count = inner_area.height as usize;
1150
1151        // Calculate wrapped line count and determine if scrollbar is needed
1152        let (wrapped_total_lines, needs_scrollbar) = match &self.content {
1153            PopupContent::Text(lines) => {
1154                let wrapped = wrap_text_lines(lines, wrap_width);
1155                let count = wrapped.len();
1156                (
1157                    count,
1158                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1159                )
1160            }
1161            PopupContent::Markdown(styled_lines) => {
1162                let wrapped = wrap_styled_lines(styled_lines, wrap_width);
1163                let count = wrapped.len();
1164                (
1165                    count,
1166                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1167                )
1168            }
1169            PopupContent::List { items, .. } => {
1170                let count = items.len();
1171                (
1172                    count,
1173                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1174                )
1175            }
1176            PopupContent::Custom(lines) => {
1177                let count = lines.len();
1178                (
1179                    count,
1180                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
1181                )
1182            }
1183        };
1184
1185        // Adjust content area to leave room for scrollbar if needed
1186        let content_area = if needs_scrollbar {
1187            Rect {
1188                x: inner_area.x,
1189                y: inner_area.y,
1190                width: inner_area.width.saturating_sub(scrollbar_reserved_width),
1191                height: inner_area.height,
1192            }
1193        } else {
1194            inner_area
1195        };
1196
1197        match &self.content {
1198            PopupContent::Text(lines) => {
1199                // Word-wrap lines to fit content area width
1200                let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
1201                let selection_style = Style::default().bg(theme.selection_bg);
1202
1203                let visible_lines: Vec<Line> = wrapped_lines
1204                    .iter()
1205                    .enumerate()
1206                    .skip(self.scroll_offset)
1207                    .take(content_area.height as usize)
1208                    .map(|(line_idx, line)| {
1209                        if let Some(ref sel) = self.text_selection {
1210                            // Apply selection highlighting
1211                            let chars: Vec<char> = line.chars().collect();
1212                            let spans: Vec<Span> = chars
1213                                .iter()
1214                                .enumerate()
1215                                .map(|(col, ch)| {
1216                                    if sel.contains(line_idx, col) {
1217                                        Span::styled(ch.to_string(), selection_style)
1218                                    } else {
1219                                        Span::raw(ch.to_string())
1220                                    }
1221                                })
1222                                .collect();
1223                            Line::from(spans)
1224                        } else {
1225                            Line::from(line.as_str())
1226                        }
1227                    })
1228                    .collect();
1229
1230                let paragraph = Paragraph::new(visible_lines);
1231                frame.render_widget(paragraph, content_area);
1232            }
1233            PopupContent::Markdown(styled_lines) => {
1234                // Word-wrap styled lines to fit content area width
1235                let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1236                let selection_style = Style::default().bg(theme.selection_bg);
1237
1238                // Collect link overlay info for OSC 8 rendering after the main draw
1239                // Each entry: (visible_line_idx, start_column, link_text, url)
1240                let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1241
1242                let visible_lines: Vec<Line> = wrapped_lines
1243                    .iter()
1244                    .enumerate()
1245                    .skip(self.scroll_offset)
1246                    .take(content_area.height as usize)
1247                    .map(|(line_idx, styled_line)| {
1248                        let mut col = 0usize;
1249                        let spans: Vec<Span> = styled_line
1250                            .spans
1251                            .iter()
1252                            .flat_map(|s| {
1253                                let span_start_col = col;
1254                                let span_width =
1255                                    unicode_width::UnicodeWidthStr::width(s.text.as_str());
1256                                if let Some(url) = &s.link_url {
1257                                    link_overlays.push((
1258                                        line_idx - self.scroll_offset,
1259                                        col,
1260                                        s.text.clone(),
1261                                        url.clone(),
1262                                    ));
1263                                }
1264                                col += span_width;
1265
1266                                // Check if any part of this span is selected
1267                                if let Some(ref sel) = self.text_selection {
1268                                    // Split span into selected/unselected parts
1269                                    let chars: Vec<char> = s.text.chars().collect();
1270                                    chars
1271                                        .iter()
1272                                        .enumerate()
1273                                        .map(|(i, ch)| {
1274                                            let char_col = span_start_col + i;
1275                                            if sel.contains(line_idx, char_col) {
1276                                                Span::styled(ch.to_string(), selection_style)
1277                                            } else {
1278                                                Span::styled(ch.to_string(), s.style)
1279                                            }
1280                                        })
1281                                        .collect::<Vec<_>>()
1282                                } else {
1283                                    vec![Span::styled(s.text.clone(), s.style)]
1284                                }
1285                            })
1286                            .collect();
1287                        Line::from(spans)
1288                    })
1289                    .collect();
1290
1291                let paragraph = Paragraph::new(visible_lines);
1292                frame.render_widget(paragraph, content_area);
1293
1294                // Apply OSC 8 hyperlinks following Ratatui's official workaround
1295                let buffer = frame.buffer_mut();
1296                let max_x = content_area.x + content_area.width;
1297                for (line_idx, col_start, text, url) in link_overlays {
1298                    let y = content_area.y + line_idx as u16;
1299                    if y >= content_area.y + content_area.height {
1300                        continue;
1301                    }
1302                    let start_x = content_area.x + col_start as u16;
1303                    apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1304                }
1305            }
1306            PopupContent::List { items, selected } => {
1307                let list_items: Vec<ListItem> = items
1308                    .iter()
1309                    .enumerate()
1310                    .skip(self.scroll_offset)
1311                    .take(content_area.height as usize)
1312                    .map(|(idx, item)| {
1313                        // Check if this item is hovered or selected
1314                        let is_hovered = matches!(
1315                            hover_target,
1316                            Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1317                        );
1318                        let is_selected = idx == *selected;
1319
1320                        let mut spans = Vec::new();
1321
1322                        // Add icon if present
1323                        if let Some(icon) = &item.icon {
1324                            spans.push(Span::raw(format!("{} ", icon)));
1325                        }
1326
1327                        // Add main text.  Items are "clickable" when they
1328                        // carry a `data` payload and are not disabled — those
1329                        // get an underline (like a link) so the user can see
1330                        // at a glance which rows act on click.  Header-only
1331                        // rows (no data) stay plain; disabled rows are dimmed.
1332                        // Leading whitespace is kept separate so the underline
1333                        // only sits under the visible text.
1334                        let text = &item.text;
1335                        let trimmed = text.trim_start();
1336                        let indent_len = text.len() - trimmed.len();
1337                        if indent_len > 0 {
1338                            spans.push(Span::raw(&text[..indent_len]));
1339                        }
1340                        let is_clickable = item.data.is_some() && !item.disabled;
1341                        let mut text_style = Style::default();
1342                        if is_selected {
1343                            text_style = text_style.add_modifier(Modifier::BOLD);
1344                        }
1345                        if is_clickable {
1346                            text_style = text_style.add_modifier(Modifier::UNDERLINED);
1347                        }
1348                        if item.disabled {
1349                            text_style = text_style
1350                                .fg(theme.help_separator_fg)
1351                                .add_modifier(Modifier::DIM);
1352                        }
1353                        spans.push(Span::styled(trimmed, text_style));
1354
1355                        // Add detail if present
1356                        if let Some(detail) = &item.detail {
1357                            spans.push(Span::styled(
1358                                format!(" {}", detail),
1359                                Style::default().fg(theme.help_separator_fg),
1360                            ));
1361                        }
1362
1363                        // Add an empty span without underline so ratatui doesn't
1364                        // extend the underline across the remaining row padding.
1365                        spans.push(Span::raw(""));
1366
1367                        // Add right-aligned accept key hint on the selected item
1368                        if is_selected {
1369                            if let Some(ref hint) = self.accept_key_hint {
1370                                let hint_text = format!("({})", hint);
1371                                // Calculate used width
1372                                let used_width: usize = spans
1373                                    .iter()
1374                                    .map(|s| {
1375                                        unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1376                                    })
1377                                    .sum();
1378                                let available = content_area.width as usize;
1379                                let hint_len = hint_text.len();
1380                                if used_width + hint_len + 1 < available {
1381                                    let padding = available - used_width - hint_len;
1382                                    spans.push(Span::raw(" ".repeat(padding)));
1383                                    spans.push(Span::styled(
1384                                        hint_text,
1385                                        Style::default().fg(theme.help_separator_fg),
1386                                    ));
1387                                }
1388                            }
1389                        }
1390
1391                        // Row style (background only, no underline)
1392                        let row_style = if is_selected {
1393                            Style::default().bg(theme.popup_selection_bg)
1394                        } else if is_hovered {
1395                            Style::default()
1396                                .bg(theme.menu_hover_bg)
1397                                .fg(theme.menu_hover_fg)
1398                        } else {
1399                            Style::default()
1400                        };
1401
1402                        ListItem::new(Line::from(spans)).style(row_style)
1403                    })
1404                    .collect();
1405
1406                let list = List::new(list_items);
1407                frame.render_widget(list, content_area);
1408            }
1409            PopupContent::Custom(lines) => {
1410                let visible_lines: Vec<Line> = lines
1411                    .iter()
1412                    .skip(self.scroll_offset)
1413                    .take(content_area.height as usize)
1414                    .map(|line| Line::from(line.as_str()))
1415                    .collect();
1416
1417                let paragraph = Paragraph::new(visible_lines);
1418                frame.render_widget(paragraph, content_area);
1419            }
1420        }
1421
1422        // Render scrollbar if needed
1423        if needs_scrollbar {
1424            let scrollbar_area = Rect {
1425                x: inner_area.x + inner_area.width - 1,
1426                y: inner_area.y,
1427                width: 1,
1428                height: inner_area.height,
1429            };
1430
1431            let scrollbar_state =
1432                ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1433            let scrollbar_colors = ScrollbarColors::from_theme(theme);
1434            render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1435        }
1436    }
1437}
1438
1439/// Manager for popups - can show multiple popups with z-ordering
1440#[derive(Debug, Clone)]
1441pub struct PopupManager {
1442    /// Stack of active popups (top of stack = topmost popup)
1443    popups: Vec<Popup>,
1444}
1445
1446impl PopupManager {
1447    pub fn new() -> Self {
1448        Self { popups: Vec::new() }
1449    }
1450
1451    /// Show a popup (adds to top of stack)
1452    pub fn show(&mut self, popup: Popup) {
1453        self.popups.push(popup);
1454    }
1455
1456    /// Show a popup, replacing any existing popup of the same kind.
1457    /// If a popup with the same `PopupKind` already exists in the stack,
1458    /// it is replaced in-place. Otherwise the new popup is pushed on top.
1459    pub fn show_or_replace(&mut self, popup: Popup) {
1460        if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1461            self.popups[pos] = popup;
1462        } else {
1463            self.popups.push(popup);
1464        }
1465    }
1466
1467    /// Hide the topmost popup
1468    pub fn hide(&mut self) -> Option<Popup> {
1469        self.popups.pop()
1470    }
1471
1472    /// Clear all popups
1473    pub fn clear(&mut self) {
1474        self.popups.clear();
1475    }
1476
1477    /// Get the topmost popup
1478    pub fn top(&self) -> Option<&Popup> {
1479        self.popups.last()
1480    }
1481
1482    /// Get mutable reference to topmost popup
1483    pub fn top_mut(&mut self) -> Option<&mut Popup> {
1484        self.popups.last_mut()
1485    }
1486
1487    /// Get reference to popup by index
1488    pub fn get(&self, index: usize) -> Option<&Popup> {
1489        self.popups.get(index)
1490    }
1491
1492    /// Get mutable reference to popup by index
1493    pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1494        self.popups.get_mut(index)
1495    }
1496
1497    /// Check if any popups are visible
1498    pub fn is_visible(&self) -> bool {
1499        !self.popups.is_empty()
1500    }
1501
1502    /// Check if the topmost popup is a completion popup (supports type-to-filter)
1503    pub fn is_completion_popup(&self) -> bool {
1504        self.top()
1505            .map(|p| p.kind == PopupKind::Completion)
1506            .unwrap_or(false)
1507    }
1508
1509    /// Check if the topmost popup is a hover popup
1510    pub fn is_hover_popup(&self) -> bool {
1511        self.top()
1512            .map(|p| p.kind == PopupKind::Hover)
1513            .unwrap_or(false)
1514    }
1515
1516    /// Check if the topmost popup is an action popup
1517    pub fn is_action_popup(&self) -> bool {
1518        self.top()
1519            .map(|p| p.kind == PopupKind::Action)
1520            .unwrap_or(false)
1521    }
1522
1523    /// Get all popups (for rendering)
1524    pub fn all(&self) -> &[Popup] {
1525        &self.popups
1526    }
1527
1528    /// Dismiss transient popups if present at the top.
1529    /// These popups should be dismissed when the buffer loses focus.
1530    /// Returns true if a popup was dismissed.
1531    pub fn dismiss_transient(&mut self) -> bool {
1532        let is_transient = self.popups.last().is_some_and(|p| p.transient);
1533
1534        if is_transient {
1535            self.popups.pop();
1536            true
1537        } else {
1538            false
1539        }
1540    }
1541}
1542
1543impl Default for PopupManager {
1544    fn default() -> Self {
1545        Self::new()
1546    }
1547}
1548
1549/// Overlay OSC 8 hyperlinks in 2-character chunks to keep text layout aligned.
1550///
1551/// This mirrors the approach used in Ratatui's official hyperlink example to
1552/// work around Crossterm width accounting bugs for OSC sequences.
1553fn apply_hyperlink_overlay(
1554    buffer: &mut ratatui::buffer::Buffer,
1555    start_x: u16,
1556    y: u16,
1557    max_x: u16,
1558    text: &str,
1559    url: &str,
1560) {
1561    let mut chunk_index = 0u16;
1562    let mut chars = text.chars();
1563
1564    loop {
1565        let mut chunk = String::new();
1566        for _ in 0..2 {
1567            if let Some(ch) = chars.next() {
1568                chunk.push(ch);
1569            } else {
1570                break;
1571            }
1572        }
1573
1574        if chunk.is_empty() {
1575            break;
1576        }
1577
1578        let x = start_x + chunk_index * 2;
1579        if x >= max_x {
1580            break;
1581        }
1582
1583        let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1584        buffer[(x, y)].set_symbol(&hyperlink);
1585
1586        chunk_index += 1;
1587    }
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592    use super::*;
1593    use crate::view::theme;
1594
1595    #[test]
1596    fn test_popup_list_item() {
1597        let item = PopupListItem::new("test".to_string())
1598            .with_detail("detail".to_string())
1599            .with_icon("📄".to_string());
1600
1601        assert_eq!(item.text, "test");
1602        assert_eq!(item.detail, Some("detail".to_string()));
1603        assert_eq!(item.icon, Some("📄".to_string()));
1604    }
1605
1606    #[test]
1607    fn test_popup_selection() {
1608        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1609        let items = vec![
1610            PopupListItem::new("item1".to_string()),
1611            PopupListItem::new("item2".to_string()),
1612            PopupListItem::new("item3".to_string()),
1613        ];
1614
1615        let mut popup = Popup::list(items, &theme);
1616
1617        assert_eq!(popup.selected_item().unwrap().text, "item1");
1618
1619        popup.select_next();
1620        assert_eq!(popup.selected_item().unwrap().text, "item2");
1621
1622        popup.select_next();
1623        assert_eq!(popup.selected_item().unwrap().text, "item3");
1624
1625        popup.select_next(); // Should stay at last item
1626        assert_eq!(popup.selected_item().unwrap().text, "item3");
1627
1628        popup.select_prev();
1629        assert_eq!(popup.selected_item().unwrap().text, "item2");
1630
1631        popup.select_prev();
1632        assert_eq!(popup.selected_item().unwrap().text, "item1");
1633
1634        popup.select_prev(); // Should stay at first item
1635        assert_eq!(popup.selected_item().unwrap().text, "item1");
1636    }
1637
1638    #[test]
1639    fn test_popup_manager() {
1640        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1641        let mut manager = PopupManager::new();
1642
1643        assert!(!manager.is_visible());
1644        assert_eq!(manager.top(), None);
1645
1646        let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1647        manager.show(popup1);
1648
1649        assert!(manager.is_visible());
1650        assert_eq!(manager.all().len(), 1);
1651
1652        let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1653        manager.show(popup2);
1654
1655        assert_eq!(manager.all().len(), 2);
1656
1657        manager.hide();
1658        assert_eq!(manager.all().len(), 1);
1659
1660        manager.clear();
1661        assert!(!manager.is_visible());
1662        assert_eq!(manager.all().len(), 0);
1663    }
1664
1665    #[test]
1666    fn test_popup_area_calculation() {
1667        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1668        let terminal_area = Rect {
1669            x: 0,
1670            y: 0,
1671            width: 100,
1672            height: 50,
1673        };
1674
1675        let popup = Popup::text(vec!["test".to_string()], &theme)
1676            .with_width(30)
1677            .with_max_height(10);
1678
1679        // Centered
1680        let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1681        let area = popup_centered.calculate_area(terminal_area, None);
1682        assert_eq!(area.width, 30);
1683        // Height is now based on content: 1 text line + 2 border lines = 3
1684        assert_eq!(area.height, 3);
1685        assert_eq!(area.x, (100 - 30) / 2);
1686        assert_eq!(area.y, (50 - 3) / 2);
1687
1688        // Below cursor
1689        let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1690        let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1691        assert_eq!(area.x, 20);
1692        assert_eq!(area.y, 11); // One row below cursor
1693    }
1694
1695    /// Popups computed against a dock-offset area (`x > 0`, e.g. the
1696    /// `chrome_area` to the right of the orchestrator left dock) must stay
1697    /// inside that area instead of being placed relative to column 0 and
1698    /// bleeding left under the dock. Regression test for the same class of
1699    /// bug as the keybinding-editor modal centering.
1700    #[test]
1701    fn test_popup_area_respects_left_dock_offset() {
1702        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1703        // 34-column left dock carved off a 120x40 screen.
1704        let chrome = Rect {
1705            x: 34,
1706            y: 0,
1707            width: 120 - 34,
1708            height: 40,
1709        };
1710
1711        let popup = Popup::text(vec!["test".to_string()], &theme)
1712            .with_width(30)
1713            .with_max_height(10);
1714
1715        // Centered: centred within the chrome area, never under the dock.
1716        let centered = popup
1717            .clone()
1718            .with_position(PopupPosition::Centered)
1719            .calculate_area(chrome, None);
1720        assert!(centered.x >= chrome.x, "centered popup bleeds under dock");
1721        assert_eq!(centered.x, chrome.x + (chrome.width - 30) / 2);
1722        assert!(centered.x + centered.width <= chrome.x + chrome.width);
1723
1724        // CenteredOverlay: same — offset by the area origin.
1725        let overlay = popup
1726            .clone()
1727            .with_position(PopupPosition::CenteredOverlay {
1728                width_pct: 50,
1729                height_pct: 50,
1730            })
1731            .calculate_area(chrome, None);
1732        assert!(overlay.x >= chrome.x);
1733        assert!(overlay.x + overlay.width <= chrome.x + chrome.width);
1734
1735        // BottomRight: hugs the chrome's right/bottom edges.
1736        let br = popup
1737            .clone()
1738            .with_position(PopupPosition::BottomRight)
1739            .calculate_area(chrome, None);
1740        assert_eq!(br.x + br.width, chrome.x + chrome.width);
1741        assert!(br.x >= chrome.x);
1742
1743        // Cursor-anchored near the right edge: clamped to the chrome's
1744        // absolute right edge, not column `chrome.width`.
1745        let below = popup
1746            .clone()
1747            .with_position(PopupPosition::BelowCursor)
1748            .calculate_area(chrome, Some((115, 10)));
1749        assert!(below.x >= chrome.x, "cursor popup bleeds under dock");
1750        assert!(below.x + below.width <= chrome.x + chrome.width);
1751    }
1752
1753    #[test]
1754    fn test_popup_fixed_position_clamping() {
1755        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1756        let terminal_area = Rect {
1757            x: 0,
1758            y: 0,
1759            width: 100,
1760            height: 50,
1761        };
1762
1763        let popup = Popup::text(vec!["test".to_string()], &theme)
1764            .with_width(30)
1765            .with_max_height(10);
1766
1767        // Fixed position within bounds - should stay as specified
1768        let popup_fixed = popup
1769            .clone()
1770            .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1771        let area = popup_fixed.calculate_area(terminal_area, None);
1772        assert_eq!(area.x, 10);
1773        assert_eq!(area.y, 20);
1774
1775        // Fixed position at right edge - x should be clamped
1776        let popup_right_edge = popup
1777            .clone()
1778            .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1779        let area = popup_right_edge.calculate_area(terminal_area, None);
1780        // x=99 + width=30 > 100, so x should be clamped to 100-30=70
1781        assert_eq!(area.x, 70);
1782        assert_eq!(area.y, 20);
1783
1784        // Fixed position beyond right edge - x should be clamped
1785        let popup_beyond = popup
1786            .clone()
1787            .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1788        let area = popup_beyond.calculate_area(terminal_area, None);
1789        // x=199 + width=30 > 100, so x should be clamped to 100-30=70
1790        assert_eq!(area.x, 70);
1791        assert_eq!(area.y, 20);
1792
1793        // Fixed position at bottom edge - y should be clamped
1794        let popup_bottom = popup
1795            .clone()
1796            .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1797        let area = popup_bottom.calculate_area(terminal_area, None);
1798        assert_eq!(area.x, 10);
1799        // y=49 + height=3 > 50, so y should be clamped to 50-3=47
1800        assert_eq!(area.y, 47);
1801    }
1802
1803    #[test]
1804    fn test_clamp_rect_to_bounds() {
1805        let bounds = Rect {
1806            x: 0,
1807            y: 0,
1808            width: 100,
1809            height: 50,
1810        };
1811
1812        // Rect within bounds - unchanged
1813        let rect = Rect {
1814            x: 10,
1815            y: 20,
1816            width: 30,
1817            height: 10,
1818        };
1819        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1820        assert_eq!(clamped, rect);
1821
1822        // Rect at exact right edge of bounds
1823        let rect = Rect {
1824            x: 99,
1825            y: 20,
1826            width: 30,
1827            height: 10,
1828        };
1829        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1830        assert_eq!(clamped.x, 99); // x is within bounds
1831        assert_eq!(clamped.width, 1); // width clamped to fit
1832
1833        // Rect beyond bounds
1834        let rect = Rect {
1835            x: 199,
1836            y: 60,
1837            width: 30,
1838            height: 10,
1839        };
1840        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1841        assert_eq!(clamped.x, 99); // x clamped to last valid position
1842        assert_eq!(clamped.y, 49); // y clamped to last valid position
1843        assert_eq!(clamped.width, 1); // width clamped to fit
1844        assert_eq!(clamped.height, 1); // height clamped to fit
1845    }
1846
1847    #[test]
1848    fn hyperlink_overlay_chunks_pairs() {
1849        use ratatui::{buffer::Buffer, layout::Rect};
1850
1851        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1852        buffer[(0, 0)].set_symbol("P");
1853        buffer[(1, 0)].set_symbol("l");
1854        buffer[(2, 0)].set_symbol("a");
1855        buffer[(3, 0)].set_symbol("y");
1856
1857        apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1858
1859        let first = buffer[(0, 0)].symbol().to_string();
1860        let second = buffer[(2, 0)].symbol().to_string();
1861
1862        assert!(
1863            first.contains("Pl"),
1864            "first chunk should contain 'Pl', got {first:?}"
1865        );
1866        assert!(
1867            second.contains("ay"),
1868            "second chunk should contain 'ay', got {second:?}"
1869        );
1870    }
1871
1872    #[test]
1873    fn test_popup_text_selection() {
1874        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1875        let mut popup = Popup::text(
1876            vec![
1877                "Line 0: Hello".to_string(),
1878                "Line 1: World".to_string(),
1879                "Line 2: Test".to_string(),
1880            ],
1881            &theme,
1882        );
1883
1884        // Initially no selection
1885        assert!(!popup.has_selection());
1886        assert_eq!(popup.get_selected_text(), None);
1887
1888        // Start selection at line 0, col 8 ("Hello" starts at col 8)
1889        popup.start_selection(0, 8);
1890        assert!(!popup.has_selection()); // Selection start == end
1891
1892        // Extend selection to line 1, col 8 ("World" starts at col 8)
1893        popup.extend_selection(1, 8);
1894        assert!(popup.has_selection());
1895
1896        // Get selected text: "Hello\nLine 1: "
1897        let selected = popup.get_selected_text().unwrap();
1898        assert_eq!(selected, "Hello\nLine 1: ");
1899
1900        // Clear selection
1901        popup.clear_selection();
1902        assert!(!popup.has_selection());
1903        assert_eq!(popup.get_selected_text(), None);
1904
1905        // Test single-line selection
1906        popup.start_selection(1, 8);
1907        popup.extend_selection(1, 13); // "World"
1908        let selected = popup.get_selected_text().unwrap();
1909        assert_eq!(selected, "World");
1910    }
1911
1912    #[test]
1913    fn test_popup_text_selection_contains() {
1914        let sel = PopupTextSelection {
1915            start: (1, 5),
1916            end: (2, 10),
1917        };
1918
1919        // Line 0 - before selection
1920        assert!(!sel.contains(0, 5));
1921
1922        // Line 1 - start of selection
1923        assert!(!sel.contains(1, 4)); // Before start col
1924        assert!(sel.contains(1, 5)); // At start
1925        assert!(sel.contains(1, 10)); // After start on same line
1926
1927        // Line 2 - end of selection
1928        assert!(sel.contains(2, 0)); // Beginning of last line
1929        assert!(sel.contains(2, 9)); // Before end col
1930        assert!(!sel.contains(2, 10)); // At end (exclusive)
1931        assert!(!sel.contains(2, 11)); // After end
1932
1933        // Line 3 - after selection
1934        assert!(!sel.contains(3, 0));
1935    }
1936
1937    #[test]
1938    fn test_popup_text_selection_normalized() {
1939        // Forward selection
1940        let sel = PopupTextSelection {
1941            start: (1, 5),
1942            end: (2, 10),
1943        };
1944        let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1945        assert_eq!((s_line, s_col), (1, 5));
1946        assert_eq!((e_line, e_col), (2, 10));
1947
1948        // Backward selection (user dragged up)
1949        let sel_backward = PopupTextSelection {
1950            start: (2, 10),
1951            end: (1, 5),
1952        };
1953        let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1954        assert_eq!((s_line, s_col), (1, 5));
1955        assert_eq!((e_line, e_col), (2, 10));
1956    }
1957}