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
13fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
16 let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
18 let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
20
21 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PopupPosition {
36 AtCursor,
38 BelowCursor,
40 AboveCursor,
42 Fixed { x: u16, y: u16 },
44 Centered,
46 BottomRight,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum PopupContent {
53 Text(Vec<String>),
55 Markdown(Vec<StyledLine>),
57 List {
59 items: Vec<PopupListItem>,
60 selected: usize,
61 },
62 Custom(Vec<String>),
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct PopupTextSelection {
69 pub start: (usize, usize),
71 pub end: (usize, usize),
73}
74
75impl PopupTextSelection {
76 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 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#[derive(Debug, Clone, PartialEq)]
105pub struct PopupListItem {
106 pub text: String,
108 pub detail: Option<String>,
110 pub icon: Option<String>,
112 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#[derive(Debug, Clone, PartialEq)]
151pub struct Popup {
152 pub title: Option<String>,
154
155 pub description: Option<String>,
157
158 pub transient: bool,
160
161 pub content: PopupContent,
163
164 pub position: PopupPosition,
166
167 pub width: u16,
169
170 pub max_height: u16,
172
173 pub bordered: bool,
175
176 pub border_style: Style,
178
179 pub background_style: Style,
181
182 pub scroll_offset: usize,
184
185 pub text_selection: Option<PopupTextSelection>,
187}
188
189impl Popup {
190 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 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, max_height: 20, 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 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 pub fn with_title(mut self, title: String) -> Self {
254 self.title = Some(title);
255 self
256 }
257
258 pub fn with_transient(mut self, transient: bool) -> Self {
260 self.transient = transient;
261 self
262 }
263
264 pub fn with_position(mut self, position: PopupPosition) -> Self {
266 self.position = position;
267 self
268 }
269
270 pub fn with_width(mut self, width: u16) -> Self {
272 self.width = width;
273 self
274 }
275
276 pub fn with_max_height(mut self, max_height: u16) -> Self {
278 self.max_height = max_height;
279 self
280 }
281
282 pub fn with_border_style(mut self, style: Style) -> Self {
284 self.border_style = style;
285 self
286 }
287
288 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 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 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 if *selected >= self.scroll_offset + visible {
310 self.scroll_offset = (*selected + 1).saturating_sub(visible);
311 }
312 }
313 }
314 }
315
316 pub fn select_prev(&mut self) {
318 if let PopupContent::List { items: _, selected } = &mut self.content {
319 if *selected > 0 {
320 *selected -= 1;
321 if *selected < self.scroll_offset {
323 self.scroll_offset = *selected;
324 }
325 }
326 }
327 }
328
329 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 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 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 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 if *selected >= visible {
368 self.scroll_offset = (*selected + 1).saturating_sub(visible);
369 }
370 } else {
371 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 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 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
389 } else {
390 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
392 }
393
394 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 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 fn wrapped_item_count(&self) -> usize {
422 let border_width = if self.bordered { 2 } else { 0 };
424 let scrollbar_width = 2; 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 PopupContent::List { items, .. } => items.len(),
440 PopupContent::Custom(lines) => lines.len(),
441 }
442 }
443
444 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 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 pub fn clear_selection(&mut self) {
461 self.text_selection = None;
462 }
463
464 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 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 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 let first_line = &lines[start_line];
508 result.push_str(&first_line[start_col.min(first_line.len())..]);
509 result.push('\n');
510 for line in lines.iter().take(end_line).skip(start_line + 1) {
512 result.push_str(line);
513 result.push('\n');
514 }
515 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 pub fn needs_scrollbar(&self) -> bool {
526 self.item_count() > self.visible_height()
527 }
528
529 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 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 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 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
556
557 let line_index = self.scroll_offset + relative_row;
559
560 let line = wrapped_lines.get(line_index)?;
562
563 line.link_at_column(relative_col).map(|s| s.to_string())
565 }
566
567 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 } else {
581 0
582 }
583 }
584
585 fn content_height(&self) -> u16 {
587 self.content_height_for_width(self.width)
589 }
590
591 fn content_height_for_width(&self, popup_width: u16) -> u16 {
593 let border_width = if self.bordered { 2 } else { 0 };
595 let scrollbar_reserved = 2; let content_width = popup_width
597 .saturating_sub(border_width)
598 .saturating_sub(scrollbar_reserved) as usize;
599
600 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 } else {
606 0
607 };
608
609 let content_lines = match &self.content {
610 PopupContent::Text(lines) => {
611 wrap_text_lines(lines, content_width).len() as u16
613 }
614 PopupContent::Markdown(styled_lines) => {
615 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 let border_height = if self.bordered { 2 } else { 0 };
624
625 description_lines + content_lines + border_height
626 }
627
628 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 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 (cursor_y + 1).saturating_sub(height)
655 } else {
656 cursor_y + 2
658 }
659 }
660 PopupPosition::AboveCursor => {
661 (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 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 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 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 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 let frame_area = frame.area();
750 let area = clamp_rect_to_bounds(area, frame_area);
751
752 if area.width == 0 || area.height == 0 {
754 return;
755 }
756
757 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 let content_start_y;
780 if let Some(desc) = &self.description {
781 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; 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 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 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 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 let scrollbar_reserved_width = 2; 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 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 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 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 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 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 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 if let Some(ref sel) = self.text_selection {
941 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 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 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 if let Some(icon) = &item.icon {
997 spans.push(Span::raw(format!("{} ", icon)));
998 }
999
1000 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 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 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 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#[derive(Debug, Clone)]
1066pub struct PopupManager {
1067 popups: Vec<Popup>,
1069}
1070
1071impl PopupManager {
1072 pub fn new() -> Self {
1073 Self { popups: Vec::new() }
1074 }
1075
1076 pub fn show(&mut self, popup: Popup) {
1078 self.popups.push(popup);
1079 }
1080
1081 pub fn hide(&mut self) -> Option<Popup> {
1083 self.popups.pop()
1084 }
1085
1086 pub fn clear(&mut self) {
1088 self.popups.clear();
1089 }
1090
1091 pub fn top(&self) -> Option<&Popup> {
1093 self.popups.last()
1094 }
1095
1096 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1098 self.popups.last_mut()
1099 }
1100
1101 pub fn get(&self, index: usize) -> Option<&Popup> {
1103 self.popups.get(index)
1104 }
1105
1106 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1108 self.popups.get_mut(index)
1109 }
1110
1111 pub fn is_visible(&self) -> bool {
1113 !self.popups.is_empty()
1114 }
1115
1116 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 pub fn all(&self) -> &[Popup] {
1126 &self.popups
1127 }
1128
1129 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
1150fn 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(); 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(); 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 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 assert_eq!(area.height, 3);
1286 assert_eq!(area.x, (100 - 30) / 2);
1287 assert_eq!(area.y, (50 - 3) / 2);
1288
1289 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); }
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 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 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 assert_eq!(area.x, 70);
1325 assert_eq!(area.y, 20);
1326
1327 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 assert_eq!(area.x, 70);
1334 assert_eq!(area.y, 20);
1335
1336 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 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 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 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); assert_eq!(clamped.width, 1); 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); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
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 assert!(!popup.has_selection());
1429 assert_eq!(popup.get_selected_text(), None);
1430
1431 popup.start_selection(0, 8);
1433 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1437 assert!(popup.has_selection());
1438
1439 let selected = popup.get_selected_text().unwrap();
1441 assert_eq!(selected, "Hello\nLine 1: ");
1442
1443 popup.clear_selection();
1445 assert!(!popup.has_selection());
1446 assert_eq!(popup.get_selected_text(), None);
1447
1448 popup.start_selection(1, 8);
1450 popup.extend_selection(1, 13); 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 assert!(!sel.contains(0, 5));
1464
1465 assert!(!sel.contains(1, 4)); assert!(sel.contains(1, 5)); assert!(sel.contains(1, 10)); assert!(sel.contains(2, 0)); assert!(sel.contains(2, 9)); assert!(!sel.contains(2, 10)); assert!(!sel.contains(2, 11)); assert!(!sel.contains(3, 0));
1478 }
1479
1480 #[test]
1481 fn test_popup_text_selection_normalized() {
1482 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 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}