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 content_wrap_width(&self) -> usize {
505 let border_width: u16 = if self.bordered { 2 } else { 0 };
506 let inner_width = self.width.saturating_sub(border_width);
507 let scrollbar_reserved: u16 = 2;
508 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
509
510 if conservative_width == 0 {
511 return 0;
512 }
513
514 let visible_height = self.max_height.saturating_sub(border_width) as usize;
515 let line_count = match &self.content {
516 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
517 PopupContent::Markdown(styled_lines) => {
518 wrap_styled_lines(styled_lines, conservative_width).len()
519 }
520 _ => self.item_count(),
521 };
522
523 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
524
525 if needs_scrollbar {
526 conservative_width
527 } else {
528 inner_width as usize
529 }
530 }
531
532 fn get_text_lines(&self) -> Vec<String> {
537 let wrap_width = self.content_wrap_width();
538
539 match &self.content {
540 PopupContent::Text(lines) => {
541 if wrap_width > 0 {
542 wrap_text_lines(lines, wrap_width)
543 } else {
544 lines.clone()
545 }
546 }
547 PopupContent::Markdown(styled_lines) => {
548 if wrap_width > 0 {
549 wrap_styled_lines(styled_lines, wrap_width)
550 .iter()
551 .map(|sl| sl.plain_text())
552 .collect()
553 } else {
554 styled_lines.iter().map(|sl| sl.plain_text()).collect()
555 }
556 }
557 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
558 PopupContent::Custom(lines) => lines.clone(),
559 }
560 }
561
562 pub fn get_selected_text(&self) -> Option<String> {
564 let sel = self.text_selection.as_ref()?;
565 if sel.start == sel.end {
566 return None;
567 }
568
569 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
570 let lines = self.get_text_lines();
571
572 if start_line >= lines.len() {
573 return None;
574 }
575
576 if start_line == end_line {
577 let line = &lines[start_line];
578 let end_col = end_col.min(line.len());
579 let start_col = start_col.min(end_col);
580 Some(line[start_col..end_col].to_string())
581 } else {
582 let mut result = String::new();
583 let first_line = &lines[start_line];
585 result.push_str(&first_line[start_col.min(first_line.len())..]);
586 result.push('\n');
587 for line in lines.iter().take(end_line).skip(start_line + 1) {
589 result.push_str(line);
590 result.push('\n');
591 }
592 if end_line < lines.len() {
594 let last_line = &lines[end_line];
595 result.push_str(&last_line[..end_col.min(last_line.len())]);
596 }
597 Some(result)
598 }
599 }
600
601 pub fn needs_scrollbar(&self) -> bool {
603 self.item_count() > self.visible_height()
604 }
605
606 pub fn scroll_state(&self) -> (usize, usize, usize) {
608 let total = self.item_count();
609 let visible = self.visible_height();
610 (total, visible, self.scroll_offset)
611 }
612
613 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
619 let PopupContent::Markdown(styled_lines) = &self.content else {
620 return None;
621 };
622
623 let border_width = if self.bordered { 2 } else { 0 };
625 let scrollbar_reserved = 2;
626 let content_width = self
627 .width
628 .saturating_sub(border_width)
629 .saturating_sub(scrollbar_reserved) as usize;
630
631 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
633
634 let line_index = self.scroll_offset + relative_row;
636
637 let line = wrapped_lines.get(line_index)?;
639
640 line.link_at_column(relative_col).map(|s| s.to_string())
642 }
643
644 pub fn description_height(&self) -> u16 {
647 if let Some(desc) = &self.description {
648 let border_width = if self.bordered { 2 } else { 0 };
649 let scrollbar_reserved = 2;
650 let content_width = self
651 .width
652 .saturating_sub(border_width)
653 .saturating_sub(scrollbar_reserved) as usize;
654 let desc_vec = vec![desc.clone()];
655 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
656 wrapped.len() as u16 + 1 } else {
658 0
659 }
660 }
661
662 fn content_height(&self) -> u16 {
664 self.content_height_for_width(self.width)
666 }
667
668 fn content_height_for_width(&self, popup_width: u16) -> u16 {
670 let border_width = if self.bordered { 2 } else { 0 };
672 let scrollbar_reserved = 2; let content_width = popup_width
674 .saturating_sub(border_width)
675 .saturating_sub(scrollbar_reserved) as usize;
676
677 let description_lines = if let Some(desc) = &self.description {
679 let desc_vec = vec![desc.clone()];
680 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
681 wrapped.len() as u16 + 1 } else {
683 0
684 };
685
686 let content_lines = match &self.content {
687 PopupContent::Text(lines) => {
688 wrap_text_lines(lines, content_width).len() as u16
690 }
691 PopupContent::Markdown(styled_lines) => {
692 wrap_styled_lines(styled_lines, content_width).len() as u16
694 }
695 PopupContent::List { items, .. } => items.len() as u16,
696 PopupContent::Custom(lines) => lines.len() as u16,
697 };
698
699 let border_height = if self.bordered { 2 } else { 0 };
701
702 description_lines + content_lines + border_height
703 }
704
705 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
707 match self.position {
708 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
709 let (cursor_x, cursor_y) =
710 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
711
712 let width = self.width.min(terminal_area.width);
713 let height = self
715 .content_height()
716 .min(self.max_height)
717 .min(terminal_area.height);
718
719 let x = if cursor_x + width > terminal_area.width {
720 terminal_area.width.saturating_sub(width)
721 } else {
722 cursor_x
723 };
724
725 let y = match self.position {
726 PopupPosition::AtCursor => cursor_y,
727 PopupPosition::BelowCursor => {
728 if cursor_y + 1 + height > terminal_area.height {
729 cursor_y.saturating_sub(height)
731 } else {
732 cursor_y + 1
734 }
735 }
736 PopupPosition::AboveCursor => {
737 (cursor_y + 1).saturating_sub(height)
739 }
740 _ => cursor_y,
741 };
742
743 Rect {
744 x,
745 y,
746 width,
747 height,
748 }
749 }
750 PopupPosition::Fixed { x, y } => {
751 let width = self.width.min(terminal_area.width);
752 let height = self
753 .content_height()
754 .min(self.max_height)
755 .min(terminal_area.height);
756 let x = if x + width > terminal_area.width {
758 terminal_area.width.saturating_sub(width)
759 } else {
760 x
761 };
762 let y = if y + height > terminal_area.height {
763 terminal_area.height.saturating_sub(height)
764 } else {
765 y
766 };
767 Rect {
768 x,
769 y,
770 width,
771 height,
772 }
773 }
774 PopupPosition::Centered => {
775 let width = self.width.min(terminal_area.width);
776 let height = self
777 .content_height()
778 .min(self.max_height)
779 .min(terminal_area.height);
780 let x = (terminal_area.width.saturating_sub(width)) / 2;
781 let y = (terminal_area.height.saturating_sub(height)) / 2;
782 Rect {
783 x,
784 y,
785 width,
786 height,
787 }
788 }
789 PopupPosition::BottomRight => {
790 let width = self.width.min(terminal_area.width);
791 let height = self
792 .content_height()
793 .min(self.max_height)
794 .min(terminal_area.height);
795 let x = terminal_area.width.saturating_sub(width);
797 let y = terminal_area
798 .height
799 .saturating_sub(height)
800 .saturating_sub(2);
801 Rect {
802 x,
803 y,
804 width,
805 height,
806 }
807 }
808 }
809 }
810
811 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
813 self.render_with_hover(frame, area, theme, None);
814 }
815
816 pub fn render_with_hover(
818 &self,
819 frame: &mut Frame,
820 area: Rect,
821 theme: &crate::view::theme::Theme,
822 hover_target: Option<&crate::app::HoverTarget>,
823 ) {
824 let frame_area = frame.area();
826 let area = clamp_rect_to_bounds(area, frame_area);
827
828 if area.width == 0 || area.height == 0 {
830 return;
831 }
832
833 frame.render_widget(Clear, area);
835
836 let block = if self.bordered {
837 let mut block = Block::default()
838 .borders(Borders::ALL)
839 .border_style(self.border_style)
840 .style(self.background_style);
841
842 if let Some(title) = &self.title {
843 block = block.title(title.as_str());
844 }
845
846 block
847 } else {
848 Block::default().style(self.background_style)
849 };
850
851 let inner_area = block.inner(area);
852 frame.render_widget(block, area);
853
854 let content_start_y;
856 if let Some(desc) = &self.description {
857 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
860 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
861 let desc_lines: usize = wrapped_desc.len();
862
863 for (i, line) in wrapped_desc.iter().enumerate() {
865 if i >= inner_area.height as usize {
866 break;
867 }
868 let line_area = Rect {
869 x: inner_area.x,
870 y: inner_area.y + i as u16,
871 width: inner_area.width,
872 height: 1,
873 };
874 let desc_style = Style::default().fg(theme.help_separator_fg);
875 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
876 }
877
878 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
880 } else {
881 content_start_y = inner_area.y;
882 }
883
884 let inner_area = Rect {
886 x: inner_area.x,
887 y: content_start_y,
888 width: inner_area.width,
889 height: inner_area
890 .height
891 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
892 };
893
894 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
898 let visible_lines_count = inner_area.height as usize;
899
900 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
902 PopupContent::Text(lines) => {
903 let wrapped = wrap_text_lines(lines, wrap_width);
904 let count = wrapped.len();
905 (
906 count,
907 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
908 )
909 }
910 PopupContent::Markdown(styled_lines) => {
911 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
912 let count = wrapped.len();
913 (
914 count,
915 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
916 )
917 }
918 PopupContent::List { items, .. } => {
919 let count = items.len();
920 (
921 count,
922 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
923 )
924 }
925 PopupContent::Custom(lines) => {
926 let count = lines.len();
927 (
928 count,
929 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
930 )
931 }
932 };
933
934 let content_area = if needs_scrollbar {
936 Rect {
937 x: inner_area.x,
938 y: inner_area.y,
939 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
940 height: inner_area.height,
941 }
942 } else {
943 inner_area
944 };
945
946 match &self.content {
947 PopupContent::Text(lines) => {
948 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
950 let selection_style = Style::default().bg(theme.selection_bg);
951
952 let visible_lines: Vec<Line> = wrapped_lines
953 .iter()
954 .enumerate()
955 .skip(self.scroll_offset)
956 .take(content_area.height as usize)
957 .map(|(line_idx, line)| {
958 if let Some(ref sel) = self.text_selection {
959 let chars: Vec<char> = line.chars().collect();
961 let spans: Vec<Span> = chars
962 .iter()
963 .enumerate()
964 .map(|(col, ch)| {
965 if sel.contains(line_idx, col) {
966 Span::styled(ch.to_string(), selection_style)
967 } else {
968 Span::raw(ch.to_string())
969 }
970 })
971 .collect();
972 Line::from(spans)
973 } else {
974 Line::from(line.as_str())
975 }
976 })
977 .collect();
978
979 let paragraph = Paragraph::new(visible_lines);
980 frame.render_widget(paragraph, content_area);
981 }
982 PopupContent::Markdown(styled_lines) => {
983 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
985 let selection_style = Style::default().bg(theme.selection_bg);
986
987 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
990
991 let visible_lines: Vec<Line> = wrapped_lines
992 .iter()
993 .enumerate()
994 .skip(self.scroll_offset)
995 .take(content_area.height as usize)
996 .map(|(line_idx, styled_line)| {
997 let mut col = 0usize;
998 let spans: Vec<Span> = styled_line
999 .spans
1000 .iter()
1001 .flat_map(|s| {
1002 let span_start_col = col;
1003 let span_width =
1004 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1005 if let Some(url) = &s.link_url {
1006 link_overlays.push((
1007 line_idx - self.scroll_offset,
1008 col,
1009 s.text.clone(),
1010 url.clone(),
1011 ));
1012 }
1013 col += span_width;
1014
1015 if let Some(ref sel) = self.text_selection {
1017 let chars: Vec<char> = s.text.chars().collect();
1019 chars
1020 .iter()
1021 .enumerate()
1022 .map(|(i, ch)| {
1023 let char_col = span_start_col + i;
1024 if sel.contains(line_idx, char_col) {
1025 Span::styled(ch.to_string(), selection_style)
1026 } else {
1027 Span::styled(ch.to_string(), s.style)
1028 }
1029 })
1030 .collect::<Vec<_>>()
1031 } else {
1032 vec![Span::styled(s.text.clone(), s.style)]
1033 }
1034 })
1035 .collect();
1036 Line::from(spans)
1037 })
1038 .collect();
1039
1040 let paragraph = Paragraph::new(visible_lines);
1041 frame.render_widget(paragraph, content_area);
1042
1043 let buffer = frame.buffer_mut();
1045 let max_x = content_area.x + content_area.width;
1046 for (line_idx, col_start, text, url) in link_overlays {
1047 let y = content_area.y + line_idx as u16;
1048 if y >= content_area.y + content_area.height {
1049 continue;
1050 }
1051 let start_x = content_area.x + col_start as u16;
1052 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1053 }
1054 }
1055 PopupContent::List { items, selected } => {
1056 let list_items: Vec<ListItem> = items
1057 .iter()
1058 .enumerate()
1059 .skip(self.scroll_offset)
1060 .take(content_area.height as usize)
1061 .map(|(idx, item)| {
1062 let is_hovered = matches!(
1064 hover_target,
1065 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1066 );
1067 let is_selected = idx == *selected;
1068
1069 let mut spans = Vec::new();
1070
1071 if let Some(icon) = &item.icon {
1073 spans.push(Span::raw(format!("{} ", icon)));
1074 }
1075
1076 let text_style = if is_selected {
1078 Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1079 } else {
1080 Style::default().add_modifier(Modifier::UNDERLINED)
1081 };
1082 spans.push(Span::styled(&item.text, text_style));
1083
1084 if let Some(detail) = &item.detail {
1086 spans.push(Span::styled(
1087 format!(" {}", detail),
1088 Style::default().fg(theme.help_separator_fg),
1089 ));
1090 }
1091
1092 let row_style = if is_selected {
1094 Style::default().bg(theme.popup_selection_bg)
1095 } else if is_hovered {
1096 Style::default()
1097 .bg(theme.menu_hover_bg)
1098 .fg(theme.menu_hover_fg)
1099 } else {
1100 Style::default()
1101 };
1102
1103 ListItem::new(Line::from(spans)).style(row_style)
1104 })
1105 .collect();
1106
1107 let list = List::new(list_items);
1108 frame.render_widget(list, content_area);
1109 }
1110 PopupContent::Custom(lines) => {
1111 let visible_lines: Vec<Line> = lines
1112 .iter()
1113 .skip(self.scroll_offset)
1114 .take(content_area.height as usize)
1115 .map(|line| Line::from(line.as_str()))
1116 .collect();
1117
1118 let paragraph = Paragraph::new(visible_lines);
1119 frame.render_widget(paragraph, content_area);
1120 }
1121 }
1122
1123 if needs_scrollbar {
1125 let scrollbar_area = Rect {
1126 x: inner_area.x + inner_area.width - 1,
1127 y: inner_area.y,
1128 width: 1,
1129 height: inner_area.height,
1130 };
1131
1132 let scrollbar_state =
1133 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1134 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1135 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1136 }
1137 }
1138}
1139
1140#[derive(Debug, Clone)]
1142pub struct PopupManager {
1143 popups: Vec<Popup>,
1145}
1146
1147impl PopupManager {
1148 pub fn new() -> Self {
1149 Self { popups: Vec::new() }
1150 }
1151
1152 pub fn show(&mut self, popup: Popup) {
1154 self.popups.push(popup);
1155 }
1156
1157 pub fn hide(&mut self) -> Option<Popup> {
1159 self.popups.pop()
1160 }
1161
1162 pub fn clear(&mut self) {
1164 self.popups.clear();
1165 }
1166
1167 pub fn top(&self) -> Option<&Popup> {
1169 self.popups.last()
1170 }
1171
1172 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1174 self.popups.last_mut()
1175 }
1176
1177 pub fn get(&self, index: usize) -> Option<&Popup> {
1179 self.popups.get(index)
1180 }
1181
1182 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1184 self.popups.get_mut(index)
1185 }
1186
1187 pub fn is_visible(&self) -> bool {
1189 !self.popups.is_empty()
1190 }
1191
1192 pub fn is_completion_popup(&self) -> bool {
1194 self.top()
1195 .map(|p| p.kind == PopupKind::Completion)
1196 .unwrap_or(false)
1197 }
1198
1199 pub fn is_hover_popup(&self) -> bool {
1201 self.top()
1202 .map(|p| p.kind == PopupKind::Hover)
1203 .unwrap_or(false)
1204 }
1205
1206 pub fn is_action_popup(&self) -> bool {
1208 self.top()
1209 .map(|p| p.kind == PopupKind::Action)
1210 .unwrap_or(false)
1211 }
1212
1213 pub fn all(&self) -> &[Popup] {
1215 &self.popups
1216 }
1217
1218 pub fn dismiss_transient(&mut self) -> bool {
1222 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1223
1224 if is_transient {
1225 self.popups.pop();
1226 true
1227 } else {
1228 false
1229 }
1230 }
1231}
1232
1233impl Default for PopupManager {
1234 fn default() -> Self {
1235 Self::new()
1236 }
1237}
1238
1239fn apply_hyperlink_overlay(
1244 buffer: &mut ratatui::buffer::Buffer,
1245 start_x: u16,
1246 y: u16,
1247 max_x: u16,
1248 text: &str,
1249 url: &str,
1250) {
1251 let mut chunk_index = 0u16;
1252 let mut chars = text.chars();
1253
1254 loop {
1255 let mut chunk = String::new();
1256 for _ in 0..2 {
1257 if let Some(ch) = chars.next() {
1258 chunk.push(ch);
1259 } else {
1260 break;
1261 }
1262 }
1263
1264 if chunk.is_empty() {
1265 break;
1266 }
1267
1268 let x = start_x + chunk_index * 2;
1269 if x >= max_x {
1270 break;
1271 }
1272
1273 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1274 buffer[(x, y)].set_symbol(&hyperlink);
1275
1276 chunk_index += 1;
1277 }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282 use super::*;
1283 use crate::view::theme;
1284
1285 #[test]
1286 fn test_popup_list_item() {
1287 let item = PopupListItem::new("test".to_string())
1288 .with_detail("detail".to_string())
1289 .with_icon("📄".to_string());
1290
1291 assert_eq!(item.text, "test");
1292 assert_eq!(item.detail, Some("detail".to_string()));
1293 assert_eq!(item.icon, Some("📄".to_string()));
1294 }
1295
1296 #[test]
1297 fn test_popup_selection() {
1298 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1299 let items = vec![
1300 PopupListItem::new("item1".to_string()),
1301 PopupListItem::new("item2".to_string()),
1302 PopupListItem::new("item3".to_string()),
1303 ];
1304
1305 let mut popup = Popup::list(items, &theme);
1306
1307 assert_eq!(popup.selected_item().unwrap().text, "item1");
1308
1309 popup.select_next();
1310 assert_eq!(popup.selected_item().unwrap().text, "item2");
1311
1312 popup.select_next();
1313 assert_eq!(popup.selected_item().unwrap().text, "item3");
1314
1315 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1317
1318 popup.select_prev();
1319 assert_eq!(popup.selected_item().unwrap().text, "item2");
1320
1321 popup.select_prev();
1322 assert_eq!(popup.selected_item().unwrap().text, "item1");
1323
1324 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1326 }
1327
1328 #[test]
1329 fn test_popup_manager() {
1330 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1331 let mut manager = PopupManager::new();
1332
1333 assert!(!manager.is_visible());
1334 assert_eq!(manager.top(), None);
1335
1336 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1337 manager.show(popup1);
1338
1339 assert!(manager.is_visible());
1340 assert_eq!(manager.all().len(), 1);
1341
1342 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1343 manager.show(popup2);
1344
1345 assert_eq!(manager.all().len(), 2);
1346
1347 manager.hide();
1348 assert_eq!(manager.all().len(), 1);
1349
1350 manager.clear();
1351 assert!(!manager.is_visible());
1352 assert_eq!(manager.all().len(), 0);
1353 }
1354
1355 #[test]
1356 fn test_popup_area_calculation() {
1357 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1358 let terminal_area = Rect {
1359 x: 0,
1360 y: 0,
1361 width: 100,
1362 height: 50,
1363 };
1364
1365 let popup = Popup::text(vec!["test".to_string()], &theme)
1366 .with_width(30)
1367 .with_max_height(10);
1368
1369 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1371 let area = popup_centered.calculate_area(terminal_area, None);
1372 assert_eq!(area.width, 30);
1373 assert_eq!(area.height, 3);
1375 assert_eq!(area.x, (100 - 30) / 2);
1376 assert_eq!(area.y, (50 - 3) / 2);
1377
1378 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1380 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1381 assert_eq!(area.x, 20);
1382 assert_eq!(area.y, 11); }
1384
1385 #[test]
1386 fn test_popup_fixed_position_clamping() {
1387 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1388 let terminal_area = Rect {
1389 x: 0,
1390 y: 0,
1391 width: 100,
1392 height: 50,
1393 };
1394
1395 let popup = Popup::text(vec!["test".to_string()], &theme)
1396 .with_width(30)
1397 .with_max_height(10);
1398
1399 let popup_fixed = popup
1401 .clone()
1402 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1403 let area = popup_fixed.calculate_area(terminal_area, None);
1404 assert_eq!(area.x, 10);
1405 assert_eq!(area.y, 20);
1406
1407 let popup_right_edge = popup
1409 .clone()
1410 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1411 let area = popup_right_edge.calculate_area(terminal_area, None);
1412 assert_eq!(area.x, 70);
1414 assert_eq!(area.y, 20);
1415
1416 let popup_beyond = popup
1418 .clone()
1419 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1420 let area = popup_beyond.calculate_area(terminal_area, None);
1421 assert_eq!(area.x, 70);
1423 assert_eq!(area.y, 20);
1424
1425 let popup_bottom = popup
1427 .clone()
1428 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1429 let area = popup_bottom.calculate_area(terminal_area, None);
1430 assert_eq!(area.x, 10);
1431 assert_eq!(area.y, 47);
1433 }
1434
1435 #[test]
1436 fn test_clamp_rect_to_bounds() {
1437 let bounds = Rect {
1438 x: 0,
1439 y: 0,
1440 width: 100,
1441 height: 50,
1442 };
1443
1444 let rect = Rect {
1446 x: 10,
1447 y: 20,
1448 width: 30,
1449 height: 10,
1450 };
1451 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1452 assert_eq!(clamped, rect);
1453
1454 let rect = Rect {
1456 x: 99,
1457 y: 20,
1458 width: 30,
1459 height: 10,
1460 };
1461 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1462 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1467 x: 199,
1468 y: 60,
1469 width: 30,
1470 height: 10,
1471 };
1472 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1473 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1478
1479 #[test]
1480 fn hyperlink_overlay_chunks_pairs() {
1481 use ratatui::{buffer::Buffer, layout::Rect};
1482
1483 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1484 buffer[(0, 0)].set_symbol("P");
1485 buffer[(1, 0)].set_symbol("l");
1486 buffer[(2, 0)].set_symbol("a");
1487 buffer[(3, 0)].set_symbol("y");
1488
1489 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1490
1491 let first = buffer[(0, 0)].symbol().to_string();
1492 let second = buffer[(2, 0)].symbol().to_string();
1493
1494 assert!(
1495 first.contains("Pl"),
1496 "first chunk should contain 'Pl', got {first:?}"
1497 );
1498 assert!(
1499 second.contains("ay"),
1500 "second chunk should contain 'ay', got {second:?}"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_popup_text_selection() {
1506 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1507 let mut popup = Popup::text(
1508 vec![
1509 "Line 0: Hello".to_string(),
1510 "Line 1: World".to_string(),
1511 "Line 2: Test".to_string(),
1512 ],
1513 &theme,
1514 );
1515
1516 assert!(!popup.has_selection());
1518 assert_eq!(popup.get_selected_text(), None);
1519
1520 popup.start_selection(0, 8);
1522 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1526 assert!(popup.has_selection());
1527
1528 let selected = popup.get_selected_text().unwrap();
1530 assert_eq!(selected, "Hello\nLine 1: ");
1531
1532 popup.clear_selection();
1534 assert!(!popup.has_selection());
1535 assert_eq!(popup.get_selected_text(), None);
1536
1537 popup.start_selection(1, 8);
1539 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1541 assert_eq!(selected, "World");
1542 }
1543
1544 #[test]
1545 fn test_popup_text_selection_contains() {
1546 let sel = PopupTextSelection {
1547 start: (1, 5),
1548 end: (2, 10),
1549 };
1550
1551 assert!(!sel.contains(0, 5));
1553
1554 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));
1567 }
1568
1569 #[test]
1570 fn test_popup_text_selection_normalized() {
1571 let sel = PopupTextSelection {
1573 start: (1, 5),
1574 end: (2, 10),
1575 };
1576 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1577 assert_eq!((s_line, s_col), (1, 5));
1578 assert_eq!((e_line, e_col), (2, 10));
1579
1580 let sel_backward = PopupTextSelection {
1582 start: (2, 10),
1583 end: (1, 5),
1584 };
1585 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1586 assert_eq!((s_line, s_col), (1, 5));
1587 assert_eq!((e_line, e_col), (2, 10));
1588 }
1589}