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 pub accept_key_hint: Option<String>,
210}
211
212impl Popup {
213 pub fn text(content: Vec<String>, theme: &crate::view::theme::Theme) -> Self {
215 Self {
216 kind: PopupKind::Text,
217 title: None,
218 description: None,
219 transient: false,
220 content: PopupContent::Text(content),
221 position: PopupPosition::AtCursor,
222 width: 50,
223 max_height: 15,
224 bordered: true,
225 border_style: Style::default().fg(theme.popup_border_fg),
226 background_style: Style::default().bg(theme.popup_bg),
227 scroll_offset: 0,
228 text_selection: None,
229 accept_key_hint: None,
230 }
231 }
232
233 pub fn markdown(
238 markdown_text: &str,
239 theme: &crate::view::theme::Theme,
240 registry: Option<&GrammarRegistry>,
241 ) -> Self {
242 let styled_lines = parse_markdown(markdown_text, theme, registry);
243 Self {
244 kind: PopupKind::Text,
245 title: None,
246 description: None,
247 transient: false,
248 content: PopupContent::Markdown(styled_lines),
249 position: PopupPosition::AtCursor,
250 width: 60, max_height: 20, bordered: true,
253 border_style: Style::default().fg(theme.popup_border_fg),
254 background_style: Style::default().bg(theme.popup_bg),
255 scroll_offset: 0,
256 text_selection: None,
257 accept_key_hint: None,
258 }
259 }
260
261 pub fn list(items: Vec<PopupListItem>, theme: &crate::view::theme::Theme) -> Self {
263 Self {
264 kind: PopupKind::List,
265 title: None,
266 description: None,
267 transient: false,
268 content: PopupContent::List { items, selected: 0 },
269 position: PopupPosition::AtCursor,
270 width: 50,
271 max_height: 15,
272 bordered: true,
273 border_style: Style::default().fg(theme.popup_border_fg),
274 background_style: Style::default().bg(theme.popup_bg),
275 scroll_offset: 0,
276 text_selection: None,
277 accept_key_hint: None,
278 }
279 }
280
281 pub fn with_title(mut self, title: String) -> Self {
283 self.title = Some(title);
284 self
285 }
286
287 pub fn with_kind(mut self, kind: PopupKind) -> Self {
289 self.kind = kind;
290 self
291 }
292
293 pub fn with_transient(mut self, transient: bool) -> Self {
295 self.transient = transient;
296 self
297 }
298
299 pub fn with_position(mut self, position: PopupPosition) -> Self {
301 self.position = position;
302 self
303 }
304
305 pub fn with_width(mut self, width: u16) -> Self {
307 self.width = width;
308 self
309 }
310
311 pub fn with_max_height(mut self, max_height: u16) -> Self {
313 self.max_height = max_height;
314 self
315 }
316
317 pub fn with_border_style(mut self, style: Style) -> Self {
319 self.border_style = style;
320 self
321 }
322
323 pub fn selected_item(&self) -> Option<&PopupListItem> {
325 match &self.content {
326 PopupContent::List { items, selected } => items.get(*selected),
327 _ => None,
328 }
329 }
330
331 fn visible_height(&self) -> usize {
333 let border_offset = if self.bordered { 2 } else { 0 };
334 (self.max_height as usize).saturating_sub(border_offset)
335 }
336
337 pub fn select_next(&mut self) {
339 let visible = self.visible_height();
340 if let PopupContent::List { items, selected } = &mut self.content {
341 if *selected < items.len().saturating_sub(1) {
342 *selected += 1;
343 if *selected >= self.scroll_offset + visible {
345 self.scroll_offset = (*selected + 1).saturating_sub(visible);
346 }
347 }
348 }
349 }
350
351 pub fn select_prev(&mut self) {
353 if let PopupContent::List { items: _, selected } = &mut self.content {
354 if *selected > 0 {
355 *selected -= 1;
356 if *selected < self.scroll_offset {
358 self.scroll_offset = *selected;
359 }
360 }
361 }
362 }
363
364 pub fn select_index(&mut self, index: usize) -> bool {
366 let visible = self.visible_height();
367 if let PopupContent::List { items, selected } = &mut self.content {
368 if index < items.len() {
369 *selected = index;
370 if *selected >= self.scroll_offset + visible {
372 self.scroll_offset = (*selected + 1).saturating_sub(visible);
373 } else if *selected < self.scroll_offset {
374 self.scroll_offset = *selected;
375 }
376 return true;
377 }
378 }
379 false
380 }
381
382 pub fn page_down(&mut self) {
384 let visible = self.visible_height();
385 if let PopupContent::List { items, selected } = &mut self.content {
386 *selected = (*selected + visible).min(items.len().saturating_sub(1));
387 self.scroll_offset = (*selected + 1).saturating_sub(visible);
388 } else {
389 self.scroll_offset += visible;
390 }
391 }
392
393 pub fn page_up(&mut self) {
395 let visible = self.visible_height();
396 if let PopupContent::List { items: _, selected } = &mut self.content {
397 *selected = selected.saturating_sub(visible);
398 self.scroll_offset = *selected;
399 } else {
400 self.scroll_offset = self.scroll_offset.saturating_sub(visible);
401 }
402 }
403
404 pub fn select_first(&mut self) {
406 if let PopupContent::List { items: _, selected } = &mut self.content {
407 *selected = 0;
408 self.scroll_offset = 0;
409 } else {
410 self.scroll_offset = 0;
411 }
412 }
413
414 pub fn select_last(&mut self) {
416 let visible = self.visible_height();
417 if let PopupContent::List { items, selected } = &mut self.content {
418 *selected = items.len().saturating_sub(1);
419 if *selected >= visible {
421 self.scroll_offset = (*selected + 1).saturating_sub(visible);
422 }
423 } else {
424 let content_height = self.item_count();
426 if content_height > visible {
427 self.scroll_offset = content_height.saturating_sub(visible);
428 }
429 }
430 }
431
432 pub fn scroll_by(&mut self, delta: i32) {
435 let content_len = self.wrapped_item_count();
436 let visible = self.visible_height();
437 let max_scroll = content_len.saturating_sub(visible);
438
439 if delta < 0 {
440 self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
442 } else {
443 self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
445 }
446
447 if let PopupContent::List { items, selected } = &mut self.content {
449 let visible_start = self.scroll_offset;
450 let visible_end = (self.scroll_offset + visible).min(items.len());
451
452 if *selected < visible_start {
453 *selected = visible_start;
454 } else if *selected >= visible_end {
455 *selected = visible_end.saturating_sub(1);
456 }
457 }
458 }
459
460 pub fn item_count(&self) -> usize {
462 match &self.content {
463 PopupContent::Text(lines) => lines.len(),
464 PopupContent::Markdown(lines) => lines.len(),
465 PopupContent::List { items, .. } => items.len(),
466 PopupContent::Custom(lines) => lines.len(),
467 }
468 }
469
470 fn wrapped_item_count(&self) -> usize {
475 let border_width = if self.bordered { 2 } else { 0 };
477 let scrollbar_width = 2; let wrap_width = (self.width as usize)
479 .saturating_sub(border_width)
480 .saturating_sub(scrollbar_width);
481
482 if wrap_width == 0 {
483 return self.item_count();
484 }
485
486 match &self.content {
487 PopupContent::Text(lines) => wrap_text_lines(lines, wrap_width).len(),
488 PopupContent::Markdown(styled_lines) => {
489 wrap_styled_lines(styled_lines, wrap_width).len()
490 }
491 PopupContent::List { items, .. } => items.len(),
493 PopupContent::Custom(lines) => lines.len(),
494 }
495 }
496
497 pub fn start_selection(&mut self, line: usize, col: usize) {
499 self.text_selection = Some(PopupTextSelection {
500 start: (line, col),
501 end: (line, col),
502 });
503 }
504
505 pub fn extend_selection(&mut self, line: usize, col: usize) {
507 if let Some(ref mut sel) = self.text_selection {
508 sel.end = (line, col);
509 }
510 }
511
512 pub fn clear_selection(&mut self) {
514 self.text_selection = None;
515 }
516
517 pub fn has_selection(&self) -> bool {
519 if let Some(sel) = &self.text_selection {
520 sel.start != sel.end
521 } else {
522 false
523 }
524 }
525
526 fn content_wrap_width(&self) -> usize {
529 let border_width: u16 = if self.bordered { 2 } else { 0 };
530 let inner_width = self.width.saturating_sub(border_width);
531 let scrollbar_reserved: u16 = 2;
532 let conservative_width = inner_width.saturating_sub(scrollbar_reserved) as usize;
533
534 if conservative_width == 0 {
535 return 0;
536 }
537
538 let visible_height = self.max_height.saturating_sub(border_width) as usize;
539 let line_count = match &self.content {
540 PopupContent::Text(lines) => wrap_text_lines(lines, conservative_width).len(),
541 PopupContent::Markdown(styled_lines) => {
542 wrap_styled_lines(styled_lines, conservative_width).len()
543 }
544 _ => self.item_count(),
545 };
546
547 let needs_scrollbar = line_count > visible_height && inner_width > scrollbar_reserved;
548
549 if needs_scrollbar {
550 conservative_width
551 } else {
552 inner_width as usize
553 }
554 }
555
556 fn get_text_lines(&self) -> Vec<String> {
561 let wrap_width = self.content_wrap_width();
562
563 match &self.content {
564 PopupContent::Text(lines) => {
565 if wrap_width > 0 {
566 wrap_text_lines(lines, wrap_width)
567 } else {
568 lines.clone()
569 }
570 }
571 PopupContent::Markdown(styled_lines) => {
572 if wrap_width > 0 {
573 wrap_styled_lines(styled_lines, wrap_width)
574 .iter()
575 .map(|sl| sl.plain_text())
576 .collect()
577 } else {
578 styled_lines.iter().map(|sl| sl.plain_text()).collect()
579 }
580 }
581 PopupContent::List { items, .. } => items.iter().map(|i| i.text.clone()).collect(),
582 PopupContent::Custom(lines) => lines.clone(),
583 }
584 }
585
586 pub fn get_selected_text(&self) -> Option<String> {
588 let sel = self.text_selection.as_ref()?;
589 if sel.start == sel.end {
590 return None;
591 }
592
593 let ((start_line, start_col), (end_line, end_col)) = sel.normalized();
594 let lines = self.get_text_lines();
595
596 if start_line >= lines.len() {
597 return None;
598 }
599
600 if start_line == end_line {
601 let line = &lines[start_line];
602 let end_col = end_col.min(line.len());
603 let start_col = start_col.min(end_col);
604 Some(line[start_col..end_col].to_string())
605 } else {
606 let mut result = String::new();
607 let first_line = &lines[start_line];
609 result.push_str(&first_line[start_col.min(first_line.len())..]);
610 result.push('\n');
611 for line in lines.iter().take(end_line).skip(start_line + 1) {
613 result.push_str(line);
614 result.push('\n');
615 }
616 if end_line < lines.len() {
618 let last_line = &lines[end_line];
619 result.push_str(&last_line[..end_col.min(last_line.len())]);
620 }
621 Some(result)
622 }
623 }
624
625 pub fn needs_scrollbar(&self) -> bool {
627 self.item_count() > self.visible_height()
628 }
629
630 pub fn scroll_state(&self) -> (usize, usize, usize) {
632 let total = self.item_count();
633 let visible = self.visible_height();
634 (total, visible, self.scroll_offset)
635 }
636
637 pub fn link_at_position(&self, relative_col: usize, relative_row: usize) -> Option<String> {
643 let PopupContent::Markdown(styled_lines) = &self.content else {
644 return None;
645 };
646
647 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
655 let wrapped_lines = wrap_styled_lines(styled_lines, content_width);
657
658 let line_index = self.scroll_offset + relative_row;
660
661 let line = wrapped_lines.get(line_index)?;
663
664 line.link_at_column(relative_col).map(|s| s.to_string())
666 }
667
668 pub fn description_height(&self) -> u16 {
671 if let Some(desc) = &self.description {
672 let border_width = if self.bordered { 2 } else { 0 };
673 let scrollbar_reserved = 2;
674 let content_width = self
675 .width
676 .saturating_sub(border_width)
677 .saturating_sub(scrollbar_reserved) as usize;
678 let desc_vec = vec![desc.clone()];
679 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
680 wrapped.len() as u16 + 1 } else {
682 0
683 }
684 }
685
686 fn content_height(&self) -> u16 {
688 self.content_height_for_width(self.width)
690 }
691
692 fn content_height_for_width(&self, popup_width: u16) -> u16 {
694 let border_width = if self.bordered { 2 } else { 0 };
696 let scrollbar_reserved = 2; let content_width = popup_width
698 .saturating_sub(border_width)
699 .saturating_sub(scrollbar_reserved) as usize;
700
701 let description_lines = if let Some(desc) = &self.description {
703 let desc_vec = vec![desc.clone()];
704 let wrapped = wrap_text_lines(&desc_vec, content_width.saturating_sub(2));
705 wrapped.len() as u16 + 1 } else {
707 0
708 };
709
710 let content_lines = match &self.content {
711 PopupContent::Text(lines) => {
712 wrap_text_lines(lines, content_width).len() as u16
714 }
715 PopupContent::Markdown(styled_lines) => {
716 wrap_styled_lines(styled_lines, content_width).len() as u16
718 }
719 PopupContent::List { items, .. } => items.len() as u16,
720 PopupContent::Custom(lines) => lines.len() as u16,
721 };
722
723 let border_height = if self.bordered { 2 } else { 0 };
725
726 description_lines + content_lines + border_height
727 }
728
729 pub fn calculate_area(&self, terminal_area: Rect, cursor_pos: Option<(u16, u16)>) -> Rect {
731 match self.position {
732 PopupPosition::AtCursor | PopupPosition::BelowCursor | PopupPosition::AboveCursor => {
733 let (cursor_x, cursor_y) =
734 cursor_pos.unwrap_or((terminal_area.width / 2, terminal_area.height / 2));
735
736 let width = self.width.min(terminal_area.width);
737 let height = self
739 .content_height()
740 .min(self.max_height)
741 .min(terminal_area.height);
742
743 let x = if cursor_x + width > terminal_area.width {
744 terminal_area.width.saturating_sub(width)
745 } else {
746 cursor_x
747 };
748
749 let y = match self.position {
750 PopupPosition::AtCursor => cursor_y,
751 PopupPosition::BelowCursor => {
752 if cursor_y + 1 + height > terminal_area.height {
753 cursor_y.saturating_sub(height)
755 } else {
756 cursor_y + 1
758 }
759 }
760 PopupPosition::AboveCursor => {
761 (cursor_y + 1).saturating_sub(height)
763 }
764 _ => cursor_y,
765 };
766
767 Rect {
768 x,
769 y,
770 width,
771 height,
772 }
773 }
774 PopupPosition::Fixed { x, y } => {
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 = if x + width > terminal_area.width {
782 terminal_area.width.saturating_sub(width)
783 } else {
784 x
785 };
786 let y = if y + height > terminal_area.height {
787 terminal_area.height.saturating_sub(height)
788 } else {
789 y
790 };
791 Rect {
792 x,
793 y,
794 width,
795 height,
796 }
797 }
798 PopupPosition::Centered => {
799 let width = self.width.min(terminal_area.width);
800 let height = self
801 .content_height()
802 .min(self.max_height)
803 .min(terminal_area.height);
804 let x = (terminal_area.width.saturating_sub(width)) / 2;
805 let y = (terminal_area.height.saturating_sub(height)) / 2;
806 Rect {
807 x,
808 y,
809 width,
810 height,
811 }
812 }
813 PopupPosition::BottomRight => {
814 let width = self.width.min(terminal_area.width);
815 let height = self
816 .content_height()
817 .min(self.max_height)
818 .min(terminal_area.height);
819 let x = terminal_area.width.saturating_sub(width);
821 let y = terminal_area
822 .height
823 .saturating_sub(height)
824 .saturating_sub(2);
825 Rect {
826 x,
827 y,
828 width,
829 height,
830 }
831 }
832 }
833 }
834
835 pub fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::view::theme::Theme) {
837 self.render_with_hover(frame, area, theme, None);
838 }
839
840 pub fn render_with_hover(
842 &self,
843 frame: &mut Frame,
844 area: Rect,
845 theme: &crate::view::theme::Theme,
846 hover_target: Option<&crate::app::HoverTarget>,
847 ) {
848 let frame_area = frame.area();
850 let area = clamp_rect_to_bounds(area, frame_area);
851
852 if area.width == 0 || area.height == 0 {
854 return;
855 }
856
857 frame.render_widget(Clear, area);
859
860 let block = if self.bordered {
861 let mut block = Block::default()
862 .borders(Borders::ALL)
863 .border_style(self.border_style)
864 .style(self.background_style);
865
866 if let Some(title) = &self.title {
867 block = block.title(title.as_str());
868 }
869
870 block
871 } else {
872 Block::default().style(self.background_style)
873 };
874
875 let inner_area = block.inner(area);
876 frame.render_widget(block, area);
877
878 let content_start_y;
880 if let Some(desc) = &self.description {
881 let desc_wrap_width = inner_area.width.saturating_sub(2) as usize; let desc_vec = vec![desc.clone()];
884 let wrapped_desc = wrap_text_lines(&desc_vec, desc_wrap_width);
885 let desc_lines: usize = wrapped_desc.len();
886
887 for (i, line) in wrapped_desc.iter().enumerate() {
889 if i >= inner_area.height as usize {
890 break;
891 }
892 let line_area = Rect {
893 x: inner_area.x,
894 y: inner_area.y + i as u16,
895 width: inner_area.width,
896 height: 1,
897 };
898 let desc_style = Style::default().fg(theme.help_separator_fg);
899 frame.render_widget(Paragraph::new(line.as_str()).style(desc_style), line_area);
900 }
901
902 content_start_y = inner_area.y + (desc_lines as u16).min(inner_area.height) + 1;
904 } else {
905 content_start_y = inner_area.y;
906 }
907
908 let inner_area = Rect {
910 x: inner_area.x,
911 y: content_start_y,
912 width: inner_area.width,
913 height: inner_area
914 .height
915 .saturating_sub(content_start_y - area.y - if self.bordered { 1 } else { 0 }),
916 };
917
918 let scrollbar_reserved_width = 2; let wrap_width = inner_area.width.saturating_sub(scrollbar_reserved_width) as usize;
922 let visible_lines_count = inner_area.height as usize;
923
924 let (wrapped_total_lines, needs_scrollbar) = match &self.content {
926 PopupContent::Text(lines) => {
927 let wrapped = wrap_text_lines(lines, wrap_width);
928 let count = wrapped.len();
929 (
930 count,
931 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
932 )
933 }
934 PopupContent::Markdown(styled_lines) => {
935 let wrapped = wrap_styled_lines(styled_lines, wrap_width);
936 let count = wrapped.len();
937 (
938 count,
939 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
940 )
941 }
942 PopupContent::List { items, .. } => {
943 let count = items.len();
944 (
945 count,
946 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
947 )
948 }
949 PopupContent::Custom(lines) => {
950 let count = lines.len();
951 (
952 count,
953 count > visible_lines_count && inner_area.width > scrollbar_reserved_width,
954 )
955 }
956 };
957
958 let content_area = if needs_scrollbar {
960 Rect {
961 x: inner_area.x,
962 y: inner_area.y,
963 width: inner_area.width.saturating_sub(scrollbar_reserved_width),
964 height: inner_area.height,
965 }
966 } else {
967 inner_area
968 };
969
970 match &self.content {
971 PopupContent::Text(lines) => {
972 let wrapped_lines = wrap_text_lines(lines, content_area.width as usize);
974 let selection_style = Style::default().bg(theme.selection_bg);
975
976 let visible_lines: Vec<Line> = wrapped_lines
977 .iter()
978 .enumerate()
979 .skip(self.scroll_offset)
980 .take(content_area.height as usize)
981 .map(|(line_idx, line)| {
982 if let Some(ref sel) = self.text_selection {
983 let chars: Vec<char> = line.chars().collect();
985 let spans: Vec<Span> = chars
986 .iter()
987 .enumerate()
988 .map(|(col, ch)| {
989 if sel.contains(line_idx, col) {
990 Span::styled(ch.to_string(), selection_style)
991 } else {
992 Span::raw(ch.to_string())
993 }
994 })
995 .collect();
996 Line::from(spans)
997 } else {
998 Line::from(line.as_str())
999 }
1000 })
1001 .collect();
1002
1003 let paragraph = Paragraph::new(visible_lines);
1004 frame.render_widget(paragraph, content_area);
1005 }
1006 PopupContent::Markdown(styled_lines) => {
1007 let wrapped_lines = wrap_styled_lines(styled_lines, content_area.width as usize);
1009 let selection_style = Style::default().bg(theme.selection_bg);
1010
1011 let mut link_overlays: Vec<(usize, usize, String, String)> = Vec::new();
1014
1015 let visible_lines: Vec<Line> = wrapped_lines
1016 .iter()
1017 .enumerate()
1018 .skip(self.scroll_offset)
1019 .take(content_area.height as usize)
1020 .map(|(line_idx, styled_line)| {
1021 let mut col = 0usize;
1022 let spans: Vec<Span> = styled_line
1023 .spans
1024 .iter()
1025 .flat_map(|s| {
1026 let span_start_col = col;
1027 let span_width =
1028 unicode_width::UnicodeWidthStr::width(s.text.as_str());
1029 if let Some(url) = &s.link_url {
1030 link_overlays.push((
1031 line_idx - self.scroll_offset,
1032 col,
1033 s.text.clone(),
1034 url.clone(),
1035 ));
1036 }
1037 col += span_width;
1038
1039 if let Some(ref sel) = self.text_selection {
1041 let chars: Vec<char> = s.text.chars().collect();
1043 chars
1044 .iter()
1045 .enumerate()
1046 .map(|(i, ch)| {
1047 let char_col = span_start_col + i;
1048 if sel.contains(line_idx, char_col) {
1049 Span::styled(ch.to_string(), selection_style)
1050 } else {
1051 Span::styled(ch.to_string(), s.style)
1052 }
1053 })
1054 .collect::<Vec<_>>()
1055 } else {
1056 vec![Span::styled(s.text.clone(), s.style)]
1057 }
1058 })
1059 .collect();
1060 Line::from(spans)
1061 })
1062 .collect();
1063
1064 let paragraph = Paragraph::new(visible_lines);
1065 frame.render_widget(paragraph, content_area);
1066
1067 let buffer = frame.buffer_mut();
1069 let max_x = content_area.x + content_area.width;
1070 for (line_idx, col_start, text, url) in link_overlays {
1071 let y = content_area.y + line_idx as u16;
1072 if y >= content_area.y + content_area.height {
1073 continue;
1074 }
1075 let start_x = content_area.x + col_start as u16;
1076 apply_hyperlink_overlay(buffer, start_x, y, max_x, &text, &url);
1077 }
1078 }
1079 PopupContent::List { items, selected } => {
1080 let list_items: Vec<ListItem> = items
1081 .iter()
1082 .enumerate()
1083 .skip(self.scroll_offset)
1084 .take(content_area.height as usize)
1085 .map(|(idx, item)| {
1086 let is_hovered = matches!(
1088 hover_target,
1089 Some(crate::app::HoverTarget::PopupListItem(_, hovered_idx)) if *hovered_idx == idx
1090 );
1091 let is_selected = idx == *selected;
1092
1093 let mut spans = Vec::new();
1094
1095 if let Some(icon) = &item.icon {
1097 spans.push(Span::raw(format!("{} ", icon)));
1098 }
1099
1100 let text_style = if is_selected {
1102 Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1103 } else {
1104 Style::default().add_modifier(Modifier::UNDERLINED)
1105 };
1106 spans.push(Span::styled(&item.text, text_style));
1107
1108 if let Some(detail) = &item.detail {
1110 spans.push(Span::styled(
1111 format!(" {}", detail),
1112 Style::default().fg(theme.help_separator_fg),
1113 ));
1114 }
1115
1116 if is_selected {
1118 if let Some(ref hint) = self.accept_key_hint {
1119 let hint_text = format!("({})", hint);
1120 let used_width: usize = spans
1122 .iter()
1123 .map(|s| {
1124 unicode_width::UnicodeWidthStr::width(s.content.as_ref())
1125 })
1126 .sum();
1127 let available = content_area.width as usize;
1128 let hint_len = hint_text.len();
1129 if used_width + hint_len + 1 < available {
1130 let padding = available - used_width - hint_len;
1131 spans.push(Span::raw(" ".repeat(padding)));
1132 spans.push(Span::styled(
1133 hint_text,
1134 Style::default().fg(theme.help_separator_fg),
1135 ));
1136 }
1137 }
1138 }
1139
1140 let row_style = if is_selected {
1142 Style::default().bg(theme.popup_selection_bg)
1143 } else if is_hovered {
1144 Style::default()
1145 .bg(theme.menu_hover_bg)
1146 .fg(theme.menu_hover_fg)
1147 } else {
1148 Style::default()
1149 };
1150
1151 ListItem::new(Line::from(spans)).style(row_style)
1152 })
1153 .collect();
1154
1155 let list = List::new(list_items);
1156 frame.render_widget(list, content_area);
1157 }
1158 PopupContent::Custom(lines) => {
1159 let visible_lines: Vec<Line> = lines
1160 .iter()
1161 .skip(self.scroll_offset)
1162 .take(content_area.height as usize)
1163 .map(|line| Line::from(line.as_str()))
1164 .collect();
1165
1166 let paragraph = Paragraph::new(visible_lines);
1167 frame.render_widget(paragraph, content_area);
1168 }
1169 }
1170
1171 if needs_scrollbar {
1173 let scrollbar_area = Rect {
1174 x: inner_area.x + inner_area.width - 1,
1175 y: inner_area.y,
1176 width: 1,
1177 height: inner_area.height,
1178 };
1179
1180 let scrollbar_state =
1181 ScrollbarState::new(wrapped_total_lines, visible_lines_count, self.scroll_offset);
1182 let scrollbar_colors = ScrollbarColors::from_theme(theme);
1183 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
1184 }
1185 }
1186}
1187
1188#[derive(Debug, Clone)]
1190pub struct PopupManager {
1191 popups: Vec<Popup>,
1193}
1194
1195impl PopupManager {
1196 pub fn new() -> Self {
1197 Self { popups: Vec::new() }
1198 }
1199
1200 pub fn show(&mut self, popup: Popup) {
1202 self.popups.push(popup);
1203 }
1204
1205 pub fn show_or_replace(&mut self, popup: Popup) {
1209 if let Some(pos) = self.popups.iter().position(|p| p.kind == popup.kind) {
1210 self.popups[pos] = popup;
1211 } else {
1212 self.popups.push(popup);
1213 }
1214 }
1215
1216 pub fn hide(&mut self) -> Option<Popup> {
1218 self.popups.pop()
1219 }
1220
1221 pub fn clear(&mut self) {
1223 self.popups.clear();
1224 }
1225
1226 pub fn top(&self) -> Option<&Popup> {
1228 self.popups.last()
1229 }
1230
1231 pub fn top_mut(&mut self) -> Option<&mut Popup> {
1233 self.popups.last_mut()
1234 }
1235
1236 pub fn get(&self, index: usize) -> Option<&Popup> {
1238 self.popups.get(index)
1239 }
1240
1241 pub fn get_mut(&mut self, index: usize) -> Option<&mut Popup> {
1243 self.popups.get_mut(index)
1244 }
1245
1246 pub fn is_visible(&self) -> bool {
1248 !self.popups.is_empty()
1249 }
1250
1251 pub fn is_completion_popup(&self) -> bool {
1253 self.top()
1254 .map(|p| p.kind == PopupKind::Completion)
1255 .unwrap_or(false)
1256 }
1257
1258 pub fn is_hover_popup(&self) -> bool {
1260 self.top()
1261 .map(|p| p.kind == PopupKind::Hover)
1262 .unwrap_or(false)
1263 }
1264
1265 pub fn is_action_popup(&self) -> bool {
1267 self.top()
1268 .map(|p| p.kind == PopupKind::Action)
1269 .unwrap_or(false)
1270 }
1271
1272 pub fn all(&self) -> &[Popup] {
1274 &self.popups
1275 }
1276
1277 pub fn dismiss_transient(&mut self) -> bool {
1281 let is_transient = self.popups.last().is_some_and(|p| p.transient);
1282
1283 if is_transient {
1284 self.popups.pop();
1285 true
1286 } else {
1287 false
1288 }
1289 }
1290}
1291
1292impl Default for PopupManager {
1293 fn default() -> Self {
1294 Self::new()
1295 }
1296}
1297
1298fn apply_hyperlink_overlay(
1303 buffer: &mut ratatui::buffer::Buffer,
1304 start_x: u16,
1305 y: u16,
1306 max_x: u16,
1307 text: &str,
1308 url: &str,
1309) {
1310 let mut chunk_index = 0u16;
1311 let mut chars = text.chars();
1312
1313 loop {
1314 let mut chunk = String::new();
1315 for _ in 0..2 {
1316 if let Some(ch) = chars.next() {
1317 chunk.push(ch);
1318 } else {
1319 break;
1320 }
1321 }
1322
1323 if chunk.is_empty() {
1324 break;
1325 }
1326
1327 let x = start_x + chunk_index * 2;
1328 if x >= max_x {
1329 break;
1330 }
1331
1332 let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", url, chunk);
1333 buffer[(x, y)].set_symbol(&hyperlink);
1334
1335 chunk_index += 1;
1336 }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::*;
1342 use crate::view::theme;
1343
1344 #[test]
1345 fn test_popup_list_item() {
1346 let item = PopupListItem::new("test".to_string())
1347 .with_detail("detail".to_string())
1348 .with_icon("📄".to_string());
1349
1350 assert_eq!(item.text, "test");
1351 assert_eq!(item.detail, Some("detail".to_string()));
1352 assert_eq!(item.icon, Some("📄".to_string()));
1353 }
1354
1355 #[test]
1356 fn test_popup_selection() {
1357 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1358 let items = vec![
1359 PopupListItem::new("item1".to_string()),
1360 PopupListItem::new("item2".to_string()),
1361 PopupListItem::new("item3".to_string()),
1362 ];
1363
1364 let mut popup = Popup::list(items, &theme);
1365
1366 assert_eq!(popup.selected_item().unwrap().text, "item1");
1367
1368 popup.select_next();
1369 assert_eq!(popup.selected_item().unwrap().text, "item2");
1370
1371 popup.select_next();
1372 assert_eq!(popup.selected_item().unwrap().text, "item3");
1373
1374 popup.select_next(); assert_eq!(popup.selected_item().unwrap().text, "item3");
1376
1377 popup.select_prev();
1378 assert_eq!(popup.selected_item().unwrap().text, "item2");
1379
1380 popup.select_prev();
1381 assert_eq!(popup.selected_item().unwrap().text, "item1");
1382
1383 popup.select_prev(); assert_eq!(popup.selected_item().unwrap().text, "item1");
1385 }
1386
1387 #[test]
1388 fn test_popup_manager() {
1389 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1390 let mut manager = PopupManager::new();
1391
1392 assert!(!manager.is_visible());
1393 assert_eq!(manager.top(), None);
1394
1395 let popup1 = Popup::text(vec!["test1".to_string()], &theme);
1396 manager.show(popup1);
1397
1398 assert!(manager.is_visible());
1399 assert_eq!(manager.all().len(), 1);
1400
1401 let popup2 = Popup::text(vec!["test2".to_string()], &theme);
1402 manager.show(popup2);
1403
1404 assert_eq!(manager.all().len(), 2);
1405
1406 manager.hide();
1407 assert_eq!(manager.all().len(), 1);
1408
1409 manager.clear();
1410 assert!(!manager.is_visible());
1411 assert_eq!(manager.all().len(), 0);
1412 }
1413
1414 #[test]
1415 fn test_popup_area_calculation() {
1416 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1417 let terminal_area = Rect {
1418 x: 0,
1419 y: 0,
1420 width: 100,
1421 height: 50,
1422 };
1423
1424 let popup = Popup::text(vec!["test".to_string()], &theme)
1425 .with_width(30)
1426 .with_max_height(10);
1427
1428 let popup_centered = popup.clone().with_position(PopupPosition::Centered);
1430 let area = popup_centered.calculate_area(terminal_area, None);
1431 assert_eq!(area.width, 30);
1432 assert_eq!(area.height, 3);
1434 assert_eq!(area.x, (100 - 30) / 2);
1435 assert_eq!(area.y, (50 - 3) / 2);
1436
1437 let popup_below = popup.clone().with_position(PopupPosition::BelowCursor);
1439 let area = popup_below.calculate_area(terminal_area, Some((20, 10)));
1440 assert_eq!(area.x, 20);
1441 assert_eq!(area.y, 11); }
1443
1444 #[test]
1445 fn test_popup_fixed_position_clamping() {
1446 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1447 let terminal_area = Rect {
1448 x: 0,
1449 y: 0,
1450 width: 100,
1451 height: 50,
1452 };
1453
1454 let popup = Popup::text(vec!["test".to_string()], &theme)
1455 .with_width(30)
1456 .with_max_height(10);
1457
1458 let popup_fixed = popup
1460 .clone()
1461 .with_position(PopupPosition::Fixed { x: 10, y: 20 });
1462 let area = popup_fixed.calculate_area(terminal_area, None);
1463 assert_eq!(area.x, 10);
1464 assert_eq!(area.y, 20);
1465
1466 let popup_right_edge = popup
1468 .clone()
1469 .with_position(PopupPosition::Fixed { x: 99, y: 20 });
1470 let area = popup_right_edge.calculate_area(terminal_area, None);
1471 assert_eq!(area.x, 70);
1473 assert_eq!(area.y, 20);
1474
1475 let popup_beyond = popup
1477 .clone()
1478 .with_position(PopupPosition::Fixed { x: 199, y: 20 });
1479 let area = popup_beyond.calculate_area(terminal_area, None);
1480 assert_eq!(area.x, 70);
1482 assert_eq!(area.y, 20);
1483
1484 let popup_bottom = popup
1486 .clone()
1487 .with_position(PopupPosition::Fixed { x: 10, y: 49 });
1488 let area = popup_bottom.calculate_area(terminal_area, None);
1489 assert_eq!(area.x, 10);
1490 assert_eq!(area.y, 47);
1492 }
1493
1494 #[test]
1495 fn test_clamp_rect_to_bounds() {
1496 let bounds = Rect {
1497 x: 0,
1498 y: 0,
1499 width: 100,
1500 height: 50,
1501 };
1502
1503 let rect = Rect {
1505 x: 10,
1506 y: 20,
1507 width: 30,
1508 height: 10,
1509 };
1510 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1511 assert_eq!(clamped, rect);
1512
1513 let rect = Rect {
1515 x: 99,
1516 y: 20,
1517 width: 30,
1518 height: 10,
1519 };
1520 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1521 assert_eq!(clamped.x, 99); assert_eq!(clamped.width, 1); let rect = Rect {
1526 x: 199,
1527 y: 60,
1528 width: 30,
1529 height: 10,
1530 };
1531 let clamped = super::clamp_rect_to_bounds(rect, bounds);
1532 assert_eq!(clamped.x, 99); assert_eq!(clamped.y, 49); assert_eq!(clamped.width, 1); assert_eq!(clamped.height, 1); }
1537
1538 #[test]
1539 fn hyperlink_overlay_chunks_pairs() {
1540 use ratatui::{buffer::Buffer, layout::Rect};
1541
1542 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1543 buffer[(0, 0)].set_symbol("P");
1544 buffer[(1, 0)].set_symbol("l");
1545 buffer[(2, 0)].set_symbol("a");
1546 buffer[(3, 0)].set_symbol("y");
1547
1548 apply_hyperlink_overlay(&mut buffer, 0, 0, 10, "Play", "https://example.com");
1549
1550 let first = buffer[(0, 0)].symbol().to_string();
1551 let second = buffer[(2, 0)].symbol().to_string();
1552
1553 assert!(
1554 first.contains("Pl"),
1555 "first chunk should contain 'Pl', got {first:?}"
1556 );
1557 assert!(
1558 second.contains("ay"),
1559 "second chunk should contain 'ay', got {second:?}"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_popup_text_selection() {
1565 let theme = crate::view::theme::Theme::load_builtin(theme::THEME_DARK).unwrap();
1566 let mut popup = Popup::text(
1567 vec![
1568 "Line 0: Hello".to_string(),
1569 "Line 1: World".to_string(),
1570 "Line 2: Test".to_string(),
1571 ],
1572 &theme,
1573 );
1574
1575 assert!(!popup.has_selection());
1577 assert_eq!(popup.get_selected_text(), None);
1578
1579 popup.start_selection(0, 8);
1581 assert!(!popup.has_selection()); popup.extend_selection(1, 8);
1585 assert!(popup.has_selection());
1586
1587 let selected = popup.get_selected_text().unwrap();
1589 assert_eq!(selected, "Hello\nLine 1: ");
1590
1591 popup.clear_selection();
1593 assert!(!popup.has_selection());
1594 assert_eq!(popup.get_selected_text(), None);
1595
1596 popup.start_selection(1, 8);
1598 popup.extend_selection(1, 13); let selected = popup.get_selected_text().unwrap();
1600 assert_eq!(selected, "World");
1601 }
1602
1603 #[test]
1604 fn test_popup_text_selection_contains() {
1605 let sel = PopupTextSelection {
1606 start: (1, 5),
1607 end: (2, 10),
1608 };
1609
1610 assert!(!sel.contains(0, 5));
1612
1613 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));
1626 }
1627
1628 #[test]
1629 fn test_popup_text_selection_normalized() {
1630 let sel = PopupTextSelection {
1632 start: (1, 5),
1633 end: (2, 10),
1634 };
1635 let ((s_line, s_col), (e_line, e_col)) = sel.normalized();
1636 assert_eq!((s_line, s_col), (1, 5));
1637 assert_eq!((e_line, e_col), (2, 10));
1638
1639 let sel_backward = PopupTextSelection {
1641 start: (2, 10),
1642 end: (1, 5),
1643 };
1644 let ((s_line, s_col), (e_line, e_col)) = sel_backward.normalized();
1645 assert_eq!((s_line, s_col), (1, 5));
1646 assert_eq!((e_line, e_col), (2, 10));
1647 }
1648}