Skip to main content

fresh/view/
popup.rs

1use ratatui::{
2    layout::Rect,
3    style::{Modifier, Style},
4    text::{Line, Span},
5    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
6    Frame,
7};
8
9use super::markdown::{parse_markdown, wrap_styled_lines, wrap_text_lines, StyledLine};
10
11pub mod input;
12use super::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
13use crate::primitives::grammar::GrammarRegistry;
14
15/// Clamp a rectangle to fit within bounds, preventing out-of-bounds rendering panics.
16/// Returns a rectangle that is guaranteed to be fully contained within `bounds`.
17fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
18    // Clamp x to be within bounds
19    let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
20    // Clamp y to be within bounds
21    let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
22
23    // Calculate maximum possible width/height from the clamped position
24    let max_width = (bounds.x + bounds.width).saturating_sub(x);
25    let max_height = (bounds.y + bounds.height).saturating_sub(y);
26
27    Rect {
28        x,
29        y,
30        width: rect.width.min(max_width),
31        height: rect.height.min(max_height),
32    }
33}
34
35/// Position of a popup relative to a point in the buffer
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PopupPosition {
38    /// At cursor position
39    AtCursor,
40    /// Below cursor position
41    BelowCursor,
42    /// Above cursor position
43    AboveCursor,
44    /// Fixed screen coordinates (x, y)
45    Fixed { x: u16, y: u16 },
46    /// Centered on screen
47    Centered,
48    /// Bottom right corner (above status bar)
49    BottomRight,
50}
51
52/// Kind of popup - determines input handling behavior
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PopupKind {
55    /// LSP completion popup - supports type-to-filter, Tab/Enter accept
56    Completion,
57    /// Hover/documentation popup - read-only, scroll, dismiss on keypress
58    Hover,
59    /// Action popup with selectable actions - navigate and execute
60    Action,
61    /// Generic list popup
62    List,
63    /// Generic text popup
64    Text,
65}
66
67/// Content of a popup window
68#[derive(Debug, Clone, PartialEq)]
69pub enum PopupContent {
70    /// Simple text content
71    Text(Vec<String>),
72    /// Markdown content with styling
73    Markdown(Vec<StyledLine>),
74    /// List of selectable items
75    List {
76        items: Vec<PopupListItem>,
77        selected: usize,
78    },
79    /// Custom rendered content (just store strings for now)
80    Custom(Vec<String>),
81}
82
83/// Text selection within a popup (line, column positions)
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct PopupTextSelection {
86    /// Start position (line index, column index)
87    pub start: (usize, usize),
88    /// End position (line index, column index)
89    pub end: (usize, usize),
90}
91
92impl PopupTextSelection {
93    /// Get normalized selection (start <= end)
94    pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
95        if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
96            (self.start, self.end)
97        } else {
98            (self.end, self.start)
99        }
100    }
101
102    /// Check if a position is within the selection
103    pub fn contains(&self, line: usize, col: usize) -> bool {
104        let ((start_line, start_col), (end_line, end_col)) = self.normalized();
105        if line < start_line || line > end_line {
106            return false;
107        }
108        if line == start_line && line == end_line {
109            col >= start_col && col < end_col
110        } else if line == start_line {
111            col >= start_col
112        } else if line == end_line {
113            col < end_col
114        } else {
115            true
116        }
117    }
118}
119
120/// A single item in a popup list
121#[derive(Debug, Clone, PartialEq)]
122pub struct PopupListItem {
123    /// Main text to display
124    pub text: String,
125    /// Optional secondary text (description, type info, etc.)
126    pub detail: Option<String>,
127    /// Optional icon or prefix
128    pub icon: Option<String>,
129    /// User data associated with this item (for completion, etc.)
130    pub data: Option<String>,
131}
132
133impl PopupListItem {
134    pub fn new(text: String) -> Self {
135        Self {
136            text,
137            detail: None,
138            icon: None,
139            data: None,
140        }
141    }
142
143    pub fn with_detail(mut self, detail: String) -> Self {
144        self.detail = Some(detail);
145        self
146    }
147
148    pub fn with_icon(mut self, icon: String) -> Self {
149        self.icon = Some(icon);
150        self
151    }
152
153    pub fn with_data(mut self, data: String) -> Self {
154        self.data = Some(data);
155        self
156    }
157}
158
159/// A popup/floating window
160/// This is a general-purpose UI primitive that can be used for:
161/// - Completion menus
162/// - Hover documentation
163/// - Command palette
164/// - File picker
165/// - Diagnostic messages
166/// - Quick fixes / code actions
167#[derive(Debug, Clone, PartialEq)]
168pub struct Popup {
169    /// Kind of popup - determines input handling behavior
170    pub kind: PopupKind,
171
172    /// Title of the popup (optional)
173    pub title: Option<String>,
174
175    /// Description text shown below title, above content (optional)
176    pub description: Option<String>,
177
178    /// Whether this popup is transient (dismissed on focus loss, e.g. hover, signature help)
179    pub transient: bool,
180
181    /// Content to display
182    pub content: PopupContent,
183
184    /// Position strategy
185    pub position: PopupPosition,
186
187    /// Width of popup (in columns)
188    pub width: u16,
189
190    /// Maximum height (will be clamped to available space)
191    pub max_height: u16,
192
193    /// Whether to show borders
194    pub bordered: bool,
195
196    /// Border style
197    pub border_style: Style,
198
199    /// Background style
200    pub background_style: Style,
201
202    /// Scroll offset for content (for scrolling through long lists)
203    pub scroll_offset: usize,
204
205    /// Text selection for copy/paste (None if no selection)
206    pub text_selection: Option<PopupTextSelection>,
207}
208
209impl Popup {
210    /// Create a new popup with text content using theme colors
211    pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
212        Self {
213            kind: PopupKind::Text,
214            title: None,
215            description: None,
216            transient: false,
217            content: PopupContent::Text(content),
218            position: PopupPosition::AtCursor,
219            width: 50,
220            max_height: 15,
221            bordered: true,
222            border_style: Style::default().fg(theme.popup_border_fg),
223            background_style: Style::default().bg(theme.popup_bg),
224            scroll_offset: 0,
225            text_selection: None,
226        }
227    }
228
229    /// Create a new popup with markdown content using theme colors
230    ///
231    /// If `registry` is provided, code blocks will have syntax highlighting
232    /// for ~150+ languages via syntect.
233    pub fn markdown(
234        markdown_text: &str,
235        theme: &crate::view::theme::Theme,
236        registry: Option<&GrammarRegistry>,
237    ) -> Self {
238        let styled_lines = parse_markdown(markdown_text, theme, registry);
239        Self {
240            kind: PopupKind::Text,
241            title: None,
242            description: None,
243            transient: false,
244            content: PopupContent::Markdown(styled_lines),
245            position: PopupPosition::AtCursor,
246            width: 60,      // Wider for markdown content
247            max_height: 20, // Taller for documentation
248            bordered: true,
249            border_style: Style::default().fg(theme.popup_border_fg),
250            background_style: Style::default().bg(theme.popup_bg),
251            scroll_offset: 0,
252            text_selection: None,
253        }
254    }
255
256    /// Create a new popup with a list of items using theme colors
257    pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
258        Self {
259            kind: PopupKind::List,
260            title: None,
261            description: None,
262            transient: false,
263            content: PopupContent::List { items, selected: 0 },
264            position: PopupPosition::AtCursor,
265            width: 50,
266            max_height: 15,
267            bordered: true,
268            border_style: Style::default().fg(theme.popup_border_fg),
269            background_style: Style::default().bg(theme.popup_bg),
270            scroll_offset: 0,
271            text_selection: None,
272        }
273    }
274
275    /// Set the title
276    pub fn with_title(mut self, title: String) -> Self {
277        self.title = Some(title);
278        self
279    }
280
281    /// Set the popup kind (determines input handling behavior)
282    pub fn with_kind(mut self, kind: PopupKind) -> Self {
283        self.kind = kind;
284        self
285    }
286
287    /// Mark this popup as transient (will be dismissed on focus loss)
288    pub fn with_transient(mut self, transient: bool) -> Self {
289        self.transient = transient;
290        self
291    }
292
293    /// Set the position
294    pub fn with_position(mut self, position: PopupPosition) -> Self {
295        self.position = position;
296        self
297    }
298
299    /// Set the width
300    pub fn with_width(mut self, width: u16) -> Self {
301        self.width = width;
302        self
303    }
304
305    /// Set the max height
306    pub fn with_max_height(mut self, max_height: u16) -> Self {
307        self.max_height = max_height;
308        self
309    }
310
311    /// Set border style
312    pub fn with_border_style(mut self, style: Style) -> Self {
313        self.border_style = style;
314        self
315    }
316
317    /// Get the currently selected item (if this is a list popup)
318    pub fn selected_item(&self) -> Option<&PopupListItem> {
319        match &self.content {
320            PopupContent::List { items, selected } => items.get(*selected),
321            _ => None,
322        }
323    }
324
325    /// Get the actual visible content height (accounting for borders)
326    fn visible_height(&self) -> usize {
327        let border_offset = if self.bordered { 2 } else { 0 };
328        (self.max_height as usize).saturating_sub(border_offset)
329    }
330
331    /// Move selection down (for list popups)
332    pub fn select_next(&mut self) {
333        let visible = self.visible_height();
334        if let PopupContent::List { items, selected } = &mut self.content {
335            if *selected < items.len().saturating_sub(1) {
336                *selected += 1;
337                // Adjust scroll if needed (use visible_height to account for borders)
338                if *selected >= self.scroll_offset + visible {
339                    self.scroll_offset = (*selected + 1).saturating_sub(visible);
340                }
341            }
342        }
343    }
344
345    /// Move selection up (for list popups)
346    pub fn select_prev(&mut self) {
347        if let PopupContent::List { items: _, selected } = &mut self.content {
348            if *selected > 0 {
349                *selected -= 1;
350                // Adjust scroll if needed
351                if *selected < self.scroll_offset {
352                    self.scroll_offset = *selected;
353                }
354            }
355        }
356    }
357
358    /// Scroll down by one page
359    pub fn page_down(&mut self) {
360        let visible = self.visible_height();
361        if let PopupContent::List { items, selected } = &mut self.content {
362            *selected = (*selected + visible).min(items.len().saturating_sub(1));
363            self.scroll_offset = (*selected + 1).saturating_sub(visible);
364        } else {
365            self.scroll_offset += visible;
366        }
367    }
368
369    /// Scroll up by one page
370    pub fn page_up(&mut self) {
371        let visible = self.visible_height();
372        if let PopupContent::List { items: _, selected } = &mut self.content {
373            *selected = selected.saturating_sub(visible);
374            self.scroll_offset = *selected;
375        } else {
376            self.scroll_offset = self.scroll_offset.saturating_sub(visible);
377        }
378    }
379
380    /// Select the first item (for list popups)
381    pub fn select_first(&mut self) {
382        if let PopupContent::List { items: _, selected } = &mut self.content {
383            *selected = 0;
384            self.scroll_offset = 0;
385        } else {
386            self.scroll_offset = 0;
387        }
388    }
389
390    /// Select the last item (for list popups)
391    pub fn select_last(&mut self) {
392        let visible = self.visible_height();
393        if let PopupContent::List { items, selected } = &mut self.content {
394            *selected = items.len().saturating_sub(1);
395            // Ensure the last item is visible
396            if *selected >= visible {
397                self.scroll_offset = (*selected + 1).saturating_sub(visible);
398            }
399        } else {
400            // For non-list content, scroll to the end
401            let content_height = self.item_count();
402            if content_height > visible {
403                self.scroll_offset = content_height.saturating_sub(visible);
404            }
405        }
406    }
407
408    /// Scroll by a delta amount (positive = down, negative = up)
409    /// Used for mouse wheel scrolling
410    pub fn scroll_by(&mut self, delta: i32) {
411        let content_len = self.wrapped_item_count();
412        let visible = self.visible_height();
413        let max_scroll = content_len.saturating_sub(visible);
414
415        if delta < 0 {
416            // Scroll up
417            self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
418        } else {
419            // Scroll down
420            self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
421        }
422
423        // For list popups, adjust selection to stay visible
424        if let PopupContent::List { items, selected } = &mut self.content {
425            let visible_start = self.scroll_offset;
426            let visible_end = (self.scroll_offset + visible).min(items.len());
427
428            if *selected < visible_start {
429                *selected = visible_start;
430            } else if *selected >= visible_end {
431                *selected = visible_end.saturating_sub(1);
432            }
433        }
434    }
435
436    /// Get the total number of items/lines in the popup
437    pub fn item_count(&self) -> usize {
438        match &self.content {
439            PopupContent::Text(lines) => lines.len(),
440            PopupContent::Markdown(lines) => lines.len(),
441            PopupContent::List { items, .. } => items.len(),
442            PopupContent::Custom(lines) => lines.len(),
443        }
444    }
445
446    /// Get the total number of wrapped lines in the popup
447    ///
448    /// This accounts for line wrapping based on the popup width,
449    /// which is necessary for correct scroll calculations.
450    fn wrapped_item_count(&self) -> usize {
451        // Calculate wrap width same as render: width - borders (2) - scrollbar (2)
452        let border_width = if self.bordered { 2 } else { 0 };
453        let scrollbar_width = 2; // 1 for scrollbar + 1 for spacing
454        let wrap_width = (self.width as usize)
455            .saturating_sub(border_width)
456            .saturating_sub(scrollbar_width);
457
458        if wrap_width == 0 {
459            return self.item_count();
460        }
461
462        match &self.content {
463            PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
464            PopupContent::Markdown(styled_lines) => {
465                wrap_styled_lines(styled_lines, wrap_width).len()
466            }
467            // Lists and custom content don't wrap
468            PopupContent::List { items, .. } => items.len(),
469            PopupContent::Custom(lines) => lines.len(),
470        }
471    }
472
473    /// Start text selection at position (used for mouse click)
474    pub fn start_selection(&mut self, line: usize, col: usize) {
475        self.text_selection = Some(PopupTextSelection {
476            start: (line, col),
477            end: (line, col),
478        });
479    }
480
481    /// Extend text selection to position (used for mouse drag)
482    pub fn extend_selection(&mut self, line: usize, col: usize) {
483        if let Some(ref mut sel) = self.text_selection {
484            sel.end = (line, col);
485        }
486    }
487
488    /// Clear text selection
489    pub fn clear_selection(&mut self) {
490        self.text_selection = None;
491    }
492
493    /// Check if popup has active text selection
494    pub fn has_selection(&self) -> bool {
495        if let Some(sel) = &self.text_selection {
496            sel.start != sel.end
497        } else {
498            false
499        }
500    }
501
502    /// Compute the effective content wrap width, replicating the logic
503    /// from `render_with_hover` so line indices match visual positions.
504    fn content_wrap_width(&self) -> usize {
505        let border_width: u16 = if self.bordered { 2 } else { 0 };
506        let inner_width = self.width.saturating_sub(border_width);
507        let scrollbar_reserved: u16 = 2;
508        let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
509
510        if conservative_width == 0 {
511            return 0;
512        }
513
514        let visible_height = self.max_height.saturating_sub(border_width) as usize;
515        let line_count = match &self.content {
516            PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
517            PopupContent::Markdown(styled_lines) => {
518                wrap_styled_lines(styled_lines, conservative_width).len()
519            }
520            _ => self.item_count(),
521        };
522
523        let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
524
525        if needs_scrollbar {
526            conservative_width
527        } else {
528            inner_width as usize
529        }
530    }
531
532    /// Get plain text lines from popup content, wrapped to match rendering.
533    ///
534    /// Selection coordinates are in wrapped-line space (visual positions),
535    /// so this must wrap lines identically to how `render_with_hover` does.
536    fn get_text_lines(&self) -> Vec<String> {
537        let wrap_width = self.content_wrap_width();
538
539        match &self.content {
540            PopupContent::Text(lines) => {
541                if wrap_width > 0 {
542                    wrap_text_lines(lines, wrap_width)
543                } else {
544                    lines.clone()
545                }
546            }
547            PopupContent::Markdown(styled_lines) => {
548                if wrap_width > 0 {
549                    wrap_styled_lines(styled_lines, wrap_width)
550                        .iter()
551                        .map(|sl| sl.plain_text())
552                        .collect()
553                } else {
554                    styled_lines.iter().map(|sl| sl.plain_text()).collect()
555                }
556            }
557            PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
558            PopupContent::Custom(lines) => lines.clone(),
559        }
560    }
561
562    /// Get selected text from popup content
563    pub fn get_selected_text(&self) -> Option<String> {
564        let sel = self.text_selection.as_ref()?;
565        if sel.start == sel.end {
566            return None;
567        }
568
569        let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
570        let lines = self.get_text_lines();
571
572        if start_line >= lines.len() {
573            return None;
574        }
575
576        if start_line == end_line {
577            let line = &lines[start_line];
578            let end_col = end_col.min(line.len());
579            let start_col = start_col.min(end_col);
580            Some(line[start_col..end_col].to_string())
581        } else {
582            let mut result = String::new();
583            // First line from start_col to end
584            let first_line = &lines[start_line];
585            result.push_str(&first_line[start_col.min(first_line.len())..]);
586            result.push('\n');
587            // Middle lines (full)
588            for line in lines.iter().take(end_line).skip(start_line + 1) {
589                result.push_str(line);
590                result.push('\n');
591            }
592            // Last line from start to end_col
593            if end_line < lines.len() {
594                let last_line = &lines[end_line];
595                result.push_str(&last_line[..end_col.min(last_line.len())]);
596            }
597            Some(result)
598        }
599    }
600
601    /// Check if the popup needs a scrollbar (content exceeds visible area)
602    pub fn needs_scrollbar(&self) -> bool {
603        self.item_count() > self.visible_height()
604    }
605
606    /// Get scroll state for scrollbar rendering
607    pub fn scroll_state(&self) -> (usize, usize, usize) {
608        let total = self.item_count();
609        let visible = self.visible_height();
610        (total, visible, self.scroll_offset)
611    }
612
613    /// Find the link URL at a given relative position within the popup content area.
614    /// `relative_col` and `relative_row` are relative to the inner content area (after borders).
615    /// Returns None if:
616    /// - The popup doesn't contain markdown content
617    /// - The position doesn't have a link
618    pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
619        let PopupContent::Markdown(styled_lines) = &self.content else {
620            return None;
621        };
622
623        // Calculate the content width for wrapping
624        let border_width = if self.bordered { 2 } else { 0 };
625        let scrollbar_reserved = 2;
626        let content_width = self
627            .width
628            .saturating_sub(border_width)
629            .saturating_sub(scrollbar_reserved) as usize;
630
631        // Wrap the styled lines
632        let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
633
634        // Account for scroll offset
635        let line_index = self.scroll_offset + relative_row;
636
637        // Get the line at this position
638        let line = wrapped_lines.get(line_index)?;
639
640        // Find the link at the column position
641        line.link_at_column(relative_col).map(|s| s.to_string())
642    }
643
644    /// Get the height of the description area (including blank line separator)
645    /// Returns 0 if there is no description.
646    pub fn description_height(&self) -> u16 {
647        if let Some(desc) = &self.description {
648            let border_width = if self.bordered { 2 } else { 0 };
649            let scrollbar_reserved = 2;
650            let content_width = self
651                .width
652                .saturating_sub(border_width)
653                .saturating_sub(scrollbar_reserved) as usize;
654            let desc_vec = vec![desc.clone()];
655            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
656            wrapped.len() as u16 + 1 // +1 for blank line after description
657        } else {
658            0
659        }
660    }
661
662    /// Calculate the actual content height based on the popup content
663    fn content_height(&self) -> u16 {
664        // Use the popup's configured width for wrapping calculation
665        self.content_height_for_width(self.width)
666    }
667
668    /// Calculate content height for a specific width, accounting for word wrapping
669    fn content_height_for_width(&self, popup_width: u16) -> u16 {
670        // Calculate the effective content width (accounting for borders and scrollbar)
671        let border_width = if self.bordered { 2 } else { 0 };
672        let scrollbar_reserved = 2; // Reserve space for potential scrollbar
673        let content_width = popup_width
674            .saturating_sub(border_width)
675            .saturating_sub(scrollbar_reserved) as usize;
676
677        // Calculate description height if present
678        let description_lines = if let Some(desc) = &self.description {
679            let desc_vec = vec![desc.clone()];
680            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
681            wrapped.len() as u16 + 1 // +1 for blank line after description
682        } else {
683            0
684        };
685
686        let content_lines = match &self.content {
687            PopupContent::Text(lines) => {
688                // Count wrapped lines
689                wrap_text_lines(lines, content_width).len() as u16
690            }
691            PopupContent::Markdown(styled_lines) => {
692                // Count wrapped styled lines
693                wrap_styled_lines(styled_lines, content_width).len() as u16
694            }
695            PopupContent::List { items, .. } => items.len() as u16,
696            PopupContent::Custom(lines) => lines.len() as u16,
697        };
698
699        // Add border lines if bordered
700        let border_height = if self.bordered { 2 } else { 0 };
701
702        description_lines + content_lines + border_height
703    }
704
705    /// Calculate the area where this popup should be rendered
706    pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
707        match self.position {
708            PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
709                let (cursor_x, cursor_y) =
710                    cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
711
712                let width = self.width.min(terminal_area.width);
713                // Use the minimum of max_height, actual content height, and terminal height
714                let height = self
715                    .content_height()
716                    .min(self.max_height)
717                    .min(terminal_area.height);
718
719                let x = if cursor_x + width > terminal_area.width {
720                    terminal_area.width.saturating_sub(width)
721                } else {
722                    cursor_x
723                };
724
725                let y = match self.position {
726                    PopupPosition::AtCursor => cursor_y,
727                    PopupPosition::BelowCursor => {
728                        if cursor_y + 1 + height > terminal_area.height {
729                            // Not enough space below, put above cursor
730                            cursor_y.saturating_sub(height)
731                        } else {
732                            // Directly below cursor
733                            cursor_y + 1
734                        }
735                    }
736                    PopupPosition::AboveCursor => {
737                        // Position so bottom of popup is one row above cursor
738                        (cursor_y + 1).saturating_sub(height)
739                    }
740                    _ => cursor_y,
741                };
742
743                Rect {
744                    x,
745                    y,
746                    width,
747                    height,
748                }
749            }
750            PopupPosition::Fixed { x, y } => {
751                let width = self.width.min(terminal_area.width);
752                let height = self
753                    .content_height()
754                    .min(self.max_height)
755                    .min(terminal_area.height);
756                // Clamp x and y to ensure popup stays within terminal bounds
757                let x = if x + width > terminal_area.width {
758                    terminal_area.width.saturating_sub(width)
759                } else {
760                    x
761                };
762                let y = if y + height > terminal_area.height {
763                    terminal_area.height.saturating_sub(height)
764                } else {
765                    y
766                };
767                Rect {
768                    x,
769                    y,
770                    width,
771                    height,
772                }
773            }
774            PopupPosition::Centered => {
775                let width = self.width.min(terminal_area.width);
776                let height = self
777                    .content_height()
778                    .min(self.max_height)
779                    .min(terminal_area.height);
780                let x = (terminal_area.width.saturating_sub(width)) / 2;
781                let y = (terminal_area.height.saturating_sub(height)) / 2;
782                Rect {
783                    x,
784                    y,
785                    width,
786                    height,
787                }
788            }
789            PopupPosition::BottomRight => {
790                let width = self.width.min(terminal_area.width);
791                let height = self
792                    .content_height()
793                    .min(self.max_height)
794                    .min(terminal_area.height);
795                // Position in bottom right, leaving 2 rows for status bar
796                let x = terminal_area.width.saturating_sub(width);
797                let y = terminal_area
798                    .height
799                    .saturating_sub(height)
800                    .saturating_sub(2);
801                Rect {
802                    x,
803                    y,
804                    width,
805                    height,
806                }
807            }
808        }
809    }
810
811    /// Render the popup to the frame
812    pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
813        self.render_with_hover(frame, area, theme, None);
814    }
815
816    /// Render the popup to the frame with hover highlighting
817    pub fn render_with_hover(
818        &self,
819        frame: &mut Frame,
820        area: Rect,
821        theme: &crate::view::theme::Theme,
822        hover_target: Option<&crate::app::HoverTarget>,
823    ) {
824        // Defensive bounds checking: clamp area to frame bounds to prevent panic
825        let frame_area = frame.area();
826        let area = clamp_rect_to_bounds(area, frame_area);
827
828        // Skip rendering if area is empty after clamping
829        if area.width == 0 || area.height == 0 {
830            return;
831        }
832
833        // Clear the area behind the popup first to hide underlying text
834        frame.render_widget(Clear, area);
835
836        let block = if self.bordered {
837            let mut block = Block::default()
838                .borders(Borders::ALL)
839                .border_style(self.border_style)
840                .style(self.background_style);
841
842            if let Some(title) = &self.title {
843                block = block.title(title.as_str());
844            }
845
846            block
847        } else {
848            Block::default().style(self.background_style)
849        };
850
851        let inner_area = block.inner(area);
852        frame.render_widget(block, area);
853
854        // Render description if present, and adjust content area
855        let content_start_y;
856        if let Some(desc) = &self.description {
857            // Word-wrap description to fit inner width
858            let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; // Leave some padding
859            let desc_vec = vec![desc.clone()];
860            let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
861            let desc_lines: usize = wrapped_desc.len();
862
863            // Render each description line
864            for (i, line) in wrapped_desc.iter().enumerate() {
865                if i >= inner_area.height as usize {
866                    break;
867                }
868                let line_area = Rect {
869                    x: inner_area.x,
870                    y: inner_area.y + i as u16,
871                    width: inner_area.width,
872                    height: 1,
873                };
874                let desc_style = Style::default().fg(theme.help_separator_fg);
875                frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
876            }
877
878            // Add blank line after description
879            content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
880        } else {
881            content_start_y = inner_area.y;
882        }
883
884        // Adjust inner_area to start after description
885        let inner_area = Rect {
886            x: inner_area.x,
887            y: content_start_y,
888            width: inner_area.width,
889            height: inner_area
890                .height
891                .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
892        };
893
894        // For text and markdown content, we need to wrap first to determine if scrollbar is needed.
895        // We wrap to the width that would be available if scrollbar is shown (conservative approach).
896        let scrollbar_reserved_width = 2; // 1 for scrollbar + 1 for spacing
897        let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
898        let visible_lines_count = inner_area.height as usize;
899
900        // Calculate wrapped line count and determine if scrollbar is needed
901        let (wrapped_total_lines, needs_scrollbar) = match &self.content {
902            PopupContent::Text(lines) => {
903                let wrapped = wrap_text_lines(lines, wrap_width);
904                let count = wrapped.len();
905                (
906                    count,
907                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
908                )
909            }
910            PopupContent::Markdown(styled_lines) => {
911                let wrapped = wrap_styled_lines(styled_lines, wrap_width);
912                let count = wrapped.len();
913                (
914                    count,
915                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
916                )
917            }
918            PopupContent::List { items, .. } => {
919                let count = items.len();
920                (
921                    count,
922                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
923                )
924            }
925            PopupContent::Custom(lines) => {
926                let count = lines.len();
927                (
928                    count,
929                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
930                )
931            }
932        };
933
934        // Adjust content area to leave room for scrollbar if needed
935        let content_area = if needs_scrollbar {
936            Rect {
937                x: inner_area.x,
938                y: inner_area.y,
939                width: inner_area.width.saturating_sub(scrollbar_reserved_width),
940                height: inner_area.height,
941            }
942        } else {
943            inner_area
944        };
945
946        match &self.content {
947            PopupContent::Text(lines) => {
948                // Word-wrap lines to fit content area width
949                let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
950                let selection_style = Style::default().bg(theme.selection_bg);
951
952                let visible_lines: Vec<Line> = wrapped_lines
953                    .iter()
954                    .enumerate()
955                    .skip(self.scroll_offset)
956                    .take(content_area.height as usize)
957                    .map(|(line_idx, line)| {
958                        if let Some(ref sel) = self.text_selection {
959                            // Apply selection highlighting
960                            let chars: Vec<char> = line.chars().collect();
961                            let spans: Vec<Span> = chars
962                                .iter()
963                                .enumerate()
964                                .map(|(col, ch)| {
965                                    if sel.contains(line_idx, col) {
966                                        Span::styled(ch.to_string(), selection_style)
967                                    } else {
968                                        Span::raw(ch.to_string())
969                                    }
970                                })
971                                .collect();
972                            Line::from(spans)
973                        } else {
974                            Line::from(line.as_str())
975                        }
976                    })
977                    .collect();
978
979                let paragraph = Paragraph::new(visible_lines);
980                frame.render_widget(paragraph, content_area);
981            }
982            PopupContent::Markdown(styled_lines) => {
983                // Word-wrap styled lines to fit content area width
984                let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
985                let selection_style = Style::default().bg(theme.selection_bg);
986
987                // Collect link overlay info for OSC 8 rendering after the main draw
988                // Each entry: (visible_line_idx, start_column, link_text, url)
989                let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
990
991                let visible_lines: Vec<Line> = wrapped_lines
992                    .iter()
993                    .enumerate()
994                    .skip(self.scroll_offset)
995                    .take(content_area.height as usize)
996                    .map(|(line_idx, styled_line)| {
997                        let mut col = 0usize;
998                        let spans: Vec<Span> = styled_line
999                            .spans
1000                            .iter()
1001                            .flat_map(|s| {
1002                                let span_start_col = col;
1003                                let span_width =
1004                                    unicode_width::UnicodeWidthStr::width(s.text.as_str());
1005                                if let Some(url) = &s.link_url {
1006                                    link_overlays.push((
1007                                        line_idx - self.scroll_offset,
1008                                        col,
1009                                        s.text.clone(),
1010                                        url.clone(),
1011                                    ));
1012                                }
1013                                col += span_width;
1014
1015                                // Check if any part of this span is selected
1016                                if let Some(ref sel) = self.text_selection {
1017                                    // Split span into selected/unselected parts
1018                                    let chars: Vec<char> = s.text.chars().collect();
1019                                    chars
1020                                        .iter()
1021                                        .enumerate()
1022                                        .map(|(i, ch)| {
1023                                            let char_col = span_start_col + i;
1024                                            if sel.contains(line_idx, char_col) {
1025                                                Span::styled(ch.to_string(), selection_style)
1026                                            } else {
1027                                                Span::styled(ch.to_string(), s.style)
1028                                            }
1029                                        })
1030                                        .collect::<Vec<_>>()
1031                                } else {
1032                                    vec![Span::styled(s.text.clone(), s.style)]
1033                                }
1034                            })
1035                            .collect();
1036                        Line::from(spans)
1037                    })
1038                    .collect();
1039
1040                let paragraph = Paragraph::new(visible_lines);
1041                frame.render_widget(paragraph, content_area);
1042
1043                // Apply OSC 8 hyperlinks following Ratatui's official workaround
1044                let buffer = frame.buffer_mut();
1045                let max_x = content_area.x + content_area.width;
1046                for (line_idx, col_start, text, url) in link_overlays {
1047                    let y = content_area.y + line_idx as u16;
1048                    if y >= content_area.y + content_area.height {
1049                        continue;
1050                    }
1051                    let start_x = content_area.x + col_start as u16;
1052                    apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1053                }
1054            }
1055            PopupContent::List { items, selected } => {
1056                let list_items: Vec<ListItem> = items
1057                    .iter()
1058                    .enumerate()
1059                    .skip(self.scroll_offset)
1060                    .take(content_area.height as usize)
1061                    .map(|(idx, item)| {
1062                        // Check if this item is hovered or selected
1063                        let is_hovered = matches!(
1064                            hover_target,
1065                            Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1066                        );
1067                        let is_selected = idx == *selected;
1068
1069                        let mut spans = Vec::new();
1070
1071                        // Add icon if present
1072                        if let Some(icon) = &item.icon {
1073                            spans.push(Span::raw(format!("{} ", icon)));
1074                        }
1075
1076                        // Add main text with underline for clickable items
1077                        let text_style = if is_selected {
1078                            Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1079                        } else {
1080                            Style::default().add_modifier(Modifier::UNDERLINED)
1081                        };
1082                        spans.push(Span::styled(&item.text, text_style));
1083
1084                        // Add detail if present
1085                        if let Some(detail) = &item.detail {
1086                            spans.push(Span::styled(
1087                                format!(" {}", detail),
1088                                Style::default().fg(theme.help_separator_fg),
1089                            ));
1090                        }
1091
1092                        // Row style (background only, no underline)
1093                        let row_style = if is_selected {
1094                            Style::default().bg(theme.popup_selection_bg)
1095                        } else if is_hovered {
1096                            Style::default()
1097                                .bg(theme.menu_hover_bg)
1098                                .fg(theme.menu_hover_fg)
1099                        } else {
1100                            Style::default()
1101                        };
1102
1103                        ListItem::new(Line::from(spans)).style(row_style)
1104                    })
1105                    .collect();
1106
1107                let list = List::new(list_items);
1108                frame.render_widget(list, content_area);
1109            }
1110            PopupContent::Custom(lines) => {
1111                let visible_lines: Vec<Line> = lines
1112                    .iter()
1113                    .skip(self.scroll_offset)
1114                    .take(content_area.height as usize)
1115                    .map(|line| Line::from(line.as_str()))
1116                    .collect();
1117
1118                let paragraph = Paragraph::new(visible_lines);
1119                frame.render_widget(paragraph, content_area);
1120            }
1121        }
1122
1123        // Render scrollbar if needed
1124        if needs_scrollbar {
1125            let scrollbar_area = Rect {
1126                x: inner_area.x + inner_area.width - 1,
1127                y: inner_area.y,
1128                width: 1,
1129                height: inner_area.height,
1130            };
1131
1132            let scrollbar_state =
1133                ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1134            let scrollbar_colors = ScrollbarColors::from_theme(theme);
1135            render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1136        }
1137    }
1138}
1139
1140/// Manager for popups - can show multiple popups with z-ordering
1141#[derive(Debug, Clone)]
1142pub struct PopupManager {
1143    /// Stack of active popups (top of stack = topmost popup)
1144    popups: Vec<Popup>,
1145}
1146
1147impl PopupManager {
1148    pub fn new() -> Self {
1149        Self { popups: Vec::new() }
1150    }
1151
1152    /// Show a popup (adds to top of stack)
1153    pub fn show(&mut self, popup: Popup) {
1154        self.popups.push(popup);
1155    }
1156
1157    /// Hide the topmost popup
1158    pub fn hide(&mut self) -> Option<Popup> {
1159        self.popups.pop()
1160    }
1161
1162    /// Clear all popups
1163    pub fn clear(&mut self) {
1164        self.popups.clear();
1165    }
1166
1167    /// Get the topmost popup
1168    pub fn top(&self) -> Option<&Popup> {
1169        self.popups.last()
1170    }
1171
1172    /// Get mutable reference to topmost popup
1173    pub fn top_mut(&mut self) -> Option<&mut Popup> {
1174        self.popups.last_mut()
1175    }
1176
1177    /// Get reference to popup by index
1178    pub fn get(&self, index: usize) -> Option<&Popup> {
1179        self.popups.get(index)
1180    }
1181
1182    /// Get mutable reference to popup by index
1183    pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1184        self.popups.get_mut(index)
1185    }
1186
1187    /// Check if any popups are visible
1188    pub fn is_visible(&self) -> bool {
1189        !self.popups.is_empty()
1190    }
1191
1192    /// Check if the topmost popup is a completion popup (supports type-to-filter)
1193    pub fn is_completion_popup(&self) -> bool {
1194        self.top()
1195            .map(|p| p.kind == PopupKind::Completion)
1196            .unwrap_or(false)
1197    }
1198
1199    /// Check if the topmost popup is a hover popup
1200    pub fn is_hover_popup(&self) -> bool {
1201        self.top()
1202            .map(|p| p.kind == PopupKind::Hover)
1203            .unwrap_or(false)
1204    }
1205
1206    /// Check if the topmost popup is an action popup
1207    pub fn is_action_popup(&self) -> bool {
1208        self.top()
1209            .map(|p| p.kind == PopupKind::Action)
1210            .unwrap_or(false)
1211    }
1212
1213    /// Get all popups (for rendering)
1214    pub fn all(&self) -> &[Popup] {
1215        &self.popups
1216    }
1217
1218    /// Dismiss transient popups if present at the top.
1219    /// These popups should be dismissed when the buffer loses focus.
1220    /// Returns true if a popup was dismissed.
1221    pub fn dismiss_transient(&mut self) -> bool {
1222        let is_transient = self.popups.last().is_some_and(|p| p.transient);
1223
1224        if is_transient {
1225            self.popups.pop();
1226            true
1227        } else {
1228            false
1229        }
1230    }
1231}
1232
1233impl Default for PopupManager {
1234    fn default() -> Self {
1235        Self::new()
1236    }
1237}
1238
1239/// Overlay OSC 8 hyperlinks in 2-character chunks to keep text layout aligned.
1240///
1241/// This mirrors the approach used in Ratatui's official hyperlink example to
1242/// work around Crossterm width accounting bugs for OSC sequences.
1243fn apply_hyperlink_overlay(
1244    buffer: &mut ratatui::buffer::Buffer,
1245    start_x: u16,
1246    y: u16,
1247    max_x: u16,
1248    text: &str,
1249    url: &str,
1250) {
1251    let mut chunk_index = 0u16;
1252    let mut chars = text.chars();
1253
1254    loop {
1255        let mut chunk = String::new();
1256        for _ in 0..2 {
1257            if let Some(ch) = chars.next() {
1258                chunk.push(ch);
1259            } else {
1260                break;
1261            }
1262        }
1263
1264        if chunk.is_empty() {
1265            break;
1266        }
1267
1268        let x = start_x + chunk_index * 2;
1269        if x >= max_x {
1270            break;
1271        }
1272
1273        let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1274        buffer[(x, y)].set_symbol(&hyperlink);
1275
1276        chunk_index += 1;
1277    }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use crate::view::theme;
1284
1285    #[test]
1286    fn test_popup_list_item() {
1287        let item = PopupListItem::new("test".to_string())
1288            .with_detail("detail".to_string())
1289            .with_icon("📄".to_string());
1290
1291        assert_eq!(item.text, "test");
1292        assert_eq!(item.detail, Some("detail".to_string()));
1293        assert_eq!(item.icon, Some("📄".to_string()));
1294    }
1295
1296    #[test]
1297    fn test_popup_selection() {
1298        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1299        let items = vec![
1300            PopupListItem::new("item1".to_string()),
1301            PopupListItem::new("item2".to_string()),
1302            PopupListItem::new("item3".to_string()),
1303        ];
1304
1305        let mut popup = Popup::list(items, &theme);
1306
1307        assert_eq!(popup.selected_item().unwrap().text, "item1");
1308
1309        popup.select_next();
1310        assert_eq!(popup.selected_item().unwrap().text, "item2");
1311
1312        popup.select_next();
1313        assert_eq!(popup.selected_item().unwrap().text, "item3");
1314
1315        popup.select_next(); // Should stay at last item
1316        assert_eq!(popup.selected_item().unwrap().text, "item3");
1317
1318        popup.select_prev();
1319        assert_eq!(popup.selected_item().unwrap().text, "item2");
1320
1321        popup.select_prev();
1322        assert_eq!(popup.selected_item().unwrap().text, "item1");
1323
1324        popup.select_prev(); // Should stay at first item
1325        assert_eq!(popup.selected_item().unwrap().text, "item1");
1326    }
1327
1328    #[test]
1329    fn test_popup_manager() {
1330        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1331        let mut manager = PopupManager::new();
1332
1333        assert!(!manager.is_visible());
1334        assert_eq!(manager.top(), None);
1335
1336        let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1337        manager.show(popup1);
1338
1339        assert!(manager.is_visible());
1340        assert_eq!(manager.all().len(), 1);
1341
1342        let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1343        manager.show(popup2);
1344
1345        assert_eq!(manager.all().len(), 2);
1346
1347        manager.hide();
1348        assert_eq!(manager.all().len(), 1);
1349
1350        manager.clear();
1351        assert!(!manager.is_visible());
1352        assert_eq!(manager.all().len(), 0);
1353    }
1354
1355    #[test]
1356    fn test_popup_area_calculation() {
1357        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1358        let terminal_area = Rect {
1359            x: 0,
1360            y: 0,
1361            width: 100,
1362            height: 50,
1363        };
1364
1365        let popup = Popup::text(vec!["test".to_string()], &theme)
1366            .with_width(30)
1367            .with_max_height(10);
1368
1369        // Centered
1370        let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1371        let area = popup_centered.calculate_area(terminal_area, None);
1372        assert_eq!(area.width, 30);
1373        // Height is now based on content: 1 text line + 2 border lines = 3
1374        assert_eq!(area.height, 3);
1375        assert_eq!(area.x, (100 - 30) / 2);
1376        assert_eq!(area.y, (50 - 3) / 2);
1377
1378        // Below cursor
1379        let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1380        let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1381        assert_eq!(area.x, 20);
1382        assert_eq!(area.y, 11); // One row below cursor
1383    }
1384
1385    #[test]
1386    fn test_popup_fixed_position_clamping() {
1387        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1388        let terminal_area = Rect {
1389            x: 0,
1390            y: 0,
1391            width: 100,
1392            height: 50,
1393        };
1394
1395        let popup = Popup::text(vec!["test".to_string()], &theme)
1396            .with_width(30)
1397            .with_max_height(10);
1398
1399        // Fixed position within bounds - should stay as specified
1400        let popup_fixed = popup
1401            .clone()
1402            .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1403        let area = popup_fixed.calculate_area(terminal_area, None);
1404        assert_eq!(area.x, 10);
1405        assert_eq!(area.y, 20);
1406
1407        // Fixed position at right edge - x should be clamped
1408        let popup_right_edge = popup
1409            .clone()
1410            .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1411        let area = popup_right_edge.calculate_area(terminal_area, None);
1412        // x=99 + width=30 > 100, so x should be clamped to 100-30=70
1413        assert_eq!(area.x, 70);
1414        assert_eq!(area.y, 20);
1415
1416        // Fixed position beyond right edge - x should be clamped
1417        let popup_beyond = popup
1418            .clone()
1419            .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1420        let area = popup_beyond.calculate_area(terminal_area, None);
1421        // x=199 + width=30 > 100, so x should be clamped to 100-30=70
1422        assert_eq!(area.x, 70);
1423        assert_eq!(area.y, 20);
1424
1425        // Fixed position at bottom edge - y should be clamped
1426        let popup_bottom = popup
1427            .clone()
1428            .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1429        let area = popup_bottom.calculate_area(terminal_area, None);
1430        assert_eq!(area.x, 10);
1431        // y=49 + height=3 > 50, so y should be clamped to 50-3=47
1432        assert_eq!(area.y, 47);
1433    }
1434
1435    #[test]
1436    fn test_clamp_rect_to_bounds() {
1437        let bounds = Rect {
1438            x: 0,
1439            y: 0,
1440            width: 100,
1441            height: 50,
1442        };
1443
1444        // Rect within bounds - unchanged
1445        let rect = Rect {
1446            x: 10,
1447            y: 20,
1448            width: 30,
1449            height: 10,
1450        };
1451        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1452        assert_eq!(clamped, rect);
1453
1454        // Rect at exact right edge of bounds
1455        let rect = Rect {
1456            x: 99,
1457            y: 20,
1458            width: 30,
1459            height: 10,
1460        };
1461        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1462        assert_eq!(clamped.x, 99); // x is within bounds
1463        assert_eq!(clamped.width, 1); // width clamped to fit
1464
1465        // Rect beyond bounds
1466        let rect = Rect {
1467            x: 199,
1468            y: 60,
1469            width: 30,
1470            height: 10,
1471        };
1472        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1473        assert_eq!(clamped.x, 99); // x clamped to last valid position
1474        assert_eq!(clamped.y, 49); // y clamped to last valid position
1475        assert_eq!(clamped.width, 1); // width clamped to fit
1476        assert_eq!(clamped.height, 1); // height clamped to fit
1477    }
1478
1479    #[test]
1480    fn hyperlink_overlay_chunks_pairs() {
1481        use ratatui::{buffer::Buffer, layout::Rect};
1482
1483        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1484        buffer[(0, 0)].set_symbol("P");
1485        buffer[(1, 0)].set_symbol("l");
1486        buffer[(2, 0)].set_symbol("a");
1487        buffer[(3, 0)].set_symbol("y");
1488
1489        apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1490
1491        let first = buffer[(0, 0)].symbol().to_string();
1492        let second = buffer[(2, 0)].symbol().to_string();
1493
1494        assert!(
1495            first.contains("Pl"),
1496            "first chunk should contain 'Pl', got {first:?}"
1497        );
1498        assert!(
1499            second.contains("ay"),
1500            "second chunk should contain 'ay', got {second:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_popup_text_selection() {
1506        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1507        let mut popup = Popup::text(
1508            vec![
1509                "Line 0: Hello".to_string(),
1510                "Line 1: World".to_string(),
1511                "Line 2: Test".to_string(),
1512            ],
1513            &theme,
1514        );
1515
1516        // Initially no selection
1517        assert!(!popup.has_selection());
1518        assert_eq!(popup.get_selected_text(), None);
1519
1520        // Start selection at line 0, col 8 ("Hello" starts at col 8)
1521        popup.start_selection(0, 8);
1522        assert!(!popup.has_selection()); // Selection start == end
1523
1524        // Extend selection to line 1, col 8 ("World" starts at col 8)
1525        popup.extend_selection(1, 8);
1526        assert!(popup.has_selection());
1527
1528        // Get selected text: "Hello\nLine 1: "
1529        let selected = popup.get_selected_text().unwrap();
1530        assert_eq!(selected, "Hello\nLine 1: ");
1531
1532        // Clear selection
1533        popup.clear_selection();
1534        assert!(!popup.has_selection());
1535        assert_eq!(popup.get_selected_text(), None);
1536
1537        // Test single-line selection
1538        popup.start_selection(1, 8);
1539        popup.extend_selection(1, 13); // "World"
1540        let selected = popup.get_selected_text().unwrap();
1541        assert_eq!(selected, "World");
1542    }
1543
1544    #[test]
1545    fn test_popup_text_selection_contains() {
1546        let sel = PopupTextSelection {
1547            start: (1, 5),
1548            end: (2, 10),
1549        };
1550
1551        // Line 0 - before selection
1552        assert!(!sel.contains(0, 5));
1553
1554        // Line 1 - start of selection
1555        assert!(!sel.contains(1, 4)); // Before start col
1556        assert!(sel.contains(1, 5)); // At start
1557        assert!(sel.contains(1, 10)); // After start on same line
1558
1559        // Line 2 - end of selection
1560        assert!(sel.contains(2, 0)); // Beginning of last line
1561        assert!(sel.contains(2, 9)); // Before end col
1562        assert!(!sel.contains(2, 10)); // At end (exclusive)
1563        assert!(!sel.contains(2, 11)); // After end
1564
1565        // Line 3 - after selection
1566        assert!(!sel.contains(3, 0));
1567    }
1568
1569    #[test]
1570    fn test_popup_text_selection_normalized() {
1571        // Forward selection
1572        let sel = PopupTextSelection {
1573            start: (1, 5),
1574            end: (2, 10),
1575        };
1576        let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1577        assert_eq!((s_line, s_col), (1, 5));
1578        assert_eq!((e_line, e_col), (2, 10));
1579
1580        // Backward selection (user dragged up)
1581        let sel_backward = PopupTextSelection {
1582            start: (2, 10),
1583            end: (1, 5),
1584        };
1585        let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1586        assert_eq!((s_line, s_col), (1, 5));
1587        assert_eq!((e_line, e_col), (2, 10));
1588    }
1589}