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    /// Get plain text lines from popup content
503    fn get_text_lines(&self) -> Vec<String> {
504        match &self.content {
505            PopupContent::Text(lines) => lines.clone(),
506            PopupContent::Markdown(styled_lines) => {
507                styled_lines.iter().map(|sl| sl.plain_text()).collect()
508            }
509            PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
510            PopupContent::Custom(lines) => lines.clone(),
511        }
512    }
513
514    /// Get selected text from popup content
515    pub fn get_selected_text(&self) -> Option<String> {
516        let sel = self.text_selection.as_ref()?;
517        if sel.start == sel.end {
518            return None;
519        }
520
521        let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
522        let lines = self.get_text_lines();
523
524        if start_line >= lines.len() {
525            return None;
526        }
527
528        if start_line == end_line {
529            let line = &lines[start_line];
530            let end_col = end_col.min(line.len());
531            let start_col = start_col.min(end_col);
532            Some(line[start_col..end_col].to_string())
533        } else {
534            let mut result = String::new();
535            // First line from start_col to end
536            let first_line = &lines[start_line];
537            result.push_str(&first_line[start_col.min(first_line.len())..]);
538            result.push('\n');
539            // Middle lines (full)
540            for line in lines.iter().take(end_line).skip(start_line + 1) {
541                result.push_str(line);
542                result.push('\n');
543            }
544            // Last line from start to end_col
545            if end_line < lines.len() {
546                let last_line = &lines[end_line];
547                result.push_str(&last_line[..end_col.min(last_line.len())]);
548            }
549            Some(result)
550        }
551    }
552
553    /// Check if the popup needs a scrollbar (content exceeds visible area)
554    pub fn needs_scrollbar(&self) -> bool {
555        self.item_count() > self.visible_height()
556    }
557
558    /// Get scroll state for scrollbar rendering
559    pub fn scroll_state(&self) -> (usize, usize, usize) {
560        let total = self.item_count();
561        let visible = self.visible_height();
562        (total, visible, self.scroll_offset)
563    }
564
565    /// Find the link URL at a given relative position within the popup content area.
566    /// `relative_col` and `relative_row` are relative to the inner content area (after borders).
567    /// Returns None if:
568    /// - The popup doesn't contain markdown content
569    /// - The position doesn't have a link
570    pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
571        let PopupContent::Markdown(styled_lines) = &self.content else {
572            return None;
573        };
574
575        // Calculate the content width for wrapping
576        let border_width = if self.bordered { 2 } else { 0 };
577        let scrollbar_reserved = 2;
578        let content_width = self
579            .width
580            .saturating_sub(border_width)
581            .saturating_sub(scrollbar_reserved) as usize;
582
583        // Wrap the styled lines
584        let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
585
586        // Account for scroll offset
587        let line_index = self.scroll_offset + relative_row;
588
589        // Get the line at this position
590        let line = wrapped_lines.get(line_index)?;
591
592        // Find the link at the column position
593        line.link_at_column(relative_col).map(|s| s.to_string())
594    }
595
596    /// Get the height of the description area (including blank line separator)
597    /// Returns 0 if there is no description.
598    pub fn description_height(&self) -> u16 {
599        if let Some(desc) = &self.description {
600            let border_width = if self.bordered { 2 } else { 0 };
601            let scrollbar_reserved = 2;
602            let content_width = self
603                .width
604                .saturating_sub(border_width)
605                .saturating_sub(scrollbar_reserved) as usize;
606            let desc_vec = vec![desc.clone()];
607            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
608            wrapped.len() as u16 + 1 // +1 for blank line after description
609        } else {
610            0
611        }
612    }
613
614    /// Calculate the actual content height based on the popup content
615    fn content_height(&self) -> u16 {
616        // Use the popup's configured width for wrapping calculation
617        self.content_height_for_width(self.width)
618    }
619
620    /// Calculate content height for a specific width, accounting for word wrapping
621    fn content_height_for_width(&self, popup_width: u16) -> u16 {
622        // Calculate the effective content width (accounting for borders and scrollbar)
623        let border_width = if self.bordered { 2 } else { 0 };
624        let scrollbar_reserved = 2; // Reserve space for potential scrollbar
625        let content_width = popup_width
626            .saturating_sub(border_width)
627            .saturating_sub(scrollbar_reserved) as usize;
628
629        // Calculate description height if present
630        let description_lines = if let Some(desc) = &self.description {
631            let desc_vec = vec![desc.clone()];
632            let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
633            wrapped.len() as u16 + 1 // +1 for blank line after description
634        } else {
635            0
636        };
637
638        let content_lines = match &self.content {
639            PopupContent::Text(lines) => {
640                // Count wrapped lines
641                wrap_text_lines(lines, content_width).len() as u16
642            }
643            PopupContent::Markdown(styled_lines) => {
644                // Count wrapped styled lines
645                wrap_styled_lines(styled_lines, content_width).len() as u16
646            }
647            PopupContent::List { items, .. } => items.len() as u16,
648            PopupContent::Custom(lines) => lines.len() as u16,
649        };
650
651        // Add border lines if bordered
652        let border_height = if self.bordered { 2 } else { 0 };
653
654        description_lines + content_lines + border_height
655    }
656
657    /// Calculate the area where this popup should be rendered
658    pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
659        match self.position {
660            PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
661                let (cursor_x, cursor_y) =
662                    cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
663
664                let width = self.width.min(terminal_area.width);
665                // Use the minimum of max_height, actual content height, and terminal height
666                let height = self
667                    .content_height()
668                    .min(self.max_height)
669                    .min(terminal_area.height);
670
671                let x = if cursor_x + width > terminal_area.width {
672                    terminal_area.width.saturating_sub(width)
673                } else {
674                    cursor_x
675                };
676
677                let y = match self.position {
678                    PopupPosition::AtCursor => cursor_y,
679                    PopupPosition::BelowCursor => {
680                        if cursor_y + 2 + height > terminal_area.height {
681                            // Not enough space below, put above cursor
682                            // Position so bottom of popup is one row above cursor
683                            (cursor_y + 1).saturating_sub(height)
684                        } else {
685                            // Below cursor with two row gap
686                            cursor_y + 2
687                        }
688                    }
689                    PopupPosition::AboveCursor => {
690                        // Position so bottom of popup is one row above cursor
691                        (cursor_y + 1).saturating_sub(height)
692                    }
693                    _ => cursor_y,
694                };
695
696                Rect {
697                    x,
698                    y,
699                    width,
700                    height,
701                }
702            }
703            PopupPosition::Fixed { x, y } => {
704                let width = self.width.min(terminal_area.width);
705                let height = self
706                    .content_height()
707                    .min(self.max_height)
708                    .min(terminal_area.height);
709                // Clamp x and y to ensure popup stays within terminal bounds
710                let x = if x + width > terminal_area.width {
711                    terminal_area.width.saturating_sub(width)
712                } else {
713                    x
714                };
715                let y = if y + height > terminal_area.height {
716                    terminal_area.height.saturating_sub(height)
717                } else {
718                    y
719                };
720                Rect {
721                    x,
722                    y,
723                    width,
724                    height,
725                }
726            }
727            PopupPosition::Centered => {
728                let width = self.width.min(terminal_area.width);
729                let height = self
730                    .content_height()
731                    .min(self.max_height)
732                    .min(terminal_area.height);
733                let x = (terminal_area.width.saturating_sub(width)) / 2;
734                let y = (terminal_area.height.saturating_sub(height)) / 2;
735                Rect {
736                    x,
737                    y,
738                    width,
739                    height,
740                }
741            }
742            PopupPosition::BottomRight => {
743                let width = self.width.min(terminal_area.width);
744                let height = self
745                    .content_height()
746                    .min(self.max_height)
747                    .min(terminal_area.height);
748                // Position in bottom right, leaving 2 rows for status bar
749                let x = terminal_area.width.saturating_sub(width);
750                let y = terminal_area
751                    .height
752                    .saturating_sub(height)
753                    .saturating_sub(2);
754                Rect {
755                    x,
756                    y,
757                    width,
758                    height,
759                }
760            }
761        }
762    }
763
764    /// Render the popup to the frame
765    pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
766        self.render_with_hover(frame, area, theme, None);
767    }
768
769    /// Render the popup to the frame with hover highlighting
770    pub fn render_with_hover(
771        &self,
772        frame: &mut Frame,
773        area: Rect,
774        theme: &crate::view::theme::Theme,
775        hover_target: Option<&crate::app::HoverTarget>,
776    ) {
777        // Defensive bounds checking: clamp area to frame bounds to prevent panic
778        let frame_area = frame.area();
779        let area = clamp_rect_to_bounds(area, frame_area);
780
781        // Skip rendering if area is empty after clamping
782        if area.width == 0 || area.height == 0 {
783            return;
784        }
785
786        // Clear the area behind the popup first to hide underlying text
787        frame.render_widget(Clear, area);
788
789        let block = if self.bordered {
790            let mut block = Block::default()
791                .borders(Borders::ALL)
792                .border_style(self.border_style)
793                .style(self.background_style);
794
795            if let Some(title) = &self.title {
796                block = block.title(title.as_str());
797            }
798
799            block
800        } else {
801            Block::default().style(self.background_style)
802        };
803
804        let inner_area = block.inner(area);
805        frame.render_widget(block, area);
806
807        // Render description if present, and adjust content area
808        let content_start_y;
809        if let Some(desc) = &self.description {
810            // Word-wrap description to fit inner width
811            let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; // Leave some padding
812            let desc_vec = vec![desc.clone()];
813            let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
814            let desc_lines: usize = wrapped_desc.len();
815
816            // Render each description line
817            for (i, line) in wrapped_desc.iter().enumerate() {
818                if i >= inner_area.height as usize {
819                    break;
820                }
821                let line_area = Rect {
822                    x: inner_area.x,
823                    y: inner_area.y + i as u16,
824                    width: inner_area.width,
825                    height: 1,
826                };
827                let desc_style = Style::default().fg(theme.help_separator_fg);
828                frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
829            }
830
831            // Add blank line after description
832            content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
833        } else {
834            content_start_y = inner_area.y;
835        }
836
837        // Adjust inner_area to start after description
838        let inner_area = Rect {
839            x: inner_area.x,
840            y: content_start_y,
841            width: inner_area.width,
842            height: inner_area
843                .height
844                .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
845        };
846
847        // For text and markdown content, we need to wrap first to determine if scrollbar is needed.
848        // We wrap to the width that would be available if scrollbar is shown (conservative approach).
849        let scrollbar_reserved_width = 2; // 1 for scrollbar + 1 for spacing
850        let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
851        let visible_lines_count = inner_area.height as usize;
852
853        // Calculate wrapped line count and determine if scrollbar is needed
854        let (wrapped_total_lines, needs_scrollbar) = match &self.content {
855            PopupContent::Text(lines) => {
856                let wrapped = wrap_text_lines(lines, wrap_width);
857                let count = wrapped.len();
858                (
859                    count,
860                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
861                )
862            }
863            PopupContent::Markdown(styled_lines) => {
864                let wrapped = wrap_styled_lines(styled_lines, wrap_width);
865                let count = wrapped.len();
866                (
867                    count,
868                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
869                )
870            }
871            PopupContent::List { items, .. } => {
872                let count = items.len();
873                (
874                    count,
875                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
876                )
877            }
878            PopupContent::Custom(lines) => {
879                let count = lines.len();
880                (
881                    count,
882                    count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
883                )
884            }
885        };
886
887        // Adjust content area to leave room for scrollbar if needed
888        let content_area = if needs_scrollbar {
889            Rect {
890                x: inner_area.x,
891                y: inner_area.y,
892                width: inner_area.width.saturating_sub(scrollbar_reserved_width),
893                height: inner_area.height,
894            }
895        } else {
896            inner_area
897        };
898
899        match &self.content {
900            PopupContent::Text(lines) => {
901                // Word-wrap lines to fit content area width
902                let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
903                let selection_style = Style::default().bg(theme.selection_bg);
904
905                let visible_lines: Vec<Line> = wrapped_lines
906                    .iter()
907                    .enumerate()
908                    .skip(self.scroll_offset)
909                    .take(content_area.height as usize)
910                    .map(|(line_idx, line)| {
911                        if let Some(ref sel) = self.text_selection {
912                            // Apply selection highlighting
913                            let chars: Vec<char> = line.chars().collect();
914                            let spans: Vec<Span> = chars
915                                .iter()
916                                .enumerate()
917                                .map(|(col, ch)| {
918                                    if sel.contains(line_idx, col) {
919                                        Span::styled(ch.to_string(), selection_style)
920                                    } else {
921                                        Span::raw(ch.to_string())
922                                    }
923                                })
924                                .collect();
925                            Line::from(spans)
926                        } else {
927                            Line::from(line.as_str())
928                        }
929                    })
930                    .collect();
931
932                let paragraph = Paragraph::new(visible_lines);
933                frame.render_widget(paragraph, content_area);
934            }
935            PopupContent::Markdown(styled_lines) => {
936                // Word-wrap styled lines to fit content area width
937                let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
938                let selection_style = Style::default().bg(theme.selection_bg);
939
940                // Collect link overlay info for OSC 8 rendering after the main draw
941                // Each entry: (visible_line_idx, start_column, link_text, url)
942                let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
943
944                let visible_lines: Vec<Line> = wrapped_lines
945                    .iter()
946                    .enumerate()
947                    .skip(self.scroll_offset)
948                    .take(content_area.height as usize)
949                    .map(|(line_idx, styled_line)| {
950                        let mut col = 0usize;
951                        let spans: Vec<Span> = styled_line
952                            .spans
953                            .iter()
954                            .flat_map(|s| {
955                                let span_start_col = col;
956                                let span_width =
957                                    unicode_width::UnicodeWidthStr::width(s.text.as_str());
958                                if let Some(url) = &s.link_url {
959                                    link_overlays.push((
960                                        line_idx - self.scroll_offset,
961                                        col,
962                                        s.text.clone(),
963                                        url.clone(),
964                                    ));
965                                }
966                                col += span_width;
967
968                                // Check if any part of this span is selected
969                                if let Some(ref sel) = self.text_selection {
970                                    // Split span into selected/unselected parts
971                                    let chars: Vec<char> = s.text.chars().collect();
972                                    chars
973                                        .iter()
974                                        .enumerate()
975                                        .map(|(i, ch)| {
976                                            let char_col = span_start_col + i;
977                                            if sel.contains(line_idx, char_col) {
978                                                Span::styled(ch.to_string(), selection_style)
979                                            } else {
980                                                Span::styled(ch.to_string(), s.style)
981                                            }
982                                        })
983                                        .collect::<Vec<_>>()
984                                } else {
985                                    vec![Span::styled(s.text.clone(), s.style)]
986                                }
987                            })
988                            .collect();
989                        Line::from(spans)
990                    })
991                    .collect();
992
993                let paragraph = Paragraph::new(visible_lines);
994                frame.render_widget(paragraph, content_area);
995
996                // Apply OSC 8 hyperlinks following Ratatui's official workaround
997                let buffer = frame.buffer_mut();
998                let max_x = content_area.x + content_area.width;
999                for (line_idx, col_start, text, url) in link_overlays {
1000                    let y = content_area.y + line_idx as u16;
1001                    if y >= content_area.y + content_area.height {
1002                        continue;
1003                    }
1004                    let start_x = content_area.x + col_start as u16;
1005                    apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1006                }
1007            }
1008            PopupContent::List { items, selected } => {
1009                let list_items: Vec<ListItem> = items
1010                    .iter()
1011                    .enumerate()
1012                    .skip(self.scroll_offset)
1013                    .take(content_area.height as usize)
1014                    .map(|(idx, item)| {
1015                        // Check if this item is hovered or selected
1016                        let is_hovered = matches!(
1017                            hover_target,
1018                            Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1019                        );
1020                        let is_selected = idx == *selected;
1021
1022                        let mut spans = Vec::new();
1023
1024                        // Add icon if present
1025                        if let Some(icon) = &item.icon {
1026                            spans.push(Span::raw(format!("{} ", icon)));
1027                        }
1028
1029                        // Add main text with underline for clickable items
1030                        let text_style = if is_selected {
1031                            Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1032                        } else {
1033                            Style::default().add_modifier(Modifier::UNDERLINED)
1034                        };
1035                        spans.push(Span::styled(&item.text, text_style));
1036
1037                        // Add detail if present
1038                        if let Some(detail) = &item.detail {
1039                            spans.push(Span::styled(
1040                                format!(" {}", detail),
1041                                Style::default().fg(theme.help_separator_fg),
1042                            ));
1043                        }
1044
1045                        // Row style (background only, no underline)
1046                        let row_style = if is_selected {
1047                            Style::default().bg(theme.popup_selection_bg)
1048                        } else if is_hovered {
1049                            Style::default()
1050                                .bg(theme.menu_hover_bg)
1051                                .fg(theme.menu_hover_fg)
1052                        } else {
1053                            Style::default()
1054                        };
1055
1056                        ListItem::new(Line::from(spans)).style(row_style)
1057                    })
1058                    .collect();
1059
1060                let list = List::new(list_items);
1061                frame.render_widget(list, content_area);
1062            }
1063            PopupContent::Custom(lines) => {
1064                let visible_lines: Vec<Line> = lines
1065                    .iter()
1066                    .skip(self.scroll_offset)
1067                    .take(content_area.height as usize)
1068                    .map(|line| Line::from(line.as_str()))
1069                    .collect();
1070
1071                let paragraph = Paragraph::new(visible_lines);
1072                frame.render_widget(paragraph, content_area);
1073            }
1074        }
1075
1076        // Render scrollbar if needed
1077        if needs_scrollbar {
1078            let scrollbar_area = Rect {
1079                x: inner_area.x + inner_area.width - 1,
1080                y: inner_area.y,
1081                width: 1,
1082                height: inner_area.height,
1083            };
1084
1085            let scrollbar_state =
1086                ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1087            let scrollbar_colors = ScrollbarColors::from_theme(theme);
1088            render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1089        }
1090    }
1091}
1092
1093/// Manager for popups - can show multiple popups with z-ordering
1094#[derive(Debug, Clone)]
1095pub struct PopupManager {
1096    /// Stack of active popups (top of stack = topmost popup)
1097    popups: Vec<Popup>,
1098}
1099
1100impl PopupManager {
1101    pub fn new() -> Self {
1102        Self { popups: Vec::new() }
1103    }
1104
1105    /// Show a popup (adds to top of stack)
1106    pub fn show(&mut self, popup: Popup) {
1107        self.popups.push(popup);
1108    }
1109
1110    /// Hide the topmost popup
1111    pub fn hide(&mut self) -> Option<Popup> {
1112        self.popups.pop()
1113    }
1114
1115    /// Clear all popups
1116    pub fn clear(&mut self) {
1117        self.popups.clear();
1118    }
1119
1120    /// Get the topmost popup
1121    pub fn top(&self) -> Option<&Popup> {
1122        self.popups.last()
1123    }
1124
1125    /// Get mutable reference to topmost popup
1126    pub fn top_mut(&mut self) -> Option<&mut Popup> {
1127        self.popups.last_mut()
1128    }
1129
1130    /// Get reference to popup by index
1131    pub fn get(&self, index: usize) -> Option<&Popup> {
1132        self.popups.get(index)
1133    }
1134
1135    /// Get mutable reference to popup by index
1136    pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1137        self.popups.get_mut(index)
1138    }
1139
1140    /// Check if any popups are visible
1141    pub fn is_visible(&self) -> bool {
1142        !self.popups.is_empty()
1143    }
1144
1145    /// Check if the topmost popup is a completion popup (supports type-to-filter)
1146    pub fn is_completion_popup(&self) -> bool {
1147        self.top()
1148            .map(|p| p.kind == PopupKind::Completion)
1149            .unwrap_or(false)
1150    }
1151
1152    /// Check if the topmost popup is a hover popup
1153    pub fn is_hover_popup(&self) -> bool {
1154        self.top()
1155            .map(|p| p.kind == PopupKind::Hover)
1156            .unwrap_or(false)
1157    }
1158
1159    /// Check if the topmost popup is an action popup
1160    pub fn is_action_popup(&self) -> bool {
1161        self.top()
1162            .map(|p| p.kind == PopupKind::Action)
1163            .unwrap_or(false)
1164    }
1165
1166    /// Get all popups (for rendering)
1167    pub fn all(&self) -> &[Popup] {
1168        &self.popups
1169    }
1170
1171    /// Dismiss transient popups if present at the top.
1172    /// These popups should be dismissed when the buffer loses focus.
1173    /// Returns true if a popup was dismissed.
1174    pub fn dismiss_transient(&mut self) -> bool {
1175        let is_transient = self.popups.last().is_some_and(|p| p.transient);
1176
1177        if is_transient {
1178            self.popups.pop();
1179            true
1180        } else {
1181            false
1182        }
1183    }
1184}
1185
1186impl Default for PopupManager {
1187    fn default() -> Self {
1188        Self::new()
1189    }
1190}
1191
1192/// Overlay OSC 8 hyperlinks in 2-character chunks to keep text layout aligned.
1193///
1194/// This mirrors the approach used in Ratatui's official hyperlink example to
1195/// work around Crossterm width accounting bugs for OSC sequences.
1196fn apply_hyperlink_overlay(
1197    buffer: &mut ratatui::buffer::Buffer,
1198    start_x: u16,
1199    y: u16,
1200    max_x: u16,
1201    text: &str,
1202    url: &str,
1203) {
1204    let mut chunk_index = 0u16;
1205    let mut chars = text.chars();
1206
1207    loop {
1208        let mut chunk = String::new();
1209        for _ in 0..2 {
1210            if let Some(ch) = chars.next() {
1211                chunk.push(ch);
1212            } else {
1213                break;
1214            }
1215        }
1216
1217        if chunk.is_empty() {
1218            break;
1219        }
1220
1221        let x = start_x + chunk_index * 2;
1222        if x >= max_x {
1223            break;
1224        }
1225
1226        let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1227        buffer[(x, y)].set_symbol(&hyperlink);
1228
1229        chunk_index += 1;
1230    }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236    use crate::view::theme;
1237
1238    #[test]
1239    fn test_popup_list_item() {
1240        let item = PopupListItem::new("test".to_string())
1241            .with_detail("detail".to_string())
1242            .with_icon("📄".to_string());
1243
1244        assert_eq!(item.text, "test");
1245        assert_eq!(item.detail, Some("detail".to_string()));
1246        assert_eq!(item.icon, Some("📄".to_string()));
1247    }
1248
1249    #[test]
1250    fn test_popup_selection() {
1251        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1252        let items = vec![
1253            PopupListItem::new("item1".to_string()),
1254            PopupListItem::new("item2".to_string()),
1255            PopupListItem::new("item3".to_string()),
1256        ];
1257
1258        let mut popup = Popup::list(items, &theme);
1259
1260        assert_eq!(popup.selected_item().unwrap().text, "item1");
1261
1262        popup.select_next();
1263        assert_eq!(popup.selected_item().unwrap().text, "item2");
1264
1265        popup.select_next();
1266        assert_eq!(popup.selected_item().unwrap().text, "item3");
1267
1268        popup.select_next(); // Should stay at last item
1269        assert_eq!(popup.selected_item().unwrap().text, "item3");
1270
1271        popup.select_prev();
1272        assert_eq!(popup.selected_item().unwrap().text, "item2");
1273
1274        popup.select_prev();
1275        assert_eq!(popup.selected_item().unwrap().text, "item1");
1276
1277        popup.select_prev(); // Should stay at first item
1278        assert_eq!(popup.selected_item().unwrap().text, "item1");
1279    }
1280
1281    #[test]
1282    fn test_popup_manager() {
1283        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1284        let mut manager = PopupManager::new();
1285
1286        assert!(!manager.is_visible());
1287        assert_eq!(manager.top(), None);
1288
1289        let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1290        manager.show(popup1);
1291
1292        assert!(manager.is_visible());
1293        assert_eq!(manager.all().len(), 1);
1294
1295        let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1296        manager.show(popup2);
1297
1298        assert_eq!(manager.all().len(), 2);
1299
1300        manager.hide();
1301        assert_eq!(manager.all().len(), 1);
1302
1303        manager.clear();
1304        assert!(!manager.is_visible());
1305        assert_eq!(manager.all().len(), 0);
1306    }
1307
1308    #[test]
1309    fn test_popup_area_calculation() {
1310        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1311        let terminal_area = Rect {
1312            x: 0,
1313            y: 0,
1314            width: 100,
1315            height: 50,
1316        };
1317
1318        let popup = Popup::text(vec!["test".to_string()], &theme)
1319            .with_width(30)
1320            .with_max_height(10);
1321
1322        // Centered
1323        let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1324        let area = popup_centered.calculate_area(terminal_area, None);
1325        assert_eq!(area.width, 30);
1326        // Height is now based on content: 1 text line + 2 border lines = 3
1327        assert_eq!(area.height, 3);
1328        assert_eq!(area.x, (100 - 30) / 2);
1329        assert_eq!(area.y, (50 - 3) / 2);
1330
1331        // Below cursor
1332        let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1333        let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1334        assert_eq!(area.x, 20);
1335        assert_eq!(area.y, 12); // Two rows below cursor (allows space for cursor line)
1336    }
1337
1338    #[test]
1339    fn test_popup_fixed_position_clamping() {
1340        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1341        let terminal_area = Rect {
1342            x: 0,
1343            y: 0,
1344            width: 100,
1345            height: 50,
1346        };
1347
1348        let popup = Popup::text(vec!["test".to_string()], &theme)
1349            .with_width(30)
1350            .with_max_height(10);
1351
1352        // Fixed position within bounds - should stay as specified
1353        let popup_fixed = popup
1354            .clone()
1355            .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1356        let area = popup_fixed.calculate_area(terminal_area, None);
1357        assert_eq!(area.x, 10);
1358        assert_eq!(area.y, 20);
1359
1360        // Fixed position at right edge - x should be clamped
1361        let popup_right_edge = popup
1362            .clone()
1363            .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1364        let area = popup_right_edge.calculate_area(terminal_area, None);
1365        // x=99 + width=30 > 100, so x should be clamped to 100-30=70
1366        assert_eq!(area.x, 70);
1367        assert_eq!(area.y, 20);
1368
1369        // Fixed position beyond right edge - x should be clamped
1370        let popup_beyond = popup
1371            .clone()
1372            .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1373        let area = popup_beyond.calculate_area(terminal_area, None);
1374        // x=199 + width=30 > 100, so x should be clamped to 100-30=70
1375        assert_eq!(area.x, 70);
1376        assert_eq!(area.y, 20);
1377
1378        // Fixed position at bottom edge - y should be clamped
1379        let popup_bottom = popup
1380            .clone()
1381            .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1382        let area = popup_bottom.calculate_area(terminal_area, None);
1383        assert_eq!(area.x, 10);
1384        // y=49 + height=3 > 50, so y should be clamped to 50-3=47
1385        assert_eq!(area.y, 47);
1386    }
1387
1388    #[test]
1389    fn test_clamp_rect_to_bounds() {
1390        let bounds = Rect {
1391            x: 0,
1392            y: 0,
1393            width: 100,
1394            height: 50,
1395        };
1396
1397        // Rect within bounds - unchanged
1398        let rect = Rect {
1399            x: 10,
1400            y: 20,
1401            width: 30,
1402            height: 10,
1403        };
1404        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1405        assert_eq!(clamped, rect);
1406
1407        // Rect at exact right edge of bounds
1408        let rect = Rect {
1409            x: 99,
1410            y: 20,
1411            width: 30,
1412            height: 10,
1413        };
1414        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1415        assert_eq!(clamped.x, 99); // x is within bounds
1416        assert_eq!(clamped.width, 1); // width clamped to fit
1417
1418        // Rect beyond bounds
1419        let rect = Rect {
1420            x: 199,
1421            y: 60,
1422            width: 30,
1423            height: 10,
1424        };
1425        let clamped = super::clamp_rect_to_bounds(rect, bounds);
1426        assert_eq!(clamped.x, 99); // x clamped to last valid position
1427        assert_eq!(clamped.y, 49); // y clamped to last valid position
1428        assert_eq!(clamped.width, 1); // width clamped to fit
1429        assert_eq!(clamped.height, 1); // height clamped to fit
1430    }
1431
1432    #[test]
1433    fn hyperlink_overlay_chunks_pairs() {
1434        use ratatui::{buffer::Buffer, layout::Rect};
1435
1436        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1437        buffer[(0, 0)].set_symbol("P");
1438        buffer[(1, 0)].set_symbol("l");
1439        buffer[(2, 0)].set_symbol("a");
1440        buffer[(3, 0)].set_symbol("y");
1441
1442        apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1443
1444        let first = buffer[(0, 0)].symbol().to_string();
1445        let second = buffer[(2, 0)].symbol().to_string();
1446
1447        assert!(
1448            first.contains("Pl"),
1449            "first chunk should contain 'Pl', got {first:?}"
1450        );
1451        assert!(
1452            second.contains("ay"),
1453            "second chunk should contain 'ay', got {second:?}"
1454        );
1455    }
1456
1457    #[test]
1458    fn test_popup_text_selection() {
1459        let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1460        let mut popup = Popup::text(
1461            vec![
1462                "Line 0: Hello".to_string(),
1463                "Line 1: World".to_string(),
1464                "Line 2: Test".to_string(),
1465            ],
1466            &theme,
1467        );
1468
1469        // Initially no selection
1470        assert!(!popup.has_selection());
1471        assert_eq!(popup.get_selected_text(), None);
1472
1473        // Start selection at line 0, col 8 ("Hello" starts at col 8)
1474        popup.start_selection(0, 8);
1475        assert!(!popup.has_selection()); // Selection start == end
1476
1477        // Extend selection to line 1, col 8 ("World" starts at col 8)
1478        popup.extend_selection(1, 8);
1479        assert!(popup.has_selection());
1480
1481        // Get selected text: "Hello\nLine 1: "
1482        let selected = popup.get_selected_text().unwrap();
1483        assert_eq!(selected, "Hello\nLine 1: ");
1484
1485        // Clear selection
1486        popup.clear_selection();
1487        assert!(!popup.has_selection());
1488        assert_eq!(popup.get_selected_text(), None);
1489
1490        // Test single-line selection
1491        popup.start_selection(1, 8);
1492        popup.extend_selection(1, 13); // "World"
1493        let selected = popup.get_selected_text().unwrap();
1494        assert_eq!(selected, "World");
1495    }
1496
1497    #[test]
1498    fn test_popup_text_selection_contains() {
1499        let sel = PopupTextSelection {
1500            start: (1, 5),
1501            end: (2, 10),
1502        };
1503
1504        // Line 0 - before selection
1505        assert!(!sel.contains(0, 5));
1506
1507        // Line 1 - start of selection
1508        assert!(!sel.contains(1, 4)); // Before start col
1509        assert!(sel.contains(1, 5)); // At start
1510        assert!(sel.contains(1, 10)); // After start on same line
1511
1512        // Line 2 - end of selection
1513        assert!(sel.contains(2, 0)); // Beginning of last line
1514        assert!(sel.contains(2, 9)); // Before end col
1515        assert!(!sel.contains(2, 10)); // At end (exclusive)
1516        assert!(!sel.contains(2, 11)); // After end
1517
1518        // Line 3 - after selection
1519        assert!(!sel.contains(3, 0));
1520    }
1521
1522    #[test]
1523    fn test_popup_text_selection_normalized() {
1524        // Forward selection
1525        let sel = PopupTextSelection {
1526            start: (1, 5),
1527            end: (2, 10),
1528        };
1529        let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1530        assert_eq!((s_line, s_col), (1, 5));
1531        assert_eq!((e_line, e_col), (2, 10));
1532
1533        // Backward selection (user dragged up)
1534        let sel_backward = PopupTextSelection {
1535            start: (2, 10),
1536            end: (1, 5),
1537        };
1538        let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1539        assert_eq!((s_line, s_col), (1, 5));
1540        assert_eq!((e_line, e_col), (2, 10));
1541    }
1542}