1use ratatui::{
2 layout::Rect,
3 style::{Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
6 Frame,
7};
8
9use super::markdown::{parse_markdown, wrap_styled_lines, wrap_text_lines, StyledLine};
10
11pub mod input;
12use super::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
13use crate::primitives::grammar::GrammarRegistry;
14
15fn clamp_rect_to_bounds(rect: Rect, bounds: Rect) -> Rect {
18 let x = rect.x.min(bounds.x + bounds.width.saturating_sub(1));
20 let y = rect.y.min(bounds.y + bounds.height.saturating_sub(1));
22
23 let max_width = (bounds.x + bounds.width).saturating_sub(x);
25 let max_height = (bounds.y + bounds.height).saturating_sub(y);
26
27 Rect {
28 x,
29 y,
30 width: rect.width.min(max_width),
31 height: rect.height.min(max_height),
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PopupPosition {
38 AtCursor,
40 BelowCursor,
42 AboveCursor,
44 Fixed { x: u16, y: u16 },
46 Centered,
48 BottomRight,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum PopupKind {
55 Completion,
57 Hover,
59 Action,
61 List,
63 Text,
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub enum PopupContent {
70 Text(Vec<String>),
72 Markdown(Vec<StyledLine>),
74 List {
76 items: Vec<PopupListItem>,
77 selected: usize,
78 },
79 Custom(Vec<String>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct PopupTextSelection {
86 pub start: (usize, usize),
88 pub end: (usize, usize),
90}
91
92impl PopupTextSelection {
93 pub fn normalized(&self) -> ((usize, usize), (usize, usize)) {
95 if self.start.0 < self.end.0 || (self.start.0 == self.end.0 && self.start.1 <= self.end.1) {
96 (self.start, self.end)
97 } else {
98 (self.end, self.start)
99 }
100 }
101
102 pub fn contains(&self, line: usize, col: usize) -> bool {
104 let ((start_line, start_col), (end_line, end_col)) = self.normalized();
105 if line < start_line || line > end_line {
106 return false;
107 }
108 if line == start_line && line == end_line {
109 col >= start_col && col < end_col
110 } else if line == start_line {
111 col >= start_col
112 } else if line == end_line {
113 col < end_col
114 } else {
115 true
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub struct PopupListItem {
123 pub text: String,
125 pub detail: Option<String>,
127 pub icon: Option<String>,
129 pub data: Option<String>,
131}
132
133impl PopupListItem {
134 pub fn new(text: String) -> Self {
135 Self {
136 text,
137 detail: None,
138 icon: None,
139 data: None,
140 }
141 }
142
143 pub fn with_detail(mut self, detail: String) -> Self {
144 self.detail = Some(detail);
145 self
146 }
147
148 pub fn with_icon(mut self, icon: String) -> Self {
149 self.icon = Some(icon);
150 self
151 }
152
153 pub fn with_data(mut self, data: String) -> Self {
154 self.data = Some(data);
155 self
156 }
157}
158
159#[derive(Debug, Clone, PartialEq)]
168pub struct Popup {
169 pub kind: PopupKind,
171
172 pub title: Option<String>,
174
175 pub description: Option<String>,
177
178 pub transient: bool,
180
181 pub content: PopupContent,
183
184 pub position: PopupPosition,
186
187 pub width: u16,
189
190 pub max_height: u16,
192
193 pub bordered: bool,
195
196 pub border_style: Style,
198
199 pub background_style: Style,
201
202 pub scroll_offset: usize,
204
205 pub text_selection: Option<PopupTextSelection>,
207}
208
209impl Popup {
210 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
212 Self {
213 kind: PopupKind::Text,
214 title: None,
215 description: None,
216 transient: false,
217 content: PopupContent::Text(content),
218 position: PopupPosition::AtCursor,
219 width: 50,
220 max_height: 15,
221 bordered: true,
222 border_style: Style::default().fg(theme.popup_border_fg),
223 background_style: Style::default().bg(theme.popup_bg),
224 scroll_offset: 0,
225 text_selection: None,
226 }
227 }
228
229 pub fn markdown(
234 markdown_text: &str,
235 theme: &crate::view::theme::Theme,
236 registry: Option<&GrammarRegistry>,
237 ) -> Self {
238 let styled_lines = parse_markdown(markdown_text, theme, registry);
239 Self {
240 kind: PopupKind::Text,
241 title: None,
242 description: None,
243 transient: false,
244 content: PopupContent::Markdown(styled_lines),
245 position: PopupPosition::AtCursor,
246 width: 60, max_height: 20, bordered: true,
249 border_style: Style::default().fg(theme.popup_border_fg),
250 background_style: Style::default().bg(theme.popup_bg),
251 scroll_offset: 0,
252 text_selection: None,
253 }
254 }
255
256 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
258 Self {
259 kind: PopupKind::List,
260 title: None,
261 description: None,
262 transient: false,
263 content: PopupContent::List { items, selected: 0 },
264 position: PopupPosition::AtCursor,
265 width: 50,
266 max_height: 15,
267 bordered: true,
268 border_style: Style::default().fg(theme.popup_border_fg),
269 background_style: Style::default().bg(theme.popup_bg),
270 scroll_offset: 0,
271 text_selection: None,
272 }
273 }
274
275 pub fn with_title(mut self, title: String) -> Self {
277 self.title = Some(title);
278 self
279 }
280
281 pub fn with_kind(mut self, kind: PopupKind) -> Self {
283 self.kind = kind;
284 self
285 }
286
287 pub fn with_transient(mut self, transient: bool) -> Self {
289 self.transient = transient;
290 self
291 }
292
293 pub fn with_position(mut self, position: PopupPosition) -> Self {
295 self.position = position;
296 self
297 }
298
299 pub fn with_width(mut self, width: u16) -> Self {
301 self.width = width;
302 self
303 }
304
305 pub fn with_max_height(mut self, max_height: u16) -> Self {
307 self.max_height = max_height;
308 self
309 }
310
311 pub fn with_border_style(mut self, style: Style) -> Self {
313 self.border_style = style;
314 self
315 }
316
317 pub fn selected_item(&self) -> Option<&PopupListItem> {
319 match &self.content {
320 PopupContent::List { items, selected } => items.get(*selected),
321 _ => None,
322 }
323 }
324
325 fn visible_height(&self) -> usize {
327 let border_offset = if self.bordered { 2 } else { 0 };
328 (self.max_height as usize).saturating_sub(border_offset)
329 }
330
331 pub fn select_next(&mut self) {
333 let visible = self.visible_height();
334 if let PopupContent::List { items, selected } = &mut self.content {
335 if *selected < items.len().saturating_sub(1) {
336 *selected += 1;
337 if *selected >= self.scroll_offset + visible {
339 self.scroll_offset = (*selected + 1).saturating_sub(visible);
340 }
341 }
342 }
343 }
344
345 pub fn select_prev(&mut self) {
347 if let PopupContent::List { items: _, selected } = &mut self.content {
348 if *selected > 0 {
349 *selected -= 1;
350 if *selected < self.scroll_offset {
352 self.scroll_offset = *selected;
353 }
354 }
355 }
356 }
357
358 pub fn page_down(&mut self) {
360 let visible = self.visible_height();
361 if let PopupContent::List { items, selected } = &mut self.content {
362 *selected = (*selected + visible).min(items.len().saturating_sub(1));
363 self.scroll_offset = (*selected + 1).saturating_sub(visible);
364 } else {
365 self.scroll_offset += visible;
366 }
367 }
368
369 pub fn page_up(&mut self) {
371 let visible = self.visible_height();
372 if let PopupContent::List { items: _, selected } = &mut self.content {
373 *selected = selected.saturating_sub(visible);
374 self.scroll_offset = *selected;
375 } else {
376 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
377 }
378 }
379
380 pub fn select_first(&mut self) {
382 if let PopupContent::List { items: _, selected } = &mut self.content {
383 *selected = 0;
384 self.scroll_offset = 0;
385 } else {
386 self.scroll_offset = 0;
387 }
388 }
389
390 pub fn select_last(&mut self) {
392 let visible = self.visible_height();
393 if let PopupContent::List { items, selected } = &mut self.content {
394 *selected = items.len().saturating_sub(1);
395 if *selected >= visible {
397 self.scroll_offset = (*selected + 1).saturating_sub(visible);
398 }
399 } else {
400 let content_height = self.item_count();
402 if content_height > visible {
403 self.scroll_offset = content_height.saturating_sub(visible);
404 }
405 }
406 }
407
408 pub fn scroll_by(&mut self, delta: i32) {
411 let content_len = self.wrapped_item_count();
412 let visible = self.visible_height();
413 let max_scroll = content_len.saturating_sub(visible);
414
415 if delta < 0 {
416 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
418 } else {
419 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
421 }
422
423 if let PopupContent::List { items, selected } = &mut self.content {
425 let visible_start = self.scroll_offset;
426 let visible_end = (self.scroll_offset + visible).min(items.len());
427
428 if *selected < visible_start {
429 *selected = visible_start;
430 } else if *selected >= visible_end {
431 *selected = visible_end.saturating_sub(1);
432 }
433 }
434 }
435
436 pub fn item_count(&self) -> usize {
438 match &self.content {
439 PopupContent::Text(lines) => lines.len(),
440 PopupContent::Markdown(lines) => lines.len(),
441 PopupContent::List { items, .. } => items.len(),
442 PopupContent::Custom(lines) => lines.len(),
443 }
444 }
445
446 fn wrapped_item_count(&self) -> usize {
451 let border_width = if self.bordered { 2 } else { 0 };
453 let scrollbar_width = 2; let wrap_width = (self.width as usize)
455 .saturating_sub(border_width)
456 .saturating_sub(scrollbar_width);
457
458 if wrap_width == 0 {
459 return self.item_count();
460 }
461
462 match &self.content {
463 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
464 PopupContent::Markdown(styled_lines) => {
465 wrap_styled_lines(styled_lines, wrap_width).len()
466 }
467 PopupContent::List { items, .. } => items.len(),
469 PopupContent::Custom(lines) => lines.len(),
470 }
471 }
472
473 pub fn start_selection(&mut self, line: usize, col: usize) {
475 self.text_selection = Some(PopupTextSelection {
476 start: (line, col),
477 end: (line, col),
478 });
479 }
480
481 pub fn extend_selection(&mut self, line: usize, col: usize) {
483 if let Some(ref mut sel) = self.text_selection {
484 sel.end = (line, col);
485 }
486 }
487
488 pub fn clear_selection(&mut self) {
490 self.text_selection = None;
491 }
492
493 pub fn has_selection(&self) -> bool {
495 if let Some(sel) = &self.text_selection {
496 sel.start != sel.end
497 } else {
498 false
499 }
500 }
501
502 fn get_text_lines(&self) -> Vec<String> {
504 match &self.content {
505 PopupContent::Text(lines) => lines.clone(),
506 PopupContent::Markdown(styled_lines) => {
507 styled_lines.iter().map(|sl| sl.plain_text()).collect()
508 }
509 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
510 PopupContent::Custom(lines) => lines.clone(),
511 }
512 }
513
514 pub fn get_selected_text(&self) -> Option<String> {
516 let sel = self.text_selection.as_ref()?;
517 if sel.start == sel.end {
518 return None;
519 }
520
521 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
522 let lines = self.get_text_lines();
523
524 if start_line >= lines.len() {
525 return None;
526 }
527
528 if start_line == end_line {
529 let line = &lines[start_line];
530 let end_col = end_col.min(line.len());
531 let start_col = start_col.min(end_col);
532 Some(line[start_col..end_col].to_string())
533 } else {
534 let mut result = String::new();
535 let first_line = &lines[start_line];
537 result.push_str(&first_line[start_col.min(first_line.len())..]);
538 result.push('\n');
539 for line in lines.iter().take(end_line).skip(start_line + 1) {
541 result.push_str(line);
542 result.push('\n');
543 }
544 if end_line < lines.len() {
546 let last_line = &lines[end_line];
547 result.push_str(&last_line[..end_col.min(last_line.len())]);
548 }
549 Some(result)
550 }
551 }
552
553 pub fn needs_scrollbar(&self) -> bool {
555 self.item_count() > self.visible_height()
556 }
557
558 pub fn scroll_state(&self) -> (usize, usize, usize) {
560 let total = self.item_count();
561 let visible = self.visible_height();
562 (total, visible, self.scroll_offset)
563 }
564
565 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
571 let PopupContent::Markdown(styled_lines) = &self.content else {
572 return None;
573 };
574
575 let border_width = if self.bordered { 2 } else { 0 };
577 let scrollbar_reserved = 2;
578 let content_width = self
579 .width
580 .saturating_sub(border_width)
581 .saturating_sub(scrollbar_reserved) as usize;
582
583 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
585
586 let line_index = self.scroll_offset + relative_row;
588
589 let line = wrapped_lines.get(line_index)?;
591
592 line.link_at_column(relative_col).map(|s| s.to_string())
594 }
595
596 pub fn description_height(&self) -> u16 {
599 if let Some(desc) = &self.description {
600 let border_width = if self.bordered { 2 } else { 0 };
601 let scrollbar_reserved = 2;
602 let content_width = self
603 .width
604 .saturating_sub(border_width)
605 .saturating_sub(scrollbar_reserved) as usize;
606 let desc_vec = vec![desc.clone()];
607 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
608 wrapped.len() as u16 + 1 } else {
610 0
611 }
612 }
613
614 fn content_height(&self) -> u16 {
616 self.content_height_for_width(self.width)
618 }
619
620 fn content_height_for_width(&self, popup_width: u16) -> u16 {
622 let border_width = if self.bordered { 2 } else { 0 };
624 let scrollbar_reserved = 2; let content_width = popup_width
626 .saturating_sub(border_width)
627 .saturating_sub(scrollbar_reserved) as usize;
628
629 let description_lines = if let Some(desc) = &self.description {
631 let desc_vec = vec![desc.clone()];
632 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
633 wrapped.len() as u16 + 1 } else {
635 0
636 };
637
638 let content_lines = match &self.content {
639 PopupContent::Text(lines) => {
640 wrap_text_lines(lines, content_width).len() as u16
642 }
643 PopupContent::Markdown(styled_lines) => {
644 wrap_styled_lines(styled_lines, content_width).len() as u16
646 }
647 PopupContent::List { items, .. } => items.len() as u16,
648 PopupContent::Custom(lines) => lines.len() as u16,
649 };
650
651 let border_height = if self.bordered { 2 } else { 0 };
653
654 description_lines + content_lines + border_height
655 }
656
657 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
659 match self.position {
660 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
661 let (cursor_x, cursor_y) =
662 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
663
664 let width = self.width.min(terminal_area.width);
665 let height = self
667 .content_height()
668 .min(self.max_height)
669 .min(terminal_area.height);
670
671 let x = if cursor_x + width > terminal_area.width {
672 terminal_area.width.saturating_sub(width)
673 } else {
674 cursor_x
675 };
676
677 let y = match self.position {
678 PopupPosition::AtCursor => cursor_y,
679 PopupPosition::BelowCursor => {
680 if cursor_y + 2 + height > terminal_area.height {
681 (cursor_y + 1).saturating_sub(height)
684 } else {
685 cursor_y + 2
687 }
688 }
689 PopupPosition::AboveCursor => {
690 (cursor_y + 1).saturating_sub(height)
692 }
693 _ => cursor_y,
694 };
695
696 Rect {
697 x,
698 y,
699 width,
700 height,
701 }
702 }
703 PopupPosition::Fixed { x, y } => {
704 let width = self.width.min(terminal_area.width);
705 let height = self
706 .content_height()
707 .min(self.max_height)
708 .min(terminal_area.height);
709 let x = if x + width > terminal_area.width {
711 terminal_area.width.saturating_sub(width)
712 } else {
713 x
714 };
715 let y = if y + height > terminal_area.height {
716 terminal_area.height.saturating_sub(height)
717 } else {
718 y
719 };
720 Rect {
721 x,
722 y,
723 width,
724 height,
725 }
726 }
727 PopupPosition::Centered => {
728 let width = self.width.min(terminal_area.width);
729 let height = self
730 .content_height()
731 .min(self.max_height)
732 .min(terminal_area.height);
733 let x = (terminal_area.width.saturating_sub(width)) / 2;
734 let y = (terminal_area.height.saturating_sub(height)) / 2;
735 Rect {
736 x,
737 y,
738 width,
739 height,
740 }
741 }
742 PopupPosition::BottomRight => {
743 let width = self.width.min(terminal_area.width);
744 let height = self
745 .content_height()
746 .min(self.max_height)
747 .min(terminal_area.height);
748 let x = terminal_area.width.saturating_sub(width);
750 let y = terminal_area
751 .height
752 .saturating_sub(height)
753 .saturating_sub(2);
754 Rect {
755 x,
756 y,
757 width,
758 height,
759 }
760 }
761 }
762 }
763
764 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
766 self.render_with_hover(frame, area, theme, None);
767 }
768
769 pub fn render_with_hover(
771 &self,
772 frame: &mut Frame,
773 area: Rect,
774 theme: &crate::view::theme::Theme,
775 hover_target: Option<&crate::app::HoverTarget>,
776 ) {
777 let frame_area = frame.area();
779 let area = clamp_rect_to_bounds(area, frame_area);
780
781 if area.width == 0 || area.height == 0 {
783 return;
784 }
785
786 frame.render_widget(Clear, area);
788
789 let block = if self.bordered {
790 let mut block = Block::default()
791 .borders(Borders::ALL)
792 .border_style(self.border_style)
793 .style(self.background_style);
794
795 if let Some(title) = &self.title {
796 block = block.title(title.as_str());
797 }
798
799 block
800 } else {
801 Block::default().style(self.background_style)
802 };
803
804 let inner_area = block.inner(area);
805 frame.render_widget(block, area);
806
807 let content_start_y;
809 if let Some(desc) = &self.description {
810 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
813 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
814 let desc_lines: usize = wrapped_desc.len();
815
816 for (i, line) in wrapped_desc.iter().enumerate() {
818 if i >= inner_area.height as usize {
819 break;
820 }
821 let line_area = Rect {
822 x: inner_area.x,
823 y: inner_area.y + i as u16,
824 width: inner_area.width,
825 height: 1,
826 };
827 let desc_style = Style::default().fg(theme.help_separator_fg);
828 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
829 }
830
831 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
833 } else {
834 content_start_y = inner_area.y;
835 }
836
837 let inner_area = Rect {
839 x: inner_area.x,
840 y: content_start_y,
841 width: inner_area.width,
842 height: inner_area
843 .height
844 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
845 };
846
847 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
851 let visible_lines_count = inner_area.height as usize;
852
853 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
855 PopupContent::Text(lines) => {
856 let wrapped = wrap_text_lines(lines, wrap_width);
857 let count = wrapped.len();
858 (
859 count,
860 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
861 )
862 }
863 PopupContent::Markdown(styled_lines) => {
864 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
865 let count = wrapped.len();
866 (
867 count,
868 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
869 )
870 }
871 PopupContent::List { items, .. } => {
872 let count = items.len();
873 (
874 count,
875 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
876 )
877 }
878 PopupContent::Custom(lines) => {
879 let count = lines.len();
880 (
881 count,
882 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
883 )
884 }
885 };
886
887 let content_area = if needs_scrollbar {
889 Rect {
890 x: inner_area.x,
891 y: inner_area.y,
892 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
893 height: inner_area.height,
894 }
895 } else {
896 inner_area
897 };
898
899 match &self.content {
900 PopupContent::Text(lines) => {
901 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
903 let selection_style = Style::default().bg(theme.selection_bg);
904
905 let visible_lines: Vec<Line> = wrapped_lines
906 .iter()
907 .enumerate()
908 .skip(self.scroll_offset)
909 .take(content_area.height as usize)
910 .map(|(line_idx, line)| {
911 if let Some(ref sel) = self.text_selection {
912 let chars: Vec<char> = line.chars().collect();
914 let spans: Vec<Span> = chars
915 .iter()
916 .enumerate()
917 .map(|(col, ch)| {
918 if sel.contains(line_idx, col) {
919 Span::styled(ch.to_string(), selection_style)
920 } else {
921 Span::raw(ch.to_string())
922 }
923 })
924 .collect();
925 Line::from(spans)
926 } else {
927 Line::from(line.as_str())
928 }
929 })
930 .collect();
931
932 let paragraph = Paragraph::new(visible_lines);
933 frame.render_widget(paragraph, content_area);
934 }
935 PopupContent::Markdown(styled_lines) => {
936 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
938 let selection_style = Style::default().bg(theme.selection_bg);
939
940 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
943
944 let visible_lines: Vec<Line> = wrapped_lines
945 .iter()
946 .enumerate()
947 .skip(self.scroll_offset)
948 .take(content_area.height as usize)
949 .map(|(line_idx, styled_line)| {
950 let mut col = 0usize;
951 let spans: Vec<Span> = styled_line
952 .spans
953 .iter()
954 .flat_map(|s| {
955 let span_start_col = col;
956 let span_width =
957 unicode_width::UnicodeWidthStr::width(s.text.as_str());
958 if let Some(url) = &s.link_url {
959 link_overlays.push((
960 line_idx - self.scroll_offset,
961 col,
962 s.text.clone(),
963 url.clone(),
964 ));
965 }
966 col += span_width;
967
968 if let Some(ref sel) = self.text_selection {
970 let chars: Vec<char> = s.text.chars().collect();
972 chars
973 .iter()
974 .enumerate()
975 .map(|(i, ch)| {
976 let char_col = span_start_col + i;
977 if sel.contains(line_idx, char_col) {
978 Span::styled(ch.to_string(), selection_style)
979 } else {
980 Span::styled(ch.to_string(), s.style)
981 }
982 })
983 .collect::<Vec<_>>()
984 } else {
985 vec![Span::styled(s.text.clone(), s.style)]
986 }
987 })
988 .collect();
989 Line::from(spans)
990 })
991 .collect();
992
993 let paragraph = Paragraph::new(visible_lines);
994 frame.render_widget(paragraph, content_area);
995
996 let buffer = frame.buffer_mut();
998 let max_x = content_area.x + content_area.width;
999 for (line_idx, col_start, text, url) in link_overlays {
1000 let y = content_area.y + line_idx as u16;
1001 if y >= content_area.y + content_area.height {
1002 continue;
1003 }
1004 let start_x = content_area.x + col_start as u16;
1005 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1006 }
1007 }
1008 PopupContent::List { items, selected } => {
1009 let list_items: Vec<ListItem> = items
1010 .iter()
1011 .enumerate()
1012 .skip(self.scroll_offset)
1013 .take(content_area.height as usize)
1014 .map(|(idx, item)| {
1015 let is_hovered = matches!(
1017 hover_target,
1018 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1019 );
1020 let is_selected = idx == *selected;
1021
1022 let mut spans = Vec::new();
1023
1024 if let Some(icon) = &item.icon {
1026 spans.push(Span::raw(format!("{} ", icon)));
1027 }
1028
1029 let text_style = if is_selected {
1031 Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1032 } else {
1033 Style::default().add_modifier(Modifier::UNDERLINED)
1034 };
1035 spans.push(Span::styled(&item.text, text_style));
1036
1037 if let Some(detail) = &item.detail {
1039 spans.push(Span::styled(
1040 format!(" {}", detail),
1041 Style::default().fg(theme.help_separator_fg),
1042 ));
1043 }
1044
1045 let row_style = if is_selected {
1047 Style::default().bg(theme.popup_selection_bg)
1048 } else if is_hovered {
1049 Style::default()
1050 .bg(theme.menu_hover_bg)
1051 .fg(theme.menu_hover_fg)
1052 } else {
1053 Style::default()
1054 };
1055
1056 ListItem::new(Line::from(spans)).style(row_style)
1057 })
1058 .collect();
1059
1060 let list = List::new(list_items);
1061 frame.render_widget(list, content_area);
1062 }
1063 PopupContent::Custom(lines) => {
1064 let visible_lines: Vec<Line> = lines
1065 .iter()
1066 .skip(self.scroll_offset)
1067 .take(content_area.height as usize)
1068 .map(|line| Line::from(line.as_str()))
1069 .collect();
1070
1071 let paragraph = Paragraph::new(visible_lines);
1072 frame.render_widget(paragraph, content_area);
1073 }
1074 }
1075
1076 if needs_scrollbar {
1078 let scrollbar_area = Rect {
1079 x: inner_area.x + inner_area.width - 1,
1080 y: inner_area.y,
1081 width: 1,
1082 height: inner_area.height,
1083 };
1084
1085 let scrollbar_state =
1086 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1087 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1088 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1089 }
1090 }
1091}
1092
1093#[derive(Debug, Clone)]
1095pub struct PopupManager {
1096 popups: Vec<Popup>,
1098}
1099
1100impl PopupManager {
1101 pub fn new() -> Self {
1102 Self { popups: Vec::new() }
1103 }
1104
1105 pub fn show(&mut self, popup: Popup) {
1107 self.popups.push(popup);
1108 }
1109
1110 pub fn hide(&mut self) -> Option<Popup> {
1112 self.popups.pop()
1113 }
1114
1115 pub fn clear(&mut self) {
1117 self.popups.clear();
1118 }
1119
1120 pub fn top(&self) -> Option<&Popup> {
1122 self.popups.last()
1123 }
1124
1125 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1127 self.popups.last_mut()
1128 }
1129
1130 pub fn get(&self, index: usize) -> Option<&Popup> {
1132 self.popups.get(index)
1133 }
1134
1135 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1137 self.popups.get_mut(index)
1138 }
1139
1140 pub fn is_visible(&self) -> bool {
1142 !self.popups.is_empty()
1143 }
1144
1145 pub fn is_completion_popup(&self) -> bool {
1147 self.top()
1148 .map(|p| p.kind == PopupKind::Completion)
1149 .unwrap_or(false)
1150 }
1151
1152 pub fn is_hover_popup(&self) -> bool {
1154 self.top()
1155 .map(|p| p.kind == PopupKind::Hover)
1156 .unwrap_or(false)
1157 }
1158
1159 pub fn is_action_popup(&self) -> bool {
1161 self.top()
1162 .map(|p| p.kind == PopupKind::Action)
1163 .unwrap_or(false)
1164 }
1165
1166 pub fn all(&self) -> &[Popup] {
1168 &self.popups
1169 }
1170
1171 pub fn dismiss_transient(&mut self) -> bool {
1175 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1176
1177 if is_transient {
1178 self.popups.pop();
1179 true
1180 } else {
1181 false
1182 }
1183 }
1184}
1185
1186impl Default for PopupManager {
1187 fn default() -> Self {
1188 Self::new()
1189 }
1190}
1191
1192fn apply_hyperlink_overlay(
1197 buffer: &mut ratatui::buffer::Buffer,
1198 start_x: u16,
1199 y: u16,
1200 max_x: u16,
1201 text: &str,
1202 url: &str,
1203) {
1204 let mut chunk_index = 0u16;
1205 let mut chars = text.chars();
1206
1207 loop {
1208 let mut chunk = String::new();
1209 for _ in 0..2 {
1210 if let Some(ch) = chars.next() {
1211 chunk.push(ch);
1212 } else {
1213 break;
1214 }
1215 }
1216
1217 if chunk.is_empty() {
1218 break;
1219 }
1220
1221 let x = start_x + chunk_index * 2;
1222 if x >= max_x {
1223 break;
1224 }
1225
1226 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1227 buffer[(x, y)].set_symbol(&hyperlink);
1228
1229 chunk_index += 1;
1230 }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use crate::view::theme;
1237
1238 #[test]
1239 fn test_popup_list_item() {
1240 let item = PopupListItem::new("test".to_string())
1241 .with_detail("detail".to_string())
1242 .with_icon("📄".to_string());
1243
1244 assert_eq!(item.text, "test");
1245 assert_eq!(item.detail, Some("detail".to_string()));
1246 assert_eq!(item.icon, Some("📄".to_string()));
1247 }
1248
1249 #[test]
1250 fn test_popup_selection() {
1251 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1252 let items = vec![
1253 PopupListItem::new("item1".to_string()),
1254 PopupListItem::new("item2".to_string()),
1255 PopupListItem::new("item3".to_string()),
1256 ];
1257
1258 let mut popup = Popup::list(items, &theme);
1259
1260 assert_eq!(popup.selected_item().unwrap().text, "item1");
1261
1262 popup.select_next();
1263 assert_eq!(popup.selected_item().unwrap().text, "item2");
1264
1265 popup.select_next();
1266 assert_eq!(popup.selected_item().unwrap().text, "item3");
1267
1268 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1270
1271 popup.select_prev();
1272 assert_eq!(popup.selected_item().unwrap().text, "item2");
1273
1274 popup.select_prev();
1275 assert_eq!(popup.selected_item().unwrap().text, "item1");
1276
1277 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1279 }
1280
1281 #[test]
1282 fn test_popup_manager() {
1283 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1284 let mut manager = PopupManager::new();
1285
1286 assert!(!manager.is_visible());
1287 assert_eq!(manager.top(), None);
1288
1289 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1290 manager.show(popup1);
1291
1292 assert!(manager.is_visible());
1293 assert_eq!(manager.all().len(), 1);
1294
1295 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1296 manager.show(popup2);
1297
1298 assert_eq!(manager.all().len(), 2);
1299
1300 manager.hide();
1301 assert_eq!(manager.all().len(), 1);
1302
1303 manager.clear();
1304 assert!(!manager.is_visible());
1305 assert_eq!(manager.all().len(), 0);
1306 }
1307
1308 #[test]
1309 fn test_popup_area_calculation() {
1310 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1311 let terminal_area = Rect {
1312 x: 0,
1313 y: 0,
1314 width: 100,
1315 height: 50,
1316 };
1317
1318 let popup = Popup::text(vec!["test".to_string()], &theme)
1319 .with_width(30)
1320 .with_max_height(10);
1321
1322 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1324 let area = popup_centered.calculate_area(terminal_area, None);
1325 assert_eq!(area.width, 30);
1326 assert_eq!(area.height, 3);
1328 assert_eq!(area.x, (100 - 30) / 2);
1329 assert_eq!(area.y, (50 - 3) / 2);
1330
1331 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1333 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1334 assert_eq!(area.x, 20);
1335 assert_eq!(area.y, 12); }
1337
1338 #[test]
1339 fn test_popup_fixed_position_clamping() {
1340 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1341 let terminal_area = Rect {
1342 x: 0,
1343 y: 0,
1344 width: 100,
1345 height: 50,
1346 };
1347
1348 let popup = Popup::text(vec!["test".to_string()], &theme)
1349 .with_width(30)
1350 .with_max_height(10);
1351
1352 let popup_fixed = popup
1354 .clone()
1355 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1356 let area = popup_fixed.calculate_area(terminal_area, None);
1357 assert_eq!(area.x, 10);
1358 assert_eq!(area.y, 20);
1359
1360 let popup_right_edge = popup
1362 .clone()
1363 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1364 let area = popup_right_edge.calculate_area(terminal_area, None);
1365 assert_eq!(area.x, 70);
1367 assert_eq!(area.y, 20);
1368
1369 let popup_beyond = popup
1371 .clone()
1372 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1373 let area = popup_beyond.calculate_area(terminal_area, None);
1374 assert_eq!(area.x, 70);
1376 assert_eq!(area.y, 20);
1377
1378 let popup_bottom = popup
1380 .clone()
1381 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1382 let area = popup_bottom.calculate_area(terminal_area, None);
1383 assert_eq!(area.x, 10);
1384 assert_eq!(area.y, 47);
1386 }
1387
1388 #[test]
1389 fn test_clamp_rect_to_bounds() {
1390 let bounds = Rect {
1391 x: 0,
1392 y: 0,
1393 width: 100,
1394 height: 50,
1395 };
1396
1397 let rect = Rect {
1399 x: 10,
1400 y: 20,
1401 width: 30,
1402 height: 10,
1403 };
1404 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1405 assert_eq!(clamped, rect);
1406
1407 let rect = Rect {
1409 x: 99,
1410 y: 20,
1411 width: 30,
1412 height: 10,
1413 };
1414 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1415 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1420 x: 199,
1421 y: 60,
1422 width: 30,
1423 height: 10,
1424 };
1425 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1426 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1431
1432 #[test]
1433 fn hyperlink_overlay_chunks_pairs() {
1434 use ratatui::{buffer::Buffer, layout::Rect};
1435
1436 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1437 buffer[(0, 0)].set_symbol("P");
1438 buffer[(1, 0)].set_symbol("l");
1439 buffer[(2, 0)].set_symbol("a");
1440 buffer[(3, 0)].set_symbol("y");
1441
1442 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1443
1444 let first = buffer[(0, 0)].symbol().to_string();
1445 let second = buffer[(2, 0)].symbol().to_string();
1446
1447 assert!(
1448 first.contains("Pl"),
1449 "first chunk should contain 'Pl', got {first:?}"
1450 );
1451 assert!(
1452 second.contains("ay"),
1453 "second chunk should contain 'ay', got {second:?}"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_popup_text_selection() {
1459 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1460 let mut popup = Popup::text(
1461 vec![
1462 "Line 0: Hello".to_string(),
1463 "Line 1: World".to_string(),
1464 "Line 2: Test".to_string(),
1465 ],
1466 &theme,
1467 );
1468
1469 assert!(!popup.has_selection());
1471 assert_eq!(popup.get_selected_text(), None);
1472
1473 popup.start_selection(0, 8);
1475 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1479 assert!(popup.has_selection());
1480
1481 let selected = popup.get_selected_text().unwrap();
1483 assert_eq!(selected, "Hello\nLine 1: ");
1484
1485 popup.clear_selection();
1487 assert!(!popup.has_selection());
1488 assert_eq!(popup.get_selected_text(), None);
1489
1490 popup.start_selection(1, 8);
1492 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1494 assert_eq!(selected, "World");
1495 }
1496
1497 #[test]
1498 fn test_popup_text_selection_contains() {
1499 let sel = PopupTextSelection {
1500 start: (1, 5),
1501 end: (2, 10),
1502 };
1503
1504 assert!(!sel.contains(0, 5));
1506
1507 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));
1520 }
1521
1522 #[test]
1523 fn test_popup_text_selection_normalized() {
1524 let sel = PopupTextSelection {
1526 start: (1, 5),
1527 end: (2, 10),
1528 };
1529 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1530 assert_eq!((s_line, s_col), (1, 5));
1531 assert_eq!((e_line, e_col), (2, 10));
1532
1533 let sel_backward = PopupTextSelection {
1535 start: (2, 10),
1536 end: (1, 5),
1537 };
1538 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1539 assert_eq!((s_line, s_col), (1, 5));
1540 assert_eq!((e_line, e_col), (2, 10));
1541 }
1542}