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